- Add CalendarScheduler class to manage periodic heartbeat and slot execution - Implement agent wakeup logic when Idle and slots are pending - Handle slot status transitions (attended, ongoing, deferred) - Support both real and virtual slot materialization - Add task context building for different event types (job, system, entertainment) - Integrate scheduler into main plugin index.ts - Add new plugin tools: harborforge_calendar_status, complete, abort
496 lines
15 KiB
TypeScript
496 lines
15 KiB
TypeScript
/**
|
|
* HarborForge Plugin for OpenClaw
|
|
*
|
|
* 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).
|
|
*
|
|
* 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 { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge';
|
|
import {
|
|
createCalendarBridgeClient,
|
|
createCalendarScheduler,
|
|
CalendarScheduler,
|
|
AgentWakeContext,
|
|
} from './calendar';
|
|
|
|
interface PluginAPI {
|
|
logger: {
|
|
info: (...args: any[]) => void;
|
|
error: (...args: any[]) => void;
|
|
debug: (...args: any[]) => void;
|
|
warn: (...args: any[]) => void;
|
|
};
|
|
version?: string;
|
|
config?: Record<string, unknown>;
|
|
pluginConfig?: Record<string, unknown>;
|
|
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 {
|
|
id: 'harbor-forge',
|
|
name: 'HarborForge',
|
|
register(api: PluginAPI) {
|
|
const logger = api.logger || {
|
|
info: (...args: any[]) => console.log('[HarborForge]', ...args),
|
|
error: (...args: any[]) => console.error('[HarborForge]', ...args),
|
|
debug: (...args: any[]) => console.debug('[HarborForge]', ...args),
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
if (!port || port <= 0) return null;
|
|
return new MonitorBridgeClient(port);
|
|
}
|
|
|
|
/**
|
|
* Collect current system telemetry snapshot.
|
|
* This data is exposed to the Monitor bridge when it queries the plugin.
|
|
*/
|
|
function collectTelemetry() {
|
|
const live = resolveConfig();
|
|
const load = loadavg();
|
|
return {
|
|
identifier: live.identifier || hostname(),
|
|
platform: platform(),
|
|
hostname: hostname(),
|
|
uptime: uptime(),
|
|
memory: {
|
|
total: totalmem(),
|
|
free: freemem(),
|
|
used: totalmem() - freemem(),
|
|
usagePercent: ((totalmem() - freemem()) / totalmem()) * 100,
|
|
},
|
|
load: {
|
|
avg1: load[0],
|
|
avg5: load[1],
|
|
avg15: load[2],
|
|
},
|
|
openclaw: {
|
|
version: api.version || 'unknown',
|
|
pluginVersion: '0.3.0',
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
// Periodic metadata push interval handle
|
|
let metaPushInterval: ReturnType<typeof setInterval> | 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.
|
|
* Failures are non-fatal — Monitor continues to work without this data.
|
|
*/
|
|
async function pushMetaToMonitor() {
|
|
const bridgeClient = getBridgeClient();
|
|
if (!bridgeClient) return;
|
|
|
|
const meta: OpenClawMeta = {
|
|
version: api.version || 'unknown',
|
|
plugin_version: '0.3.0',
|
|
agents: [], // TODO: populate from api agent list when available
|
|
};
|
|
|
|
const ok = await bridgeClient.pushOpenClawMeta(meta);
|
|
if (ok) {
|
|
logger.debug('pushed OpenClaw metadata to Monitor bridge');
|
|
} else {
|
|
logger.debug('Monitor bridge unreachable for metadata push (non-fatal)');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
// Fallback: query backend for agent status
|
|
const live = resolveConfig();
|
|
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);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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');
|
|
}
|
|
}
|
|
|
|
api.on('gateway_start', () => {
|
|
logger.info('HarborForge plugin active');
|
|
|
|
// 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.
|
|
const live = resolveConfig();
|
|
const intervalSec = live.reportIntervalSec || 30;
|
|
|
|
// Initial push (delayed 2s to let Monitor bridge start)
|
|
setTimeout(() => pushMetaToMonitor(), 2000);
|
|
|
|
metaPushInterval = setInterval(
|
|
() => pushMetaToMonitor(),
|
|
intervalSec * 1000,
|
|
);
|
|
|
|
// Start calendar scheduler (delayed to let everything initialize)
|
|
if (live.enabled !== false) {
|
|
setTimeout(() => startCalendarScheduler(), 5000);
|
|
}
|
|
});
|
|
|
|
api.on('gateway_stop', () => {
|
|
logger.info('HarborForge plugin stopping');
|
|
|
|
if (metaPushInterval) {
|
|
clearInterval(metaPushInterval);
|
|
metaPushInterval = null;
|
|
}
|
|
|
|
stopCalendarScheduler();
|
|
});
|
|
|
|
// Tool: plugin status
|
|
api.registerTool(() => ({
|
|
name: 'harborforge_status',
|
|
description: 'Get HarborForge plugin status and current telemetry snapshot',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {},
|
|
},
|
|
async execute() {
|
|
const live = resolveConfig();
|
|
const bridgeClient = getBridgeClient();
|
|
let monitorBridge = null;
|
|
|
|
if (bridgeClient) {
|
|
const health = await bridgeClient.health();
|
|
monitorBridge = health
|
|
? { connected: true, ...health }
|
|
: { connected: false, error: 'Monitor bridge unreachable' };
|
|
}
|
|
|
|
// Get calendar scheduler status
|
|
const calendarStatus = calendarScheduler ? {
|
|
running: calendarScheduler.isRunning(),
|
|
processing: calendarScheduler.isProcessing(),
|
|
currentSlot: calendarScheduler.getCurrentSlot(),
|
|
} : 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,
|
|
reportIntervalSec: live.reportIntervalSec,
|
|
hasApiKey: Boolean(live.apiKey),
|
|
},
|
|
monitorBridge,
|
|
calendar: calendarStatus,
|
|
telemetry: collectTelemetry(),
|
|
};
|
|
},
|
|
}));
|
|
|
|
// Tool: telemetry snapshot (for Monitor bridge queries)
|
|
api.registerTool(() => ({
|
|
name: 'harborforge_telemetry',
|
|
description: 'Get current system telemetry data for HarborForge Monitor',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {},
|
|
},
|
|
async execute() {
|
|
return collectTelemetry();
|
|
},
|
|
}));
|
|
|
|
// Tool: query Monitor bridge for host hardware telemetry
|
|
api.registerTool(() => ({
|
|
name: 'harborforge_monitor_telemetry',
|
|
description: 'Query HarborForge Monitor bridge for host hardware telemetry (CPU, memory, disk, etc.)',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {},
|
|
},
|
|
async execute() {
|
|
const bridgeClient = getBridgeClient();
|
|
if (!bridgeClient) {
|
|
return {
|
|
error: 'Monitor bridge not configured (monitor_port not set or 0)',
|
|
};
|
|
}
|
|
|
|
const data = await bridgeClient.telemetry();
|
|
if (!data) {
|
|
return {
|
|
error: 'Monitor bridge unreachable',
|
|
};
|
|
}
|
|
|
|
return data;
|
|
},
|
|
}));
|
|
|
|
// 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(),
|
|
};
|
|
},
|
|
}));
|
|
|
|
// 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' };
|
|
},
|
|
}));
|
|
|
|
logger.info('HarborForge plugin registered (id: harbor-forge)');
|
|
},
|
|
};
|