diff --git a/src/cloud.ts b/src/cloud.ts index 1b535b17..a04cae65 100644 --- a/src/cloud.ts +++ b/src/cloud.ts @@ -30,6 +30,7 @@ import { getRequestMetrics } from './request-tracker.js' import { listApprovalQueue, listAgentEvents, listAgentRuns, type AgentRun } from './agent-runs.js' import { syncTeamContextToAgents } from './team-context-writer.js' import { getUnpushedTrustEvents, markTrustEventsPushed } from './trust-events.js' +import { pollAndProcessCommands, COMMAND_POLL_ACTIVE_MS, COMMAND_POLL_IDLE_MS } from './commands/drain.js' /** * Docker identity guard: detect when a container has inherited cloud @@ -169,7 +170,7 @@ export function queueCanvasPushEvent(event: Record): void { } /** Check if the system is idle */ -function isIdle(): boolean { +export function isIdle(): boolean { return Date.now() - lastActivityAt > IDLE_THRESHOLD_MS } @@ -227,8 +228,8 @@ export function getConnectionHealth() { } } -let config: CloudConfig | null = null -let state: CloudState = { +export let config: CloudConfig | null = null +export let state: CloudState = { hostId: null, credential: null, heartbeatTimer: null, @@ -2045,18 +2046,10 @@ async function syncInsightsToCloud(): Promise { } // ---- Command polling + context_sync handler ---- - -const COMMAND_POLL_ACTIVE_MS = 10_000 // 10s when active -const COMMAND_POLL_IDLE_MS = 60_000 // 60s when idle -let commandPollErrors = 0 -let lastCommandPollAt = 0 - -interface PendingCommand { - id: string - type: string - payload: Record - status: string -} +// +// Relocated to src/commands/drain.ts (§11e step 1, behavior-zero). +// `pollAndProcessCommands` is imported from there at the top of this +// file; the in-flight call sites below are unchanged. // ─── Capability context injection ───────────────────────────────────────────── @@ -2138,174 +2131,8 @@ async function syncCapabilityContext(): Promise { } } -async function pollAndProcessCommands(): Promise { - if (!state.hostId || !config || !state.running) return - - const now = Date.now() - const interval = isIdle() ? COMMAND_POLL_IDLE_MS : COMMAND_POLL_ACTIVE_MS - if (now - lastCommandPollAt < interval) return - lastCommandPollAt = now - - const result = await cloudGet<{ commands: PendingCommand[] }>( - `/api/hosts/${state.hostId}/commands?status=pending` - ) - - if (!result.success || !result.data?.commands) { - commandPollErrors++ - if (commandPollErrors <= 3 || commandPollErrors % 20 === 0) { - console.warn(`☁️ [Commands] Poll failed (${commandPollErrors}): ${result.error}`) - } - return - } - - if (commandPollErrors > 0) { - console.log(`☁️ [Commands] Poll recovered after ${commandPollErrors} errors`) - commandPollErrors = 0 - } - - for (const cmd of result.data.commands) { - try { - await handleCommand(cmd) - } catch (err: any) { - console.warn(`☁️ [Commands] Failed to handle ${cmd.type} (${cmd.id}): ${err?.message}`) - // Ack as failed so it doesn't re-run - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'fail', - error: err?.message || 'Handler error', - }).catch(() => {}) - } - } -} - -async function handleCommand(cmd: PendingCommand): Promise { - if (cmd.type === 'context_sync') { - await handleContextSync(cmd) - } else if (cmd.type === 'run_approve') { - await handleRunApprove(cmd) - } else { - console.log(`☁️ [Commands] Unknown command type: ${cmd.type} (${cmd.id}) — skipping`) - // Ack unknown commands so they don't pile up - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'complete', - result: { skipped: true, reason: 'unknown_type' }, - }) - } -} - -async function handleRunApprove(cmd: PendingCommand): Promise { - if (!state.hostId) return - - const eventId = cmd.payload?.eventId as string - const decision = cmd.payload?.decision as string - const actor = cmd.payload?.actor as string || 'cloud-dashboard' - const rationale = cmd.payload?.rationale as string || '' - - if (!eventId || !decision) { - console.warn(`☁️ [Commands] run_approve missing eventId/decision (${cmd.id}) — failing`) - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'fail', - error: 'eventId and decision are required', - }) - return - } - - console.log(`☁️ [Commands] Processing run_approve: ${decision} for ${eventId} (${cmd.id})`) - - // Ack immediately - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'ack', - }) - - // Execute locally against the approval queue - const port = process.env.REFLECTT_NODE_PORT || '4445' - try { - const res = await fetch(`http://127.0.0.1:${port}/approval-queue/${encodeURIComponent(eventId)}/decide`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ decision, actor, rationale }), - }) - - const result = await res.json().catch(() => ({ success: false })) - - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'complete', - result: { eventId, decision, status: res.status, ...(result as Record) }, - }) - - console.log(`☁️ [Commands] run_approve ${decision} for ${eventId} — ${res.status}`) - } catch (err: any) { - console.warn(`☁️ [Commands] run_approve failed for ${eventId}: ${err?.message}`) - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'fail', - error: err?.message || 'Local approval-queue call failed', - }) - } -} - -async function handleContextSync(cmd: PendingCommand): Promise { - if (!state.hostId) return - - // Require explicit agent — no hardcoded fallback - const agent = (cmd.payload?.agent as string)?.trim() - if (!agent) { - console.warn(`☁️ [Commands] context_sync missing payload.agent (${cmd.id}) — failing`) - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'fail', - error: 'payload.agent is required', - }) - return - } - - console.log(`☁️ [Commands] Processing context_sync for agent=${agent} (${cmd.id})`) - - // Ack immediately (in-progress) - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'ack', - }) - - // Fetch context snapshot from local node - const port = process.env.REFLECTT_NODE_PORT || '4445' - let contextData: Record - try { - const localRes = await fetch(`http://127.0.0.1:${port}/context/inject/${encodeURIComponent(agent)}`) - if (!localRes.ok) throw new Error(`Local context fetch failed: ${localRes.status}`) - contextData = await localRes.json() as Record - } catch (err: any) { - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'fail', - error: `Failed to fetch local context: ${err?.message}`, - }) - throw err - } - - // Push to cloud — use computed_at from injection payload when available - const computedAt = (typeof contextData.computed_at === 'number' && contextData.computed_at > 0) - ? contextData.computed_at - : Date.now() - - const syncResult = await cloudPost(`/api/hosts/${state.hostId}/context/sync`, { - agent, - computed_at: computedAt, - budgets: contextData.budgets || { totalTokens: 0, layers: {} }, - autosummary_enabled: Boolean(contextData.autosummary_enabled), - layers: contextData.layers || {}, - }) - - if (syncResult.success) { - console.log(`☁️ [Commands] context_sync completed for ${agent} (${cmd.id})`) - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'complete', - result: { syncedAt: Date.now(), agent }, - }) - markCloudActivity() // Mark as active - } else { - console.warn(`☁️ [Commands] context_sync failed for ${agent}: ${syncResult.error}`) - await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { - action: 'fail', - error: syncResult.error, - }) - } -} +// pollAndProcessCommands / handleCommand / handleContextSync / +// handleRunApprove relocated to src/commands/drain.ts (§11e step 1). // ---- HTTP helper ---- @@ -2401,7 +2228,7 @@ async function attemptCredentialReclaim(): Promise { } } -async function cloudGet(path: string, _retried = false): Promise> { +export async function cloudGet(path: string, _retried = false): Promise> { if (!config) return { success: false, error: 'Not configured' } try { @@ -2435,7 +2262,7 @@ async function cloudGet(path: string, _retried = false): Promise(path: string, body: unknown, _retried = false): Promise> { +export async function cloudPost(path: string, body: unknown, _retried = false): Promise> { if (!config) return { success: false, error: 'Not configured' } try { diff --git a/src/commands/drain.ts b/src/commands/drain.ts new file mode 100644 index 00000000..75a5d671 --- /dev/null +++ b/src/commands/drain.ts @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) Reflectt AI + +// Command Drain — node-side cloud command queue runtime. +// +// Behavior-zero relocation of the inline command runtime previously +// living in `src/cloud.ts`. Same poll cadence (10s active / 60s idle), +// same dispatch (inline if/else by `cmd.type`), same ack contract. +// No registry yet — that lands in step 2 of §11e in +// `reflectt-cloud/docs/COMMAND_PLUGIN_SKILL_ARCHITECTURE.md`. +// +// Scope locks (kai msg-1777492135899 / link msg-1777492193718): +// - keep step 1 behavior-zero +// - same poll cadence +// - same ack contract +// - same inline dispatch semantics (no registry creep until step 2) +// - no auth / intake churn +// - no new verbs + +import { state, config, isIdle, cloudGet, cloudPost, markCloudActivity } from '../cloud.js' + +export const COMMAND_POLL_ACTIVE_MS = 10_000 // 10s when active +export const COMMAND_POLL_IDLE_MS = 60_000 // 60s when idle +let commandPollErrors = 0 +let lastCommandPollAt = 0 + +interface PendingCommand { + id: string + type: string + payload: Record + status: string +} + +export async function pollAndProcessCommands(): Promise { + if (!state.hostId || !config || !state.running) return + + const now = Date.now() + const interval = isIdle() ? COMMAND_POLL_IDLE_MS : COMMAND_POLL_ACTIVE_MS + if (now - lastCommandPollAt < interval) return + lastCommandPollAt = now + + const result = await cloudGet<{ commands: PendingCommand[] }>( + `/api/hosts/${state.hostId}/commands?status=pending` + ) + + if (!result.success || !result.data?.commands) { + commandPollErrors++ + if (commandPollErrors <= 3 || commandPollErrors % 20 === 0) { + console.warn(`☁️ [Commands] Poll failed (${commandPollErrors}): ${result.error}`) + } + return + } + + if (commandPollErrors > 0) { + console.log(`☁️ [Commands] Poll recovered after ${commandPollErrors} errors`) + commandPollErrors = 0 + } + + for (const cmd of result.data.commands) { + try { + await handleCommand(cmd) + } catch (err: any) { + console.warn(`☁️ [Commands] Failed to handle ${cmd.type} (${cmd.id}): ${err?.message}`) + // Ack as failed so it doesn't re-run + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'fail', + error: err?.message || 'Handler error', + }).catch(() => {}) + } + } +} + +async function handleCommand(cmd: PendingCommand): Promise { + if (cmd.type === 'context_sync') { + await handleContextSync(cmd) + } else if (cmd.type === 'run_approve') { + await handleRunApprove(cmd) + } else { + console.log(`☁️ [Commands] Unknown command type: ${cmd.type} (${cmd.id}) — skipping`) + // Ack unknown commands so they don't pile up + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'complete', + result: { skipped: true, reason: 'unknown_type' }, + }) + } +} + +async function handleRunApprove(cmd: PendingCommand): Promise { + if (!state.hostId) return + + const eventId = cmd.payload?.eventId as string + const decision = cmd.payload?.decision as string + const actor = cmd.payload?.actor as string || 'cloud-dashboard' + const rationale = cmd.payload?.rationale as string || '' + + if (!eventId || !decision) { + console.warn(`☁️ [Commands] run_approve missing eventId/decision (${cmd.id}) — failing`) + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'fail', + error: 'eventId and decision are required', + }) + return + } + + console.log(`☁️ [Commands] Processing run_approve: ${decision} for ${eventId} (${cmd.id})`) + + // Ack immediately + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'ack', + }) + + // Execute locally against the approval queue + const port = process.env.REFLECTT_NODE_PORT || '4445' + try { + const res = await fetch(`http://127.0.0.1:${port}/approval-queue/${encodeURIComponent(eventId)}/decide`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision, actor, rationale }), + }) + + const result = await res.json().catch(() => ({ success: false })) + + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'complete', + result: { eventId, decision, status: res.status, ...(result as Record) }, + }) + + console.log(`☁️ [Commands] run_approve ${decision} for ${eventId} — ${res.status}`) + } catch (err: any) { + console.warn(`☁️ [Commands] run_approve failed for ${eventId}: ${err?.message}`) + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'fail', + error: err?.message || 'Local approval-queue call failed', + }) + } +} + +async function handleContextSync(cmd: PendingCommand): Promise { + if (!state.hostId) return + + // Require explicit agent — no hardcoded fallback + const agent = (cmd.payload?.agent as string)?.trim() + if (!agent) { + console.warn(`☁️ [Commands] context_sync missing payload.agent (${cmd.id}) — failing`) + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'fail', + error: 'payload.agent is required', + }) + return + } + + console.log(`☁️ [Commands] Processing context_sync for agent=${agent} (${cmd.id})`) + + // Ack immediately (in-progress) + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'ack', + }) + + // Fetch context snapshot from local node + const port = process.env.REFLECTT_NODE_PORT || '4445' + let contextData: Record + try { + const localRes = await fetch(`http://127.0.0.1:${port}/context/inject/${encodeURIComponent(agent)}`) + if (!localRes.ok) throw new Error(`Local context fetch failed: ${localRes.status}`) + contextData = await localRes.json() as Record + } catch (err: any) { + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'fail', + error: `Failed to fetch local context: ${err?.message}`, + }) + throw err + } + + // Push to cloud — use computed_at from injection payload when available + const computedAt = (typeof contextData.computed_at === 'number' && contextData.computed_at > 0) + ? contextData.computed_at + : Date.now() + + const syncResult = await cloudPost(`/api/hosts/${state.hostId}/context/sync`, { + agent, + computed_at: computedAt, + budgets: contextData.budgets || { totalTokens: 0, layers: {} }, + autosummary_enabled: Boolean(contextData.autosummary_enabled), + layers: contextData.layers || {}, + }) + + if (syncResult.success) { + console.log(`☁️ [Commands] context_sync completed for ${agent} (${cmd.id})`) + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'complete', + result: { syncedAt: Date.now(), agent }, + }) + markCloudActivity() // Mark as active + } else { + console.warn(`☁️ [Commands] context_sync failed for ${agent}: ${syncResult.error}`) + await cloudPost(`/api/hosts/${state.hostId}/commands/${cmd.id}/ack`, { + action: 'fail', + error: syncResult.error, + }) + } +}