263 lines
8.5 KiB
TypeScript
263 lines
8.5 KiB
TypeScript
/**
|
|
* 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<CalendarBridgeConfig>;
|
|
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<CalendarHeartbeatResponse | null> {
|
|
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<CalendarHeartbeatResponse>(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<boolean> {
|
|
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<CalendarSlotResponse | null> {
|
|
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<boolean> {
|
|
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<T>(
|
|
url: string,
|
|
init: RequestInit
|
|
): Promise<T | null> {
|
|
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<boolean> {
|
|
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<string, unknown>; 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,
|
|
});
|
|
}
|