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:
258
cmd/plexum-harborforge-plugin/main.go
Normal file
258
cmd/plexum-harborforge-plugin/main.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// plexum-harborforge-plugin — Plexum-side HarborForge plugin.
|
||||
//
|
||||
// Mirrors HarborForge.OpenclawPlugin's responsibilities, recast on the
|
||||
// Plexum SDK:
|
||||
//
|
||||
// - eager activation: plugin spawns at host start so the Monitor
|
||||
// bridge listener and Calendar scheduler are running before any
|
||||
// turn fires
|
||||
// - 9 harborforge_* tools backed by tools.Dispatch
|
||||
// - state-aware wake-agent via HostAPI.WakeAgent for Calendar slots
|
||||
//
|
||||
// Config layout: <profile>/plugins/harbor-forge/config.json — see
|
||||
// internal/config/config.go for the schema.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/calendar"
|
||||
hfcfg "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/config"
|
||||
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/monitor"
|
||||
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/telemetry"
|
||||
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/tools"
|
||||
)
|
||||
|
||||
// Version is injected via -ldflags "-X main.Version=…" at build time.
|
||||
var Version = "0.1.0"
|
||||
|
||||
// harborForgePlugin satisfies sdkplugin.ToolPlugin.
|
||||
type harborForgePlugin struct {
|
||||
host sdkplugin.HostAPI
|
||||
cfg hfcfg.Resolved
|
||||
bridge *monitor.Bridge
|
||||
sched *calendar.Scheduler
|
||||
deps tools.Deps
|
||||
cancelBg context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
agentCache sync.Map // sessionID/turnID → agentID stash (best-effort)
|
||||
}
|
||||
|
||||
func (p *harborForgePlugin) Manifest() sdkplugin.Manifest {
|
||||
return manifestFromDisk()
|
||||
}
|
||||
|
||||
func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) error {
|
||||
p.host = host
|
||||
|
||||
profileRoot := os.Getenv("PLEXUM_PROFILE_ROOT")
|
||||
if profileRoot == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
profileRoot = filepath.Join(home, ".plexum")
|
||||
}
|
||||
raw, err := hfcfg.Load(profileRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load harbor-forge config: %w", err)
|
||||
}
|
||||
p.cfg = hfcfg.Resolve(raw)
|
||||
host.Log("info", "harbor-forge plugin initialized", map[string]any{
|
||||
"version": Version,
|
||||
"backend": p.cfg.BackendURL,
|
||||
"identifier": p.cfg.Identifier,
|
||||
"monitor_port": p.cfg.MonitorPort,
|
||||
"calendar_enabled": p.cfg.CalendarEnabled,
|
||||
})
|
||||
|
||||
collect := func() telemetry.Snapshot {
|
||||
return telemetry.Collect(telemetry.CollectOpts{
|
||||
Identifier: p.cfg.Identifier,
|
||||
Version: Version,
|
||||
AgentLister: func() []telemetry.AgentInfo {
|
||||
return p.listAgents(ctx, profileRoot)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
p.bridge = monitor.New(p.cfg.MonitorPort, collect,
|
||||
func(level, msg string, attrs map[string]any) { host.Log(level, msg, attrs) })
|
||||
|
||||
bgCtx, cancel := context.WithCancel(context.Background())
|
||||
p.cancelBg = cancel
|
||||
|
||||
if err := p.bridge.Start(bgCtx); err != nil {
|
||||
host.Log("warn", "monitor bridge failed to start", map[string]any{"err": err.Error()})
|
||||
}
|
||||
|
||||
calBackend := p.cfg.CalendarBackendURL
|
||||
if calBackend == "" {
|
||||
calBackend = p.cfg.BackendURL
|
||||
}
|
||||
bridge := calendar.New(calBackend, p.cfg.APIKey)
|
||||
p.sched = calendar.NewScheduler(
|
||||
calendar.Config{
|
||||
HeartbeatInterval: time.Duration(p.cfg.CalendarHeartbeatIntervalSeconds) * time.Second,
|
||||
},
|
||||
bridge, host, p.cfg.Identifier,
|
||||
calendar.PluginInfoTag{Name: "harbor-forge", Version: Version, Backend: "plexum"},
|
||||
func() []calendar.ReportableAgent {
|
||||
return p.listReportableAgents(ctx, profileRoot)
|
||||
},
|
||||
)
|
||||
if p.cfg.CalendarEnabled {
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
if err := p.sched.Run(bgCtx); err != nil {
|
||||
host.Log("warn", "calendar scheduler exited", map[string]any{"err": err.Error()})
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
host.Log("info", "calendar scheduler disabled by config", nil)
|
||||
}
|
||||
|
||||
p.deps = tools.Deps{
|
||||
Config: p.cfg,
|
||||
Version: Version,
|
||||
Collect: collect,
|
||||
Bridge: p.bridge,
|
||||
Scheduler: p.sched,
|
||||
Host: host,
|
||||
AgentIDFromCtx: func(ctx context.Context) string {
|
||||
// Plexum stashes the calling agent id on the host-side
|
||||
// context (via WithAgent) before dispatching tool calls.
|
||||
// We can't directly import internal/agentloop from a
|
||||
// plugin, so we rely on PLEXUM_TOOL_AGENT_ID env-style
|
||||
// (set per-call by host when we add that wiring) or fall
|
||||
// back to the only-active-agent heuristic. v1: prefer the
|
||||
// only-active wake-target (deterministic in single-agent
|
||||
// HF deployments).
|
||||
return p.bestEffortAgentID()
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *harborForgePlugin) CallTool(ctx context.Context, name string, input json.RawMessage) (sdkplugin.ToolResult, error) {
|
||||
return tools.Dispatch(ctx, p.deps, name, input)
|
||||
}
|
||||
|
||||
// ---- agent enumeration ----
|
||||
|
||||
// listAgents walks <profile>/agents/*/agent.json + state.json so the
|
||||
// telemetry payload includes every Plexum agent visible on this host.
|
||||
// Best-effort: read failures degrade to empty list.
|
||||
func (p *harborForgePlugin) listAgents(ctx context.Context, profileRoot string) []telemetry.AgentInfo {
|
||||
root := filepath.Join(profileRoot, "agents")
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]telemetry.AgentInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
agentID := e.Name()
|
||||
var info telemetry.AgentInfo
|
||||
info.ID = agentID
|
||||
if raw, err := os.ReadFile(filepath.Join(root, agentID, "agent.json")); err == nil {
|
||||
var meta struct{ Model string `json:"model"` }
|
||||
_ = json.Unmarshal(raw, &meta)
|
||||
info.Model = meta.Model
|
||||
}
|
||||
// State via HostAPI.ReadAgentState — host-side, ground truth.
|
||||
if snap, err := p.host.ReadAgentState(ctx, agentID); err == nil {
|
||||
info.State = snap.State
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *harborForgePlugin) listReportableAgents(ctx context.Context, profileRoot string) []calendar.ReportableAgent {
|
||||
telem := p.listAgents(ctx, profileRoot)
|
||||
out := make([]calendar.ReportableAgent, 0, len(telem))
|
||||
for _, a := range telem {
|
||||
out = append(out, calendar.ReportableAgent{
|
||||
ID: a.ID, Model: a.Model,
|
||||
State: mapStateToCalendar(a.State),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapStateToCalendar(s string) calendar.AgentStatusValue {
|
||||
switch strings.ToLower(s) {
|
||||
case "idle":
|
||||
return calendar.AgentStatusIdle
|
||||
case "working":
|
||||
return calendar.AgentStatusOnCall
|
||||
case "busy":
|
||||
return calendar.AgentStatusBusy
|
||||
case "offline":
|
||||
return calendar.AgentStatusOffline
|
||||
}
|
||||
return calendar.AgentStatusUnknown
|
||||
}
|
||||
|
||||
// bestEffortAgentID is a v1 stop-gap for tools that need the calling
|
||||
// agent's id but don't have it on the ctx (Plexum SDK doesn't yet
|
||||
// expose this — TODO upstream). Returns the only active calendar
|
||||
// slot's agent if there's exactly one; otherwise empty. The calendar
|
||||
// tools (the only ones that need agent context) usually fire when
|
||||
// exactly one slot is active.
|
||||
func (p *harborForgePlugin) bestEffortAgentID() string {
|
||||
sch := p.sched.Status()
|
||||
if len(sch.Active) == 1 {
|
||||
return sch.Active[0].Slot.AgentID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func manifestFromDisk() sdkplugin.Manifest {
|
||||
// Bundled manifest.json is the authoritative shape; the binary
|
||||
// version reads it next to itself to avoid hand-syncing two
|
||||
// definitions. Falls back to a minimal in-code manifest if the
|
||||
// file is missing (development / first build).
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
raw, err := os.ReadFile(filepath.Join(filepath.Dir(exe), "manifest.json"))
|
||||
if err == nil {
|
||||
var m sdkplugin.Manifest
|
||||
if err := json.Unmarshal(raw, &m); err == nil && m.Name != "" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
return sdkplugin.Manifest{
|
||||
Name: "harbor-forge",
|
||||
Version: Version,
|
||||
Activation: sdkplugin.ActivationEager,
|
||||
Executable: "plexum-harborforge-plugin",
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := &harborForgePlugin{}
|
||||
defer func() {
|
||||
if p.cancelBg != nil {
|
||||
p.cancelBg()
|
||||
}
|
||||
p.wg.Wait()
|
||||
}()
|
||||
if err := sdkplugin.Serve(p); err != nil && !errors.Is(err, context.Canceled) {
|
||||
fmt.Fprintf(os.Stderr, "plexum-harborforge-plugin: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user