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:
129
internal/calendar/bridge.go
Normal file
129
internal/calendar/bridge.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Bridge — thin HTTP client for the HarborForge backend's Calendar API.
|
||||
// All operations carry the API key as Authorization: Bearer; absent
|
||||
// key means missing-auth errors from the backend (caller should
|
||||
// handle them as transient and log).
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bridge is the typed wrapper around an HTTP client + backend URL.
|
||||
type Bridge struct {
|
||||
BackendURL string
|
||||
APIKey string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// New constructs a bridge with a sensible default timeout.
|
||||
func New(backendURL, apiKey string) *Bridge {
|
||||
return &Bridge{
|
||||
BackendURL: strings.TrimRight(backendURL, "/"),
|
||||
APIKey: apiKey,
|
||||
HTTP: &http.Client{Timeout: 20 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat POSTs /calendar/agent/heartbeat. Returns the backend's
|
||||
// reply or an error.
|
||||
func (b *Bridge) Heartbeat(ctx context.Context, payload HeartbeatPayload) (HeartbeatResponse, error) {
|
||||
raw, err := b.post(ctx, "/calendar/agent/heartbeat", payload)
|
||||
if err != nil {
|
||||
return HeartbeatResponse{}, err
|
||||
}
|
||||
var out HeartbeatResponse
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return HeartbeatResponse{}, fmt.Errorf("decode heartbeat: %w (body=%q)", err, truncate(raw, 200))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateSlotStatus POSTs /calendar/slot/<id>/status to mark a slot
|
||||
// completed / aborted / paused / resumed.
|
||||
func (b *Bridge) UpdateSlotStatus(ctx context.Context, slotID string, update SlotUpdate) error {
|
||||
if slotID == "" {
|
||||
return errors.New("calendar: slot id required")
|
||||
}
|
||||
_, err := b.post(ctx, "/calendar/slot/"+slotID+"/status", update)
|
||||
return err
|
||||
}
|
||||
|
||||
// RestartPending GETs /restart/status — returns the backend's
|
||||
// current restart-requested flag.
|
||||
func (b *Bridge) RestartPending(ctx context.Context) (bool, error) {
|
||||
raw, err := b.get(ctx, "/restart/status")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var out struct {
|
||||
Pending bool `json:"pending"`
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return false, fmt.Errorf("decode restart-status: %w", err)
|
||||
}
|
||||
}
|
||||
return out.Pending, nil
|
||||
}
|
||||
|
||||
// post serialises body as JSON, attaches Authorization, returns
|
||||
// response body bytes. Non-2xx becomes an error with the body
|
||||
// included for diagnostics.
|
||||
func (b *Bridge) post(ctx context.Context, path string, body any) ([]byte, error) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal %s: %w", path, err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.BackendURL+path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if b.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+b.APIKey)
|
||||
}
|
||||
return b.do(req)
|
||||
}
|
||||
|
||||
func (b *Bridge) get(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.BackendURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+b.APIKey)
|
||||
}
|
||||
return b.do(req)
|
||||
}
|
||||
|
||||
func (b *Bridge) do(req *http.Request) ([]byte, error) {
|
||||
res, err := b.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", req.Method, req.URL.Path, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%s %s → %d: %s",
|
||||
req.Method, req.URL.Path, res.StatusCode, truncate(body, 300))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) <= n {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:n]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user