diff --git a/.gitignore b/.gitignore index 3146d6e..9b618a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ plugin/node_modules/ -plugin/*.js -plugin/*.js.map -plugin/*.d.ts -plugin/*.d.ts.map +plugin/dist/ +plugin/**/*.js +plugin/**/*.js.map +plugin/**/*.d.ts +plugin/**/*.d.ts.map diff --git a/README.md b/README.md index 41ed680..27fc914 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,12 @@ HarborForge.OpenclawPlugin/ │ ├── openclaw.plugin.json │ ├── index.ts │ ├── core/ -│ │ ├── live-config.ts +│ │ ├── config.ts +│ │ ├── managed-monitor.ts │ │ └── monitor-bridge.ts +│ ├── hooks/ +│ │ ├── gateway-start.ts +│ │ └── gateway-stop.ts │ └── package.json ├── skills/ │ └── hf/ @@ -98,8 +102,6 @@ node scripts/install.mjs --uninstall } ``` -> 说明:`monitor_port` 是当前主字段;为兼容旧配置,插件仍接受 `monitorPort`。 - 然后重启: ```bash diff --git a/docs/PLG-CAL-001-calendar-heartbeat-format.md b/docs/PLG-CAL-001-calendar-heartbeat-format.md new file mode 100644 index 0000000..687fd5e --- /dev/null +++ b/docs/PLG-CAL-001-calendar-heartbeat-format.md @@ -0,0 +1,261 @@ +# PLG-CAL-001 — Calendar Heartbeat Format Specification + +> **Task:** HarborForge OpenclawPlugin / Monitor 联动 +> **Subtask:** PLG-CAL-001 — 插件侧定义 Calendar 心跳请求格式 +> **Status:** ✅ Implemented (types + client + spec) +> **Date:** 2026-04-01 + +--- + +## Overview + +This document specifies the request/response format for the Calendar heartbeat +communication between the OpenClaw HarborForge plugin and the HarborForge backend. + +Heartbeat direction: **Plugin → Backend** + +The plugin sends a heartbeat every minute (aligned with the existing Monitor +heartbeat interval). The backend returns today's pending TimeSlots for the agent. + +--- + +## 1. How `claw_identifier` is Determined + +`claw_identifier` identifies the server/claw instance. It is the same value used +in the Monitor heartbeat system (`MonitoredServer.identifier`). + +**Priority order:** + +1. **`config.identifier`** — if set in the plugin config (`harbor-forge.identifier`) +2. **`os.hostname()`** — auto-detected from the machine hostname (fallback) + +```typescript +// In plugin/calendar/calendar-bridge.ts +const clawIdentifier = baseConfig.identifier || hostname(); +``` + +--- + +## 2. How `agent_id` is Determined + +`agent_id` is the OpenClaw agent identifier (`$AGENT_ID`). + +- Set by OpenClaw at agent startup as an environment variable +- The plugin reads it via `process.env.AGENT_ID` +- Globally unique within a single OpenClaw gateway deployment + +```typescript +// In plugin/index.ts (caller) +const agentId = process.env.AGENT_ID || 'unknown'; +``` + +--- + +## 3. Heartbeat Request + +**Endpoint:** `GET /calendar/agent/heartbeat` + +### Headers + +| Header | Value | Notes | +|--------|-------|-------| +| `Content-Type` | `application/json` | Always set | +| `X-Agent-ID` | `$AGENT_ID` | OpenClaw agent identifier | +| `X-Claw-Identifier` | `claw_identifier` | Server identifier | + +### Request Body (JSON) + +```json +{ + "claw_identifier": "srv1390517", + "agent_id": "developer" +} +``` + +### Field Definitions + +| Field | Type | Source | Notes | +|-------|------|--------|-------| +| `claw_identifier` | string | Plugin config or `hostname()` | Identifies the OpenClaw server instance | +| `agent_id` | string | `process.env.AGENT_ID` | Identifies the agent session | + +--- + +## 4. Heartbeat Response + +### Success (HTTP 200) + +```json +{ + "slots": [ + { + "id": 42, + "virtual_id": null, + "user_id": 1, + "date": "2026-04-01", + "slot_type": "work", + "estimated_duration": 30, + "scheduled_at": "09:00:00", + "started_at": null, + "attended": false, + "actual_duration": null, + "event_type": "job", + "event_data": { + "type": "Task", + "code": "TASK-123" + }, + "priority": 50, + "status": "not_started", + "plan_id": null + } + ], + "agent_status": "idle", + "message": "2 slots pending" +} +``` + +### Field Definitions + +| Field | Type | Notes | +|-------|------|-------| +| `slots` | `CalendarSlotResponse[]` | Pending slots, sorted by `priority` DESC | +| `agent_status` | `AgentStatusValue` | Current backend-observed agent status | +| `message` | string (optional) | Human-readable summary | + +### `CalendarSlotResponse` Fields + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `number \| null` | Real slot DB id. `null` for virtual slots. | +| `virtual_id` | `string \| null` | `plan-{plan_id}-{date}`. `null` for real slots. | +| `user_id` | number | Owner HarborForge user id | +| `date` | string | ISO date `YYYY-MM-DD` | +| `slot_type` | `SlotType` | `work \| on_call \| entertainment \| system` | +| `estimated_duration` | number | Minutes (1-50) | +| `scheduled_at` | string | ISO time `HH:MM:SS` | +| `started_at` | `string \| null` | Actual start time when slot begins | +| `attended` | boolean | `true` once agent begins the slot | +| `actual_duration` | `number \| null` | Real minutes when slot finishes | +| `event_type` | `EventType \| null` | `job \| entertainment \| system_event` | +| `event_data` | object \| null | See §4a below | +| `priority` | number | 0-99, higher = more urgent | +| `status` | `SlotStatus` | `not_started \| deferred \| ...` | +| `plan_id` | `number \| null` | Source plan if materialized from SchedulePlan | + +### §4a — `event_data` Shapes + +**When `event_type == "job"`:** +```json +{ + "type": "Task", + "code": "TASK-42", + "working_sessions": ["session-id-1"] +} +``` + +**When `event_type == "system_event"`:** +```json +{ + "event": "ScheduleToday" +} +``` +Valid events: `ScheduleToday | SummaryToday | ScheduledGatewayRestart` + +--- + +## 5. Slot Update Requests (Plugin → Backend after execution) + +After attending / finishing / deferring a slot, the plugin calls: + +**Real slot:** `PATCH /calendar/slots/{slot_id}/agent-update` +**Virtual slot:** `PATCH /calendar/slots/virtual/{virtual_id}/agent-update` + +### Headers + +Same as heartbeat (see §3). + +### Request Body + +```json +{ + "status": "ongoing", + "started_at": "09:02:31", + "actual_duration": null +} +``` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `status` | `SlotStatus` | **Required** | New status after agent action | +| `started_at` | string | On attending | ISO time `HH:MM:SS` | +| `actual_duration` | number | On finishing | Real minutes | + +### Status Transition Values + +| Action | `status` value | +|--------|---------------| +| Agent begins slot | `ongoing` | +| Agent finishes slot | `finished` | +| Agent defers slot | `deferred` | +| Agent aborts slot | `aborted` | +| Agent pauses slot | `paused` | + +--- + +## 6. Backend Endpoint Summary + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/calendar/agent/heartbeat` | X-Agent-ID + X-Claw-Identifier | Fetch pending slots for today | +| PATCH | `/calendar/slots/{id}/agent-update` | X-Agent-ID + X-Claw-Identifier | Update real slot status | +| PATCH | `/calendar/slots/virtual/{vid}/agent-update` | X-Agent-ID + X-Claw-Identifier | Update virtual slot status | +| POST | `/calendar/agent/status` | X-Agent-ID + X-Claw-Identifier | Report agent status change | + +--- + +## 7. Error Handling + +- **Backend unreachable:** Plugin logs warning, returns `null` from heartbeat. + Agent continues to operate without Calendar integration. +- **Invalid credentials (401/403):** Logged as error. No retry on same interval. +- **Rate limiting (429):** Plugin should mark agent as `Exhausted` and not retry + until the `Retry-After` header indicates. + +--- + +## 8. TypeScript Reference + +Full type definitions are in `plugin/calendar/types.ts`: + +```typescript +// Request +interface CalendarHeartbeatRequest { + claw_identifier: string; + agent_id: string; +} + +// Response +interface CalendarHeartbeatResponse { + slots: CalendarSlotResponse[]; + agent_status: AgentStatusValue; + message?: string; +} + +// Slot update +interface SlotAgentUpdate { + status: SlotStatus; + started_at?: string; // ISO time HH:MM:SS + actual_duration?: number; +} +``` + +--- + +## 9. Implementation Files + +| File | Purpose | +|------|---------| +| `plugin/calendar/types.ts` | TypeScript interfaces for all request/response shapes | +| `plugin/calendar/calendar-bridge.ts` | `CalendarBridgeClient` HTTP client | +| `plugin/calendar/index.ts` | Module entry point | +| `docs/PLG-CAL-001-calendar-heartbeat-format.md` | This specification | diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..60eb846 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,17 @@ +{ + "name": "harbor-forge-openclaw-plugin", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "harbor-forge-openclaw-plugin", + "version": "0.2.0", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + } + } +} diff --git a/plugin/calendar/calendar-bridge.ts b/plugin/calendar/calendar-bridge.ts new file mode 100644 index 0000000..dca5cf5 --- /dev/null +++ b/plugin/calendar/calendar-bridge.ts @@ -0,0 +1,262 @@ +/** + * HarborForge Calendar Bridge Client + * + * PLG-CAL-001: Handles HTTP communication between the OpenClaw plugin + * and the HarborForge backend for Calendar heartbeat and slot updates. + * + * Request authentication: + * • X-Agent-ID header — set to process.env.AGENT_ID + * • X-Claw-Identifier header — set to the server's claw_identifier + * (from plugin config or hostname fallback) + * + * Base URL: + * Derived from plugin config: backendUrl + "/calendar" + * Default backendUrl: "https://monitor.hangman-lab.top" + * + * Endpoints used: + * POST /calendar/agent/heartbeat — fetch pending slots + * PATCH /calendar/slots/{id}/agent-update — update real slot status + * PATCH /calendar/slots/virtual/{vid}/agent-update — update virtual slot status + * + * References: + * • NEXT_WAVE_DEV_DIRECTION.md §6.1 (Heartbeat flow) + * • HarborForge.Backend/app/services/agent_heartbeat.py (BE-AGT-001) + */ + +import { + CalendarHeartbeatRequest, + CalendarHeartbeatResponse, + CalendarSlotResponse, + SlotAgentUpdate, + SlotStatus, +} from './types'; + +export interface CalendarBridgeConfig { + /** HarborForge backend base URL (e.g. "https://monitor.hangman-lab.top") */ + backendUrl: string; + /** Server/claw identifier (from plugin config or hostname fallback) */ + clawIdentifier: string; + /** OpenClaw agent ID ($AGENT_ID), set at agent startup */ + agentId: string; + /** HTTP request timeout in milliseconds (default: 5000) */ + timeoutMs?: number; +} + +export class CalendarBridgeClient { + private baseUrl: string; + private config: Required; + private timeoutMs: number; + + constructor(config: CalendarBridgeConfig) { + this.baseUrl = config.backendUrl.replace(/\/$/, ''); // strip trailing slash + this.config = { + timeoutMs: 5000, + ...config, + }; + this.timeoutMs = this.config.timeoutMs; + } + + /** + * Fetch today's pending calendar slots for this agent. + * + * Heartbeat flow (§6.1): + * 1. Plugin sends heartbeat every minute + * 2. Backend returns slots where status is NotStarted or Deferred + * AND scheduled_at <= now + * 3. Plugin selects highest-priority slot (if any) + * 4. For remaining slots, plugin sets status = Deferred + priority += 1 + * + * @returns CalendarHeartbeatResponse or null if the backend is unreachable + */ + async heartbeat(): Promise { + const url = `${this.baseUrl}/calendar/agent/heartbeat`; + const body: CalendarHeartbeatRequest = { + claw_identifier: this.config.clawIdentifier, + agent_id: this.config.agentId, + }; + + try { + const response = await this.fetchJson(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Agent-ID': this.config.agentId, + 'X-Claw-Identifier': this.config.clawIdentifier, + }, + body: JSON.stringify(body), + }); + return response; + } catch (err) { + // Non-fatal: backend unreachable — return null, plugin continues + return null; + } + } + + /** + * Update a real (materialized) slot's status after agent execution. + * + * Used by the plugin to report: + * - Slot attended (attended=true, started_at=now, status=Ongoing) + * - Slot finished (actual_duration set, status=Finished) + * - Slot deferred (status=Deferred, priority += 1) + * - Slot aborted (status=Aborted) + * + * @param slotId Real slot DB id + * @param update Status update payload + * @returns true on success, false on failure + */ + async updateSlot(slotId: number, update: SlotAgentUpdate): Promise { + const url = `${this.baseUrl}/calendar/slots/${slotId}/agent-update`; + return this.sendBoolean('PATCH', url, update); + } + + /** + * Update a virtual (plan-generated) slot's status after agent execution. + * + * When updating a virtual slot, the backend first materializes it + * (creates a real TimeSlot row), then applies the update. + * The returned slot will have a real id on subsequent calls. + * + * @param virtualId Virtual slot id in format "plan-{plan_id}-{date}" + * @param update Status update payload + * @returns Updated CalendarSlotResponse on success, null on failure + */ + async updateVirtualSlot( + virtualId: string, + update: SlotAgentUpdate + ): Promise { + const url = `${this.baseUrl}/calendar/slots/virtual/${encodeURIComponent(virtualId)}/agent-update`; + try { + const response = await this.fetchJson<{ slot: CalendarSlotResponse }>(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-Agent-ID': this.config.agentId, + 'X-Claw-Identifier': this.config.clawIdentifier, + }, + body: JSON.stringify(update), + }); + return response?.slot ?? null; + } catch { + return null; + } + } + + /** + * Report the agent's current runtime status to HarborForge. + * + * Used to push agent status transitions: + * idle → busy / on_call (when starting a slot) + * busy / on_call → idle (when finishing a slot) + * → exhausted (on rate-limit / billing error, with recovery_at) + * → offline (after 2 min with no heartbeat) + * + * @param status New agent status + * @param recoveryAt ISO timestamp for expected Exhausted recovery (optional) + * @param exhaustReason "rate_limit" | "billing" (required if status=exhausted) + */ + async reportAgentStatus(params: { + status: 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline'; + recoveryAt?: string; + exhaustReason?: 'rate_limit' | 'billing'; + }): Promise { + const url = `${this.baseUrl}/calendar/agent/status`; + const body = { + agent_id: this.config.agentId, + claw_identifier: this.config.clawIdentifier, + ...params, + }; + return this.sendBoolean('POST', url, body); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private async fetchJson( + url: string, + init: RequestInit + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + clearTimeout(timeout); + + if (!response.ok) return null; + return (await response.json()) as T; + } catch { + clearTimeout(timeout); + return null; + } + } + + private async sendBoolean(method: 'POST' | 'PATCH', url: string, body: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Agent-ID': this.config.agentId, + 'X-Claw-Identifier': this.config.clawIdentifier, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + clearTimeout(timeout); + return response.ok; + } catch { + clearTimeout(timeout); + return false; + } + } +} + +// --------------------------------------------------------------------------- +// Utility: build CalendarBridgeConfig from plugin API context +// --------------------------------------------------------------------------- + +import { hostname } from 'os'; +import { getPluginConfig } from '../core/config'; + +export interface CalendarPluginConfig { + /** Backend URL for calendar API (overrides monitor backendUrl) */ + calendarBackendUrl?: string; + /** Server identifier (overrides auto-detected hostname) */ + identifier?: string; + /** Agent ID from OpenClaw ($AGENT_ID) */ + agentId: string; + /** HTTP timeout for calendar API calls (default: 5000) */ + timeoutMs?: number; +} + +/** + * Build a CalendarBridgeClient from the OpenClaw plugin API context. + * + * @param api OpenClaw plugin API (register() receives this) + * @param fallbackUrl Fallback backend URL if not configured + * @param agentId $AGENT_ID from OpenClaw environment + */ +export function createCalendarBridgeClient( + api: { config?: Record; logger?: { debug?: (...args: unknown[]) => void } }, + fallbackUrl: string, + agentId: string +): CalendarBridgeClient { + const baseConfig = getPluginConfig(api as any); + + const clawIdentifier = baseConfig.identifier || hostname(); + + return new CalendarBridgeClient({ + backendUrl: baseConfig.backendUrl || fallbackUrl, + clawIdentifier, + agentId, + timeoutMs: 5000, + }); +} diff --git a/plugin/calendar/index.ts b/plugin/calendar/index.ts new file mode 100644 index 0000000..8d2bed7 --- /dev/null +++ b/plugin/calendar/index.ts @@ -0,0 +1,33 @@ +/** + * HarborForge Calendar — Plugin Module + * + * PLG-CAL-001: Calendar heartbeat request/response format definition. + * PLG-CAL-002: Plugin-side slot execution scheduler and agent wakeup. + * + * Exports: + * • Types for heartbeat request/response and slot update + * • CalendarBridgeClient — HTTP client for backend communication + * • createCalendarBridgeClient — factory from plugin API context + * • CalendarScheduler — manages periodic heartbeat and slot execution + * • createCalendarScheduler — factory for scheduler + * • AgentWakeContext — context passed to agent when waking + * + * Usage in plugin/index.ts: + * import { createCalendarBridgeClient, createCalendarScheduler } from './calendar'; + * + * const agentId = process.env.AGENT_ID || 'unknown'; + * const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId); + * + * const scheduler = createCalendarScheduler({ + * bridge: calendar, + * getAgentStatus: async () => { ... }, + * wakeAgent: async (context) => { ... }, + * logger: api.logger, + * }); + * + * scheduler.start(); + */ + +export * from './types'; +export * from './calendar-bridge'; +export * from './scheduler'; diff --git a/plugin/calendar/scheduler.ts b/plugin/calendar/scheduler.ts new file mode 100644 index 0000000..bb0675f --- /dev/null +++ b/plugin/calendar/scheduler.ts @@ -0,0 +1,953 @@ +/** + * HarborForge Calendar Scheduler + * + * PLG-CAL-002: Plugin-side handling for pending slot execution. + * PLG-CAL-004: ScheduledGatewayRestart event handling with state persistence. + * + * Responsibilities: + * - Run calendar heartbeat every minute + * - Detect when agent is Idle and slots are pending + * - Wake agent with task context + * - Handle slot status transitions (attended, ongoing, deferred) + * - Manage agent status transitions (idle → busy/on_call) + * - Persist state on ScheduledGatewayRestart and restore on startup + * - Send final heartbeat before graceful shutdown + * + * Design reference: NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism) + */ + +import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { CalendarBridgeClient } from './calendar-bridge'; +import { + CalendarSlotResponse, + SlotStatus, + AgentStatusValue, + SlotAgentUpdate, + CalendarEventDataJob, + CalendarEventDataSystemEvent, +} from './types'; + +export interface CalendarSchedulerConfig { + /** Calendar bridge client for backend communication */ + bridge: CalendarBridgeClient; + /** Function to get current agent status from backend */ + getAgentStatus: () => Promise; + /** Function to wake/spawn agent with task context */ + wakeAgent: (context: AgentWakeContext) => Promise; + /** Logger instance */ + logger: { + info: (...args: any[]) => void; + error: (...args: any[]) => void; + debug: (...args: any[]) => void; + warn: (...args: any[]) => void; + }; + /** Heartbeat interval in milliseconds (default: 60000) */ + heartbeatIntervalMs?: number; + /** Enable verbose debug logging */ + debug?: boolean; + /** Directory for state persistence (default: plugin data dir) */ + stateDir?: string; +} + +/** + * Context passed to agent when waking for slot execution. + * This is the payload the agent receives to understand what to do. + */ +export interface AgentWakeContext { + /** The slot to execute */ + slot: CalendarSlotResponse; + /** Human-readable task description */ + taskDescription: string; + /** Prompt/instructions for the agent */ + prompt: string; + /** Whether this is a virtual slot (needs materialization) */ + isVirtual: boolean; +} + +/** + * Persisted state structure for recovery after restart. + */ +interface PersistedState { + /** Version for migration compatibility */ + version: number; + /** When the state was persisted */ + persistedAt: string; + /** Reason for persistence (e.g., 'ScheduledGatewayRestart') */ + reason: string; + /** The slot that was being executed when persisted */ + currentSlot: CalendarSlotResponse | null; + /** Deferred slot IDs at persistence time */ + deferredSlotIds: string[]; + /** Whether a slot was in progress */ + isProcessing: boolean; + /** Agent status at persistence time */ + agentStatus: AgentStatusValue | null; +} + +/** + * Current execution state tracked by the scheduler. + */ +interface SchedulerState { + /** Whether scheduler is currently running */ + isRunning: boolean; + /** Currently executing slot (null if idle) */ + currentSlot: CalendarSlotResponse | null; + /** Last heartbeat timestamp */ + lastHeartbeatAt: Date | null; + /** Interval handle for cleanup */ + intervalHandle: ReturnType | null; + /** Set of slot IDs that have been deferred in current session */ + deferredSlotIds: Set; + /** Whether agent is currently processing a slot */ + isProcessing: boolean; + /** Whether a gateway restart is scheduled/pending */ + isRestartPending: boolean; +} + +/** State file name */ +const STATE_FILENAME = 'calendar-scheduler-state.json'; +/** State file version for migration compatibility */ +const STATE_VERSION = 1; + +/** + * CalendarScheduler manages the periodic heartbeat and slot execution lifecycle. + */ +export class CalendarScheduler { + private config: Required; + private state: SchedulerState; + private stateFilePath: string; + + constructor(config: CalendarSchedulerConfig) { + this.config = { + heartbeatIntervalMs: 60000, // 1 minute default + debug: false, + stateDir: this.getDefaultStateDir(), + ...config, + }; + + this.stateFilePath = join(this.config.stateDir, STATE_FILENAME); + + this.state = { + isRunning: false, + currentSlot: null, + lastHeartbeatAt: null, + intervalHandle: null, + deferredSlotIds: new Set(), + isProcessing: false, + isRestartPending: false, + }; + + // Attempt to restore state from previous persistence + this.restoreState(); + } + + /** + * Get default state directory (plugin data directory or temp fallback). + */ + private getDefaultStateDir(): string { + // Try to use the plugin's directory or a standard data location + const candidates = [ + process.env.OPENCLAW_PLUGIN_DATA_DIR, + process.env.HARBORFORGE_PLUGIN_DIR, + join(process.cwd(), '.harborforge'), + join(process.cwd(), 'data'), + '/tmp/harborforge', + ]; + + for (const dir of candidates) { + if (dir) { + try { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + // Test write access + const testFile = join(dir, '.write-test'); + writeFileSync(testFile, '', { flag: 'w' }); + return dir; + } catch { + continue; + } + } + } + + // Fallback to current working directory + return process.cwd(); + } + + /** + * Persist current state to disk for recovery after restart. + */ + private persistState(reason: string): void { + try { + const persistedState: PersistedState = { + version: STATE_VERSION, + persistedAt: new Date().toISOString(), + reason, + currentSlot: this.state.currentSlot, + deferredSlotIds: Array.from(this.state.deferredSlotIds), + isProcessing: this.state.isProcessing, + agentStatus: null, // Will be determined at restore time + }; + + writeFileSync(this.stateFilePath, JSON.stringify(persistedState, null, 2)); + this.config.logger.info(`[PLG-CAL-004] State persisted to ${this.stateFilePath} (reason: ${reason})`); + } catch (err) { + this.config.logger.error('[PLG-CAL-004] Failed to persist state:', err); + } + } + + /** + * Restore state from disk if available. + */ + private restoreState(): void { + try { + if (!existsSync(this.stateFilePath)) { + return; + } + + const data = readFileSync(this.stateFilePath, 'utf-8'); + const persisted: PersistedState = JSON.parse(data); + + // Validate version + if (persisted.version !== STATE_VERSION) { + this.config.logger.warn(`[PLG-CAL-004] State version mismatch: ${persisted.version} vs ${STATE_VERSION}`); + this.clearPersistedState(); + return; + } + + // Restore deferred slot IDs + if (persisted.deferredSlotIds && persisted.deferredSlotIds.length > 0) { + this.state.deferredSlotIds = new Set(persisted.deferredSlotIds); + this.config.logger.info(`[PLG-CAL-004] Restored ${persisted.deferredSlotIds.length} deferred slot(s)`); + } + + // If there was a slot in progress, mark it for replanning + if (persisted.isProcessing && persisted.currentSlot) { + this.config.logger.warn( + `[PLG-CAL-004] Previous session had in-progress slot: ${this.getSlotId(persisted.currentSlot)}` + ); + // The slot will be picked up by the next heartbeat and can be resumed or deferred + } + + this.config.logger.info(`[PLG-CAL-004] State restored from ${persisted.persistedAt} (reason: ${persisted.reason})`); + + // Clear the persisted state after successful restore + this.clearPersistedState(); + } catch (err) { + this.config.logger.error('[PLG-CAL-004] Failed to restore state:', err); + } + } + + /** + * Clear persisted state file after successful restore. + */ + private clearPersistedState(): void { + try { + if (existsSync(this.stateFilePath)) { + // In a real implementation, we might want to archive instead of delete + // For now, we'll just clear the content to mark as processed + writeFileSync(this.stateFilePath, JSON.stringify({ restored: true, at: new Date().toISOString() })); + } + } catch (err) { + this.config.logger.error('[PLG-CAL-004] Failed to clear persisted state:', err); + } + } + + /** + * Send a final heartbeat to the backend before shutdown. + */ + private async sendFinalHeartbeat(reason: string): Promise { + try { + this.config.logger.info(`[PLG-CAL-004] Sending final heartbeat (reason: ${reason})`); + + // Send agent status update indicating we're going offline + await this.config.bridge.reportAgentStatus({ status: 'offline' }); + + this.config.logger.info('[PLG-CAL-004] Final heartbeat sent successfully'); + } catch (err) { + this.config.logger.error('[PLG-CAL-004] Failed to send final heartbeat:', err); + } + } + + /** + * Handle ScheduledGatewayRestart event. + * PLG-CAL-004: Persist state, send final heartbeat, pause scheduled tasks. + */ + private async handleScheduledGatewayRestart(slot: CalendarSlotResponse): Promise { + this.config.logger.info('[PLG-CAL-004] Handling ScheduledGatewayRestart event'); + + // 1. Mark restart as pending to prevent new slot processing + this.state.isRestartPending = true; + + // 2. Persist current state + this.persistState('ScheduledGatewayRestart'); + + // 3. If there's a current slot, pause it gracefully + if (this.state.isProcessing && this.state.currentSlot) { + this.config.logger.info('[PLG-CAL-004] Pausing current slot before restart'); + await this.pauseCurrentSlot(); + } + + // 4. Send final heartbeat + await this.sendFinalHeartbeat('ScheduledGatewayRestart'); + + // 5. Stop the scheduler (pause scheduled tasks) + this.config.logger.info('[PLG-CAL-004] Stopping scheduler due to gateway restart'); + this.stop(); + + // 6. Mark the slot as finished (since we've handled the restart) + const update: SlotAgentUpdate = { + status: SlotStatus.FINISHED, + actual_duration: 0, // Restart preparation doesn't take time + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + } catch (err) { + this.config.logger.error('[PLG-CAL-004] Failed to mark restart slot as finished:', err); + } + } + + /** + * Start the calendar scheduler. + * Begins periodic heartbeat to check for pending slots. + */ + start(): void { + if (this.state.isRunning) { + this.config.logger.warn('Calendar scheduler already running'); + return; + } + + this.state.isRunning = true; + this.state.isRestartPending = false; + this.config.logger.info('Calendar scheduler started'); + + // Run initial heartbeat immediately + this.runHeartbeat(); + + // Schedule periodic heartbeats + this.state.intervalHandle = setInterval( + () => this.runHeartbeat(), + this.config.heartbeatIntervalMs + ); + } + + /** + * Stop the calendar scheduler. + * Cleans up intervals and resets state. + */ + stop(): void { + this.state.isRunning = false; + + if (this.state.intervalHandle) { + clearInterval(this.state.intervalHandle); + this.state.intervalHandle = null; + } + + this.config.logger.info('Calendar scheduler stopped'); + } + + /** + * Execute a single heartbeat cycle. + * Fetches pending slots and handles execution logic. + */ + async runHeartbeat(): Promise { + if (!this.state.isRunning) { + return; + } + + // Skip heartbeat if restart is pending + if (this.state.isRestartPending) { + this.logDebug('Heartbeat skipped: gateway restart pending'); + return; + } + + this.state.lastHeartbeatAt = new Date(); + + try { + // Fetch pending slots from backend + const response = await this.config.bridge.heartbeat(); + + if (!response) { + this.logDebug('Heartbeat: backend unreachable'); + return; + } + + this.logDebug( + `Heartbeat: ${response.slots.length} slots pending, agent_status=${response.agent_status}` + ); + + // If agent is not idle, defer all pending slots + if (response.agent_status !== 'idle') { + await this.handleNonIdleAgent(response.slots, response.agent_status); + return; + } + + // Agent is idle again - previously deferred slots should become eligible + // for selection in the next planning pass. + if (this.state.deferredSlotIds.size > 0) { + this.logDebug( + `Agent returned to idle; clearing ${this.state.deferredSlotIds.size} deferred slot marker(s) for replanning` + ); + this.state.deferredSlotIds.clear(); + } + + // Agent is idle - handle pending slots + await this.handleIdleAgent(response.slots); + } catch (err) { + this.config.logger.error('Heartbeat error:', err); + } + } + + /** + * Handle slots when agent is not idle. + * Defer all pending slots with priority boost. + */ + private async handleNonIdleAgent( + slots: CalendarSlotResponse[], + agentStatus: AgentStatusValue + ): Promise { + if (slots.length === 0) { + return; + } + + this.config.logger.info( + `Agent not idle (status=${agentStatus}), deferring ${slots.length} slot(s)` + ); + + for (const slot of slots) { + const slotId = this.getSlotId(slot); + + // Skip if already deferred this session + if (this.state.deferredSlotIds.has(slotId)) { + continue; + } + + // Mark slot as deferred with priority boost (+1) + await this.deferSlot(slot); + this.state.deferredSlotIds.add(slotId); + } + } + + /** + * Handle slots when agent is idle. + * Select highest priority slot and wake agent. + */ + private async handleIdleAgent(slots: CalendarSlotResponse[]): Promise { + if (slots.length === 0) { + return; + } + + // Filter out already deferred slots in this session + const eligibleSlots = slots.filter( + (s) => !this.state.deferredSlotIds.has(this.getSlotId(s)) + ); + + if (eligibleSlots.length === 0) { + this.logDebug('All pending slots have been deferred this session'); + return; + } + + // Select highest priority slot (backend already sorts by priority DESC) + const [selectedSlot, ...remainingSlots] = eligibleSlots; + + this.config.logger.info( + `Selected slot for execution: id=${this.getSlotId(selectedSlot)}, ` + + `type=${selectedSlot.slot_type}, priority=${selectedSlot.priority}` + ); + + // Mark remaining slots as deferred + for (const slot of remainingSlots) { + await this.deferSlot(slot); + this.state.deferredSlotIds.add(this.getSlotId(slot)); + } + + // Check if this is a ScheduledGatewayRestart event + if (this.isScheduledGatewayRestart(selectedSlot)) { + await this.handleScheduledGatewayRestart(selectedSlot); + return; + } + + // Wake agent to execute selected slot + await this.executeSlot(selectedSlot); + } + + /** + * Check if a slot is a ScheduledGatewayRestart system event. + */ + private isScheduledGatewayRestart(slot: CalendarSlotResponse): boolean { + if (slot.event_type !== 'system_event' || !slot.event_data) { + return false; + } + const sysData = slot.event_data as CalendarEventDataSystemEvent; + return sysData.event === 'ScheduledGatewayRestart'; + } + + /** + * Execute a slot by waking the agent. + */ + private async executeSlot(slot: CalendarSlotResponse): Promise { + if (this.state.isProcessing) { + this.config.logger.warn('Already processing a slot, deferring new slot'); + await this.deferSlot(slot); + return; + } + + this.state.isProcessing = true; + this.state.currentSlot = slot; + + try { + // Mark slot as attended and ongoing before waking agent + const update: SlotAgentUpdate = { + status: SlotStatus.ONGOING, + started_at: this.formatTime(new Date()), + }; + + let updateSuccess: boolean; + if (slot.id) { + updateSuccess = await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + const updated = await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + updateSuccess = updated !== null; + // Update slot reference if materialized + if (updated) { + this.state.currentSlot = updated; + } + } else { + updateSuccess = false; + } + + if (!updateSuccess) { + this.config.logger.error('Failed to update slot status before execution'); + this.state.isProcessing = false; + this.state.currentSlot = null; + return; + } + + // Report agent status change to backend + const newAgentStatus = slot.slot_type === 'on_call' ? 'on_call' : 'busy'; + await this.config.bridge.reportAgentStatus({ status: newAgentStatus }); + + // Build wake context for agent + const wakeContext = this.buildWakeContext(slot); + + // Wake the agent + const wakeSuccess = await this.config.wakeAgent(wakeContext); + + if (!wakeSuccess) { + this.config.logger.error('Failed to wake agent for slot execution'); + // Revert slot to not_started status + await this.revertSlot(slot); + await this.config.bridge.reportAgentStatus({ status: 'idle' }); + this.state.isProcessing = false; + this.state.currentSlot = null; + await this.triggerReplan('wake failure'); + return; + } + + // Note: isProcessing remains true until agent signals completion + // This is handled by external completion callback + } catch (err) { + this.config.logger.error('Error executing slot:', err); + this.state.isProcessing = false; + this.state.currentSlot = null; + } + } + + /** + * Build the wake context for an agent based on slot details. + */ + private buildWakeContext(slot: CalendarSlotResponse): AgentWakeContext { + const isVirtual = slot.virtual_id !== null; + const slotId = this.getSlotId(slot); + + // Build task description based on event type + let taskDescription: string; + let prompt: string; + + if (slot.event_type === 'job' && slot.event_data) { + const jobData = slot.event_data as CalendarEventDataJob; + taskDescription = `${jobData.type} ${jobData.code}`; + prompt = this.buildJobPrompt(slot, jobData); + } else if (slot.event_type === 'system_event' && slot.event_data) { + const sysData = slot.event_data as CalendarEventDataSystemEvent; + taskDescription = `System Event: ${sysData.event}`; + prompt = this.buildSystemPrompt(slot, sysData); + } else if (slot.event_type === 'entertainment') { + taskDescription = 'Entertainment slot'; + prompt = this.buildEntertainmentPrompt(slot); + } else { + taskDescription = `Generic ${slot.slot_type} slot`; + prompt = this.buildGenericPrompt(slot); + } + + return { + slot, + taskDescription, + prompt, + isVirtual, + }; + } + + /** + * Build prompt for job-type slots. + */ + private buildJobPrompt( + slot: CalendarSlotResponse, + jobData: CalendarEventDataJob + ): string { + const duration = slot.estimated_duration; + const type = jobData.type; + const code = jobData.code; + + return `You have a scheduled ${type} job to work on. + +Task Code: ${code} +Estimated Duration: ${duration} minutes +Slot Type: ${slot.slot_type} +Priority: ${slot.priority} + +Please focus on this task for the allocated time. When you finish or need to pause, +report your progress back to the calendar system. + +Working sessions: ${jobData.working_sessions?.join(', ') || 'none recorded'} + +Start working on ${code} now.`; + } + + /** + * Build prompt for system event slots. + */ + private buildSystemPrompt( + slot: CalendarSlotResponse, + sysData: CalendarEventDataSystemEvent + ): string { + switch (sysData.event) { + case 'ScheduleToday': + return `System Event: Schedule Today + +Please review today's calendar and schedule any pending tasks or planning activities. +Estimated time: ${slot.estimated_duration} minutes. + +Check your calendar and plan the day's work.`; + + case 'SummaryToday': + return `System Event: Daily Summary + +Please provide a summary of today's activities and progress. +Estimated time: ${slot.estimated_duration} minutes. + +Review what was accomplished and prepare end-of-day notes.`; + + case 'ScheduledGatewayRestart': + return `System Event: Scheduled Gateway Restart + +The OpenClaw gateway is scheduled to restart soon. +Please: +1. Persist any important state +2. Complete or gracefully pause current tasks +3. Prepare for restart + +Time remaining: ${slot.estimated_duration} minutes.`; + + default: + return `System Event: ${sysData.event} + +A system event has been scheduled. Please handle accordingly. +Estimated time: ${slot.estimated_duration} minutes.`; + } + } + + /** + * Build prompt for entertainment slots. + */ + private buildEntertainmentPrompt(slot: CalendarSlotResponse): string { + return `Scheduled Entertainment Break + +Duration: ${slot.estimated_duration} minutes + +Take a break and enjoy some leisure time. This slot is reserved for non-work activities + to help maintain work-life balance.`; + } + + /** + * Build generic prompt for slots without specific event data. + */ + private buildGenericPrompt(slot: CalendarSlotResponse): string { + return `Scheduled Calendar Slot + +Type: ${slot.slot_type} +Duration: ${slot.estimated_duration} minutes +Priority: ${slot.priority} + +Please use this time for the scheduled activity.`; + } + + /** + * Mark a slot as deferred with priority boost. + */ + private async deferSlot(slot: CalendarSlotResponse): Promise { + const update: SlotAgentUpdate = { + status: SlotStatus.DEFERRED, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + this.logDebug(`Deferred slot: ${this.getSlotId(slot)}`); + } catch (err) { + this.config.logger.error('Failed to defer slot:', err); + } + } + + /** + * Revert a slot to not_started status after failed execution attempt. + */ + private async revertSlot(slot: CalendarSlotResponse): Promise { + const update: SlotAgentUpdate = { + status: SlotStatus.NOT_STARTED, + started_at: undefined, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + } catch (err) { + this.config.logger.error('Failed to revert slot:', err); + } + } + + /** + * Complete the current slot execution. + * Call this when the agent finishes the task. + */ + async completeCurrentSlot(actualDurationMinutes: number): Promise { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to complete'); + return; + } + + const slot = this.state.currentSlot; + const update: SlotAgentUpdate = { + status: SlotStatus.FINISHED, + actual_duration: actualDurationMinutes, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + + // Report agent back to idle + await this.config.bridge.reportAgentStatus({ status: 'idle' }); + + this.config.logger.info( + `Completed slot ${this.getSlotId(slot)}, actual_duration=${actualDurationMinutes}min` + ); + } catch (err) { + this.config.logger.error('Failed to complete slot:', err); + } finally { + this.state.isProcessing = false; + this.state.currentSlot = null; + await this.triggerReplan('slot completion'); + } + } + + /** + * Abort the current slot execution. + * Call this when the agent cannot complete the task. + */ + async abortCurrentSlot(reason?: string): Promise { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to abort'); + return; + } + + const slot = this.state.currentSlot; + const update: SlotAgentUpdate = { + status: SlotStatus.ABORTED, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + + // Report agent back to idle + await this.config.bridge.reportAgentStatus({ status: 'idle' }); + + this.config.logger.info( + `Aborted slot ${this.getSlotId(slot)}${reason ? `: ${reason}` : ''}` + ); + } catch (err) { + this.config.logger.error('Failed to abort slot:', err); + } finally { + this.state.isProcessing = false; + this.state.currentSlot = null; + await this.triggerReplan('slot abort'); + } + } + + /** + * Pause the current slot execution. + * Call this when the agent needs to temporarily pause. + */ + async pauseCurrentSlot(): Promise { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to pause'); + return; + } + + const slot = this.state.currentSlot; + const update: SlotAgentUpdate = { + status: SlotStatus.PAUSED, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + + this.config.logger.info(`Paused slot ${this.getSlotId(slot)}`); + } catch (err) { + this.config.logger.error('Failed to pause slot:', err); + } + } + + /** + * Resume a paused slot. + */ + async resumeCurrentSlot(): Promise { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to resume'); + return; + } + + const slot = this.state.currentSlot; + const update: SlotAgentUpdate = { + status: SlotStatus.ONGOING, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + + this.config.logger.info(`Resumed slot ${this.getSlotId(slot)}`); + } catch (err) { + this.config.logger.error('Failed to resume slot:', err); + } + } + + /** + * Trigger an immediate replanning pass after the current slot lifecycle ends. + * This lets previously deferred/not-started slots compete again as soon as + * the agent becomes idle. + */ + private async triggerReplan(reason: string): Promise { + if (!this.state.isRunning) { + return; + } + + this.logDebug(`Triggering immediate replanning after ${reason}`); + + try { + await this.runHeartbeat(); + } catch (err) { + this.config.logger.error(`Failed to trigger replanning after ${reason}:`, err); + } + } + + /** + * Get a stable ID for a slot (real or virtual). + */ + private getSlotId(slot: CalendarSlotResponse): string { + return slot.id?.toString() || slot.virtual_id || 'unknown'; + } + + /** + * Format a Date as ISO time string (HH:MM:SS). + */ + private formatTime(date: Date): string { + return date.toTimeString().split(' ')[0]; + } + + /** + * Debug logging helper. + */ + private logDebug(message: string): void { + if (this.config.debug) { + this.config.logger.debug(`[CalendarScheduler] ${message}`); + } + } + + /** + * Get current scheduler state (for introspection). + */ + getState(): Readonly { + return { ...this.state }; + } + + /** + * Check if scheduler is running. + */ + isRunning(): boolean { + return this.state.isRunning; + } + + /** + * Check if currently processing a slot. + */ + isProcessing(): boolean { + return this.state.isProcessing; + } + + /** + * Get the current slot being executed (if any). + */ + getCurrentSlot(): CalendarSlotResponse | null { + return this.state.currentSlot; + } + + /** + * Check if a gateway restart is pending. + */ + isRestartPending(): boolean { + return this.state.isRestartPending; + } + + /** + * Get the path to the state file. + */ + getStateFilePath(): string { + return this.stateFilePath; + } +} + +/** + * Factory function to create a CalendarScheduler from plugin context. + */ +export function createCalendarScheduler( + config: CalendarSchedulerConfig +): CalendarScheduler { + return new CalendarScheduler(config); +} diff --git a/plugin/calendar/types.ts b/plugin/calendar/types.ts new file mode 100644 index 0000000..90f424e --- /dev/null +++ b/plugin/calendar/types.ts @@ -0,0 +1,198 @@ +/** + * HarborForge Calendar — Plugin-side type definitions + * + * PLG-CAL-001: Define the Calendar heartbeat request/response format + * between the OpenClaw plugin and HarborForge backend. + * + * Request flow (plugin → backend): + * POST /calendar/agent/heartbeat + * Headers: + * X-Agent-ID: — OpenClaw $AGENT_ID of the calling agent + * X-Claw-Identifier: — HarborForge server identifier + * Body (JSON): + * { "claw_identifier": "...", "agent_id": "..." } + * + * Response flow (backend → plugin): + * Returns list of TimeSlots pending execution for today. + * The plugin uses slot.id / slot.virtual_id to update slot status + * via subsequent API calls. + * + * References: + * • NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism) + * • HarborForge.Backend/app/models/calendar.py — TimeSlot DB model + * • HarborForge.Backend/app/schemas/calendar.py — TimeSlot schemas + * • HarborForge.Backend/app/services/agent_heartbeat.py — BE-AGT-001 + */ + +// --------------------------------------------------------------------------- +// Enums (mirror backend enums) +// --------------------------------------------------------------------------- + +/** Slot type — mirrors backend SlotType enum */ +export enum SlotType { + WORK = 'work', + ON_CALL = 'on_call', + ENTERTAINMENT = 'entertainment', + SYSTEM = 'system', +} + +/** Slot lifecycle status — mirrors backend SlotStatus enum */ +export enum SlotStatus { + NOT_STARTED = 'not_started', + ONGOING = 'ongoing', + DEFERRED = 'deferred', + SKIPPED = 'skipped', + PAUSED = 'paused', + FINISHED = 'finished', + ABORTED = 'aborted', +} + +/** High-level event category — mirrors backend EventType enum */ +export enum EventType { + JOB = 'job', + ENTERTAINMENT = 'entertainment', + SYSTEM_EVENT = 'system_event', +} + +// --------------------------------------------------------------------------- +// Request types +// --------------------------------------------------------------------------- + +/** + * Calendar heartbeat request body sent by the plugin to HarborForge backend. + * + * How claw_identifier is determined: + * 1. Read from plugin config: `config.backendUrl` is the base URL. + * 2. If not set, fall back to `os.hostname()` (plugin machine hostname). + * + * How agent_id is determined: + * - Read from OpenClaw environment variable: `process.env.AGENT_ID` + * - This is set by OpenClaw at agent startup and uniquely identifies + * the running agent instance within a single OpenClaw gateway. + */ +export interface CalendarHeartbeatRequest { + /** HarborForge server/claw identifier (matches MonitoredServer.identifier) */ + claw_identifier: string; + /** OpenClaw agent ID ($AGENT_ID) for this agent session */ + agent_id: string; +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +/** + * A single calendar slot returned in the heartbeat response. + * + * For **real** (materialized) slots: `id` is set, `virtual_id` is null. + * For **virtual** (plan-generated) slots: `id` is null, `virtual_id` + * is the `plan-{plan_id}-{date}` identifier. + * + * Key fields the plugin uses: + * - `id` / `virtual_id` — to update slot status after execution + * - `event_type` — to determine what action to take + * - `event_data` — job details / system event type + * - `slot_type` — work vs on_call (affects agent status transition) + * - `scheduled_at` — planned start time (HH:MM:SS) + * - `estimated_duration` — expected minutes (for time-tracking) + * - `priority` — for multi-slot competition logic + * - `status` — current status (NotStarted / Deferred) + */ +export interface CalendarSlotResponse { + /** Real slot DB id. Null for virtual slots. */ + id: number | null; + /** Virtual slot id (plan-{plan_id}-{date}). Null for real slots. */ + virtual_id: string | null; + /** Owner user id */ + user_id: number; + /** Calendar date */ + date: string; // ISO date string: "YYYY-MM-DD" + /** Slot type */ + slot_type: SlotType; + /** Estimated duration in minutes (1-50) */ + estimated_duration: number; + /** Planned start time (ISO time string: "HH:MM:SS") */ + scheduled_at: string; + /** Actual start time, set when slot begins (null until started) */ + started_at: string | null; + /** Whether the slot has been attended */ + attended: boolean; + /** Actual duration in minutes (set when slot finishes) */ + actual_duration: number | null; + /** Event category */ + event_type: EventType | null; + /** Event details JSON — structure depends on event_type (see below) */ + event_data: CalendarEventData | null; + /** Priority 0-99, higher = more urgent */ + priority: number; + /** Current lifecycle status */ + status: SlotStatus; + /** Source plan id if materialized from a SchedulePlan; null otherwise */ + plan_id: number | null; +} + +/** + * Event data stored inside CalendarSlotResponse.event_data. + * The shape depends on event_type. + * + * When event_type == "job": + * { "type": "Task|Support|Meeting|Essential", "code": "TASK-42", "working_sessions": ["..."] } + * + * When event_type == "system_event": + * { "event": "ScheduleToday|SummaryToday|ScheduledGatewayRestart" } + * + * When event_type == "entertainment": + * { /* TBD /\ } + */ +export interface CalendarEventDataJob { + type: 'Task' | 'Support' | 'Meeting' | 'Essential'; + code: string; + working_sessions?: string[]; +} + +export interface CalendarEventDataSystemEvent { + event: 'ScheduleToday' | 'SummaryToday' | 'ScheduledGatewayRestart'; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type CalendarEventData = CalendarEventDataJob | CalendarEventDataSystemEvent | Record; + +/** + * Full heartbeat response returned by GET /calendar/agent/heartbeat + * + * Fields: + * slots — list of pending TimeSlots for today (sorted by priority desc) + * agent_status — current agent status from the backend's perspective + * (idle | on_call | busy | exhausted | offline) + */ +export interface CalendarHeartbeatResponse { + /** Pending slots for today — sorted by priority descending */ + slots: CalendarSlotResponse[]; + /** Current agent status in HarborForge */ + agent_status: AgentStatusValue; + /** Human-readable message (optional) */ + message?: string; +} + +/** Agent status values — mirrors backend AgentStatus enum */ +export type AgentStatusValue = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline'; + +// --------------------------------------------------------------------------- +// Slot update types (for post-execution status updates) +// --------------------------------------------------------------------------- + +/** + * Request body for updating a real slot's status after agent execution. + * Called by the plugin after attending / finishing / deferring a slot. + * + * Endpoint: PATCH /calendar/slots/{slot_id}/agent-update + * (Plugin-facing variant that bypasses some user-level guards) + */ +export interface SlotAgentUpdate { + /** New status to set */ + status: SlotStatus; + /** Actual start time (ISO time string HH:MM:SS), required when attending */ + started_at?: string; + /** Actual duration in minutes, set when finishing */ + actual_duration?: number; +} diff --git a/plugin/core/config.ts b/plugin/core/config.ts new file mode 100644 index 0000000..a6c6a01 --- /dev/null +++ b/plugin/core/config.ts @@ -0,0 +1,35 @@ +import { hostname } from 'os'; + +export interface HarborForgePluginConfig { + enabled?: boolean; + backendUrl?: string; + identifier?: string; + apiKey?: string; + monitor_port?: number; + reportIntervalSec?: number; + httpFallbackIntervalSec?: number; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + calendarEnabled?: boolean; + calendarHeartbeatIntervalSec?: number; + calendarApiKey?: string; + managedMonitor?: string; +} + +interface PluginApiLike { + pluginConfig?: Record; +} + +export function getPluginConfig(api: PluginApiLike): HarborForgePluginConfig { + const cfg = (api.pluginConfig || {}) as HarborForgePluginConfig; + return { + enabled: true, + backendUrl: 'https://monitor.hangman-lab.top', + identifier: hostname(), + reportIntervalSec: 30, + httpFallbackIntervalSec: 60, + logLevel: 'info', + calendarEnabled: true, + calendarHeartbeatIntervalSec: 60, + ...cfg, + }; +} diff --git a/plugin/core/live-config.d.ts b/plugin/core/live-config.d.ts deleted file mode 100644 index 3f78c43..0000000 --- a/plugin/core/live-config.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface HarborForgeMonitorConfig { - enabled?: boolean; - backendUrl?: string; - identifier?: string; - apiKey?: string; - monitor_port?: number; - monitorPort?: number; - reportIntervalSec?: number; - httpFallbackIntervalSec?: number; - logLevel?: 'debug' | 'info' | 'warn' | 'error'; -} -interface OpenClawPluginApiLike { - config?: Record; -} -export declare function getLivePluginConfig(api: OpenClawPluginApiLike, fallback: HarborForgeMonitorConfig): HarborForgeMonitorConfig; -export {}; -//# sourceMappingURL=live-config.d.ts.map \ No newline at end of file diff --git a/plugin/core/live-config.d.ts.map b/plugin/core/live-config.d.ts.map deleted file mode 100644 index 074ffc5..0000000 --- a/plugin/core/live-config.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"live-config.d.ts","sourceRoot":"","sources":["live-config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,wBAAwB;IACvC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;CAChD;AAED,UAAU,qBAAqB;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,qBAAqB,EAC1B,QAAQ,EAAE,wBAAwB,GACjC,wBAAwB,CAgC1B"} \ No newline at end of file diff --git a/plugin/core/live-config.js b/plugin/core/live-config.js deleted file mode 100644 index 463dc7c..0000000 --- a/plugin/core/live-config.js +++ /dev/null @@ -1,32 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.getLivePluginConfig = getLivePluginConfig; -function getLivePluginConfig(api, fallback) { - const root = api.config || {}; - const plugins = root.plugins || {}; - const entries = plugins.entries || {}; - const entry = entries['harbor-forge'] || {}; - const cfg = entry.config || {}; - if (Object.keys(cfg).length > 0 || Object.keys(entry).length > 0) { - const monitorPort = typeof cfg.monitor_port === 'number' - ? cfg.monitor_port - : typeof cfg.monitorPort === 'number' - ? cfg.monitorPort - : typeof fallback.monitor_port === 'number' - ? fallback.monitor_port - : fallback.monitorPort; - return { - ...fallback, - ...cfg, - monitor_port: monitorPort, - monitorPort, - enabled: typeof cfg.enabled === 'boolean' - ? cfg.enabled - : typeof entry.enabled === 'boolean' - ? entry.enabled - : fallback.enabled, - }; - } - return fallback; -} -//# sourceMappingURL=live-config.js.map \ No newline at end of file diff --git a/plugin/core/live-config.js.map b/plugin/core/live-config.js.map deleted file mode 100644 index e0573e7..0000000 --- a/plugin/core/live-config.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"live-config.js","sourceRoot":"","sources":["live-config.ts"],"names":[],"mappings":";;AAgBA,kDAmCC;AAnCD,SAAgB,mBAAmB,CACjC,GAA0B,EAC1B,QAAkC;IAElC,MAAM,IAAI,GAAI,GAAG,CAAC,MAAkC,IAAI,EAAE,CAAC;IAC3D,MAAM,OAAO,GAAI,IAAI,CAAC,OAAmC,IAAI,EAAE,CAAC;IAChE,MAAM,OAAO,GAAI,OAAO,CAAC,OAAmC,IAAI,EAAE,CAAC;IACnE,MAAM,KAAK,GAAI,OAAO,CAAC,cAAc,CAA6B,IAAI,EAAE,CAAC;IACzE,MAAM,GAAG,GAAI,KAAK,CAAC,MAAkC,IAAI,EAAE,CAAC;IAE5D,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACjE,MAAM,WAAW,GACf,OAAO,GAAG,CAAC,YAAY,KAAK,QAAQ;YAClC,CAAC,CAAC,GAAG,CAAC,YAAY;YAClB,CAAC,CAAC,OAAO,GAAG,CAAC,WAAW,KAAK,QAAQ;gBACnC,CAAC,CAAC,GAAG,CAAC,WAAW;gBACjB,CAAC,CAAC,OAAO,QAAQ,CAAC,YAAY,KAAK,QAAQ;oBACzC,CAAC,CAAC,QAAQ,CAAC,YAAY;oBACvB,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC;QAE/B,OAAO;YACL,GAAG,QAAQ;YACX,GAAG,GAAG;YACN,YAAY,EAAE,WAAW;YACzB,WAAW;YACX,OAAO,EACL,OAAO,GAAG,CAAC,OAAO,KAAK,SAAS;gBAC9B,CAAC,CAAC,GAAG,CAAC,OAAO;gBACb,CAAC,CAAC,OAAO,KAAK,CAAC,OAAO,KAAK,SAAS;oBAClC,CAAC,CAAC,KAAK,CAAC,OAAO;oBACf,CAAC,CAAC,QAAQ,CAAC,OAAO;SACG,CAAC;IAChC,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC"} \ No newline at end of file diff --git a/plugin/core/live-config.ts b/plugin/core/live-config.ts deleted file mode 100644 index 6e3de73..0000000 --- a/plugin/core/live-config.ts +++ /dev/null @@ -1,52 +0,0 @@ -export interface HarborForgeMonitorConfig { - enabled?: boolean; - backendUrl?: string; - identifier?: string; - apiKey?: string; - monitor_port?: number; - monitorPort?: number; - reportIntervalSec?: number; - httpFallbackIntervalSec?: number; - logLevel?: 'debug' | 'info' | 'warn' | 'error'; -} - -interface OpenClawPluginApiLike { - config?: Record; -} - -export function getLivePluginConfig( - api: OpenClawPluginApiLike, - fallback: HarborForgeMonitorConfig -): HarborForgeMonitorConfig { - const root = (api.config as Record) || {}; - const plugins = (root.plugins as Record) || {}; - const entries = (plugins.entries as Record) || {}; - const entry = (entries['harbor-forge'] as Record) || {}; - const cfg = (entry.config as Record) || {}; - - if (Object.keys(cfg).length > 0 || Object.keys(entry).length > 0) { - const monitorPort = - typeof cfg.monitor_port === 'number' - ? cfg.monitor_port - : typeof cfg.monitorPort === 'number' - ? cfg.monitorPort - : typeof fallback.monitor_port === 'number' - ? fallback.monitor_port - : fallback.monitorPort; - - return { - ...fallback, - ...cfg, - monitor_port: monitorPort, - monitorPort, - enabled: - typeof cfg.enabled === 'boolean' - ? cfg.enabled - : typeof entry.enabled === 'boolean' - ? entry.enabled - : fallback.enabled, - } as HarborForgeMonitorConfig; - } - - return fallback; -} diff --git a/plugin/core/managed-monitor.ts b/plugin/core/managed-monitor.ts new file mode 100644 index 0000000..3f543e2 --- /dev/null +++ b/plugin/core/managed-monitor.ts @@ -0,0 +1,59 @@ +import { spawn, type ChildProcess } from 'child_process'; +import { existsSync } from 'fs'; + +export interface ManagedMonitorConfig { + managedMonitor?: string; + backendUrl?: string; + identifier?: string; + apiKey?: string; + monitor_port?: number; + reportIntervalSec?: number; + logLevel?: string; +} + +let monitorProcess: ChildProcess | null = null; + +export function startManagedMonitor( + logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void; error: (...args: any[]) => void }, + config: ManagedMonitorConfig, +): void { + if (!config.managedMonitor) return; + if (monitorProcess) { + logger.info('HarborForge managed monitor already running'); + return; + } + if (!existsSync(config.managedMonitor)) { + logger.warn(`HarborForge managed monitor path not found: ${config.managedMonitor}`); + return; + } + + const args: string[] = []; + if (config.backendUrl) args.push('--backend-url', String(config.backendUrl)); + if (config.identifier) args.push('--identifier', String(config.identifier)); + if (config.apiKey) args.push('--api-key', String(config.apiKey)); + if (config.monitor_port) args.push('--monitor-port', String(config.monitor_port)); + if (config.reportIntervalSec) args.push('--report-interval', String(config.reportIntervalSec)); + if (config.logLevel) args.push('--log-level', String(config.logLevel)); + + monitorProcess = spawn(config.managedMonitor, args, { + stdio: 'inherit', + detached: false, + }); + + monitorProcess.on('exit', (code, signal) => { + logger.warn(`HarborForge managed monitor exited code=${code ?? 'null'} signal=${signal ?? 'null'}`); + monitorProcess = null; + }); + + logger.info(`HarborForge managed monitor started: ${config.managedMonitor}`); +} + +export function stopManagedMonitor( + logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void }, +): void { + if (!monitorProcess) return; + const pid = monitorProcess.pid; + monitorProcess.kill('SIGTERM'); + monitorProcess = null; + logger.info(`HarborForge managed monitor stopped pid=${pid ?? 'unknown'}`); +} diff --git a/plugin/core/monitor-bridge.d.ts b/plugin/core/monitor-bridge.d.ts deleted file mode 100644 index afa27f9..0000000 --- a/plugin/core/monitor-bridge.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Monitor Bridge Client - * - * Queries the local HarborForge.Monitor bridge endpoint on MONITOR_PORT - * to enrich plugin telemetry with host/hardware data. - * - * If the bridge is unreachable, all methods return null gracefully — - * the plugin continues to function without Monitor data. - */ -export interface MonitorHealth { - status: string; - monitor_version: string; - identifier: string; -} -export interface MonitorTelemetryResponse { - status: string; - monitor_version: string; - identifier: string; - telemetry?: { - identifier: string; - plugin_version: string; - cpu_pct: number; - mem_pct: number; - disk_pct: number; - swap_pct: number; - load_avg: number[]; - uptime_seconds: number; - nginx_installed: boolean; - nginx_sites: string[]; - }; - last_updated?: string; -} -export declare class MonitorBridgeClient { - private baseUrl; - private timeoutMs; - constructor(port: number, timeoutMs?: number); - health(): Promise; - telemetry(): Promise; - /** - * POST OpenClaw metadata to the Monitor bridge so it can enrich - * its heartbeat uploads with OpenClaw version, plugin version, - * and agent information. - */ - pushOpenClawMeta(meta: OpenClawMeta): Promise; - private fetchJson; -} -/** - * OpenClaw metadata payload sent to the Monitor bridge. - */ -export interface OpenClawMeta { - version: string; - plugin_version: string; - agents?: any[]; -} -//# sourceMappingURL=monitor-bridge.d.ts.map \ No newline at end of file diff --git a/plugin/core/monitor-bridge.d.ts.map b/plugin/core/monitor-bridge.d.ts.map deleted file mode 100644 index d170573..0000000 --- a/plugin/core/monitor-bridge.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"monitor-bridge.d.ts","sourceRoot":"","sources":["monitor-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE;QACV,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;QACvB,eAAe,EAAE,OAAO,CAAC;QACzB,WAAW,EAAE,MAAM,EAAE,CAAC;KACvB,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAS;gBAEd,IAAI,EAAE,MAAM,EAAE,SAAS,SAAO;IAKpC,MAAM,IAAI,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAIvC,SAAS,IAAI,OAAO,CAAC,wBAAwB,GAAG,IAAI,CAAC;IAI3D;;;;OAIG;IACG,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC;YAmB9C,SAAS;CAgBxB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,GAAG,EAAE,CAAC;CAChB"} \ No newline at end of file diff --git a/plugin/core/monitor-bridge.js b/plugin/core/monitor-bridge.js deleted file mode 100644 index 144b4e4..0000000 --- a/plugin/core/monitor-bridge.js +++ /dev/null @@ -1,66 +0,0 @@ -"use strict"; -/** - * Monitor Bridge Client - * - * Queries the local HarborForge.Monitor bridge endpoint on MONITOR_PORT - * to enrich plugin telemetry with host/hardware data. - * - * If the bridge is unreachable, all methods return null gracefully — - * the plugin continues to function without Monitor data. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.MonitorBridgeClient = void 0; -class MonitorBridgeClient { - baseUrl; - timeoutMs; - constructor(port, timeoutMs = 3000) { - this.baseUrl = `http://127.0.0.1:${port}`; - this.timeoutMs = timeoutMs; - } - async health() { - return this.fetchJson('/health'); - } - async telemetry() { - return this.fetchJson('/telemetry'); - } - /** - * POST OpenClaw metadata to the Monitor bridge so it can enrich - * its heartbeat uploads with OpenClaw version, plugin version, - * and agent information. - */ - async pushOpenClawMeta(meta) { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.timeoutMs); - const response = await fetch(`${this.baseUrl}/openclaw`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(meta), - signal: controller.signal, - }); - clearTimeout(timeout); - return response.ok; - } - catch { - return false; - } - } - async fetchJson(path) { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.timeoutMs); - const response = await fetch(`${this.baseUrl}${path}`, { - signal: controller.signal, - }); - clearTimeout(timeout); - if (!response.ok) - return null; - return (await response.json()); - } - catch { - return null; - } - } -} -exports.MonitorBridgeClient = MonitorBridgeClient; -//# sourceMappingURL=monitor-bridge.js.map \ No newline at end of file diff --git a/plugin/core/monitor-bridge.js.map b/plugin/core/monitor-bridge.js.map deleted file mode 100644 index 174276a..0000000 --- a/plugin/core/monitor-bridge.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"monitor-bridge.js","sourceRoot":"","sources":["monitor-bridge.ts"],"names":[],"mappings":";AAAA;;;;;;;;GAQG;;;AA2BH,MAAa,mBAAmB;IACtB,OAAO,CAAS;IAChB,SAAS,CAAS;IAE1B,YAAY,IAAY,EAAE,SAAS,GAAG,IAAI;QACxC,IAAI,CAAC,OAAO,GAAG,oBAAoB,IAAI,EAAE,CAAC;QAC1C,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC7B,CAAC;IAED,KAAK,CAAC,MAAM;QACV,OAAO,IAAI,CAAC,SAAS,CAAgB,SAAS,CAAC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,SAAS;QACb,OAAO,IAAI,CAAC,SAAS,CAA2B,YAAY,CAAC,CAAC;IAChE,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,gBAAgB,CAAC,IAAkB;QACvC,IAAI,CAAC;YACH,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;YACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;YAErE,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,WAAW,EAAE;gBACvD,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;gBAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;gBAC1B,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;YACH,YAAY,CAAC,OAAO,CAAC,CAAC;YAEtB,OAAO,QAAQ,CAAC,EAAE,CAAC;QACrB,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;IAEO,KAAK,CAAC,SAAS,CAAI,IAAY;QACrC,IAAI,CAAC;YACH,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;YACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;YAErE,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,EAAE;gBACrD,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;YACH,YAAY,CAAC,OAAO,CAAC,CAAC;YAEtB,IAAI,CAAC,QAAQ,CAAC,EAAE;gBAAE,OAAO,IAAI,CAAC;YAC9B,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAM,CAAC;QACtC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;CACF;AAzDD,kDAyDC"} \ No newline at end of file diff --git a/plugin/core/openclaw-agents.ts b/plugin/core/openclaw-agents.ts new file mode 100644 index 0000000..b48a3c6 --- /dev/null +++ b/plugin/core/openclaw-agents.ts @@ -0,0 +1,85 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +export interface OpenClawAgentInfo { + name: string; + isDefault?: boolean; + identity?: string; + workspace?: string; + agentDir?: string; + model?: string; + routingRules?: number; + routing?: string; +} + +export async function listOpenClawAgents(logger?: { debug?: (...args: any[]) => void; warn?: (...args: any[]) => void }): Promise { + try { + const { stdout } = await execFileAsync('openclaw', ['agents', 'list'], { + timeout: 15000, + maxBuffer: 1024 * 1024, + }); + return parseOpenClawAgents(stdout); + } catch (err) { + logger?.warn?.('Failed to run `openclaw agents list`', err); + return []; + } +} + +export function parseOpenClawAgents(text: string): OpenClawAgentInfo[] { + const lines = text.split(/\r?\n/); + const out: OpenClawAgentInfo[] = []; + let current: OpenClawAgentInfo | null = null; + + const push = () => { + if (current) out.push(current); + current = null; + }; + + for (const raw of lines) { + const line = raw.trimEnd(); + if (!line.trim() || line.startsWith('Agents:') || line.startsWith('Routing rules map') || line.startsWith('Channel status reflects')) continue; + if (line.startsWith('- ')) { + push(); + const m = line.match(/^-\s+(.+?)(?:\s+\((default)\))?$/); + current = { + name: m?.[1] || line.slice(2).trim(), + isDefault: m?.[2] === 'default', + }; + continue; + } + if (!current) continue; + const trimmed = line.trim(); + const idx = trimmed.indexOf(':'); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + switch (key) { + case 'Identity': + current.identity = value; + break; + case 'Workspace': + current.workspace = value; + break; + case 'Agent dir': + current.agentDir = value; + break; + case 'Model': + current.model = value; + break; + case 'Routing rules': { + const n = Number(value); + current.routingRules = Number.isFinite(n) ? n : undefined; + break; + } + case 'Routing': + current.routing = value; + break; + default: + break; + } + } + push(); + return out; +} diff --git a/plugin/hooks/gateway-start.ts b/plugin/hooks/gateway-start.ts new file mode 100644 index 0000000..26aee53 --- /dev/null +++ b/plugin/hooks/gateway-start.ts @@ -0,0 +1,37 @@ +import { hostname } from 'os'; +import { getPluginConfig } from '../core/config'; +import { startManagedMonitor } from '../core/managed-monitor'; + +export function registerGatewayStartHook(api: any, deps: { + logger: any; + pushMetaToMonitor: () => Promise; + startCalendarScheduler: () => void; + setMetaPushInterval: (handle: ReturnType) => void; +}) { + const { logger, pushMetaToMonitor, startCalendarScheduler, setMetaPushInterval } = deps; + + api.on('gateway_start', () => { + logger.info('HarborForge plugin active'); + const live = getPluginConfig(api); + + startManagedMonitor(logger, { + managedMonitor: live.managedMonitor, + backendUrl: live.backendUrl, + identifier: live.identifier, + apiKey: live.apiKey, + monitor_port: live.monitor_port, + reportIntervalSec: live.reportIntervalSec, + logLevel: live.logLevel, + }); + + const intervalSec = live.reportIntervalSec || 30; + setTimeout(() => void pushMetaToMonitor(), 2000); + setMetaPushInterval(setInterval(() => void pushMetaToMonitor(), intervalSec * 1000)); + + if (live.enabled !== false && live.calendarEnabled !== false) { + setTimeout(() => startCalendarScheduler(), 5000); + } + + logger.info(`HarborForge startup config identifier=${live.identifier || hostname()} backend=${live.backendUrl}`); + }); +} diff --git a/plugin/hooks/gateway-stop.ts b/plugin/hooks/gateway-stop.ts new file mode 100644 index 0000000..04dce16 --- /dev/null +++ b/plugin/hooks/gateway-stop.ts @@ -0,0 +1,23 @@ +import { stopManagedMonitor } from '../core/managed-monitor'; + +export function registerGatewayStopHook(api: any, deps: { + logger: any; + getMetaPushInterval: () => ReturnType | null; + clearMetaPushInterval: () => void; + stopCalendarScheduler: () => void; +}) { + const { logger, getMetaPushInterval, clearMetaPushInterval, stopCalendarScheduler } = deps; + + api.on('gateway_stop', () => { + logger.info('HarborForge plugin stopping'); + + const handle = getMetaPushInterval(); + if (handle) { + clearInterval(handle); + clearMetaPushInterval(); + } + + stopCalendarScheduler(); + stopManagedMonitor(logger); + }); +} diff --git a/plugin/index.ts b/plugin/index.ts index abef652..24f0f1d 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -4,13 +4,25 @@ * Provides monitor-related tools and exposes OpenClaw metadata * for the HarborForge Monitor bridge (via monitor_port). * + * Also integrates with HarborForge Calendar system to wake agents + * for scheduled tasks (PLG-CAL-002, PLG-CAL-004). + * * Sidecar architecture has been removed. Telemetry data is now * served directly by the plugin when Monitor queries via the * local monitor_port communication path. */ import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'os'; -import { getLivePluginConfig, type HarborForgeMonitorConfig } from './core/live-config'; +import { getPluginConfig } from './core/config'; import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge'; +import { listOpenClawAgents } from './core/openclaw-agents'; +import { registerGatewayStartHook } from './hooks/gateway-start'; +import { registerGatewayStopHook } from './hooks/gateway-stop'; +import { + createCalendarBridgeClient, + createCalendarScheduler, + CalendarScheduler, + AgentWakeContext, +} from './calendar'; interface PluginAPI { logger: { @@ -24,6 +36,15 @@ interface PluginAPI { pluginConfig?: Record; on: (event: string, handler: () => void) => void; registerTool: (factory: (ctx: any) => any) => void; + /** Spawn a sub-agent with task context (OpenClaw 2.1+) */ + spawn?: (options: { + agentId?: string; + task: string; + model?: string; + timeoutSeconds?: number; + }) => Promise<{ sessionId: string; status: string }>; + /** Get current agent status */ + getAgentStatus?: () => Promise<{ status: string } | null>; } export default { @@ -37,27 +58,16 @@ export default { warn: (...args: any[]) => console.warn('[HarborForge]', ...args), }; - const baseConfig: HarborForgeMonitorConfig = { - enabled: true, - backendUrl: 'https://monitor.hangman-lab.top', - identifier: '', - reportIntervalSec: 30, - httpFallbackIntervalSec: 60, - logLevel: 'info', - ...(api.pluginConfig || {}), - }; - function resolveConfig() { - return getLivePluginConfig(api, baseConfig); + return getPluginConfig(api); } /** * Get the monitor bridge client if monitor_port is configured. - * Legacy alias monitorPort is still accepted. */ function getBridgeClient(): MonitorBridgeClient | null { - const live = resolveConfig() as any; - const port = live.monitor_port ?? live.monitorPort; + const live = resolveConfig(); + const port = live.monitor_port; if (!port || port <= 0) return null; return new MonitorBridgeClient(port); } @@ -87,7 +97,7 @@ export default { }, openclaw: { version: api.version || 'unknown', - pluginVersion: '0.2.0', + pluginVersion: '0.3.1', // Bumped for PLG-CAL-004 }, timestamp: new Date().toISOString(), }; @@ -96,6 +106,9 @@ export default { // Periodic metadata push interval handle let metaPushInterval: ReturnType | null = null; + // Calendar scheduler instance + let calendarScheduler: CalendarScheduler | null = null; + /** * Push OpenClaw metadata to the Monitor bridge. * This enriches Monitor heartbeats with OpenClaw version/plugin/agent info. @@ -107,8 +120,8 @@ export default { const meta: OpenClawMeta = { version: api.version || 'unknown', - plugin_version: '0.2.0', - agents: [], // TODO: populate from api agent list when available + plugin_version: '0.3.1', + agents: await listOpenClawAgents(logger), }; const ok = await bridgeClient.pushOpenClawMeta(meta); @@ -119,30 +132,188 @@ export default { } } - api.on('gateway_start', () => { - logger.info('HarborForge plugin active'); + /** + * Get current agent status from OpenClaw. + * Falls back to querying backend if OpenClaw API unavailable. + */ + async function getAgentStatus(): Promise<'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline' | null> { + // Try OpenClaw API first (if available) + if (api.getAgentStatus) { + try { + const status = await api.getAgentStatus(); + if (status?.status) { + return status.status as 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline'; + } + } catch (err) { + logger.debug('Failed to get agent status from OpenClaw API:', err); + } + } - // Push metadata to Monitor bridge on startup and periodically. - // Interval aligns with typical Monitor heartbeat cycle (30s). - // If Monitor bridge is unreachable, pushes silently fail. + // Fallback: query backend for agent status const live = resolveConfig(); - const intervalSec = live.reportIntervalSec || 30; + const agentId = process.env.AGENT_ID || 'unknown'; + try { + const response = await fetch(`${live.backendUrl}/calendar/agent/status?agent_id=${agentId}`, { + headers: { + 'X-Agent-ID': agentId, + 'X-Claw-Identifier': live.identifier || hostname(), + }, + }); + if (response.ok) { + const data = await response.json(); + return data.status; + } + } catch (err) { + logger.debug('Failed to get agent status from backend:', err); + } - // Initial push (delayed 2s to let Monitor bridge start) - setTimeout(() => pushMetaToMonitor(), 2000); + return null; + } - metaPushInterval = setInterval( - () => pushMetaToMonitor(), - intervalSec * 1000, + /** + * Wake/spawn agent with task context for slot execution. + * This is the callback invoked by CalendarScheduler when a slot is ready. + */ + async function wakeAgent(context: AgentWakeContext): Promise { + logger.info(`Waking agent for slot: ${context.taskDescription}`); + + try { + // Method 1: Use OpenClaw spawn API if available (preferred) + if (api.spawn) { + const result = await api.spawn({ + task: context.prompt, + timeoutSeconds: context.slot.estimated_duration * 60, // Convert to seconds + }); + + if (result?.sessionId) { + logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`); + + // Track session completion + trackSessionCompletion(result.sessionId, context); + return true; + } + } + + // Method 2: Send notification/alert to wake agent (fallback) + // This relies on the agent's heartbeat to check for notifications + logger.warn('OpenClaw spawn API not available, using notification fallback'); + + // Send calendar wakeup notification via backend + const live = resolveConfig(); + const agentId = process.env.AGENT_ID || 'unknown'; + + const notifyResponse = await fetch(`${live.backendUrl}/calendar/agent/notify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Agent-ID': agentId, + 'X-Claw-Identifier': live.identifier || hostname(), + }, + body: JSON.stringify({ + agent_id: agentId, + message: context.prompt, + slot_id: context.slot.id || context.slot.virtual_id, + task_description: context.taskDescription, + }), + }); + + return notifyResponse.ok; + + } catch (err) { + logger.error('Failed to wake agent:', err); + return false; + } + } + + /** + * Track session completion and update slot status accordingly. + */ + function trackSessionCompletion(sessionId: string, context: AgentWakeContext): void { + // Poll for session completion (simplified approach) + // In production, this would use webhooks or event streaming + const pollInterval = 30000; // 30 seconds + const maxDuration = context.slot.estimated_duration * 60 * 1000; // Convert to ms + const startTime = Date.now(); + + const poll = async () => { + if (!calendarScheduler) return; + + const elapsed = Date.now() - startTime; + + // Check if session is complete (would use actual API in production) + // For now, estimate completion based on duration + if (elapsed >= maxDuration) { + // Assume completion + const actualMinutes = Math.round(elapsed / 60000); + await calendarScheduler.completeCurrentSlot(actualMinutes); + return; + } + + // Continue polling + setTimeout(poll, pollInterval); + }; + + // Start polling + setTimeout(poll, pollInterval); + } + + /** + * Initialize and start the calendar scheduler. + */ + function startCalendarScheduler(): void { + const live = resolveConfig(); + const agentId = process.env.AGENT_ID || 'unknown'; + + // Create calendar bridge client + const calendarBridge = createCalendarBridgeClient( + api, + live.backendUrl || 'https://monitor.hangman-lab.top', + agentId ); + + // Create and start scheduler + calendarScheduler = createCalendarScheduler({ + bridge: calendarBridge, + getAgentStatus, + wakeAgent, + logger, + heartbeatIntervalMs: 60000, // 1 minute + debug: live.logLevel === 'debug', + }); + + calendarScheduler.start(); + logger.info('Calendar scheduler started'); + } + + /** + * Stop the calendar scheduler. + */ + function stopCalendarScheduler(): void { + if (calendarScheduler) { + calendarScheduler.stop(); + calendarScheduler = null; + logger.info('Calendar scheduler stopped'); + } + } + + registerGatewayStartHook(api, { + logger, + pushMetaToMonitor, + startCalendarScheduler, + setMetaPushInterval(handle) { + metaPushInterval = handle; + }, }); - api.on('gateway_stop', () => { - logger.info('HarborForge plugin stopping'); - if (metaPushInterval) { - clearInterval(metaPushInterval); + registerGatewayStopHook(api, { + logger, + getMetaPushInterval() { + return metaPushInterval; + }, + clearMetaPushInterval() { metaPushInterval = null; - } + }, + stopCalendarScheduler, }); // Tool: plugin status @@ -165,16 +336,25 @@ export default { : { connected: false, error: 'Monitor bridge unreachable' }; } + // Get calendar scheduler status + const calendarStatus = calendarScheduler ? { + running: calendarScheduler.isRunning(), + processing: calendarScheduler.isProcessing(), + currentSlot: calendarScheduler.getCurrentSlot(), + isRestartPending: calendarScheduler.isRestartPending(), + } : null; + return { enabled: live.enabled !== false, config: { backendUrl: live.backendUrl, identifier: live.identifier || hostname(), - monitorPort: (live as any).monitor_port ?? (live as any).monitorPort ?? null, + monitorPort: live.monitor_port ?? null, reportIntervalSec: live.reportIntervalSec, hasApiKey: Boolean(live.apiKey), }, monitorBridge, + calendar: calendarStatus, telemetry: collectTelemetry(), }; }, @@ -220,6 +400,139 @@ export default { }, })); + // Tool: calendar slot management + api.registerTool(() => ({ + name: 'harborforge_calendar_status', + description: 'Get current calendar scheduler status and pending slots', + parameters: { + type: 'object', + properties: {}, + }, + async execute() { + if (!calendarScheduler) { + return { error: 'Calendar scheduler not running' }; + } + + return { + running: calendarScheduler.isRunning(), + processing: calendarScheduler.isProcessing(), + currentSlot: calendarScheduler.getCurrentSlot(), + state: calendarScheduler.getState(), + isRestartPending: calendarScheduler.isRestartPending(), + stateFilePath: calendarScheduler.getStateFilePath(), + }; + }, + })); + + // Tool: complete current slot (for agent to report completion) + api.registerTool(() => ({ + name: 'harborforge_calendar_complete', + description: 'Complete the current calendar slot with actual duration', + parameters: { + type: 'object', + properties: { + actualDurationMinutes: { + type: 'number', + description: 'Actual time spent on the task in minutes', + }, + }, + required: ['actualDurationMinutes'], + }, + async execute(params: { actualDurationMinutes: number }) { + if (!calendarScheduler) { + return { error: 'Calendar scheduler not running' }; + } + + await calendarScheduler.completeCurrentSlot(params.actualDurationMinutes); + return { success: true, message: 'Slot completed' }; + }, + })); + + // Tool: abort current slot (for agent to report failure) + api.registerTool(() => ({ + name: 'harborforge_calendar_abort', + description: 'Abort the current calendar slot', + parameters: { + type: 'object', + properties: { + reason: { + type: 'string', + description: 'Reason for aborting', + }, + }, + }, + async execute(params: { reason?: string }) { + if (!calendarScheduler) { + return { error: 'Calendar scheduler not running' }; + } + + await calendarScheduler.abortCurrentSlot(params.reason); + return { success: true, message: 'Slot aborted' }; + }, + })); + + // Tool: pause current slot + api.registerTool(() => ({ + name: 'harborforge_calendar_pause', + description: 'Pause the current calendar slot', + parameters: { + type: 'object', + properties: {}, + }, + async execute() { + if (!calendarScheduler) { + return { error: 'Calendar scheduler not running' }; + } + + await calendarScheduler.pauseCurrentSlot(); + return { success: true, message: 'Slot paused' }; + }, + })); + + // Tool: resume current slot + api.registerTool(() => ({ + name: 'harborforge_calendar_resume', + description: 'Resume the paused calendar slot', + parameters: { + type: 'object', + properties: {}, + }, + async execute() { + if (!calendarScheduler) { + return { error: 'Calendar scheduler not running' }; + } + + await calendarScheduler.resumeCurrentSlot(); + return { success: true, message: 'Slot resumed' }; + }, + })); + + // Tool: check ScheduledGatewayRestart status + api.registerTool(() => ({ + name: 'harborforge_restart_status', + description: 'Check if a gateway restart is pending (PLG-CAL-004)', + parameters: { + type: 'object', + properties: {}, + }, + async execute() { + if (!calendarScheduler) { + return { error: 'Calendar scheduler not running' }; + } + + const isPending = calendarScheduler.isRestartPending(); + const stateFilePath = calendarScheduler.getStateFilePath(); + + return { + isRestartPending: isPending, + stateFilePath: stateFilePath, + message: isPending + ? 'A gateway restart has been scheduled. The scheduler has been paused.' + : 'No gateway restart is pending.', + }; + }, + })); + logger.info('HarborForge plugin registered (id: harbor-forge)'); }, }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index fe9fbc9..186c986 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -3,48 +3,66 @@ "name": "HarborForge", "version": "0.2.0", "description": "HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration", - "entry": "./index.js", + "entry": "./dist/index.js", "configSchema": { "type": "object", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", + "enabled": { + "type": "boolean", "default": true, "description": "Enable the HarborForge plugin" }, - "backendUrl": { - "type": "string", + "backendUrl": { + "type": "string", "default": "https://monitor.hangman-lab.top", - "description": "HarborForge Monitor backend URL" + "description": "HarborForge backend base URL (shared by Monitor and Calendar API)" }, - "identifier": { + "identifier": { "type": "string", - "description": "Server identifier (auto-detected from hostname if not set)" + "description": "Server/claw identifier. Used as claw_identifier in Calendar heartbeat and as MonitoredServer.identifier. Auto-detected from hostname if not set." }, - "apiKey": { + "apiKey": { "type": "string", - "description": "API Key from HarborForge Monitor admin panel (optional but required for authentication)" + "description": "API Key from HarborForge Monitor admin panel (optional but required for Monitor authentication)" }, "monitor_port": { "type": "number", "description": "Local port for communication between HarborForge Monitor and this plugin" }, - "reportIntervalSec": { - "type": "number", + "reportIntervalSec": { + "type": "number", "default": 30, "description": "How often to report metrics (seconds)" }, - "httpFallbackIntervalSec": { - "type": "number", + "httpFallbackIntervalSec": { + "type": "number", "default": 60, "description": "HTTP heartbeat interval when WS unavailable" }, - "logLevel": { - "type": "string", - "enum": ["debug", "info", "warn", "error"], + "logLevel": { + "type": "string", + "enum": ["debug", "info", "warn", "error"], "default": "info", "description": "Logging level" + }, + "calendarEnabled": { + "type": "boolean", + "default": true, + "description": "Enable Calendar heartbeat integration (PLG-CAL-001). When enabled, plugin sends periodic heartbeat to /calendar/agent/heartbeat to receive pending TimeSlots." + }, + "calendarHeartbeatIntervalSec": { + "type": "number", + "default": 60, + "description": "How often to send Calendar heartbeat to backend (seconds). Defaults to 60s (1 minute)." + }, + "calendarApiKey": { + "type": "string", + "description": "API key for Calendar API authentication. If not set, uses apiKey or plugin auto-authentication via X-Agent-ID header." + }, + "managedMonitor": { + "type": "string", + "description": "Absolute path to an installed HarborForge.Monitor binary managed by this plugin installer. If set, gateway_start/gateway_stop hooks will start/stop the monitor process automatically." } } } diff --git a/plugin/package.json b/plugin/package.json index 0e17992..c7ba015 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -2,9 +2,10 @@ "name": "harbor-forge-plugin", "version": "0.2.0", "description": "OpenClaw plugin for HarborForge monitor bridge and CLI integration", - "main": "index.js", + "main": "dist/index.js", "scripts": { - "build": "tsc", + "clean": "rm -rf dist", + "build": "npm run clean && tsc", "watch": "tsc --watch" }, "devDependencies": { diff --git a/plugin/tsconfig.json b/plugin/tsconfig.json index 3d63e25..b095193 100644 --- a/plugin/tsconfig.json +++ b/plugin/tsconfig.json @@ -6,12 +6,11 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "outDir": "./", + "outDir": "./dist", "rootDir": "./", - "declaration": true, - "declarationMap": true, + "declaration": false, "sourceMap": true }, "include": ["**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "dist"] } diff --git a/scripts/install.mjs b/scripts/install.mjs index 12a9bfb..54c66e7 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -30,6 +30,7 @@ const PLUGIN_NAME = 'harbor-forge'; const OLD_PLUGIN_NAME = 'harborforge-monitor'; const PLUGIN_SRC_DIR = join(__dirname, 'plugin'); const SKILLS_SRC_DIR = join(__dirname, 'skills'); +const MONITOR_REPO_URL = 'https://git.hangman-lab.top/zhi/HarborForge.Monitor.git'; const args = process.argv.slice(2); const options = { @@ -40,6 +41,8 @@ const options = { uninstall: args.includes('--uninstall'), installOnly: args.includes('--install'), installCli: args.includes('--install-cli'), + installMonitor: 'no', + monitorBranch: 'main', }; const profileIdx = args.indexOf('--openclaw-profile-path'); @@ -47,6 +50,16 @@ if (profileIdx !== -1 && args[profileIdx + 1]) { options.openclawProfilePath = resolve(args[profileIdx + 1]); } +const installMonitorIdx = args.indexOf('--install-monitor'); +if (installMonitorIdx !== -1 && args[installMonitorIdx + 1]) { + options.installMonitor = String(args[installMonitorIdx + 1]).toLowerCase(); +} + +const monitorBranchIdx = args.indexOf('--monitor-branch'); +if (monitorBranchIdx !== -1 && args[monitorBranchIdx + 1]) { + options.monitorBranch = String(args[monitorBranchIdx + 1]); +} + function resolveOpenclawPath() { if (options.openclawProfilePath) return options.openclawProfilePath; if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH); @@ -85,6 +98,14 @@ function setOpenclawConfig(key, value) { exec(`openclaw config set ${key} '${JSON.stringify(value)}' --json`, { silent: true }); } +function assertConfigValue(key, predicate, description) { + const value = getOpenclawConfig(key, undefined); + if (!predicate(value)) { + throw new Error(`Config verification failed for ${key}: ${description}`); + } + return value; +} + function unsetOpenclawConfig(key) { try { exec(`openclaw config unset ${key}`, { silent: true }); } catch {} } @@ -100,6 +121,10 @@ function copyDir(src, dest, { exclude = [] } = {}) { } } +function shellEscape(value) { + return `'${String(value).replace(/'/g, `'"'"'`)}'`; +} + function detectEnvironment() { const totalSteps = options.installCli ? 6 : 5; logStep(1, totalSteps, 'Detecting environment...'); @@ -164,6 +189,26 @@ async function build() { logOk('plugin compiled'); } +function installManagedMonitor(openclawPath) { + if (options.installMonitor !== 'yes') return null; + const tmpDir = join('/tmp', `harborforge-monitor-${Date.now()}`); + const monitorDestDir = join(openclawPath, 'plugins', PLUGIN_NAME, 'bin'); + const binaryPath = join(monitorDestDir, 'HarborForge.Monitor'); + mkdirSync(monitorDestDir, { recursive: true }); + try { + exec(`git clone --branch ${shellEscape(options.monitorBranch)} ${shellEscape(MONITOR_REPO_URL)} ${shellEscape(tmpDir)}`, { silent: !options.verbose }); + exec(`go build -o ${shellEscape(binaryPath)} ./cmd/harborforge-monitor`, { cwd: tmpDir, silent: !options.verbose }); + chmodSync(binaryPath, 0o755); + logOk(`Managed monitor installed → ${binaryPath} (branch hint: ${options.monitorBranch})`); + return binaryPath; + } catch (err) { + logWarn(`Managed monitor build failed: ${err.message}`); + return null; + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + function clearInstallTargets(openclawPath) { // Remove new plugin dir const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); @@ -244,7 +289,7 @@ async function install() { // Copy compiled plugin (no server directory — sidecar removed) mkdirSync(destDir, { recursive: true }); - copyDir(PLUGIN_SRC_DIR, destDir); + copyDir(PLUGIN_SRC_DIR, destDir, { exclude: ['node_modules', '.git'] }); logOk(`Plugin files → ${destDir}`); // Copy skills (exclude hf/ unless --install-cli) @@ -263,7 +308,8 @@ async function install() { exec('npm install --omit=dev', { cwd: destDir, silent: !options.verbose }); logOk('Runtime deps installed'); - return { destDir }; + const managedMonitorPath = installManagedMonitor(openclawPath); + return { destDir, managedMonitorPath }; } async function installCli() { @@ -321,6 +367,7 @@ async function configure() { paths.push(destDir); setOpenclawConfig('plugins.load.paths', paths); } + assertConfigValue('plugins.load.paths', (value) => Array.isArray(value) && value.includes(destDir), `missing ${destDir}`); logOk(`plugins.load.paths includes ${destDir}`); const allow = getOpenclawConfig('plugins.allow', []); @@ -328,12 +375,35 @@ async function configure() { allow.push(PLUGIN_NAME); setOpenclawConfig('plugins.allow', allow); } + assertConfigValue('plugins.allow', (value) => Array.isArray(value) && value.includes(PLUGIN_NAME), `missing ${PLUGIN_NAME}`); logOk(`plugins.allow includes ${PLUGIN_NAME}`); + + const enabledKey = `plugins.entries.${PLUGIN_NAME}.enabled`; + const configEnabledKey = `plugins.entries.${PLUGIN_NAME}.config.enabled`; + if (getOpenclawConfig(enabledKey, undefined) === undefined) { + setOpenclawConfig(enabledKey, true); + } + if (getOpenclawConfig(configEnabledKey, undefined) === undefined) { + setOpenclawConfig(configEnabledKey, true); + } + assertConfigValue(enabledKey, (value) => value === true, 'expected true'); + assertConfigValue(configEnabledKey, (value) => value === true, 'expected true'); + + if (options.installMonitor === 'yes') { + const binaryPath = join(openclawPath, 'plugins', PLUGIN_NAME, 'bin', 'HarborForge.Monitor'); + const managedMonitorKey = `plugins.entries.${PLUGIN_NAME}.config.managedMonitor`; + if (getOpenclawConfig(managedMonitorKey, undefined) === undefined) { + setOpenclawConfig(managedMonitorKey, binaryPath); + } + assertConfigValue(managedMonitorKey, (value) => value === binaryPath, `expected ${binaryPath}`); + logOk(`managedMonitor configured → ${binaryPath}`); + } logOk('Plugin configured (remember to set apiKey in plugins.entries.harbor-forge.config)'); } catch (err) { - logWarn(`Config failed: ${err.message}`); + logErr(`Config failed: ${err.message}`); + throw err; } } @@ -354,7 +424,7 @@ function summary() { log('Next steps:', 'blue'); log(' 1. Register server in HarborForge Monitor to get apiKey', 'cyan'); log(' 2. Edit ~/.openclaw/openclaw.json under plugins.entries.harbor-forge.config:', 'cyan'); - log(' (prefer monitor_port; legacy monitorPort is still accepted)', 'cyan'); + log(' (monitor_port is required for the local monitor bridge)', 'cyan'); log(' {', 'cyan'); log(' "plugins": {', 'cyan'); log(' "entries": {', 'cyan'); @@ -390,6 +460,12 @@ async function uninstall() { rmSync(hfBinary, { force: true }); logOk('Removed hf CLI binary'); } + + const managedDir = join(openclawPath, 'plugins', PLUGIN_NAME, 'bin'); + if (existsSync(managedDir)) { + rmSync(managedDir, { recursive: true, force: true }); + logOk('Removed managed HarborForge monitor'); + } log('\nRun: openclaw gateway restart', 'yellow'); }