Files
HarborForge.PlexumPlugin/internal/calendar/bridge.go
hzhang 754e5183f7 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
2026-06-03 11:11:36 +01:00

130 lines
3.5 KiB
Go

// 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]) + "…"
}