feat(serve): Add runtime.activity fields to daemon status API (#6270)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(serve): add runtime.activity fields to daemon status API

Add activePrompts, lastActivityAt, and idleSinceMs to the
GET /daemon/status runtime section. These fields already exist on the
bridge (and are exposed via GET /health?deep=1) but were missing from
the richer status endpoint that operators use for troubleshooting.

The idleSinceMs value is computed from a cached lastActivityAt read
(same pattern as the health handler) to ensure consistency within a
single response.

* feat(serve): add MCP server health summary to workspace status

Extract serversConnected, serversErrored, and serversDisabled counts
from the MCP servers array into the workspace.mcp.summary object.
Operators can see MCP fleet health at a glance without expanding the
full JSON.

* fix(serve): guard activity fields against undefined bridge getters

Add ?? null / ?? 0 fallbacks for lastActivityAt and activePromptCount
to prevent RangeError when a test fake bridge omits these properties.
This commit is contained in:
jinye 2026-07-04 04:19:02 +08:00 committed by GitHub
parent 9f87c90a50
commit 5dc2e1501f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 114 additions and 1 deletions

View file

@ -326,6 +326,11 @@ Response shape:
"inbound": { "count": 0, "totalBytes": 0, "maxBytes": 0 },
"outbound": { "count": 0, "totalBytes": 0, "maxBytes": 0 }
}
},
"activity": {
"activePrompts": 0,
"lastActivityAt": null,
"idleSinceMs": null
}
}
}
@ -346,6 +351,8 @@ mounted, `/daemon/status` may report `daemon_runtime_starting`; if the async
runtime mount fails, it reports `daemon_runtime_failed` while non-status
runtime routes return `503`.
`runtime.activity` reports daemon-wide prompt activity. `activePrompts` counts sessions with an in-flight prompt. `lastActivityAt` is the ISO 8601 timestamp of the last prompt start/end or session spawn; `null` when the daemon has never processed any activity since boot. `idleSinceMs` is computed from `lastActivityAt` at response generation time.
`runtime.channel.live` reports the ACP bridge channel inside the daemon. It is
not the channel-adapter worker. Daemon-managed channels use
`runtime.channelWorker`, whose `state` is one of `disabled`, `starting`,

View file

@ -307,6 +307,37 @@ describe('buildDaemonStatusResponse', () => {
});
});
it('summarizes MCP server health in workspace.mcp.summary', async () => {
const response = await buildDaemonStatusResponse(
'full',
makeOptions({
mcpStatus: {
v: 1,
workspaceCwd: BASE_WORKSPACE,
initialized: true,
servers: [
{ name: 'a', mcpStatus: 'connected', disabled: false },
{ name: 'b', mcpStatus: 'connected', disabled: false },
{
name: 'c',
mcpStatus: 'disconnected',
status: 'error',
disabled: false,
},
{ name: 'd', disabled: true },
],
},
}),
);
const mcpSummary = response.full?.workspace?.['mcp']?.summary;
expect(mcpSummary).toMatchObject({
serversCount: 4,
serversConnected: 2,
serversErrored: 1,
serversDisabled: 1,
});
});
it('marks a timed-out full workspace section unavailable', async () => {
vi.useFakeTimers();
@ -399,6 +430,34 @@ describe('buildDaemonStatusResponse', () => {
expect(response.runtime).not.toHaveProperty('perf');
});
it('includes activity fields in runtime', async () => {
vi.useFakeTimers({ now: 1719990005000 });
const response = await buildDaemonStatusResponse(
'summary',
makeOptions({
activePromptCount: 3,
lastActivityAt: 1719990000000,
}),
);
expect(response.runtime.activity).toEqual({
activePrompts: 3,
lastActivityAt: '2024-07-03T07:00:00.000Z',
idleSinceMs: 5000,
});
});
it('reports null activity when daemon has never been active', async () => {
const response = await buildDaemonStatusResponse(
'summary',
makeOptions({ activePromptCount: 0, lastActivityAt: null }),
);
expect(response.runtime.activity).toEqual({
activePrompts: 0,
lastActivityAt: null,
idleSinceMs: null,
});
});
});
interface MakeOptionsInput {
@ -418,6 +477,8 @@ interface MakeOptionsInput {
outbound: { count: number; totalBytes: number; maxBytes: number };
};
};
activePromptCount?: number;
lastActivityAt?: number | null;
}
function makeOptions(input: MakeOptionsInput = {}): BuildDaemonStatusOptions {
@ -431,6 +492,8 @@ function makeOptions(input: MakeOptionsInput = {}): BuildDaemonStatusOptions {
getDaemonStatusSnapshot: () => input.bridgeSnapshot ?? BASE_BRIDGE_SNAPSHOT,
getWorkspaceToolsStatus: async () =>
input.toolsStatus ?? okStatus({ tools: [] }),
activePromptCount: input.activePromptCount ?? 0,
lastActivityAt: input.lastActivityAt ?? null,
} as unknown as AcpSessionBridge;
const workspace = {
getWorkspaceMcpStatus: async () =>

View file

@ -170,6 +170,11 @@ interface DaemonStatusRuntime {
rejectedSinceStart: Record<RateLimitTier, number>;
};
perf?: DaemonPerfSnapshot;
activity: {
activePrompts: number;
lastActivityAt: string | null;
idleSinceMs: number | null;
};
process: NodeJS.MemoryUsage;
}
@ -239,6 +244,7 @@ export async function buildDaemonStatusResponse(
input: BuildDaemonStatusOptions,
): Promise<DaemonStatusResponse> {
const bridgeSnapshot = input.bridge.getDaemonStatusSnapshot();
const lastActivity = input.bridge.lastActivityAt ?? null;
const acpSnapshot = input.acpHandle?.registry.getSnapshot();
const rateLimitHits = input.rateLimiter?.getHitCounts() ?? zeroRateHits();
const channelWorker = input.getChannelWorkerSnapshot?.() ?? {
@ -336,6 +342,12 @@ export async function buildDaemonStatusResponse(
rejectedSinceStart: rateLimitHits,
},
...(input.getPerfSnapshot ? { perf: input.getPerfSnapshot() } : {}),
activity: {
activePrompts: input.bridge.activePromptCount ?? 0,
lastActivityAt:
lastActivity !== null ? new Date(lastActivity).toISOString() : null,
idleSinceMs: lastActivity !== null ? Date.now() - lastActivity : null,
},
process: process.memoryUsage(),
},
...(full ? { full } : {}),
@ -669,9 +681,35 @@ function summarizeStatusData(data: unknown): SectionSummary {
}
}
summarizeMcpServers(data, summary);
return summary;
}
function summarizeMcpServers(
data: StatusRecord,
summary: SectionSummary,
): void {
const servers = data['servers'];
if (!Array.isArray(servers)) return;
let connected = 0;
let errored = 0;
let disabled = 0;
for (const server of servers) {
if (!isRecord(server)) continue;
if (server['disabled'] === true) {
disabled++;
} else if (server['status'] === 'error') {
errored++;
} else if (server['mcpStatus'] === 'connected') {
connected++;
}
}
summary['serversConnected'] = connected;
summary['serversErrored'] = errored;
summary['serversDisabled'] = disabled;
}
function collectStatuses(data: unknown): string[] {
const statuses: string[] = [];
visitStatusContainers(data, (record) => {

View file

@ -1140,6 +1140,11 @@ function createBootstrapServeApp(input: {
read: 0,
},
},
activity: {
activePrompts: 0,
lastActivityAt: null,
idleSinceMs: null,
},
process: process.memoryUsage(),
},
...(detail.detail === 'full'
@ -1240,7 +1245,7 @@ function isCorsPreflightRequest(req: Request): boolean {
Boolean(req.headers.origin) &&
Boolean(
req.headers['access-control-request-method'] ||
req.headers['access-control-request-headers'],
req.headers['access-control-request-headers'],
)
);
}