From 29560a4f2fb5beedb8a29905e47e961b20ffc87e Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 4 May 2026 08:23:05 -0700 Subject: [PATCH] feat(node): add read-only team fleet route --- src/cloud.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/server.ts | 39 ++++++++++++++++++++++++++++++++++++++- tests/api.test.ts | 8 ++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/cloud.ts b/src/cloud.ts index 4ebc0c58..99fe8ed3 100644 --- a/src/cloud.ts +++ b/src/cloud.ts @@ -331,6 +331,15 @@ export function getCloudStatus() { } } +/** + * Fetch the current team fleet snapshot for this host. + * Uses the host credential to read the persisted cross-host truth from cloud. + */ +export async function getTeamFleet(): Promise> { + if (!state.hostId) return { success: false, error: 'Host not registered with cloud' } + return cloudGet(`/api/hosts/${state.hostId}/team/fleet`) +} + /** * Initialize and start cloud integration. * Call this after the server is listening. @@ -2219,6 +2228,35 @@ interface CloudApiResponse { error?: string } +export interface TeamFleetAgentSnapshot { + id?: string + name?: string + displayName?: string + state?: string + status?: string + [key: string]: unknown +} + +export interface TeamFleetHostSnapshot { + id: string + teamId: string + name: string + status: string + lastSeen: string + agents: TeamFleetAgentSnapshot[] + activeTasks: unknown[] + slowTasks: unknown[] + appVersion: string | null + gitSha: string | null + buildTimestamp: string | null + convergence?: unknown +} + +export interface TeamFleetSnapshot { + teamId: string + hosts: TeamFleetHostSnapshot[] +} + async function cloudGet(path: string): Promise> { if (!config) return { success: false, error: 'Not configured' } diff --git a/src/server.ts b/src/server.ts index be5555bd..bf972972 100644 --- a/src/server.ts +++ b/src/server.ts @@ -178,7 +178,7 @@ import { createRun, getRun, subscribeRun, approveRun, rejectRun, executeGithubIs import { validateIntent as macOSValidateIntent, isKillSwitchEngaged, engageKillSwitch, resetKillSwitch } from './macos-accessibility.js' import { calendarManager, type BlockType, type CreateBlockInput, type UpdateBlockInput } from './calendar.js' import { calendarEvents, type CreateEventInput, type UpdateEventInput, type AttendeeStatus } from './calendar-events.js' -import { requestImmediateCanvasSync, queueCanvasPushEvent, readCapabilityContext } from './cloud.js' +import { requestImmediateCanvasSync, queueCanvasPushEvent, readCapabilityContext, getTeamFleet } from './cloud.js' import { startReminderEngine, stopReminderEngine, getReminderEngineStats } from './calendar-reminder-engine.js' import { startDeployMonitor, stopDeployMonitor } from './deploy-monitor.js' import { exportICS, exportEventICS, importICS, parseICS } from './calendar-ical.js' @@ -3028,6 +3028,43 @@ export async function createServer(): Promise { } }) + // Cross-host team fleet snapshot (read-only) + app.get('/team/fleet', async (_request, reply) => { + const result = await getTeamFleet() + if (!result.success || !result.data) { + reply.code(result.error === 'Host not registered with cloud' ? 503 : 502) + return { + success: false, + error: result.error || 'Failed to fetch team fleet', + } + } + + return { + success: true, + teamId: result.data.teamId, + hosts: result.data.hosts.map((host) => ({ + hostId: host.id, + hostName: host.name, + status: host.status, + lastSeen: host.lastSeen, + appVersion: host.appVersion, + gitSha: host.gitSha, + buildTimestamp: host.buildTimestamp, + agents: host.agents.map((agent) => ({ + id: typeof agent.id === 'string' ? agent.id : null, + name: typeof agent.name === 'string' ? agent.name : null, + displayName: typeof agent.displayName === 'string' ? agent.displayName : null, + state: typeof agent.state === 'string' + ? agent.state + : (typeof agent.status === 'string' ? agent.status : null), + })), + activeTasks: host.activeTasks, + slowTasks: host.slowTasks, + convergence: host.convergence ?? null, + })), + } + }) + // Team health monitoring app.get('/health/team', async (request, reply) => { const health = await healthMonitor.getHealth() diff --git a/tests/api.test.ts b/tests/api.test.ts index a6c15e59..26689f10 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -181,6 +181,14 @@ describe('Health', () => { expect(Array.isArray(body.assignmentRoleNames)).toBe(true) }) + it('GET /team/fleet returns a clear error when cloud fleet truth is unavailable', async () => { + const { status, body } = await req('GET', '/team/fleet') + expect(status === 502 || status === 503).toBe(true) + expect(body.success).toBe(false) + expect(typeof body.error).toBe('string') + expect(body.error.length).toBeGreaterThan(0) + }) + it('GET /health/team includes host-local scope metadata', async () => { const { status, body } = await req('GET', '/health/team') expect(status).toBe(200)