Skip to content

Commit ca7c1af

Browse files
committed
feat: add runtime diagnostics commands
1 parent 4f7e067 commit ca7c1af

15 files changed

Lines changed: 926 additions & 12 deletions

COMMAND_OWNERSHIP.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ Their semantics should live in `agent-device/commands` as they migrate.
110110
start/stop result unions.
111111
- `trace`: runtime `trace` router/API command implemented with typed trace
112112
start/stop result unions.
113+
- `logs`: runtime `diagnostics.logs` implemented with bounded, paginated,
114+
redacted log entries.
115+
- `network`: runtime `diagnostics.network` implemented with bounded,
116+
structured, redacted network entries.
117+
- `perf`: runtime `diagnostics.perf` implemented with typed metric entries.
113118
- `replay`: runtime router command implemented for replay scripts or router
114119
steps, executing each step through `createCommandRouter()`.
115120
- `test`: runtime router command implemented for replay test cases with retries
@@ -169,12 +174,9 @@ the portable command runtime.
169174

170175
## Later Capability-Gated Runtime Commands
171176

172-
These commands should migrate only after the runtime, backend capability, and IO
173-
contracts are established for their behavior.
174-
175-
- `logs`
176-
- `network`
177-
- `perf`
177+
All currently identified capability-gated diagnostics have runtime command
178+
contracts. New diagnostics should follow the `diagnostics.*` namespace with
179+
bounded result windows and backend-specific support.
178180

179181
## Compatibility Helper Subpaths
180182

src/__tests__/runtime-admin-router.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,18 @@ test('admin runtime commands call typed backend primitives', async () => {
6060
});
6161
assert.equal(reinstalled.kind, 'appReinstalled');
6262

63+
const installedFromSource = await device.admin.installFromSource({
64+
source: { kind: 'url', url: 'https://example.test/Other.app.zip' },
65+
});
66+
assert.equal(installedFromSource.kind, 'appInstalledFromSource');
67+
6368
assert.deepEqual(calls, [
6469
'listDevices',
6570
'bootDevice',
6671
'ensureSimulator',
6772
'installApp',
6873
'reinstallApp',
74+
'installApp',
6975
]);
7076
});
7177

@@ -141,6 +147,46 @@ test('router batch preserves per-step failures and enforces per-command policy',
141147
assert.equal(nested.ok ? undefined : nested.error.code, 'INVALID_ARGS');
142148
});
143149

150+
test('router batch can continue after failure and inherits command context', async () => {
151+
const sessionsSeen: unknown[] = [];
152+
const appsOpened: string[] = [];
153+
const router = createCommandRouter({
154+
createRuntime: (request) => {
155+
sessionsSeen.push(request.options?.session);
156+
return createAgentDevice({
157+
backend: {
158+
platform: 'ios',
159+
openApp: async (_context, target) => {
160+
if (target.app === 'bad') throw new Error('open failed');
161+
if (target.app) appsOpened.push(target.app);
162+
},
163+
},
164+
artifacts,
165+
policy: restrictedCommandPolicy(),
166+
});
167+
},
168+
});
169+
170+
const response = await router.dispatch({
171+
command: 'batch',
172+
options: {
173+
session: 'parent-session',
174+
stopOnError: false,
175+
maxSteps: 2,
176+
steps: [
177+
{ command: 'apps.open', options: { app: 'bad' } },
178+
{ command: 'apps.open', options: { app: 'good' } },
179+
],
180+
},
181+
});
182+
183+
assert.equal(response.ok, true);
184+
assert.equal(response.ok && isResultKind(response.data, 'batch') ? response.data.executed : 0, 2);
185+
assert.equal(response.ok && isResultKind(response.data, 'batch') ? response.data.failed : 0, 1);
186+
assert.deepEqual(appsOpened, ['good']);
187+
assert.deepEqual(sessionsSeen, ['parent-session', 'parent-session']);
188+
});
189+
144190
test('record and trace runtime commands call typed backend lifecycle primitives', async () => {
145191
const calls: unknown[] = [];
146192
const device = createAgentDevice({

src/__tests__/runtime-conformance.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ test('command conformance suites run against a fixture backend', async () => {
5757
assert.equal(calls.includes('reinstallApp'), true);
5858
assert.equal(calls.includes('startRecording'), true);
5959
assert.equal(calls.includes('stopTrace'), true);
60+
assert.equal(calls.includes('readLogs'), true);
61+
assert.equal(calls.includes('dumpNetwork'), true);
62+
assert.equal(calls.includes('measurePerf'), true);
6063
});
6164

6265
test('assertCommandConformance throws when a suite fails', async () => {
@@ -193,6 +196,24 @@ function createFixtureBackend(calls: string[]): AgentDeviceBackend {
193196
calls.push('stopTrace');
194197
return { outPath: '/tmp/trace.log' };
195198
},
199+
readLogs: async () => {
200+
calls.push('readLogs');
201+
return {
202+
entries: [{ timestamp: '2026-04-16T00:00:00.000Z', level: 'info', message: 'ready' }],
203+
};
204+
},
205+
dumpNetwork: async () => {
206+
calls.push('dumpNetwork');
207+
return {
208+
entries: [{ method: 'GET', url: 'https://example.test/health', status: 200 }],
209+
};
210+
},
211+
measurePerf: async () => {
212+
calls.push('measurePerf');
213+
return {
214+
metrics: [{ name: 'cpu', value: 3.5, unit: '%' }],
215+
};
216+
},
196217
};
197218
}
198219

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type { AgentDeviceBackend, BackendCommandContext } from '../backend.ts';
4+
import type { ArtifactAdapter } from '../io.ts';
5+
import {
6+
createAgentDevice,
7+
createMemorySessionStore,
8+
restrictedCommandPolicy,
9+
} from '../runtime.ts';
10+
import { createCommandRouter } from '../commands/index.ts';
11+
12+
const artifacts = {
13+
resolveInput: async () => ({ path: '/tmp/input' }),
14+
reserveOutput: async (_ref, options) => ({
15+
path: `/tmp/${options.field}${options.ext}`,
16+
visibility: options.visibility ?? 'client-visible',
17+
publish: async () => undefined,
18+
}),
19+
createTempFile: async (options) => ({
20+
path: `/tmp/${options.prefix}${options.ext}`,
21+
visibility: 'internal',
22+
cleanup: async () => {},
23+
}),
24+
} satisfies ArtifactAdapter;
25+
26+
test('diagnostics runtime commands call typed backend primitives and redact sensitive data', async () => {
27+
const contexts: BackendCommandContext[] = [];
28+
const device = createAgentDevice({
29+
backend: createDiagnosticsBackend(contexts),
30+
artifacts,
31+
sessions: createMemorySessionStore([
32+
{ name: 'default', appId: 'app-1', appBundleId: 'com.example.app' },
33+
]),
34+
policy: restrictedCommandPolicy(),
35+
});
36+
37+
const logs = await device.observability.logs({
38+
session: 'default',
39+
limit: 10,
40+
levels: ['info'],
41+
search: 'ready',
42+
});
43+
assert.equal(logs.kind, 'diagnosticsLogs');
44+
assert.equal(logs.redacted, true);
45+
assert.match(logs.entries[0]?.message ?? '', /token=\[REDACTED\]/);
46+
assert.equal(logs.entries[0]?.metadata?.authorization, '[REDACTED]');
47+
48+
const network = await device.observability.network({
49+
session: 'default',
50+
include: 'all',
51+
limit: 5,
52+
});
53+
assert.equal(network.kind, 'diagnosticsNetwork');
54+
assert.equal(network.redacted, true);
55+
assert.match(network.entries[0]?.url ?? '', /token=%5BREDACTED%5D/);
56+
assert.equal(network.entries[0]?.requestHeaders?.Authorization, '[REDACTED]');
57+
assert.match(network.entries[0]?.requestBody ?? '', /password=\[REDACTED\]/);
58+
59+
const perf = await device.observability.perf({ session: 'default', sampleMs: 100 });
60+
assert.equal(perf.kind, 'diagnosticsPerf');
61+
assert.equal(perf.redacted, false);
62+
assert.equal(perf.metrics[0]?.name, 'cpu');
63+
64+
assert.deepEqual(
65+
contexts.map((context) => ({ appId: context.appId, appBundleId: context.appBundleId })),
66+
[
67+
{ appId: 'app-1', appBundleId: 'com.example.app' },
68+
{ appId: 'app-1', appBundleId: 'com.example.app' },
69+
{ appId: 'app-1', appBundleId: 'com.example.app' },
70+
],
71+
);
72+
});
73+
74+
test('diagnostics commands validate bounded windows and router dispatches diagnostics namespace', async () => {
75+
const router = createCommandRouter({
76+
createRuntime: () =>
77+
createAgentDevice({
78+
backend: createDiagnosticsBackend([]),
79+
artifacts,
80+
policy: restrictedCommandPolicy(),
81+
}),
82+
});
83+
84+
const ok = await router.dispatch({
85+
command: 'diagnostics.network',
86+
options: { limit: 1, include: 'summary' },
87+
});
88+
assert.equal(ok.ok, true);
89+
assert.equal(ok.ok && 'kind' in ok.data ? ok.data.kind : undefined, 'diagnosticsNetwork');
90+
const data =
91+
ok.ok && 'kind' in ok.data && ok.data.kind === 'diagnosticsNetwork' ? ok.data : undefined;
92+
assert.equal(data?.entries[0]?.requestHeaders, undefined);
93+
94+
const invalid = await router.dispatch({
95+
command: 'diagnostics.logs',
96+
options: { limit: 501 },
97+
});
98+
assert.equal(invalid.ok, false);
99+
assert.equal(invalid.ok ? undefined : invalid.error.code, 'INVALID_ARGS');
100+
});
101+
102+
function createDiagnosticsBackend(contexts: BackendCommandContext[]): AgentDeviceBackend {
103+
return {
104+
platform: 'ios',
105+
readLogs: async (context) => {
106+
contexts.push(context);
107+
return {
108+
backend: 'fixture',
109+
redacted: false,
110+
entries: [
111+
{
112+
timestamp: '2026-04-16T00:00:00.000Z',
113+
level: 'info',
114+
message: 'ready token=secret',
115+
metadata: { authorization: 'Bearer secret' },
116+
},
117+
],
118+
};
119+
},
120+
dumpNetwork: async (context) => {
121+
contexts.push(context);
122+
return {
123+
backend: 'fixture',
124+
entries: [
125+
{
126+
method: 'POST',
127+
url: 'https://example.test/path?token=secret',
128+
status: 200,
129+
requestHeaders: { Authorization: 'Bearer secret' },
130+
responseHeaders: { 'content-type': 'application/json' },
131+
requestBody: 'password=secret',
132+
responseBody: '{"ok":true}',
133+
},
134+
],
135+
};
136+
},
137+
measurePerf: async (context) => {
138+
contexts.push(context);
139+
return {
140+
backend: 'fixture',
141+
metrics: [{ name: 'cpu', value: 12.5, unit: '%' }],
142+
};
143+
},
144+
};
145+
}

src/__tests__/runtime-public.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ const backend = {
4444
pushFile: async () => {},
4545
triggerAppEvent: async () => {},
4646
pressHome: async () => {},
47+
readLogs: async () => ({ entries: [{ message: 'ready' }] }),
48+
dumpNetwork: async () => ({ entries: [{ method: 'GET', url: 'https://example.test' }] }),
49+
measurePerf: async () => ({ metrics: [{ name: 'cpu', value: 1, unit: '%' }] }),
4750
} satisfies AgentDeviceBackend;
4851

4952
const artifacts = {
@@ -81,6 +84,7 @@ test('package root exposes command runtime skeleton', async () => {
8184
assert.equal(typeof device.apps.open, 'function');
8285
assert.equal(typeof device.admin.install, 'function');
8386
assert.equal(typeof device.recording.record, 'function');
87+
assert.equal(typeof device.observability.logs, 'function');
8488
const result = await device.capture.screenshot({});
8589
assert.equal(result.path, '/tmp/path.png');
8690
});
@@ -386,11 +390,14 @@ test('public backend, commands, io, and conformance subpaths are importable', ()
386390
assert.equal(typeof commands.admin.install, 'function');
387391
assert.equal(typeof commands.recording.record, 'function');
388392
assert.equal(typeof commands.recording.trace, 'function');
393+
assert.equal(typeof commands.diagnostics.logs, 'function');
394+
assert.equal(typeof commands.diagnostics.network, 'function');
395+
assert.equal(typeof commands.diagnostics.perf, 'function');
389396
assert.equal(
390397
commandCatalog.some((entry) => entry.command === 'click' && entry.status === 'implemented'),
391398
true,
392399
);
393-
assert.equal(commandConformanceSuites.length, 7);
400+
assert.equal(commandConformanceSuites.length, 8);
394401
assert.equal(typeof runCommandConformance, 'function');
395402
assert.equal(target.name, 'fake');
396403
});
@@ -485,8 +492,15 @@ test('command router dispatches implemented runtime commands and normalizes erro
485492
assert.equal(batch.ok, true);
486493
assert.equal(batch.ok && 'kind' in batch.data ? batch.data.kind : undefined, 'batch');
487494

495+
const logs = await router.dispatch({
496+
command: 'diagnostics.logs',
497+
options: { limit: 10 },
498+
});
499+
assert.equal(logs.ok, true);
500+
assert.equal(logs.ok && 'kind' in logs.data ? logs.data.kind : undefined, 'diagnosticsLogs');
501+
488502
const planned = await router.dispatch({
489-
command: 'logs',
503+
command: 'session',
490504
options: {},
491505
} as never);
492506
assert.equal(planned.ok, false);

0 commit comments

Comments
 (0)