mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(serve): Add runtime.activity fields to daemon status API (#6270)
* 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:
parent
9f87c90a50
commit
5dc2e1501f
4 changed files with 114 additions and 1 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 () =>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue