diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 9c024f02a..cea340fe6 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -379,6 +379,53 @@ test('metro reload forwards host, port, bundle URL, and timeout to client.metro. assert.equal(stdout, 'Reloaded React Native apps via http://127.0.0.1:9090/reload\n'); }); +test('events prints a parsed session timeline from the generic CLI route', async () => { + let observed: Parameters[0] | undefined; + const client = createStubClient({ + installFromSource: async () => { + throw new Error('unexpected install call'); + }, + events: async (options) => { + observed = options; + return { + path: '/tmp/session/events.ndjson', + cursor: '0', + limit: 100, + events: [ + { + version: 1, + ts: '2026-07-02T12:00:00.000Z', + session: 'default', + kind: 'action.recorded', + command: 'click', + summary: 'Tapped @e14', + details: { ref: '@e14' }, + }, + ], + }; + }, + }); + + const output = await captureOutput(async () => { + const handled = await tryRunClientBackedCommand({ + command: 'events', + positionals: [], + flags: { + json: false, + help: false, + version: false, + }, + client, + }); + assert.equal(handled, true); + }); + + assert.equal(observed?.limit, undefined); + assert.equal(observed?.cursor, undefined); + assert.match(output.stdout, /action click\s+Tapped @e14/); + assert.match(output.stderr, /path=\/tmp\/session\/events\.ndjson/); +}); + test('screenshot forwards --overlay-refs to the client capture API', async () => { let observed: | { @@ -1074,6 +1121,7 @@ async function captureOutput( function createStubClient(params: { installFromSource: AgentDeviceClient['apps']['installFromSource']; listApps?: AgentDeviceClient['apps']['list']; + events?: AgentDeviceClient['observability']['events']; prepareMetro?: AgentDeviceClient['metro']['prepare']; reloadMetro?: AgentDeviceClient['metro']['reload']; open?: AgentDeviceClient['apps']['open']; @@ -1197,7 +1245,10 @@ function createStubClient(params: { interactions: createThrowingMethodGroup(), replay: createThrowingMethodGroup(), batch: createThrowingMethodGroup(), - observability: createThrowingMethodGroup(), + observability: { + ...createThrowingMethodGroup(), + events: params.events ?? unexpectedCommandCall, + }, debug: createThrowingMethodGroup(), recording: createThrowingMethodGroup(), settings: { diff --git a/src/client/client-shared.ts b/src/client/client-shared.ts index 17c905f57..743f38586 100644 --- a/src/client/client-shared.ts +++ b/src/client/client-shared.ts @@ -149,6 +149,7 @@ export function serializeOpenResult(result: AppOpenResult): Record Promise; logs: (options?: LogsOptions) => Promise; + events: (options?: EventsOptions) => Promise; network: (options?: NetworkOptions) => Promise; audio: (options?: AudioOptions) => Promise; }; diff --git a/src/client/client.ts b/src/client/client.ts index 93b39bb35..aed095526 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -189,6 +189,7 @@ export function createAgentDeviceClient( return { session, sessionStateDir: readOptionalString(data, 'sessionStateDir'), + eventLogPath: readOptionalString(data, 'eventLogPath'), appName: readOptionalString(data, 'appName'), appBundleId, appId, @@ -333,6 +334,7 @@ export function createAgentDeviceClient( observability: { perf: async (options = {}) => await executeCommand('perf', options), logs: async (options = {}) => await executeCommand('logs', options), + events: async (options = {}) => await executeCommand('events', options), network: async (options = {}) => await executeCommand('network', options), audio: async (options = {}) => await executeCommand('audio', options), }, diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 502f02c5a..9020e2f47 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -13,6 +13,7 @@ export const PUBLIC_COMMANDS = { clipboard: 'clipboard', devices: 'devices', doctor: 'doctor', + events: 'events', diff: 'diff', fill: 'fill', find: 'find', @@ -121,6 +122,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.doctor, + PUBLIC_COMMANDS.events, PUBLIC_COMMANDS.gesture, PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.test, diff --git a/src/commands/observability/index.test.ts b/src/commands/observability/index.test.ts index 9222984cd..d03c9ccb5 100644 --- a/src/commands/observability/index.test.ts +++ b/src/commands/observability/index.test.ts @@ -5,6 +5,10 @@ import { audioCommandDefinition, audioCommandMetadata, audioDaemonWriter, + eventsCliReader, + eventsCommandDefinition, + eventsCommandMetadata, + eventsDaemonWriter, logsCliReader, logsCommandDefinition, logsCommandMetadata, @@ -14,6 +18,7 @@ import { networkCommandMetadata, networkDaemonWriter, } from './index.ts'; +import { observabilityCliOutputFormatters } from './output.ts'; const NO_FLAGS = {} as CliFlags; @@ -30,6 +35,8 @@ describe('observability command interface', () => { test('owns logs and network public metadata', () => { expect(audioCommandMetadata.name).toBe('audio'); expect(audioCommandDefinition.name).toBe('audio'); + expect(eventsCommandMetadata.name).toBe('events'); + expect(eventsCommandDefinition.name).toBe('events'); expect(logsCommandMetadata.name).toBe('logs'); expect(logsCommandDefinition.name).toBe('logs'); expect(networkCommandMetadata.name).toBe('network'); @@ -68,6 +75,81 @@ describe('observability command interface', () => { }); }); + test('reads events pagination as compact daemon positionals', () => { + expect(eventsCliReader(['25', '100'], NO_FLAGS)).toEqual({ + limit: 25, + cursor: '100', + }); + expect(eventsCliReader(['', '100'], NO_FLAGS)).toEqual({ + limit: undefined, + cursor: '100', + }); + expect(eventsDaemonWriter({ limit: 25, cursor: '100' })).toMatchObject({ + command: 'events', + positionals: ['25', '100'], + }); + expect(eventsDaemonWriter({ cursor: '100' })).toMatchObject({ + command: 'events', + positionals: ['', '100'], + }); + }); + + test('formats events as a compact human timeline', () => { + const output = observabilityCliOutputFormatters.events({ + input: {}, + result: { + path: '/tmp/session/events.ndjson', + cursor: '0', + limit: 100, + events: [ + { + version: 1, + ts: '2026-07-02T12:00:00.000Z', + session: 'default', + kind: 'request.started', + command: 'open', + summary: 'Started open', + }, + { + version: 1, + ts: '2026-07-02T12:00:00.250Z', + session: 'default', + kind: 'request.finished', + command: 'open', + status: 'ok', + summary: 'Finished open', + details: { durationMs: 250 }, + }, + { + version: 1, + ts: '2026-07-02T12:00:01.000Z', + session: 'default', + kind: 'action.recorded', + command: 'fill', + summary: 'Filled @e14', + details: { ref: '@e14', textLength: 8 }, + }, + ], + }, + }); + + expect(output.text).toContain('2026-07-02 12:00:00.000Z start open'); + expect(output.text).toContain('2026-07-02 12:00:00.250Z ok open 250ms'); + expect(output.text).toContain('2026-07-02 12:00:01.000Z action fill'); + expect(output.text).toContain('Filled @e14 (text=8 chars)'); + expect(output.stderr).toContain('path=/tmp/session/events.ndjson'); + }); + + test('formats empty events page with a readable message', () => { + const output = observabilityCliOutputFormatters.events({ + input: {}, + result: { path: '/tmp/session/events.ndjson', cursor: '0', limit: 100, events: [] }, + }); + + expect(output.text).toBe('No session events found.'); + expect(output.stderr).toContain('cursor=0'); + }); + test('reads network include from flag or positional', () => { expect(networkCliReader(['dump', '25', 'headers'], NO_FLAGS)).toEqual({ action: 'dump', diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index 0c9d48d5e..ba28ca790 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -1,4 +1,9 @@ -import type { AudioOptions, LogsOptions, NetworkOptions } from '../../client/client-types.ts'; +import type { + AudioOptions, + EventsOptions, + LogsOptions, + NetworkOptions, +} from '../../client/client-types.ts'; import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../kernel/contracts.ts'; import { AppError } from '../../kernel/errors.ts'; import { parseStringMember } from '../../utils/string-enum.ts'; @@ -20,6 +25,7 @@ import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import { observabilityCliOutputFormatters } from './output.ts'; const LOGS_COMMAND_NAME = 'logs'; +const EVENTS_COMMAND_NAME = 'events'; const NETWORK_COMMAND_NAME = 'network'; const AUDIO_COMMAND_NAME = 'audio'; const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; @@ -27,6 +33,7 @@ const AUDIO_ACTION_VALUES = ['probe'] as const; const AUDIO_PROBE_ACTION_VALUES = ['start', 'status', 'stop'] as const; const logsCommandDescription = 'Manage session app logs.'; +const eventsCommandDescription = 'Read the session event timeline.'; const networkCommandDescription = 'Show recent HTTP traffic.'; const audioCommandDescription = 'Probe audio levels.'; @@ -40,6 +47,15 @@ export const logsCommandMetadata = defineFieldCommandMetadata( }, ); +export const eventsCommandMetadata = defineFieldCommandMetadata( + EVENTS_COMMAND_NAME, + eventsCommandDescription, + { + limit: integerField(), + cursor: stringField(), + }, +); + export const networkCommandMetadata = defineFieldCommandMetadata( NETWORK_COMMAND_NAME, networkCommandDescription, @@ -65,6 +81,11 @@ export const logsCommandDefinition = defineExecutableCommand(logsCommandMetadata client.observability.logs(input), ); +export const eventsCommandDefinition = defineExecutableCommand( + eventsCommandMetadata, + (client, input) => client.observability.events(input), +); + export const networkCommandDefinition = defineExecutableCommand( networkCommandMetadata, (client, input) => client.observability.network(input), @@ -85,6 +106,14 @@ const logsCliSchema = { allowedFlags: ['restart'], } as const satisfies CommandSchemaOverride; +const eventsCliSchema = { + usageOverride: 'events [limit] [cursor]', + listUsageOverride: 'events', + helpDescription: 'Read the daemon-owned session event timeline as paged JSON-friendly entries', + summary: 'Read session event timeline', + positionalArgs: ['limit?', 'cursor?'], +} as const satisfies CommandSchemaOverride; + const networkCliSchema = { usageOverride: 'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]', @@ -113,6 +142,12 @@ export const logsCliReader: CliReader = (positionals, flags) => ({ restart: flags.restart, }); +export const eventsCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + limit: positionals[0]?.trim() ? optionalCliNumber(positionals[0]) : undefined, + cursor: positionals[1], +}); + export const networkCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), action: readNetworkAction(positionals[0]), @@ -132,6 +167,9 @@ export const logsDaemonWriter: DaemonWriter = direct(LOGS_COMMAND_NAME, (input) logsPositionals(input as LogsOptions), ); +export const eventsDaemonWriter: DaemonWriter = (input) => + request(EVENTS_COMMAND_NAME, eventsPositionals(input as EventsOptions), input); + export const networkDaemonWriter: DaemonWriter = (input) => request(NETWORK_COMMAND_NAME, networkPositionals(input as NetworkOptions), { ...input, @@ -151,6 +189,16 @@ const logsCommandFacet = defineCommandFacet({ cliOutputFormatter: observabilityCliOutputFormatters.logs, }); +const eventsCommandFacet = defineCommandFacet({ + name: EVENTS_COMMAND_NAME, + metadata: eventsCommandMetadata, + definition: eventsCommandDefinition, + cliSchema: eventsCliSchema, + cliReader: eventsCliReader, + daemonWriter: eventsDaemonWriter, + cliOutputFormatter: observabilityCliOutputFormatters.events, +}); + const networkCommandFacet = defineCommandFacet({ name: NETWORK_COMMAND_NAME, metadata: networkCommandMetadata, @@ -173,13 +221,18 @@ const audioCommandFacet = defineCommandFacet({ export const observabilityCommandFamily = defineCommandFamilyFromFacets({ name: 'observability', - commands: [logsCommandFacet, networkCommandFacet, audioCommandFacet], + commands: [logsCommandFacet, eventsCommandFacet, networkCommandFacet, audioCommandFacet], }); function logsPositionals(input: { action?: string; message?: string }): string[] { return [input.action ?? 'path', ...optionalString(input.message)]; } +function eventsPositionals(input: EventsOptions): string[] { + if (input.cursor === undefined) return optionalNumber(input.limit); + return [input.limit === undefined ? '' : String(input.limit), input.cursor]; +} + function networkPositionals(input: NetworkOptions): string[] { return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)]; } diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index 99d07de5e..6dca8612a 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -23,6 +23,24 @@ type LogsCliResult = LogsActionFields & { notes?: readonly string[]; }; +type EventsCliEntry = { + ts?: string; + kind?: string; + requestId?: string; + command?: string; + status?: string; + summary?: string; + details?: Record; +}; + +type EventsCliResult = { + path?: string; + cursor?: string; + nextCursor?: string; + limit?: number; + events?: readonly EventsCliEntry[]; +}; + const LOG_ACTION_FIELD_KEYS = [ 'started', 'stopped', @@ -80,6 +98,17 @@ function logsCliOutput(data: LogsCliResult): CliOutput { }; } +function eventsCliOutput(data: EventsCliResult): CliOutput { + const events = data.events ?? []; + return { + data, + text: events.length > 0 ? formatEventEntries(events) : 'No session events found.', + stderr: joinDefinedLines([ + formatKeyValueFields(data, ['path', 'cursor', 'nextCursor', 'limit'] as const), + ]), + }; +} + function networkCliOutput(data: NetworkCliResult): CliOutput { const lines: string[] = []; const entries = data.entries ?? []; @@ -136,10 +165,109 @@ function audioCliOutput(data: AudioCliResult): CliOutput { export const observabilityCliOutputFormatters = { logs: resultOutput(logsCliOutput), + events: resultOutput(eventsCliOutput), network: resultOutput(networkCliOutput), audio: resultOutput(audioCliOutput), } as const satisfies Record; +function formatEventEntries(entries: readonly EventsCliEntry[]): string { + const rows = entries.map(formatEventRow); + const labelWidth = Math.min(Math.max(...rows.map((row) => row.label.length), 'event'.length), 32); + return rows.map((row) => formatEventRowLine(row, labelWidth)).join('\n'); +} + +function formatEventRow(entry: EventsCliEntry): { + timestamp: string; + label: string; + summary: string; +} { + return { + timestamp: formatEventTimestamp(entry.ts), + label: formatEventLabel(entry), + summary: formatEventSummary(entry), + }; +} + +function formatEventRowLine( + row: { timestamp: string; label: string; summary: string }, + labelWidth: number, +): string { + const label = row.label.padEnd(labelWidth); + const prefix = row.timestamp ? `${row.timestamp} ${label}` : label.trimEnd(); + return row.summary ? `${prefix} ${row.summary}` : prefix.trimEnd(); +} + +function formatEventTimestamp(value: string | undefined): string { + if (!value) return ''; + return value.replace('T', ' '); +} + +function formatEventLabel(entry: EventsCliEntry): string { + const command = entry.command ?? 'command'; + switch (entry.kind) { + case 'request.started': + return `start ${command}`; + case 'request.finished': + return joinDefinedWords([ + entry.status === 'error' ? 'error' : 'ok', + command, + formatDuration(readNumber(entry.details?.durationMs)), + ]); + case 'action.recorded': + return `action ${command}`; + default: + return joinDefinedWords([entry.kind ?? 'event', entry.command]); + } +} + +function formatEventSummary(entry: EventsCliEntry): string { + const summary = compactDefaultSummary(entry.summary, entry); + const hints = formatEventHints(entry, summary); + return `${summary}${hints}`.trim(); +} + +function compactDefaultSummary(summary: string | undefined, entry: EventsCliEntry): string { + const text = summary?.trim() ?? ''; + const command = entry.command ?? ''; + if (entry.kind === 'request.started' && text === `Started ${command}`) return ''; + if (entry.kind === 'request.finished' && text === `Finished ${command}`) return ''; + return text; +} + +function formatEventHints(entry: EventsCliEntry, summary: string): string { + if (entry.kind !== 'action.recorded') return ''; + const details = entry.details; + if (!details) return ''; + const hints = [ + formatActionTargetHint(details, summary), + formatTextLengthHint(readNumber(details.textLength)), + ].filter((hint): hint is string => Boolean(hint)); + return hints.length > 0 ? ` (${hints.join(', ')})` : ''; +} + +function formatActionTargetHint( + details: Record, + summary: string, +): string | undefined { + const target = readString(details.ref) ?? readString(details.selector) ?? formatPoint(details); + if (!target || summary.includes(target)) return undefined; + return `target=${target}`; +} + +function formatPoint(details: Record): string | undefined { + const x = readNumber(details.x); + const y = readNumber(details.y); + return x === undefined || y === undefined ? undefined : `(${x}, ${y})`; +} + +function formatTextLengthHint(length: number | undefined): string | undefined { + return length === undefined ? undefined : `text=${length} chars`; +} + +function formatDuration(durationMs: number | undefined): string | undefined { + return durationMs === undefined ? undefined : `${Math.round(durationMs)}ms`; +} + function formatAudioArray(label: string, value: readonly number[] | undefined): string | undefined { if (!Array.isArray(value)) return undefined; const numbers = value.filter( @@ -160,6 +288,18 @@ function formatActionField(key: string, value: true | number | null | undefined) return value == null ? '' : `${key}=${value}`; } +function joinDefinedWords(words: Array): string { + return words.filter((word): word is string => Boolean(word)).join(' '); +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + function formatNetworkEntry(entry: NetworkCliEntry): string[] { const method = entry.method ?? 'HTTP'; const url = entry.url ?? ''; diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 81b146769..0fd9e3a29 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -34,6 +34,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.doctor, + PUBLIC_COMMANDS.events, PUBLIC_COMMANDS.gesture, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.replay, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 99bdaee49..3297a5af6 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -174,6 +174,16 @@ const RAW_COMMAND_DESCRIPTORS = [ capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, batchable: true, }, + { + name: PUBLIC_COMMANDS.events, + daemon: { + route: 'session', + sessionKind: 'observability', + allowInvalidRecording: true, + ...REQUEST_EXECUTION_EXEMPT, + }, + batchable: false, + }, { name: PUBLIC_COMMANDS.network, daemon: { route: 'session', sessionKind: 'observability' }, diff --git a/src/daemon/__tests__/request-router-events.test.ts b/src/daemon/__tests__/request-router-events.test.ts new file mode 100644 index 000000000..84ebd91af --- /dev/null +++ b/src/daemon/__tests__/request-router-events.test.ts @@ -0,0 +1,166 @@ +import { test, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequestHandler } from '../request-router.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeIosSession } from '../../__tests__/test-utils/index.ts'; + +test('events reads the daemon-owned session timeline without appending poll noise', async () => { + const sessionStore = makeSessionStore('agent-device-router-events-'); + sessionStore.recordEvent('events-session', { + kind: 'action.recorded', + command: 'click', + summary: 'Tapped @14 (10, 20)', + details: { ref: '14', x: 10, y: 20 }, + }); + const eventLogPath = sessionStore.resolveEventLogPath('events-session'); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'events-session', + command: 'events', + positionals: ['10'], + flags: {}, + meta: { requestId: 'req-events' }, + }); + + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(response.data?.path).toBe(eventLogPath); + expect(response.data?.events).toEqual([ + expect.objectContaining({ + kind: 'action.recorded', + command: 'click', + summary: 'Tapped @14 (10, 20)', + }), + ]); + expect(fs.readFileSync(eventLogPath, 'utf8').trim().split('\n')).toHaveLength(1); +}); + +test('events accepts a blank limit placeholder for cursor-only reads', async () => { + const sessionStore = makeSessionStore('agent-device-router-events-cursor-'); + sessionStore.recordEvent('events-session', { + kind: 'action.recorded', + command: 'open', + summary: 'Opened session', + }); + sessionStore.recordEvent('events-session', { + kind: 'action.recorded', + command: 'click', + summary: 'Tapped @14', + }); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'events-session', + command: 'events', + positionals: ['', '1'], + flags: {}, + meta: { requestId: 'req-events-cursor' }, + }); + + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(response.data?.cursor).toBe('1'); + expect(response.data?.limit).toBe(100); + expect(response.data?.events).toEqual([ + expect.objectContaining({ + kind: 'action.recorded', + command: 'click', + summary: 'Tapped @14', + }), + ]); +}); + +test('request timeline records thrown request failures after scope creation', async () => { + const sessionStore = makeSessionStore('agent-device-router-events-throws-'); + sessionStore.set('events-session', makeIosSession('events-session')); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'events-session', + command: 'click', + positionals: ['10', '20'], + flags: { platform: 'android' }, + meta: { requestId: 'req-selector-conflict' }, + }); + + expect(response.ok).toBe(false); + const page = sessionStore.readEvents('events-session'); + expect(page.events).toEqual([ + expect.objectContaining({ + kind: 'request.started', + command: 'click', + requestId: 'req-selector-conflict', + }), + expect.objectContaining({ + kind: 'request.finished', + command: 'click', + requestId: 'req-selector-conflict', + status: 'error', + }), + ]); +}); + +test('request timeline records setup failures after start is appended', async () => { + const sessionStore = makeSessionStore('agent-device-router-events-setup-failure-'); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'open', + positionals: ['Expo Go'], + flags: {}, + meta: { requestId: 'req-proxy-open', leaseProvider: 'proxy' }, + }); + + expect(response.ok).toBe(false); + const page = sessionStore.readEvents('default'); + expect(page.events).toEqual([ + expect.objectContaining({ + kind: 'request.started', + command: 'open', + requestId: 'req-proxy-open', + }), + expect.objectContaining({ + kind: 'request.finished', + command: 'open', + requestId: 'req-proxy-open', + status: 'error', + }), + ]); +}); diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 84df8764d..7dab61c54 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -90,7 +90,11 @@ test('open returns and creates the session state directory', async () => { expect(response.data?.requestLogPath).toEqual( path.join(String(response.data?.sessionStateDir), 'requests', 'req-open-state.ndjson'), ); + expect(response.data?.eventLogPath).toEqual( + path.join(String(response.data?.sessionStateDir), 'events.ndjson'), + ); expect(fs.existsSync(String(response.data?.sessionStateDir))).toBe(true); + expect(fs.existsSync(String(response.data?.eventLogPath))).toBe(true); } }); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 17ece0a89..207e69abe 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -143,6 +143,44 @@ test('saveScript flag enables .ad session log writing', () => { assert.equal(listSessionScriptFiles(root).length, 1); }); +test('recordAction writes a paged session event log', () => { + const { store, session } = makeFixture('agent-device-session-events-'); + recordOpen(store, session, { platform: 'ios' }); + store.recordAction(session, { + command: 'click', + positionals: ['@14', 'Checkout'], + flags: { platform: 'ios' }, + result: { ref: '14', refLabel: 'Checkout', x: 120, y: 240, message: 'Tapped @14 (120, 240)' }, + }); + + const eventLogPath = store.resolveEventLogPath(session.name); + assert.equal(fs.existsSync(eventLogPath), true); + const firstPage = store.readEvents(session.name, { limit: 1 }); + assert.equal(firstPage.events.length, 1); + assert.equal(firstPage.events[0]?.kind, 'action.recorded'); + assert.equal(firstPage.nextCursor, '1'); + + const secondPage = store.readEvents(session.name, { cursor: firstPage.nextCursor, limit: 1 }); + assert.equal(secondPage.events[0]?.summary, 'Tapped @14 (120, 240)'); + assert.equal(secondPage.nextCursor, undefined); +}); + +test('recordAction event log redacts typed text from display positionals', () => { + const { store, session } = makeFixture('agent-device-session-events-redaction-'); + store.recordAction(session, { + command: 'fill', + positionals: ['@14', 'super-secret-token'], + flags: {}, + result: { ref: '14', text: 'super-secret-token', message: 'Filled 18 chars' }, + }); + + const page = store.readEvents(session.name); + const serialized = JSON.stringify(page.events); + assert.equal(serialized.includes('super-secret-token'), false); + assert.deepEqual(page.events[0]?.details?.positionals, ['@14', '']); + assert.equal(page.events[0]?.details?.textLength, 18); +}); + test('saveScript path writes session log to custom location', () => { const { root, store, session } = makeFixture('agent-device-session-log-custom-path-', 'sessions'); const customPath = path.join(root, 'workflows', 'my-flow.ad'); @@ -151,7 +189,7 @@ test('saveScript path writes session log to custom location', () => { store.writeSessionLog(session); assert.equal(fs.existsSync(customPath), true); - assert.equal(fs.existsSync(path.join(root, 'sessions')), false); + assert.equal(fs.existsSync(store.resolveEventLogPath(session.name)), true); }); test('writeSessionLog persists open --relaunch in script output', () => { diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index bd7af1ff7..1a23c2480 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -90,6 +90,9 @@ export async function handleSessionObservabilityCommands( if (req.command === 'logs') { return handleLogsCommand(params); } + if (req.command === 'events') { + return handleEventsCommand(params); + } if (req.command === 'network') { return handleNetworkCommand(params); } @@ -100,6 +103,26 @@ export async function handleSessionObservabilityCommands( return null; } +function handleEventsCommand(params: ObservabilityParams): DaemonResponse { + const { req, sessionName, sessionStore } = params; + const limit = readOptionalEventLimit(req.positionals?.[0]); + if (limit instanceof AppError) return { ok: false, error: normalizeError(limit) }; + return { + ok: true, + data: sessionStore.readEvents(sessionName, { + limit, + cursor: req.positionals?.[1], + }), + }; +} + +function readOptionalEventLimit(value: string | undefined): number | undefined | AppError { + if (value === undefined || value.trim() === '') return undefined; + const parsed = Number(value); + if (Number.isInteger(parsed)) return parsed; + return new AppError('INVALID_ARGS', 'events limit must be an integer.'); +} + // --------------------------------------------------------------------------- // perf // --------------------------------------------------------------------------- diff --git a/src/daemon/handlers/session-open-surface.ts b/src/daemon/handlers/session-open-surface.ts index 55e3de5cf..3e7de8ddf 100644 --- a/src/daemon/handlers/session-open-surface.ts +++ b/src/daemon/handlers/session-open-surface.ts @@ -16,6 +16,7 @@ export function buildOpenResult(params: { sessionStateDir: string; runnerLogPath: string; requestLogPath: string; + eventLogPath: string; appName?: string; appBundleId?: string; surface: SessionSurface; @@ -30,6 +31,7 @@ export function buildOpenResult(params: { sessionStateDir, runnerLogPath, requestLogPath, + eventLogPath, appName, appBundleId, surface, @@ -45,6 +47,7 @@ export function buildOpenResult(params: { sessionStateDir, runnerLogPath, requestLogPath, + eventLogPath, }; if (appName) result.appName = appName; if (appBundleId) result.appBundleId = appBundleId; diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 95ffbc0cd..812f5ca79 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -299,6 +299,7 @@ async function completeOpenCommand(params: { sessionStateDir, runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), requestLogPath, + eventLogPath: sessionStore.resolveEventLogPath(sessionName), appName, appBundleId: sessionAppBundleId, surface, @@ -308,6 +309,7 @@ async function completeOpenCommand(params: { runtime, runtimeHintCount: countConfiguredRuntimeHints, }); + sessionStore.set(sessionName, nextSession); sessionStore.recordAction(nextSession, { command: 'open', positionals: openPositionals, @@ -315,7 +317,6 @@ async function completeOpenCommand(params: { runtime: req.runtime !== undefined ? runtime : undefined, result: openResult, }); - sessionStore.set(sessionName, nextSession); return { ok: true, data: openResult }; } diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index a9a4f53b0..c3d3b4173 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -6,6 +6,7 @@ import { updateDiagnosticsScope, } from '../utils/diagnostics.ts'; import { applyCommandDefaults } from '../utils/command-schema.ts'; +import { normalizeError } from '../kernel/errors.ts'; import type { DaemonCommandContext } from './context.ts'; import { contextFromFlags as contextFromFlagsWithLog } from './context.ts'; import { assertSessionSelectorMatches } from './session-selector.ts'; @@ -29,6 +30,11 @@ import { shouldLockSessionExecution, shouldValidateSessionSelector, } from './daemon-command-registry.ts'; +import { + buildRequestFinishedEvent, + buildRequestStartedEvent, + shouldRecordEventForRequest, +} from './session-event-log.ts'; import type { LeaseRegistry } from './lease-registry.ts'; import { resolveSessionRequestLogPath, @@ -48,6 +54,7 @@ export type RequestExecutionScope = { sessionName: string; requestLogPath: string; runnerLogPath: string; + startedAtMs: number; runAdmitted(task: () => Promise): Promise; runLocked(task: () => Promise): Promise; throwIfCanceled(): void; @@ -84,6 +91,7 @@ export async function createRequestExecutionScope(params: { let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); const command = scopedReq.command; + const startedAtMs = Date.now(); const sessionName = resolveEffectiveSessionName(scopedReq, sessionStore); const diagnosticsMeta = getDiagnosticsMeta(); const sessionDir = sessionStore.resolveSessionDir(sessionName); @@ -109,47 +117,80 @@ export async function createRequestExecutionScope(params: { runnerLogPath, }, }); - assertLockedLeaseAdmissionPreflight(scopedReq); - const executionLockKeys = shouldLockSessionExecution(command) - ? await resolveRequestExecutionLockKeys({ req: scopedReq, sessionName, sessionStore }) - : []; - const executionLocks = getLeaseRegistryExecutionLocks(leaseRegistry); - - const scope: RequestExecutionScope = { - req: scopedReq, - command, - sessionName, - requestLogPath, - runnerLogPath, - throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), - runAdmitted: async (task) => { - throwIfRequestCanceled(scopedReq.meta?.requestId); - await cleanupExpiredLeasedSession({ - sessionName, - sessionStore, - leaseRegistry, - teardownSession: teardownSessionResources, - }); - scopedReq = admitRequestLeaseForLockedScope({ + const shouldRecordRequestEvents = shouldRecordEventForRequest(scopedReq); + if (shouldRecordRequestEvents) { + sessionStore.recordEvent( + sessionName, + buildRequestStartedEvent({ req: scopedReq, sessionName, - sessionStore, - leaseRegistry, - }); - scope.req = scopedReq; - return await task(); - }, - runLocked: async (task) => { - throwIfRequestCanceled(scopedReq.meta?.requestId); - if (executionLockKeys.length === 0) return await scope.runAdmitted(task); - return await withRequestExecutionLocks( - executionLocks, - executionLockKeys, - async () => await scope.runAdmitted(task), + requestLogPath, + runnerLogPath, + }), + ); + } + try { + assertLockedLeaseAdmissionPreflight(scopedReq); + const executionLockKeys = shouldLockSessionExecution(command) + ? await resolveRequestExecutionLockKeys({ req: scopedReq, sessionName, sessionStore }) + : []; + const executionLocks = getLeaseRegistryExecutionLocks(leaseRegistry); + + const scope: RequestExecutionScope = { + req: scopedReq, + command, + sessionName, + requestLogPath, + runnerLogPath, + startedAtMs, + throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), + runAdmitted: async (task) => { + throwIfRequestCanceled(scopedReq.meta?.requestId); + await cleanupExpiredLeasedSession({ + sessionName, + sessionStore, + leaseRegistry, + teardownSession: teardownSessionResources, + }); + scopedReq = admitRequestLeaseForLockedScope({ + req: scopedReq, + sessionName, + sessionStore, + leaseRegistry, + }); + scope.req = scopedReq; + return await task(); + }, + runLocked: async (task) => { + throwIfRequestCanceled(scopedReq.meta?.requestId); + if (executionLockKeys.length === 0) return await scope.runAdmitted(task); + return await withRequestExecutionLocks( + executionLocks, + executionLockKeys, + async () => await scope.runAdmitted(task), + ); + }, + }; + return scope; + } catch (error) { + if (shouldRecordRequestEvents) { + sessionStore.recordEvent( + sessionName, + buildRequestFinishedEvent({ + req: scopedReq, + response: { + ok: false, + error: normalizeError(error, { + diagnosticId: getDiagnosticsMeta().diagnosticId, + logPath: requestLogPath, + }), + }, + durationMs: Math.max(0, Date.now() - startedAtMs), + }), ); - }, - }; - return scope; + } + throw error; + } } async function withRequestExecutionLocks( @@ -201,8 +242,20 @@ export function prepareLockedRequestScope(params: { }); const lockedReq = binding.req; existingSession = binding.existingSession; - const finalize = (response: DaemonResponse): DaemonResponse => - finalizeDaemonResponse(lockedReq, response, trackDownloadableArtifact); + const finalize = (response: DaemonResponse): DaemonResponse => { + const finalized = finalizeDaemonResponse(lockedReq, response, trackDownloadableArtifact); + if (shouldRecordEventForRequest(lockedReq)) { + sessionStore.recordEvent( + scope.sessionName, + buildRequestFinishedEvent({ + req: lockedReq, + response: finalized, + durationMs: Math.max(0, Date.now() - scope.startedAtMs), + }), + ); + } + return finalized; + }; if ( existingSession?.recording?.invalidatedReason && diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index ef74ed195..705c18857 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -38,6 +38,7 @@ import { prepareLockedRequestScope, type RequestExecutionScope, } from './request-execution-scope.ts'; +import { buildRequestFinishedEvent, shouldRecordEventForRequest } from './session-event-log.ts'; import { canRunReplayScopedAction } from './daemon-command-registry.ts'; import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts'; import type { LeaseLifecycleProvider } from './handlers/lease.ts'; @@ -123,9 +124,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { return unauthorizedResponse(); } + let scope: RequestExecutionScope | undefined; try { return await withTargetDeviceResolutionScope(deviceInventoryProvider, async () => { - const scope = await createRequestExecutionScope({ + scope = await createRequestExecutionScope({ req, sessionStore, leaseRegistry, @@ -133,7 +135,9 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { return await executeRequestScope(scope); }); } catch (error) { - return finalizeThrownRequestError(error); + const response = finalizeThrownRequestError(error); + recordThrownRequestEvent(sessionStore, scope, response); + return response; } } @@ -228,13 +232,16 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { return unauthorizedResponse(); } + let childScope: RequestExecutionScope | undefined; try { - const childScope = await createRequestExecutionScope({ req, sessionStore, leaseRegistry }); + childScope = await createRequestExecutionScope({ req, sessionStore, leaseRegistry }); return childScope.sessionName === parentScope.sessionName ? await executeRequestScope(childScope, providerScope) : await handleRequest(req); } catch (error) { - return finalizeThrownRequestError(error); + const response = finalizeThrownRequestError(error); + recordThrownRequestEvent(sessionStore, childScope, response); + return response; } }; } @@ -304,6 +311,22 @@ function finalizeThrownRequestError(error: unknown): DaemonResponse { return { ok: false, error: normalizedError }; } +function recordThrownRequestEvent( + sessionStore: SessionStore, + scope: RequestExecutionScope | undefined, + response: DaemonResponse, +): void { + if (!scope || !shouldRecordEventForRequest(scope.req)) return; + sessionStore.recordEvent( + scope.sessionName, + buildRequestFinishedEvent({ + req: scope.req, + response, + durationMs: Math.max(0, Date.now() - scope.startedAtMs), + }), + ); +} + // Phase 2 typed-error graft: add machine-readable signals to an error response. // Returns the error unchanged unless a signal applies, so the default wire shape // is preserved for the common codes. diff --git a/src/daemon/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index 27fde4e72..60fd67c18 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -12,22 +12,26 @@ export type RecordActionEntry = { result?: Record; }; -export function recordActionEntry(session: SessionState, entry: RecordActionEntry): void { - if (entry.flags?.noRecord) return; +export function recordActionEntry( + session: SessionState, + entry: RecordActionEntry, +): SessionAction | undefined { + if (entry.flags?.noRecord) return undefined; if (entry.flags?.saveScript) { session.recordSession = true; if (typeof entry.flags.saveScript === 'string') { session.saveScriptPath = expandSessionPath(entry.flags.saveScript); } } - session.actions.push({ + const action: SessionAction = { ts: Date.now(), command: entry.command, positionals: entry.positionals, runtime: entry.runtime, flags: sanitizeFlags(entry.flags), result: entry.result, - }); + }; + session.actions.push(action); emitDiagnostic({ level: 'debug', phase: 'record_action', @@ -36,6 +40,7 @@ export function recordActionEntry(session: SessionState, entry: RecordActionEntr session: session.name, }, }); + return action; } const SANITIZED_FLAG_KEYS = [ diff --git a/src/daemon/session-event-action.ts b/src/daemon/session-event-action.ts new file mode 100644 index 000000000..a1294c349 --- /dev/null +++ b/src/daemon/session-event-action.ts @@ -0,0 +1,161 @@ +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../command-catalog.ts'; +import type { SessionAction } from './types.ts'; + +export function buildActionSummary(action: SessionAction): string { + const message = readString(action.result?.message); + if (message) return message; + switch (action.command) { + case PUBLIC_COMMANDS.open: + return `Opened ${readActionTargetLabel(action) ?? 'session'}`; + case PUBLIC_COMMANDS.close: + return `Closed ${readString(action.result?.session) ?? 'session'}`; + case PUBLIC_COMMANDS.click: + case PUBLIC_COMMANDS.press: + return `Tapped ${readActionTargetLabel(action) ?? 'target'}`; + case PUBLIC_COMMANDS.longPress: + return `Long pressed ${readActionTargetLabel(action) ?? 'target'}`; + case PUBLIC_COMMANDS.fill: + return `Filled ${readActionTargetLabel(action) ?? 'target'}`; + case PUBLIC_COMMANDS.install: + case PUBLIC_COMMANDS.reinstall: + case INTERNAL_COMMANDS.installSource: + return `Installed ${readActionTargetLabel(action) ?? 'app'}`; + default: + return `Ran ${action.command}`; + } +} + +export function buildActionDetails(action: SessionAction): Record { + const result = action.result ?? {}; + return { + command: action.command, + positionals: buildDisplayPositionals(action), + flags: action.flags, + action: result.action, + message: result.message, + ref: result.ref, + refLabel: result.refLabel, + selector: result.selector, + selectorChain: readStringArray(result.selectorChain), + x: result.x, + y: result.y, + x2: result.x2, + y2: result.y2, + durationMs: result.durationMs, + waitedMs: result.waitedMs, + found: result.found, + path: result.path, + outPath: result.outPath, + telemetryPath: result.telemetryPath, + sessionStateDir: result.sessionStateDir, + requestLogPath: result.requestLogPath, + runnerLogPath: result.runnerLogPath, + platform: result.platform, + target: result.target, + device: result.device, + appName: result.appName, + appBundleId: result.appBundleId, + bundleId: result.bundleId, + packageName: result.packageName, + launchTarget: result.launchTarget, + textLength: typeof result.text === 'string' ? Array.from(result.text).length : undefined, + nodeCount: Array.isArray(result.nodes) ? result.nodes.length : undefined, + }; +} + +function readActionTargetLabel(action: SessionAction): string | undefined { + const result = action.result ?? {}; + return ( + readElementTargetLabel(result) ?? + readPointTargetLabel(result) ?? + readAppTargetLabel(result) ?? + action.positionals[0] + ); +} + +function readElementTargetLabel(result: Record): string | undefined { + const ref = readString(result.ref); + if (ref) return ref.startsWith('@') ? ref : `@${ref}`; + const selector = readString(result.selector); + if (selector) return selector; + return readString(result.refLabel); +} + +function readPointTargetLabel(result: Record): string | undefined { + const x = readNumber(result.x); + const y = readNumber(result.y); + if (x !== undefined && y !== undefined) return `(${x}, ${y})`; + return undefined; +} + +function readAppTargetLabel(result: Record): string | undefined { + return ( + readString(result.appName) ?? + readString(result.appBundleId) ?? + readString(result.bundleId) ?? + readString(result.packageName) + ); +} + +function buildDisplayPositionals(action: SessionAction): string[] | undefined { + if (action.command === PUBLIC_COMMANDS.type) { + return [``]; + } + if (action.command === PUBLIC_COMMANDS.fill) { + return buildFillDisplayPositionals(action); + } + if (action.command === PUBLIC_COMMANDS.find) { + return buildFindDisplayPositionals(action); + } + return action.positionals.length > 0 ? action.positionals : undefined; +} + +function buildFillDisplayPositionals(action: SessionAction): string[] { + const textPlaceholder = ``; + const result = action.result ?? {}; + const ref = readString(result.ref); + if (ref) return [ref.startsWith('@') ? ref : `@${ref}`, textPlaceholder]; + const selector = readString(result.selector); + if (selector) return [selector, textPlaceholder]; + const x = readNumber(result.x); + const y = readNumber(result.y); + if (x !== undefined && y !== undefined) return [String(x), String(y), textPlaceholder]; + return [textPlaceholder]; +} + +function buildFindDisplayPositionals(action: SessionAction): string[] | undefined { + const sensitiveActionIndex = action.positionals.findIndex( + (value) => value === 'fill' || value === 'type', + ); + if (sensitiveActionIndex < 0) { + return action.positionals.length > 0 ? action.positionals : undefined; + } + return [ + ...action.positionals.slice(0, sensitiveActionIndex + 1), + ``, + ]; +} + +function readActionTextLength(action: SessionAction): number { + const resultText = action.result?.text; + if (typeof resultText === 'string') return Array.from(resultText).length; + if (action.command === PUBLIC_COMMANDS.type) { + return Array.from(action.positionals.join(' ')).length; + } + return 0; +} + +function readString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + return value.trim().length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function readStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ? value + : undefined; +} diff --git a/src/daemon/session-event-log.ts b/src/daemon/session-event-log.ts new file mode 100644 index 000000000..1b2792e3b --- /dev/null +++ b/src/daemon/session-event-log.ts @@ -0,0 +1,229 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { PUBLIC_COMMANDS } from '../command-catalog.ts'; +import { AppError } from '../kernel/errors.ts'; +import { redactDiagnosticData } from '../kernel/redaction.ts'; +import { emitDiagnostic, getDiagnosticsMeta } from '../utils/diagnostics.ts'; +import { isRecord } from '../utils/parsing.ts'; +import type { DaemonRequest, DaemonResponse, SessionAction } from './types.ts'; +import { buildActionDetails, buildActionSummary } from './session-event-action.ts'; + +const SESSION_EVENT_LOG_FILENAME = 'events.ndjson'; +const EVENT_LOG_VERSION = 1; +const DEFAULT_EVENT_LIMIT = 100; +const MAX_EVENT_LIMIT = 500; + +export type SessionEventLogEntry = { + version: 1; + ts: string; + session: string; + kind: 'request.started' | 'request.finished' | 'action.recorded'; + requestId?: string; + command?: string; + status?: 'ok' | 'error'; + summary?: string; + details?: Record; +}; + +export type SessionEventLogPage = { + path: string; + cursor: string; + limit: number; + events: SessionEventLogEntry[]; + nextCursor?: string; +}; + +export type SessionEventLogInput = Omit & { + ts?: string; +}; + +type ReadSessionEventLogOptions = { cursor?: string; limit?: number }; +type RawSessionEventLogEntry = Record & + Pick; + +export function resolveSessionEventLogPath(sessionDir: string): string { + return path.join(sessionDir, SESSION_EVENT_LOG_FILENAME); +} + +export function shouldRecordEventForRequest(req: Pick): boolean { + return req.command !== PUBLIC_COMMANDS.events; +} + +export function appendSessionEvent( + eventLogPath: string, + sessionName: string, + event: SessionEventLogInput, +): void { + try { + fs.mkdirSync(path.dirname(eventLogPath), { recursive: true }); + const entry = redactDiagnosticData({ + version: EVENT_LOG_VERSION, + ts: event.ts ?? new Date().toISOString(), + session: sessionName, + ...event, + } satisfies SessionEventLogEntry); + fs.appendFileSync(eventLogPath, `${JSON.stringify(entry)}\n`, 'utf8'); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'session_event_log_write_failed', + data: { + path: eventLogPath, + error: error instanceof Error ? error.message : String(error), + }, + }); + } +} + +export function appendActionEvent( + eventLogPath: string, + sessionName: string, + action: SessionAction, +): void { + appendSessionEvent(eventLogPath, sessionName, { + kind: 'action.recorded', + requestId: getDiagnosticsMeta().requestId, + command: action.command, + summary: buildActionSummary(action), + details: buildActionDetails(action), + }); +} + +export function buildRequestStartedEvent(params: { + req: DaemonRequest; + sessionName: string; + requestLogPath: string; + runnerLogPath: string; +}): SessionEventLogInput { + const { req, sessionName, requestLogPath, runnerLogPath } = params; + return { + kind: 'request.started', + requestId: req.meta?.requestId ?? getDiagnosticsMeta().requestId, + command: req.command, + summary: `Started ${req.command}`, + details: { + publicSession: req.session, + effectiveSession: sessionName, + tenant: req.meta?.tenantId, + isolation: req.meta?.sessionIsolation, + requestLogPath, + runnerLogPath, + }, + }; +} + +export function buildRequestFinishedEvent(params: { + req: DaemonRequest; + response: DaemonResponse; + durationMs: number; +}): SessionEventLogInput { + const { req, response, durationMs } = params; + if (response.ok) { + return { + kind: 'request.finished', + requestId: req.meta?.requestId ?? getDiagnosticsMeta().requestId, + command: req.command, + status: 'ok', + summary: `Finished ${req.command}`, + details: { durationMs }, + }; + } + return { + kind: 'request.finished', + requestId: req.meta?.requestId ?? getDiagnosticsMeta().requestId, + command: req.command, + status: 'error', + summary: `Failed ${req.command}: ${response.error.message}`, + details: { + durationMs, + code: response.error.code, + message: response.error.message, + hint: response.error.hint, + diagnosticId: response.error.diagnosticId, + logPath: response.error.logPath, + }, + }; +} + +export function readSessionEventLog( + eventLogPath: string, + options: ReadSessionEventLogOptions = {}, +): SessionEventLogPage { + const cursor = normalizeCursor(options.cursor); + const limit = normalizeLimit(options.limit); + if (!fs.existsSync(eventLogPath)) { + return { path: eventLogPath, cursor: String(cursor), limit, events: [] }; + } + + const rawLines = fs + .readFileSync(eventLogPath, 'utf8') + .split(/\r?\n/) + .filter((line) => line.trim().length > 0); + const end = Math.min(rawLines.length, cursor + limit); + const events = rawLines.slice(cursor, end).flatMap((line) => { + const parsed = parseSessionEventLogLine(line); + return parsed ? [parsed] : []; + }); + return { + path: eventLogPath, + cursor: String(cursor), + limit, + events, + ...(end < rawLines.length ? { nextCursor: String(end) } : {}), + }; +} + +function normalizeCursor(value: string | undefined): number { + if (value === undefined || value.trim() === '') return 0; + const cursor = Number(value); + if (Number.isInteger(cursor) && cursor >= 0) return cursor; + throw new AppError('INVALID_ARGS', 'events cursor must be a non-negative integer string.'); +} + +function normalizeLimit(value: number | undefined): number { + if (value === undefined) return DEFAULT_EVENT_LIMIT; + if (Number.isInteger(value) && value >= 1 && value <= MAX_EVENT_LIMIT) return value; + throw new AppError('INVALID_ARGS', `events limit must be between 1 and ${MAX_EVENT_LIMIT}.`); +} + +function parseSessionEventLogLine(line: string): SessionEventLogEntry | undefined { + try { + const parsed = readRawSessionEventLogEntry(JSON.parse(line)); + return parsed ? buildSessionEventLogEntry(parsed) : undefined; + } catch { + return undefined; + } +} + +function readRawSessionEventLogEntry(value: unknown): RawSessionEventLogEntry | undefined { + if (!isRecord(value)) return undefined; + if (value.version !== EVENT_LOG_VERSION) return undefined; + if (typeof value.ts !== 'string' || typeof value.session !== 'string') return undefined; + if (!isSessionEventKind(value.kind)) return undefined; + return value as RawSessionEventLogEntry; +} + +function buildSessionEventLogEntry(parsed: RawSessionEventLogEntry): SessionEventLogEntry { + const details = isRecord(parsed.details) ? parsed.details : undefined; + return { + version: EVENT_LOG_VERSION, + ts: parsed.ts, + session: parsed.session, + kind: parsed.kind, + ...(typeof parsed.requestId === 'string' ? { requestId: parsed.requestId } : {}), + ...(typeof parsed.command === 'string' ? { command: parsed.command } : {}), + ...(isSessionEventStatus(parsed.status) ? { status: parsed.status } : {}), + ...(typeof parsed.summary === 'string' ? { summary: parsed.summary } : {}), + ...(details ? { details } : {}), + }; +} + +function isSessionEventKind(value: unknown): value is SessionEventLogEntry['kind'] { + return value === 'request.started' || value === 'request.finished' || value === 'action.recorded'; +} + +function isSessionEventStatus( + value: unknown, +): value is NonNullable { + return value === 'ok' || value === 'error'; +} diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index d74ae703e..152a27eba 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -5,6 +5,14 @@ import type { SessionRuntimeHints, SessionState } from './types.ts'; import { recordActionEntry, type RecordActionEntry } from './session-action-recorder.ts'; import { expandSessionPath, safeSessionName } from './session-paths.ts'; import { SessionScriptWriter } from './session-script-writer.ts'; +import { + appendActionEvent, + appendSessionEvent, + readSessionEventLog, + resolveSessionEventLogPath, + type SessionEventLogInput, + type SessionEventLogPage, +} from './session-event-log.ts'; export class SessionStore { private readonly sessions = new Map(); @@ -51,7 +59,22 @@ export class SessionStore { } recordAction(session: SessionState, entry: RecordActionEntry): void { - recordActionEntry(session, entry); + const action = recordActionEntry(session, entry); + if (action) { + const sessionName = this.resolveStoredSessionName(session); + appendActionEvent(this.resolveEventLogPath(sessionName), sessionName, action); + } + } + + recordEvent(sessionName: string, event: SessionEventLogInput): void { + appendSessionEvent(this.resolveEventLogPath(sessionName), sessionName, event); + } + + readEvents( + sessionName: string, + options: { cursor?: string; limit?: number } = {}, + ): SessionEventLogPage { + return readSessionEventLog(this.resolveEventLogPath(sessionName), options); } writeSessionLog(session: SessionState): void { @@ -90,9 +113,20 @@ export class SessionStore { return path.join(this.resolveSessionDir(sessionName), 'app-log.pid'); } + resolveEventLogPath(sessionName: string): string { + return resolveSessionEventLogPath(this.resolveSessionDir(sessionName)); + } + static expandHome(filePath: string, cwd?: string): string { return expandSessionPath(filePath, cwd); } + + private resolveStoredSessionName(session: SessionState): string { + for (const [name, value] of this.sessions) { + if (value === session) return name; + } + return session.name; + } } /** Path to session-scoped platform subprocess output, such as Apple runner xcodebuild logs. */ diff --git a/test/integration/provider-scenarios/remote-daemon-client.test.ts b/test/integration/provider-scenarios/remote-daemon-client.test.ts index ec0d5702a..ea2284070 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -317,12 +317,44 @@ function writeRemoteSuccess( payload: RemoteRpcRequest, state: RemoteDaemonState, ): void { + if (payload.params?.command === 'events') return writeEventsSuccess(res, payload); if (payload.params?.command === 'install') return writeInstallSuccess(res, payload); if (payload.params?.command === 'install_source') return writeInstallSourceSuccess(res, payload); if (payload.params?.command === 'record') return writeRecordSuccess(res, payload, state); writeScreenshotSuccess(res, payload, state.screenshotPath); } +function writeEventsSuccess(res: http.ServerResponse, payload: RemoteRpcRequest): void { + const positionals = payload.params?.positionals ?? []; + const limitArg = typeof positionals[0] === 'string' ? positionals[0] : undefined; + const cursorArg = typeof positionals[1] === 'string' ? positionals[1] : undefined; + const limit = limitArg === undefined || limitArg.trim() === '' ? 100 : Number(limitArg); + writeJson(res, 200, { + jsonrpc: '2.0', + id: payload.id, + result: { + ok: true, + data: { + path: '/remote/sessions/default/events.ndjson', + cursor: cursorArg ?? '0', + limit, + nextCursor: '6', + events: [ + { + version: 1, + ts: '2026-07-02T12:00:00.000Z', + session: 'default', + kind: 'action.recorded', + command: 'click', + summary: 'Tapped @e14', + details: { ref: '@e14' }, + }, + ], + }, + }, + }); +} + function writeInstallSuccess(res: http.ServerResponse, payload: RemoteRpcRequest): void { const positionals = payload.params?.positionals ?? []; const appPath = positionals.length === 1 ? positionals[0] : positionals[1]; @@ -531,6 +563,35 @@ async function assertRecordingArtifactRoundTrip( assert.equal(recordStopRpc?.params?.meta?.clientArtifactPaths, undefined); } +async function assertEventsRpc( + client: RemoteClient, + rpcRequests: RemoteRpcRequest[], +): Promise { + const page = await client.observability.events({ limit: 2, cursor: '4' }); + assert.equal(page.path, '/remote/sessions/default/events.ndjson'); + assert.equal(page.cursor, '4'); + assert.equal(page.limit, 2); + assert.equal(page.nextCursor, '6'); + assert.equal(Array.isArray(page.events), true); + assert.equal((page.events as Array<{ command?: string }>)[0]?.command, 'click'); + + const eventsRpc = rpcRequests.at(-1); + assert.equal(eventsRpc?.method, 'agent_device.command'); + assert.equal(eventsRpc?.params?.command, 'events'); + assert.deepEqual(eventsRpc?.params?.positionals, ['2', '4']); + assert.equal(eventsRpc?.params?.token, 'remote-token'); + + const cursorOnlyPage = await client.observability.events({ cursor: '6' }); + assert.equal(cursorOnlyPage.cursor, '6'); + assert.equal(cursorOnlyPage.limit, 100); + + const cursorOnlyRpc = rpcRequests.at(-1); + assert.equal(cursorOnlyRpc?.method, 'agent_device.command'); + assert.equal(cursorOnlyRpc?.params?.command, 'events'); + assert.deepEqual(cursorOnlyRpc?.params?.positionals, ['', '6']); + assert.equal(cursorOnlyRpc?.params?.token, 'remote-token'); +} + async function assertRemoteRpcErrorNormalization(client: RemoteClient): Promise { await assert.rejects( async () => await client.sessions.list(), @@ -584,6 +645,7 @@ test('Provider-backed integration remote daemon client materializes artifacts an await assertInstallUpload(client, paths, rpcRequests, uploadRequests); await assertInstallSourceUpload(client, paths, rpcRequests, uploadRequests); await assertRecordingArtifactRoundTrip(client, paths, rpcRequests); + await assertEventsRpc(client, rpcRequests); rejectRpcRequests(); await assertRemoteRpcErrorNormalization(client); } finally { diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index c5378db79..aafcd3313 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -247,10 +247,12 @@ Additional CLI-backed methods are exposed on their domain groups with typed opti - `client.interactions.click()`, `press()`, `longPress()`, `swipe()`, `pan()`, `fling()`, `focus()`, `type()`, `fill()`, `scroll()`, `pinch()`, `rotateGesture()`, `transformGesture()`, `get()`, `is()`, `find()` - `client.replay.run()` and `client.replay.test()` - `client.batch.run()` -- `client.observability.perf()`, `logs()`, `network()`, and `audio()` +- `client.observability.perf()`, `logs()`, `events()`, `network()`, and `audio()` - `client.recording.record()` and `client.recording.trace()` - `client.settings.update()` +`client.observability.events({ cursor, limit })` reads the session event timeline as paged JSON entries. Use `nextCursor` from the previous page to continue from the daemon-owned `events.ndjson` file without replaying already uploaded/displayed events. + `client.observability.audio()` mirrors `audio probe start|status|stop`. Use it to collect compact RMS/peak dBFS buckets while other session actions continue: ```ts diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index afe10a00f..3496344e7 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -808,6 +808,8 @@ agent-device logs clear # Truncate app.log + remove rotated app. agent-device logs clear --restart # Stop stream, clear log files, and start streaming again agent-device logs doctor # Show logs backend/tool checks and readiness hints agent-device logs mark "before submit" # Insert timeline marker into app.log +agent-device events # Print recent session request/action events +agent-device events 50 100 # Page 50 events starting at cursor 100 agent-device network dump 25 # Parse recent HTTP(s) requests (method/url/status) agent-device network dump 25 --include all # Include parsed headers/body when available (truncated) agent-device network dump 25 --include headers --platform web # Browser requests via managed agent-browser @@ -816,8 +818,8 @@ agent-device network dump 25 --include headers --platform web # Browser requests - Supported on iOS simulator, iOS physical device, and Android. - Preferred debug entrypoint: `logs clear --restart` for clean-window repro loops. - `logs start` appends to `app.log` and rotates to `app.log.1` when the file exceeds 5 MB. -- `open` prints `Session state: ` and JSON includes `sessionStateDir`, `runnerLogPath`, and `requestLogPath`. Use the session directory to inspect concurrent runs without parsing global daemon logs. -- `requests/.ndjson` contains daemon request diagnostics for the session; `runner.log` contains Apple runner and `xcodebuild` output. +- `open` prints `Session state: ` and JSON includes `sessionStateDir`, `runnerLogPath`, `requestLogPath`, and `eventLogPath`. Use the session directory to inspect concurrent runs without parsing global daemon logs. +- `events.ndjson` contains the session event timeline; `requests/.ndjson` contains daemon request diagnostics; `runner.log` contains Apple runner and `xcodebuild` output. - `network dump [limit] [summary|headers|body|all]` parses recent HTTP(s) entries from `app.log` for app/device sessions and from managed `agent-browser` request history for web sessions; `network log ...` is an alias. - Prefer `--include headers|body|all` when you want explicit detail level without relying on positional ordering. - On macOS, `logs` and `network dump` are app-scoped and parse Unified Logging output associated with the active session app. diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index e7e7a71a3..82c5269ec 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -20,6 +20,7 @@ When a session is established, human output includes a `Session state: ` l Session artifact directories contain per-run evidence for concurrent agents: - `requests/.ndjson` - daemon request diagnostics for this session. +- `events.ndjson` - session event timeline for requests and recorded actions. - `runner.log` - Apple runner and `xcodebuild` build/start output for this session. - `app.log` - app/device logs when `logs start` or `logs clear --restart` is active.