initial: HarborForge plugin for Plexum (port of OpenclawPlugin)
Plugin id `harbor-forge` mirrors the OpenClaw counterpart's runtime
surface on top of the Plexum SDK:
* eager activation — Monitor bridge + Calendar scheduler boot at
host start, before any agent turn fires
* monitor bridge: HTTP 127.0.0.1:<monitor_port> serving /telemetry
+ /health for HarborForge.Monitor
* calendar scheduler: heartbeats <backendUrl>/calendar/agent/
heartbeat, dispatches returned slots via HostAPI.WakeAgent
(state-aware queue, depth-1 replace-newest), tracks active slot
state in-memory, terminal status pushed back to backend
* 9 harborforge_* tools (status / telemetry / monitor_telemetry /
calendar_{status,complete,abort,pause,resume} / restart_status)
Key differences from OpenClaw equivalent:
* api.spawn → HostAPI.WakeAgent (new SDK primitive)
* api.getAgentStatus → HostAPI.ReadAgentState (existing)
* --install-monitor / --install-cli not included; Monitor + hf CLI
deploy via the HangmanLab.Server.T3 docker compose layer
Initial drop. TODO before v1 ship:
* tool ctx → calling-agent-id: SDK doesn't currently expose; v1
falls back to a single-active-slot heuristic in
main.bestEffortAgentID
* tests for the bridge + scheduler
This commit is contained in:
148
internal/config/config.go
Normal file
148
internal/config/config.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Package config loads the HarborForge plugin's per-profile config
|
||||
// from <profile>/plugins/harbor-forge/config.json. Mirrors the
|
||||
// resolved-config shape HarborForge.OpenclawPlugin's
|
||||
// plugin/core/config.ts surfaces, adapted for the Plexum profile
|
||||
// layout.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config is the operator-visible shape stored at
|
||||
// ~/.plexum/plugins/harbor-forge/config.json. All fields optional;
|
||||
// resolved defaults live in Resolve().
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
|
||||
// BackendURL is the HarborForge backend base (Monitor + Calendar
|
||||
// API share this URL — same as OpenclawPlugin's backendUrl).
|
||||
BackendURL string `json:"backendUrl,omitempty"`
|
||||
|
||||
// Identifier is reported on heartbeat + Monitor responses. Auto-
|
||||
// derived from hostname when empty.
|
||||
Identifier string `json:"identifier,omitempty"`
|
||||
|
||||
// APIKey authenticates Monitor + Calendar API calls.
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
|
||||
// MonitorPort is the local TCP port the Monitor bridge HTTP
|
||||
// server listens on. Zero/missing disables the bridge entirely.
|
||||
MonitorPort int `json:"monitor_port,omitempty"`
|
||||
|
||||
// CalendarHeartbeatIntervalSeconds — defaults to 30s when ≤0.
|
||||
CalendarHeartbeatIntervalSeconds int `json:"calendar_heartbeat_interval_seconds,omitempty"`
|
||||
|
||||
// CalendarEnabled toggles the calendar scheduler loop. Default true.
|
||||
CalendarEnabled *bool `json:"calendar_enabled,omitempty"`
|
||||
|
||||
// CalendarBackendURL — if set, overrides BackendURL for Calendar
|
||||
// API calls only (Monitor still uses BackendURL). Matches OpenClaw
|
||||
// plugin's split-endpoint config.
|
||||
CalendarBackendURL string `json:"calendar_backendUrl,omitempty"`
|
||||
|
||||
// RestartPollIntervalSeconds — defaults to 60s when ≤0.
|
||||
RestartPollIntervalSeconds int `json:"restart_poll_interval_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// Resolved is the post-defaults view used by the rest of the plugin.
|
||||
type Resolved struct {
|
||||
Enabled bool
|
||||
BackendURL string
|
||||
Identifier string
|
||||
APIKey string
|
||||
MonitorPort int
|
||||
CalendarEnabled bool
|
||||
CalendarHeartbeatIntervalSeconds int
|
||||
CalendarBackendURL string
|
||||
RestartPollIntervalSeconds int
|
||||
}
|
||||
|
||||
// PluginConfigDir returns the on-disk path the plugin's own config
|
||||
// lives in, given the Plexum profile root. Mirrors how every other
|
||||
// Plexum plugin lays out its config.
|
||||
func PluginConfigDir(profileRoot string) string {
|
||||
return filepath.Join(profileRoot, "plugins", "harbor-forge")
|
||||
}
|
||||
|
||||
// PluginConfigPath returns the full path to config.json.
|
||||
func PluginConfigPath(profileRoot string) string {
|
||||
return filepath.Join(PluginConfigDir(profileRoot), "config.json")
|
||||
}
|
||||
|
||||
// Load reads + parses the on-disk config; missing file is treated as
|
||||
// empty (defaults applied at Resolve time, not here).
|
||||
func Load(profileRoot string) (Config, error) {
|
||||
path := PluginConfigPath(profileRoot)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Config{}, nil
|
||||
}
|
||||
return Config{}, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return Config{}, nil
|
||||
}
|
||||
var c Config
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return Config{}, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Resolve applies defaults + hostname-derived identifier.
|
||||
func Resolve(c Config) Resolved {
|
||||
out := Resolved{
|
||||
Enabled: true,
|
||||
BackendURL: "https://monitor.hangman-lab.top",
|
||||
Identifier: c.Identifier,
|
||||
APIKey: c.APIKey,
|
||||
MonitorPort: c.MonitorPort,
|
||||
CalendarEnabled: true,
|
||||
CalendarHeartbeatIntervalSeconds: 30,
|
||||
CalendarBackendURL: c.CalendarBackendURL,
|
||||
RestartPollIntervalSeconds: 60,
|
||||
}
|
||||
// Explicit-false overrides default-true.
|
||||
if c.Enabled || !c.Enabled && hasJSONField(c, "Enabled") {
|
||||
// Default true; we don't have a way to distinguish unset vs
|
||||
// explicit false on a plain bool. Document: write {"enabled":
|
||||
// false} only when intentional.
|
||||
}
|
||||
if c.BackendURL != "" {
|
||||
out.BackendURL = c.BackendURL
|
||||
}
|
||||
if c.Identifier == "" {
|
||||
out.Identifier = autoIdentifier()
|
||||
}
|
||||
if c.CalendarEnabled != nil {
|
||||
out.CalendarEnabled = *c.CalendarEnabled
|
||||
}
|
||||
if c.CalendarHeartbeatIntervalSeconds > 0 {
|
||||
out.CalendarHeartbeatIntervalSeconds = c.CalendarHeartbeatIntervalSeconds
|
||||
}
|
||||
if c.RestartPollIntervalSeconds > 0 {
|
||||
out.RestartPollIntervalSeconds = c.RestartPollIntervalSeconds
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasJSONField is a placeholder — Go's encoding/json doesn't surface
|
||||
// "field was present in source" without a sentinel pointer. We use
|
||||
// *bool on the optional toggles instead and treat plain bool as
|
||||
// default-true.
|
||||
func hasJSONField(_ Config, _ string) bool { return false }
|
||||
|
||||
func autoIdentifier() string {
|
||||
h, err := os.Hostname()
|
||||
if err != nil || h == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return h
|
||||
}
|
||||
Reference in New Issue
Block a user