From c61103c1f4a3c2fee435bf7cb6b4d8c21440d985 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 2 Jul 2026 17:43:53 +0200 Subject: [PATCH 01/11] feat: add session event timeline --- src/__tests__/cli-client-commands.test.ts | 53 ++++- src/client/client-shared.ts | 1 + src/client/client-types.ts | 7 + src/client/client.ts | 2 + src/command-catalog.ts | 1 + src/commands/observability/index.test.ts | 74 +++++++ src/commands/observability/index.ts | 56 ++++- src/commands/observability/output.ts | 143 ++++++++++++ .../__tests__/parity.test.ts | 1 + src/core/command-descriptor/registry.ts | 10 + .../__tests__/request-router-events.test.ts | 123 +++++++++++ .../__tests__/request-router-open.test.ts | 4 + src/daemon/__tests__/session-store.test.ts | 40 +++- src/daemon/handlers/session-observability.ts | 23 ++ src/daemon/handlers/session-open-surface.ts | 3 + src/daemon/handlers/session-open.ts | 3 +- src/daemon/request-execution-scope.ts | 133 ++++++++---- src/daemon/request-router.ts | 31 ++- src/daemon/session-action-recorder.ts | 13 +- src/daemon/session-event-action.ts | 154 +++++++++++++ src/daemon/session-event-log.ts | 204 ++++++++++++++++++ src/daemon/session-store.ts | 36 +++- .../remote-daemon-client.test.ts | 48 +++++ 23 files changed, 1109 insertions(+), 54 deletions(-) create mode 100644 src/daemon/__tests__/request-router-events.test.ts create mode 100644 src/daemon/session-event-action.ts create mode 100644 src/daemon/session-event-log.ts diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 7935b029c..c056bc18b 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']; @@ -1198,7 +1246,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 21adc2bf1..74886548a 100644 --- a/src/client/client-shared.ts +++ b/src/client/client-shared.ts @@ -150,6 +150,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 10f3181aa..04eb0c6de 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -201,6 +201,7 @@ export function createAgentDeviceClient( return { session, sessionStateDir: readOptionalString(data, 'sessionStateDir'), + eventLogPath: readOptionalString(data, 'eventLogPath'), appName: readOptionalString(data, 'appName'), appBundleId, appId, @@ -345,6 +346,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 5d051c575..9a006bfaf 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -14,6 +14,7 @@ export const PUBLIC_COMMANDS = { clipboard: 'clipboard', devices: 'devices', doctor: 'doctor', + events: 'events', diff: 'diff', fill: 'fill', find: 'find', diff --git a/src/commands/observability/index.test.ts b/src/commands/observability/index.test.ts index 9222984cd..6bc914046 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,73 @@ describe('observability command interface', () => { }); }); + test('reads events pagination as compact daemon positionals', () => { + expect(eventsCliReader(['25', '100'], NO_FLAGS)).toEqual({ + limit: 25, + cursor: '100', + }); + expect(eventsDaemonWriter({ limit: 25, cursor: '100' })).toMatchObject({ + command: 'events', + positionals: ['25', '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..6f9a4dafe 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: optionalCliNumber(positionals[0]), + 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,17 @@ 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[] { + return [...optionalNumber(input.limit), ...optionalString(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..737eaaaac 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,112 @@ 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 ''; + const match = value.match( + /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)(Z|[+-]\d{2}:\d{2})?$/, + ); + return match ? `${match[1]} ${match[2]}${match[3] ?? ''}` : value; +} + +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 +291,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 2d75b1a89..fe6f0cf68 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -39,6 +39,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.capabilities, 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 81a99d984..8c6e56051 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -271,6 +271,16 @@ const RAW_COMMAND_DESCRIPTORS = [ timeoutPolicy: DEFAULT_TIMEOUT_POLICY, 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..7ccc4e4e1 --- /dev/null +++ b/src/daemon/__tests__/request-router-events.test.ts @@ -0,0 +1,123 @@ +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('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 8930efb1e..b51154af1 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 704e12557..b50610f8e 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -194,6 +194,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); } @@ -204,6 +207,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) 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 3b4307d21..b9496df68 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -344,6 +344,7 @@ async function completeOpenCommand(params: { sessionStateDir, runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), requestLogPath, + eventLogPath: sessionStore.resolveEventLogPath(sessionName), appName, appBundleId: sessionAppBundleId, surface, @@ -353,6 +354,7 @@ async function completeOpenCommand(params: { runtime, runtimeHintCount: countConfiguredRuntimeHints, }); + sessionStore.set(sessionName, nextSession); sessionStore.recordAction(nextSession, { command: 'open', positionals: openPositionals, @@ -360,7 +362,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 e9d9a6317..c0fff6e02 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -7,6 +7,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'; @@ -30,6 +31,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, @@ -49,6 +55,7 @@ export type RequestExecutionScope = { sessionName: string; requestLogPath: string; runnerLogPath: string; + startedAtMs: number; runAdmitted(task: () => Promise): Promise; runLocked(task: () => Promise): Promise; throwIfCanceled(): void; @@ -85,6 +92,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); @@ -110,47 +118,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( @@ -203,8 +244,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 1315d6373..08e43d09d 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'; @@ -124,9 +125,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, @@ -134,7 +136,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; } } @@ -229,13 +233,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; } }; } @@ -316,6 +323,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..333f388c1 --- /dev/null +++ b/src/daemon/session-event-action.ts @@ -0,0 +1,154 @@ +import { 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 'install_source': + return `Installed ${readActionTargetLabel(action) ?? 'app'}`; + default: + return `Ran ${action.command}`; + } +} + +export function buildActionDetails(action: SessionAction): Record { + const result = action.result ?? {}; + return compactDetails({ + 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 ?? {}; + const ref = readString(result.ref); + if (ref) return ref.startsWith('@') ? ref : `@${ref}`; + const selector = readString(result.selector); + if (selector) return selector; + const refLabel = readString(result.refLabel); + if (refLabel) return refLabel; + const x = readNumber(result.x); + const y = readNumber(result.y); + if (x !== undefined && y !== undefined) return `(${x}, ${y})`; + return ( + readString(result.appName) ?? + readString(result.appBundleId) ?? + readString(result.bundleId) ?? + readString(result.packageName) ?? + action.positionals[0] + ); +} + +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 { + 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 readStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ? value + : undefined; +} + +function compactDetails(record: Record): Record { + const compact: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) compact[key] = value; + } + return compact; +} diff --git a/src/daemon/session-event-log.ts b/src/daemon/session-event-log.ts new file mode 100644 index 000000000..5a31ef283 --- /dev/null +++ b/src/daemon/session-event-log.ts @@ -0,0 +1,204 @@ +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 { getDiagnosticsMeta } from '../utils/diagnostics.ts'; +import type { DaemonRequest, DaemonResponse, SessionAction } from './types.ts'; +import { buildActionDetails, buildActionSummary } from './session-event-action.ts'; + +export 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 }; + +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 { + // Event logging is best-effort and must never break device automation. + } +} + +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: compactDetails({ + 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: compactDetails({ durationMs }), + }; + } + return { + kind: 'request.finished', + requestId: req.meta?.requestId ?? getDiagnosticsMeta().requestId, + command: req.command, + status: 'error', + summary: `Failed ${req.command}: ${response.error.message}`, + details: compactDetails({ + 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('\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 = JSON.parse(line) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + const record = parsed as Partial; + if (record.version !== EVENT_LOG_VERSION) return undefined; + if (typeof record.ts !== 'string' || typeof record.session !== 'string') return undefined; + if (!isSessionEventKind(record.kind)) return undefined; + return record as SessionEventLogEntry; + } catch { + return undefined; + } +} + +function isSessionEventKind(value: unknown): value is SessionEventLogEntry['kind'] { + return value === 'request.started' || value === 'request.finished' || value === 'action.recorded'; +} + +function compactDetails(record: Record): Record { + const compact: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) compact[key] = value; + } + return compact; +} 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..23fbc3eb5 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -317,12 +317,40 @@ 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 { + writeJson(res, 200, { + jsonrpc: '2.0', + id: payload.id, + result: { + ok: true, + data: { + path: '/remote/sessions/default/events.ndjson', + cursor: payload.params?.positionals?.[1] ?? '0', + limit: Number(payload.params?.positionals?.[0] ?? 100), + 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 +559,25 @@ 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'); +} + async function assertRemoteRpcErrorNormalization(client: RemoteClient): Promise { await assert.rejects( async () => await client.sessions.list(), @@ -584,6 +631,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 { From 4173b3be1d7e7f03e851721735fb0f92886dba63 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 2 Jul 2026 18:45:26 +0200 Subject: [PATCH 02/11] fix: support cursor-only event reads --- src/commands/observability/index.test.ts | 8 ++++ src/commands/observability/index.ts | 9 +++- .../__tests__/request-router-events.test.ts | 43 +++++++++++++++++++ src/daemon/handlers/session-observability.ts | 2 +- .../remote-daemon-client.test.ts | 18 +++++++- 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/commands/observability/index.test.ts b/src/commands/observability/index.test.ts index 6bc914046..d03c9ccb5 100644 --- a/src/commands/observability/index.test.ts +++ b/src/commands/observability/index.test.ts @@ -80,10 +80,18 @@ describe('observability command interface', () => { 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', () => { diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index 6f9a4dafe..f74cdb04a 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -144,7 +144,7 @@ export const logsCliReader: CliReader = (positionals, flags) => ({ export const eventsCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), - limit: optionalCliNumber(positionals[0]), + limit: optionalEventsLimit(positionals[0]), cursor: positionals[1], }); @@ -229,13 +229,18 @@ function logsPositionals(input: { action?: string; message?: string }): string[] } function eventsPositionals(input: EventsOptions): string[] { - return [...optionalNumber(input.limit), ...optionalString(input.cursor)]; + 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)]; } +function optionalEventsLimit(value: string | undefined): number | undefined { + return value === undefined || value.trim() === '' ? undefined : optionalCliNumber(value); +} + function audioPositionals(input: AudioOptions): string[] { return [ input.action ?? 'probe', diff --git a/src/daemon/__tests__/request-router-events.test.ts b/src/daemon/__tests__/request-router-events.test.ts index 7ccc4e4e1..84ebd91af 100644 --- a/src/daemon/__tests__/request-router-events.test.ts +++ b/src/daemon/__tests__/request-router-events.test.ts @@ -47,6 +47,49 @@ test('events reads the daemon-owned session timeline without appending poll nois 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')); diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index b50610f8e..b9b483d32 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -221,7 +221,7 @@ function handleEventsCommand(params: ObservabilityParams): DaemonResponse { } function readOptionalEventLimit(value: string | undefined): number | undefined | AppError { - if (value === undefined) return undefined; + 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.'); diff --git a/test/integration/provider-scenarios/remote-daemon-client.test.ts b/test/integration/provider-scenarios/remote-daemon-client.test.ts index 23fbc3eb5..ea2284070 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -325,6 +325,10 @@ function writeRemoteSuccess( } 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, @@ -332,8 +336,8 @@ function writeEventsSuccess(res: http.ServerResponse, payload: RemoteRpcRequest) ok: true, data: { path: '/remote/sessions/default/events.ndjson', - cursor: payload.params?.positionals?.[1] ?? '0', - limit: Number(payload.params?.positionals?.[0] ?? 100), + cursor: cursorArg ?? '0', + limit, nextCursor: '6', events: [ { @@ -576,6 +580,16 @@ async function assertEventsRpc( 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 { From 3e97ceb0c4fc2b29eff2197a94f806f9268ecd9a Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 2 Jul 2026 19:01:38 +0200 Subject: [PATCH 03/11] refactor: simplify event log formatting --- src/commands/observability/output.ts | 5 +---- src/daemon/session-event-action.ts | 11 ++--------- src/daemon/session-event-details.ts | 4 ++++ src/daemon/session-event-log.ts | 15 ++++----------- 4 files changed, 11 insertions(+), 24 deletions(-) create mode 100644 src/daemon/session-event-details.ts diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index 737eaaaac..6dca8612a 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -199,10 +199,7 @@ function formatEventRowLine( function formatEventTimestamp(value: string | undefined): string { if (!value) return ''; - const match = value.match( - /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)(Z|[+-]\d{2}:\d{2})?$/, - ); - return match ? `${match[1]} ${match[2]}${match[3] ?? ''}` : value; + return value.replace('T', ' '); } function formatEventLabel(entry: EventsCliEntry): string { diff --git a/src/daemon/session-event-action.ts b/src/daemon/session-event-action.ts index 333f388c1..597d770bd 100644 --- a/src/daemon/session-event-action.ts +++ b/src/daemon/session-event-action.ts @@ -1,5 +1,6 @@ import { PUBLIC_COMMANDS } from '../command-catalog.ts'; import type { SessionAction } from './types.ts'; +import { definedEventDetails } from './session-event-details.ts'; export function buildActionSummary(action: SessionAction): string { const message = readString(action.result?.message); @@ -27,7 +28,7 @@ export function buildActionSummary(action: SessionAction): string { export function buildActionDetails(action: SessionAction): Record { const result = action.result ?? {}; - return compactDetails({ + return definedEventDetails({ command: action.command, positionals: buildDisplayPositionals(action), flags: action.flags, @@ -144,11 +145,3 @@ function readStringArray(value: unknown): string[] | undefined { ? value : undefined; } - -function compactDetails(record: Record): Record { - const compact: Record = {}; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) compact[key] = value; - } - return compact; -} diff --git a/src/daemon/session-event-details.ts b/src/daemon/session-event-details.ts new file mode 100644 index 000000000..52692e17d --- /dev/null +++ b/src/daemon/session-event-details.ts @@ -0,0 +1,4 @@ +/** Event detail builders collect optional fields; omit absent values before writing/returning them. */ +export function definedEventDetails(record: Record): Record { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); +} diff --git a/src/daemon/session-event-log.ts b/src/daemon/session-event-log.ts index 5a31ef283..61a20889d 100644 --- a/src/daemon/session-event-log.ts +++ b/src/daemon/session-event-log.ts @@ -6,6 +6,7 @@ import { redactDiagnosticData } from '../kernel/redaction.ts'; import { getDiagnosticsMeta } from '../utils/diagnostics.ts'; import type { DaemonRequest, DaemonResponse, SessionAction } from './types.ts'; import { buildActionDetails, buildActionSummary } from './session-event-action.ts'; +import { definedEventDetails } from './session-event-details.ts'; export const SESSION_EVENT_LOG_FILENAME = 'events.ndjson'; @@ -92,7 +93,7 @@ export function buildRequestStartedEvent(params: { requestId: req.meta?.requestId ?? getDiagnosticsMeta().requestId, command: req.command, summary: `Started ${req.command}`, - details: compactDetails({ + details: definedEventDetails({ publicSession: req.session, effectiveSession: sessionName, tenant: req.meta?.tenantId, @@ -116,7 +117,7 @@ export function buildRequestFinishedEvent(params: { command: req.command, status: 'ok', summary: `Finished ${req.command}`, - details: compactDetails({ durationMs }), + details: definedEventDetails({ durationMs }), }; } return { @@ -125,7 +126,7 @@ export function buildRequestFinishedEvent(params: { command: req.command, status: 'error', summary: `Failed ${req.command}: ${response.error.message}`, - details: compactDetails({ + details: definedEventDetails({ durationMs, code: response.error.code, message: response.error.message, @@ -194,11 +195,3 @@ function parseSessionEventLogLine(line: string): SessionEventLogEntry | undefine function isSessionEventKind(value: unknown): value is SessionEventLogEntry['kind'] { return value === 'request.started' || value === 'request.finished' || value === 'action.recorded'; } - -function compactDetails(record: Record): Record { - const compact: Record = {}; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) compact[key] = value; - } - return compact; -} From 12b896ea51db384cc3b37864443758ebd7a0738c Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 2 Jul 2026 19:05:03 +0200 Subject: [PATCH 04/11] refactor: trim event log helpers --- src/commands/observability/index.ts | 6 +----- src/daemon/session-event-action.ts | 5 ++--- src/daemon/session-event-details.ts | 4 ---- src/daemon/session-event-log.ts | 13 ++++++------- 4 files changed, 9 insertions(+), 19 deletions(-) delete mode 100644 src/daemon/session-event-details.ts diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index f74cdb04a..ba28ca790 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -144,7 +144,7 @@ export const logsCliReader: CliReader = (positionals, flags) => ({ export const eventsCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), - limit: optionalEventsLimit(positionals[0]), + limit: positionals[0]?.trim() ? optionalCliNumber(positionals[0]) : undefined, cursor: positionals[1], }); @@ -237,10 +237,6 @@ function networkPositionals(input: NetworkOptions): string[] { return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)]; } -function optionalEventsLimit(value: string | undefined): number | undefined { - return value === undefined || value.trim() === '' ? undefined : optionalCliNumber(value); -} - function audioPositionals(input: AudioOptions): string[] { return [ input.action ?? 'probe', diff --git a/src/daemon/session-event-action.ts b/src/daemon/session-event-action.ts index 597d770bd..782c79d67 100644 --- a/src/daemon/session-event-action.ts +++ b/src/daemon/session-event-action.ts @@ -1,6 +1,5 @@ import { PUBLIC_COMMANDS } from '../command-catalog.ts'; import type { SessionAction } from './types.ts'; -import { definedEventDetails } from './session-event-details.ts'; export function buildActionSummary(action: SessionAction): string { const message = readString(action.result?.message); @@ -28,7 +27,7 @@ export function buildActionSummary(action: SessionAction): string { export function buildActionDetails(action: SessionAction): Record { const result = action.result ?? {}; - return definedEventDetails({ + return { command: action.command, positionals: buildDisplayPositionals(action), flags: action.flags, @@ -61,7 +60,7 @@ export function buildActionDetails(action: SessionAction): Record): Record { - return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); -} diff --git a/src/daemon/session-event-log.ts b/src/daemon/session-event-log.ts index 61a20889d..3ce99ec1a 100644 --- a/src/daemon/session-event-log.ts +++ b/src/daemon/session-event-log.ts @@ -6,7 +6,6 @@ import { redactDiagnosticData } from '../kernel/redaction.ts'; import { getDiagnosticsMeta } from '../utils/diagnostics.ts'; import type { DaemonRequest, DaemonResponse, SessionAction } from './types.ts'; import { buildActionDetails, buildActionSummary } from './session-event-action.ts'; -import { definedEventDetails } from './session-event-details.ts'; export const SESSION_EVENT_LOG_FILENAME = 'events.ndjson'; @@ -93,14 +92,14 @@ export function buildRequestStartedEvent(params: { requestId: req.meta?.requestId ?? getDiagnosticsMeta().requestId, command: req.command, summary: `Started ${req.command}`, - details: definedEventDetails({ + details: { publicSession: req.session, effectiveSession: sessionName, tenant: req.meta?.tenantId, isolation: req.meta?.sessionIsolation, requestLogPath, runnerLogPath, - }), + }, }; } @@ -117,7 +116,7 @@ export function buildRequestFinishedEvent(params: { command: req.command, status: 'ok', summary: `Finished ${req.command}`, - details: definedEventDetails({ durationMs }), + details: { durationMs }, }; } return { @@ -126,14 +125,14 @@ export function buildRequestFinishedEvent(params: { command: req.command, status: 'error', summary: `Failed ${req.command}: ${response.error.message}`, - details: definedEventDetails({ + details: { durationMs, code: response.error.code, message: response.error.message, hint: response.error.hint, diagnosticId: response.error.diagnosticId, logPath: response.error.logPath, - }), + }, }; } @@ -149,7 +148,7 @@ export function readSessionEventLog( const rawLines = fs .readFileSync(eventLogPath, 'utf8') - .split('\n') + .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) => { From b92421e2d5bf10944e87a0cd3027a68d6f176d7b Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 2 Jul 2026 19:07:53 +0200 Subject: [PATCH 05/11] docs: document session event timeline --- website/docs/docs/client-api.md | 4 +++- website/docs/docs/commands.md | 6 ++++-- website/docs/docs/sessions.md | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 62108abec..0c92b5659 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -254,10 +254,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 0a8ad3e50..38be489e9 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -814,6 +814,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 @@ -822,8 +824,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. From 680331df7fc1ed93f96fd76db5fedfd240694549 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 2 Jul 2026 19:38:45 +0200 Subject: [PATCH 06/11] refactor: tighten session event log internals --- src/daemon/session-event-action.ts | 29 +++++++++++---- src/daemon/session-event-log.ts | 57 +++++++++++++++++++++++------- 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/src/daemon/session-event-action.ts b/src/daemon/session-event-action.ts index 782c79d67..a1294c349 100644 --- a/src/daemon/session-event-action.ts +++ b/src/daemon/session-event-action.ts @@ -1,4 +1,4 @@ -import { PUBLIC_COMMANDS } from '../command-catalog.ts'; +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../command-catalog.ts'; import type { SessionAction } from './types.ts'; export function buildActionSummary(action: SessionAction): string { @@ -18,7 +18,7 @@ export function buildActionSummary(action: SessionAction): string { return `Filled ${readActionTargetLabel(action) ?? 'target'}`; case PUBLIC_COMMANDS.install: case PUBLIC_COMMANDS.reinstall: - case 'install_source': + case INTERNAL_COMMANDS.installSource: return `Installed ${readActionTargetLabel(action) ?? 'app'}`; default: return `Ran ${action.command}`; @@ -65,21 +65,35 @@ export function buildActionDetails(action: SessionAction): Record): string | undefined { const ref = readString(result.ref); if (ref) return ref.startsWith('@') ? ref : `@${ref}`; const selector = readString(result.selector); if (selector) return selector; - const refLabel = readString(result.refLabel); - if (refLabel) return refLabel; + 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) ?? - action.positionals[0] + readString(result.packageName) ); } @@ -132,7 +146,8 @@ function readActionTextLength(action: SessionAction): number { } function readString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim().length > 0 ? value : undefined; + if (typeof value !== 'string') return undefined; + return value.trim().length > 0 ? value : undefined; } function readNumber(value: unknown): number | undefined { diff --git a/src/daemon/session-event-log.ts b/src/daemon/session-event-log.ts index 3ce99ec1a..1b2792e3b 100644 --- a/src/daemon/session-event-log.ts +++ b/src/daemon/session-event-log.ts @@ -3,12 +3,12 @@ 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 { getDiagnosticsMeta } from '../utils/diagnostics.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'; -export const SESSION_EVENT_LOG_FILENAME = 'events.ndjson'; - +const SESSION_EVENT_LOG_FILENAME = 'events.ndjson'; const EVENT_LOG_VERSION = 1; const DEFAULT_EVENT_LIMIT = 100; const MAX_EVENT_LIMIT = 500; @@ -38,6 +38,8 @@ export type SessionEventLogInput = Omit & + Pick; export function resolveSessionEventLogPath(sessionDir: string): string { return path.join(sessionDir, SESSION_EVENT_LOG_FILENAME); @@ -61,8 +63,15 @@ export function appendSessionEvent( ...event, } satisfies SessionEventLogEntry); fs.appendFileSync(eventLogPath, `${JSON.stringify(entry)}\n`, 'utf8'); - } catch { - // Event logging is best-effort and must never break device automation. + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'session_event_log_write_failed', + data: { + path: eventLogPath, + error: error instanceof Error ? error.message : String(error), + }, + }); } } @@ -179,18 +188,42 @@ function normalizeLimit(value: number | undefined): number { function parseSessionEventLogLine(line: string): SessionEventLogEntry | undefined { try { - const parsed = JSON.parse(line) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; - const record = parsed as Partial; - if (record.version !== EVENT_LOG_VERSION) return undefined; - if (typeof record.ts !== 'string' || typeof record.session !== 'string') return undefined; - if (!isSessionEventKind(record.kind)) return undefined; - return record as SessionEventLogEntry; + 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'; +} From 96c576eef2d4c72e71c9090214ec67c752e73d5e Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Tue, 7 Jul 2026 11:00:15 +0200 Subject: [PATCH 07/11] fix: redact event log action positionals by default --- src/daemon/__tests__/session-store.test.ts | 44 +++++++++ src/daemon/session-event-action.ts | 100 ++++++++++++++++++--- 2 files changed, 133 insertions(+), 11 deletions(-) diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 207e69abe..915c3c43d 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -181,6 +181,50 @@ test('recordAction event log redacts typed text from display positionals', () => assert.equal(page.events[0]?.details?.textLength, 18); }); +test('recordAction event log redacts payload-bearing and unknown positionals', () => { + const { store, session } = makeFixture('agent-device-session-events-payload-redaction-'); + const clipboardText = 'super-secret-token'; + const pushPayload = '{"token":"push-secret-token"}'; + const eventPayload = '{"token":"event-secret-token"}'; + const futurePayload = 'future-secret-token'; + + store.recordAction(session, { + command: 'clipboard', + positionals: ['write', clipboardText], + flags: {}, + result: { action: 'write', textLength: Array.from(clipboardText).length }, + }); + store.recordAction(session, { + command: 'push', + positionals: ['com.example.app', pushPayload], + flags: {}, + result: { message: 'Pushed notification to com.example.app' }, + }); + store.recordAction(session, { + command: 'trigger-app-event', + positionals: ['checkout', eventPayload], + flags: {}, + result: { message: 'Triggered app event checkout' }, + }); + store.recordAction(session, { + command: 'future-command', + positionals: ['public-ish', futurePayload], + flags: {}, + result: {}, + }); + + const page = store.readEvents(session.name); + const serialized = JSON.stringify(page.events); + assert.equal(serialized.includes(clipboardText), false); + assert.equal(serialized.includes(pushPayload), false); + assert.equal(serialized.includes(eventPayload), false); + assert.equal(serialized.includes(futurePayload), false); + assert.deepEqual(page.events[0]?.details?.positionals, ['write', '']); + assert.deepEqual(page.events[1]?.details?.positionals, ['com.example.app', '']); + assert.deepEqual(page.events[2]?.details?.positionals, ['checkout', '']); + assert.deepEqual(page.events[3]?.details?.positionals, ['', '']); +}); + 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'); diff --git a/src/daemon/session-event-action.ts b/src/daemon/session-event-action.ts index a1294c349..787e70400 100644 --- a/src/daemon/session-event-action.ts +++ b/src/daemon/session-event-action.ts @@ -69,7 +69,7 @@ function readActionTargetLabel(action: SessionAction): string | undefined { readElementTargetLabel(result) ?? readPointTargetLabel(result) ?? readAppTargetLabel(result) ?? - action.positionals[0] + readSafeDisplayPositional(action.positionals[0]) ); } @@ -98,8 +98,9 @@ function readAppTargetLabel(result: Record): string | undefined } function buildDisplayPositionals(action: SessionAction): string[] | undefined { + if (action.positionals.length === 0) return undefined; if (action.command === PUBLIC_COMMANDS.type) { - return [``]; + return [hiddenValueFromLength('text', readActionTextLength(action))]; } if (action.command === PUBLIC_COMMANDS.fill) { return buildFillDisplayPositionals(action); @@ -107,11 +108,20 @@ function buildDisplayPositionals(action: SessionAction): string[] | undefined { if (action.command === PUBLIC_COMMANDS.find) { return buildFindDisplayPositionals(action); } - return action.positionals.length > 0 ? action.positionals : undefined; + if (action.command === PUBLIC_COMMANDS.clipboard) { + return buildClipboardDisplayPositionals(action); + } + if (action.command === PUBLIC_COMMANDS.push) { + return buildPayloadDisplayPositionals(action, 'payload'); + } + if (action.command === PUBLIC_COMMANDS.triggerAppEvent) { + return buildPayloadDisplayPositionals(action, 'payload'); + } + return action.positionals.map(redactDisplayPositional); } function buildFillDisplayPositionals(action: SessionAction): string[] { - const textPlaceholder = ``; + const textPlaceholder = hiddenValueFromLength('text', readActionTextLength(action)); const result = action.result ?? {}; const ref = readString(result.ref); if (ref) return [ref.startsWith('@') ? ref : `@${ref}`, textPlaceholder]; @@ -124,18 +134,55 @@ function buildFillDisplayPositionals(action: SessionAction): string[] { } 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; + const queryIndex = isFindLocator(action.positionals[0]) ? 1 : 0; + const query = action.positionals[queryIndex]; + if (query === undefined) return undefined; + const actionIndex = queryIndex + 1; + const prefix = [ + ...(queryIndex === 1 ? [String(action.positionals[0])] : []), + hiddenValue('query', query), + ]; + const findAction = action.positionals[actionIndex]; + if (!findAction) return prefix; + if (findAction === 'fill' || findAction === 'type') { + return [...prefix, findAction, hiddenValueFromLength('text', readActionTextLength(action))]; + } + return [...prefix, ...action.positionals.slice(actionIndex).map(redactFindActionPositional)]; +} + +function buildClipboardDisplayPositionals(action: SessionAction): string[] { + const clipboardAction = action.positionals[0]?.toLowerCase(); + if (clipboardAction === 'read') return ['read']; + if (clipboardAction === 'write') { + return ['write', hiddenValueFromLength('text', readClipboardWriteLength(action))]; } + return action.positionals.map(redactDisplayPositional); +} + +function buildPayloadDisplayPositionals(action: SessionAction, payloadLabel: string): string[] { + const [target, payload, ...extra] = action.positionals; return [ - ...action.positionals.slice(0, sensitiveActionIndex + 1), - ``, + ...(target ? [target] : []), + ...(payload ? [hiddenValue(payloadLabel, payload)] : []), + ...extra.map(redactDisplayPositional), ]; } +function redactFindActionPositional(value: string): string { + if ( + value === 'click' || + value === 'focus' || + value === 'exists' || + value === 'get' || + value === 'text' || + value === 'attrs' || + value === 'wait' + ) { + return value; + } + return redactDisplayPositional(value); +} + function readActionTextLength(action: SessionAction): number { const resultText = action.result?.text; if (typeof resultText === 'string') return Array.from(resultText).length; @@ -145,6 +192,37 @@ function readActionTextLength(action: SessionAction): number { return 0; } +function readClipboardWriteLength(action: SessionAction): number { + const textLength = action.result?.textLength; + if (typeof textLength === 'number' && Number.isFinite(textLength)) return textLength; + return Array.from(action.positionals.slice(1).join(' ')).length; +} + +function redactDisplayPositional(value: string): string { + return readSafeDisplayPositional(value) ?? hiddenValue('arg', value); +} + +function readSafeDisplayPositional(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (/^@[a-zA-Z0-9:_-]+$/.test(trimmed)) return trimmed; + return undefined; +} + +function hiddenValue(label: string, value: string): string { + return hiddenValueFromLength(label, Array.from(value).length); +} + +function hiddenValueFromLength(label: string, length: number): string { + return `<${label}:${length} chars>`; +} + +function isFindLocator(value: string | undefined): boolean { + return ( + value === 'text' || value === 'label' || value === 'value' || value === 'role' || value === 'id' + ); +} + function readString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; return value.trim().length > 0 ? value : undefined; From cf34f711572ed44cefecd1a20a24a99850324fde Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Tue, 7 Jul 2026 11:14:09 +0200 Subject: [PATCH 08/11] fix: align event log after rebase --- src/core/command-descriptor/registry.ts | 1 + src/daemon/request-router.ts | 8 +---- src/daemon/server/daemon-runtime.ts | 8 +---- src/daemon/web-session-names.ts | 8 +++++ .../remote-daemon-client.test.ts | 30 +++++++++++-------- 5 files changed, 29 insertions(+), 26 deletions(-) create mode 100644 src/daemon/web-session-names.ts diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 8c6e56051..cb7c82eaa 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -279,6 +279,7 @@ const RAW_COMMAND_DESCRIPTORS = [ allowInvalidRecording: true, ...REQUEST_EXECUTION_EXEMPT, }, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, { diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 08e43d09d..4ddc3955a 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -42,6 +42,7 @@ import { buildRequestFinishedEvent, shouldRecordEventForRequest } from './sessio import { canRunReplayScopedAction } from './daemon-command-registry.ts'; import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts'; import type { LeaseLifecycleProvider } from './handlers/lease.ts'; +import { openWebSessionNames } from './web-session-names.ts'; // --------------------------------------------------------------------------- // Request handler API @@ -263,13 +264,6 @@ function shouldUseDefaultWebProvider(scope: LockedRequestScope): boolean { return scope.existingSession?.device.platform === 'web' || scope.req.flags?.platform === 'web'; } -function openWebSessionNames(sessionStore: SessionStore): string[] { - return sessionStore - .toArray() - .filter((session) => session.device.platform === 'web') - .map((session) => session.name); -} - function unauthorizedResponse(): DaemonResponse { return { ok: false, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 5426e9e46..e64a371b0 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -43,6 +43,7 @@ import { sleep } from '../../utils/timeouts.ts'; import { setRunnerLeaseOwnerStateDir } from '../../platforms/apple/core/runner/runner-lease.ts'; import { cleanupManagedAgentBrowserOrphans } from '../../platforms/web/agent-browser-lifecycle.ts'; import { getManagedAgentBrowserStatus } from '../../platforms/web/agent-browser-tool.ts'; +import { openWebSessionNames } from '../web-session-names.ts'; const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; @@ -321,10 +322,3 @@ export async function cleanupWebBrowserOrphansForDaemonStartup(params: { }); } } - -function openWebSessionNames(sessionStore: SessionStore): string[] { - return sessionStore - .toArray() - .filter((session) => session.device.platform === 'web') - .map((session) => session.name); -} diff --git a/src/daemon/web-session-names.ts b/src/daemon/web-session-names.ts new file mode 100644 index 000000000..c067dd8cd --- /dev/null +++ b/src/daemon/web-session-names.ts @@ -0,0 +1,8 @@ +import type { SessionStore } from './session-store.ts'; + +export function openWebSessionNames(sessionStore: SessionStore): string[] { + return sessionStore + .toArray() + .filter((session) => session.device.platform === 'web') + .map((session) => session.name); +} diff --git a/test/integration/provider-scenarios/remote-daemon-client.test.ts b/test/integration/provider-scenarios/remote-daemon-client.test.ts index ea2284070..03be1673e 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -568,28 +568,34 @@ async function assertEventsRpc( rpcRequests: RemoteRpcRequest[], ): Promise { const page = await client.observability.events({ limit: 2, cursor: '4' }); + assertEventsPage(page); + assertEventsRpcRequest(rpcRequests.at(-1), ['2', '4']); + + const cursorOnlyPage = await client.observability.events({ cursor: '6' }); + assert.equal(cursorOnlyPage.cursor, '6'); + assert.equal(cursorOnlyPage.limit, 100); + assertEventsRpcRequest(rpcRequests.at(-1), ['', '6']); +} + +function assertEventsPage( + page: Awaited>, +): void { 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); +function assertEventsRpcRequest( + eventsRpc: RemoteRpcRequest | undefined, + positionals: string[], +): void { assert.equal(eventsRpc?.method, 'agent_device.command'); assert.equal(eventsRpc?.params?.command, 'events'); - assert.deepEqual(eventsRpc?.params?.positionals, ['2', '4']); + assert.deepEqual(eventsRpc?.params?.positionals, positionals); 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 { From c054cb1a8a0729042dc54178226ffd3b2f4da8a5 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Tue, 7 Jul 2026 11:37:25 +0200 Subject: [PATCH 09/11] test: cover events in provider output guard --- .../provider-scenarios/apple-platform-output-guard.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index f8dacd3a9..dfc5401b8 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -92,6 +92,7 @@ const DRIVEN_COMMANDS: Record = { // -- observability -- [PUBLIC_COMMANDS.logs]: () => one(), + [PUBLIC_COMMANDS.events]: () => one(), [PUBLIC_COMMANDS.network]: () => one(), [PUBLIC_COMMANDS.audio]: () => one(), [PUBLIC_COMMANDS.screenshot]: ({ tmpDir }) => one([], { out: path.join(tmpDir, 'shot.png') }), From 66ae7f1e1b16f5eb795864c8d93a12fda52ebac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Jul 2026 12:28:46 +0200 Subject: [PATCH 10/11] fix: harden session event privacy --- src/cli/parser/cli-help.ts | 6 ++ src/daemon/__tests__/session-store.test.ts | 8 ++- src/daemon/session-event-action.ts | 27 +++++++- src/daemon/session-event-log.ts | 80 ++++++++++++++++++++-- website/docs/docs/client-api.md | 1 + website/docs/docs/commands.md | 1 + website/docs/docs/sessions.md | 6 ++ 7 files changed, 118 insertions(+), 11 deletions(-) diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index c148300cd..9ac22a998 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -377,6 +377,12 @@ Logs: agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/app.console.log --launch-console is only for direct iOS simulator app launches, not URL opens. +Events: + Use events for a compact session timeline without dumping full app logs. + agent-device events + agent-device events 50 100 + Events preserve command names, status, durations, paths, session/device/app identifiers, refs/selectors, and coordinates. Typed text, clipboard writes, push/event payloads, raw unknown command arguments, and matching raw message fragments are replaced with length-only placeholders. --no-record suppresses action.recorded entries, but request start/finish entries still record command, status, and timing. + Network: Use network dump for recent session HTTP traffic parsed from app logs. agent-device network dump --include headers diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 915c3c43d..e6bc88604 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -171,12 +171,14 @@ test('recordAction event log redacts typed text from display positionals', () => command: 'fill', positionals: ['@14', 'super-secret-token'], flags: {}, - result: { ref: '14', text: 'super-secret-token', message: 'Filled 18 chars' }, + result: { ref: '14', text: 'super-secret-token', message: 'Filled super-secret-token' }, }); const page = store.readEvents(session.name); const serialized = JSON.stringify(page.events); assert.equal(serialized.includes('super-secret-token'), false); + assert.equal(page.events[0]?.summary, 'Filled '); + assert.equal(page.events[0]?.details?.message, 'Filled '); assert.deepEqual(page.events[0]?.details?.positionals, ['@14', '']); assert.equal(page.events[0]?.details?.textLength, 18); }); @@ -210,7 +212,7 @@ test('recordAction event log redacts payload-bearing and unknown positionals', ( command: 'future-command', positionals: ['public-ish', futurePayload], flags: {}, - result: {}, + result: { message: `Ran ${futurePayload}` }, }); const page = store.readEvents(session.name); @@ -223,6 +225,8 @@ test('recordAction event log redacts payload-bearing and unknown positionals', ( assert.deepEqual(page.events[1]?.details?.positionals, ['com.example.app', '']); assert.deepEqual(page.events[2]?.details?.positionals, ['checkout', '']); assert.deepEqual(page.events[3]?.details?.positionals, ['', '']); + assert.equal(page.events[3]?.summary, 'Ran '); + assert.equal(page.events[3]?.details?.message, 'Ran '); }); test('saveScript path writes session log to custom location', () => { diff --git a/src/daemon/session-event-action.ts b/src/daemon/session-event-action.ts index 787e70400..7b2a3d4af 100644 --- a/src/daemon/session-event-action.ts +++ b/src/daemon/session-event-action.ts @@ -2,7 +2,7 @@ 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); + const message = readSanitizedActionMessage(action); if (message) return message; switch (action.command) { case PUBLIC_COMMANDS.open: @@ -32,7 +32,7 @@ export function buildActionDetails(action: SessionAction): Record 0) { + sanitized = sanitized.split(resultText).join(hiddenValue('text', resultText)); + } + return sanitized; +} + function readActionTargetLabel(action: SessionAction): string | undefined { const result = action.result ?? {}; return ( diff --git a/src/daemon/session-event-log.ts b/src/daemon/session-event-log.ts index 1b2792e3b..dfd3bb84c 100644 --- a/src/daemon/session-event-log.ts +++ b/src/daemon/session-event-log.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; import { PUBLIC_COMMANDS } from '../command-catalog.ts'; import { AppError } from '../kernel/errors.ts'; import { redactDiagnosticData } from '../kernel/redaction.ts'; @@ -12,6 +13,7 @@ const SESSION_EVENT_LOG_FILENAME = 'events.ndjson'; const EVENT_LOG_VERSION = 1; const DEFAULT_EVENT_LIMIT = 100; const MAX_EVENT_LIMIT = 500; +const EVENT_LOG_READ_CHUNK_BYTES = 64 * 1024; export type SessionEventLogEntry = { version: 1; @@ -155,12 +157,8 @@ export function readSessionEventLog( 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 page = readSessionEventLogLines(eventLogPath, cursor, limit); + const events = page.lines.flatMap((line) => { const parsed = parseSessionEventLogLine(line); return parsed ? [parsed] : []; }); @@ -169,10 +167,78 @@ export function readSessionEventLog( cursor: String(cursor), limit, events, - ...(end < rawLines.length ? { nextCursor: String(end) } : {}), + ...(page.nextCursor !== undefined ? { nextCursor: String(page.nextCursor) } : {}), }; } +function readSessionEventLogLines( + eventLogPath: string, + cursor: number, + limit: number, +): { lines: string[]; nextCursor?: number } { + const fd = fs.openSync(eventLogPath, 'r'); + try { + const decoder = new StringDecoder('utf8'); + const buffer = Buffer.allocUnsafe(EVENT_LOG_READ_CHUNK_BYTES); + const state = createLineScanState(cursor, limit); + let pending = ''; + let bytesRead = 0; + do { + bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null); + const chunk = decoder.write(buffer.subarray(0, bytesRead)); + pending = consumeEventLogChunk(`${pending}${chunk}`, state); + } while (bytesRead > 0 && state.nextCursor === undefined); + const remainder = `${pending}${decoder.end()}`; + if (state.nextCursor === undefined && remainder.length > 0) { + consumeEventLogLine(remainder, state); + } + return { lines: state.lines, nextCursor: state.nextCursor }; + } finally { + fs.closeSync(fd); + } +} + +function createLineScanState( + cursor: number, + limit: number, +): { + cursor: number; + limit: number; + lineIndex: number; + lines: string[]; + nextCursor?: number; +} { + return { + cursor, + limit, + lineIndex: 0, + lines: [], + }; +} + +function consumeEventLogChunk(text: string, state: ReturnType): string { + let start = 0; + for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', start)) { + consumeEventLogLine(text.slice(start, index), state); + start = index + 1; + if (state.nextCursor !== undefined) return ''; + } + return text.slice(start); +} + +function consumeEventLogLine(rawLine: string, state: ReturnType): void { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + if (line.trim().length === 0) return; + if (state.lineIndex >= state.cursor + state.limit) { + state.nextCursor = state.cursor + state.limit; + return; + } + if (state.lineIndex >= state.cursor) { + state.lines.push(line); + } + state.lineIndex += 1; +} + function normalizeCursor(value: string | undefined): number { if (value === undefined || value.trim() === '') return 0; const cursor = Number(value); diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 0c92b5659..dbfc248e9 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -259,6 +259,7 @@ Additional CLI-backed methods are exposed on their domain groups with typed opti - `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. +The event timeline keeps operational context such as command/status/timing, paths, session/device/app identifiers, refs/selectors, and coordinates. Typed text, clipboard writes, push/event payloads, raw unknown command arguments, and matching raw message fragments are replaced with length-only placeholders. `client.observability.audio()` mirrors `audio probe start|status|stop`. Use it to collect compact RMS/peak dBFS buckets while other session actions continue: diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 38be489e9..e8ea46348 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -826,6 +826,7 @@ agent-device network dump 25 --include headers --platform web # Browser requests - `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`, `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. +- Event timeline entries keep operational context such as command names, status, durations, paths, session/device/app identifiers, refs/selectors, and coordinates. Typed text, clipboard writes, push/event payloads, raw unknown command arguments, and matching raw message fragments are replaced with length-only placeholders. `--no-record` suppresses `action.recorded` entries, but request start/finish entries still record command/status/timing. - `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 82c5269ec..6e0e43bcc 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -24,6 +24,12 @@ Session artifact directories contain per-run evidence for concurrent agents: - `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. +`events.ndjson` is privacy-shaped for automation timelines: it preserves command names, status, +durations, paths, session/device/app identifiers, refs/selectors, and coordinates, while replacing +typed text, clipboard writes, push/event payloads, raw unknown command arguments, and matching raw +message fragments with length-only placeholders. `--no-record` suppresses recorded action entries; +request start/finish entries still record command, status, and timing. + The top-level daemon log is for daemon lifecycle/startup issues. Use the session artifact directory first when debugging a specific run. Open an explicitly named session only when you intentionally want a shared/reusable handle: From fe355ab1c677cd3c10bbd358d181f580ba9496fa Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Tue, 7 Jul 2026 13:19:25 +0200 Subject: [PATCH 11/11] fix: harden event message redaction --- src/daemon/__tests__/session-store.test.ts | 28 ++++++++++ src/daemon/session-event-action.ts | 61 ++++++++++++++++++---- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index e6bc88604..5e7641fd1 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -229,6 +229,34 @@ test('recordAction event log redacts payload-bearing and unknown positionals', ( assert.equal(page.events[3]?.details?.message, 'Ran '); }); +test('recordAction event log redacts overlapping unknown positionals in one pass', () => { + const { store, session } = makeFixture('agent-device-session-events-overlap-redaction-'); + + store.recordAction(session, { + command: 'future-command', + positionals: ['token', 'my-token-123'], + flags: {}, + result: { message: 'Ran my-token-123 after token' }, + }); + store.recordAction(session, { + command: 'future-command', + positionals: ['arg', 'my-arg-123'], + flags: {}, + result: { message: 'Ran my-arg-123 after arg' }, + }); + + const page = store.readEvents(session.name); + const serialized = JSON.stringify(page.events); + assert.equal(serialized.includes('my-token-123'), false); + assert.equal(serialized.includes('token'), false); + assert.equal(serialized.includes('my-arg-123'), false); + assert.equal(page.events[0]?.summary, 'Ran after '); + assert.equal(page.events[0]?.details?.message, 'Ran after '); + assert.deepEqual(page.events[0]?.details?.positionals, ['', '']); + assert.equal(page.events[1]?.summary, 'Ran after '); + assert.equal(page.events[1]?.details?.message, 'Ran after '); +}); + 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'); diff --git a/src/daemon/session-event-action.ts b/src/daemon/session-event-action.ts index 7b2a3d4af..9709bc809 100644 --- a/src/daemon/session-event-action.ts +++ b/src/daemon/session-event-action.ts @@ -1,6 +1,8 @@ import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../command-catalog.ts'; import type { SessionAction } from './types.ts'; +type ActionTextReplacement = { raw: string; display: string }; + export function buildActionSummary(action: SessionAction): string { const message = readSanitizedActionMessage(action); if (message) return message; @@ -70,20 +72,57 @@ function readSanitizedActionMessage(action: SessionAction): string | undefined { } function sanitizeActionDisplayText(action: SessionAction, value: string): string { - let sanitized = value; + const replacements = buildActionTextReplacements(action); + if (replacements.length === 0) return value; + const replacementByRaw = new Map(replacements.map(({ raw, display }) => [raw, display])); + const pattern = replacements.map(({ raw }) => escapeRegExp(raw)).join('|'); + return value.replace(new RegExp(pattern, 'g'), (raw) => replacementByRaw.get(raw) ?? raw); +} + +function buildActionTextReplacements(action: SessionAction): ActionTextReplacement[] { + return uniqueActionTextReplacements( + buildActionTextReplacementCandidates(action).sort( + (left, right) => right.raw.length - left.raw.length, + ), + ); +} + +function buildActionTextReplacementCandidates(action: SessionAction): ActionTextReplacement[] { const displayPositionals = buildDisplayPositionals(action) ?? []; - for (const [index, positional] of action.positionals.entries()) { - if (!positional) continue; - const replacement = displayPositionals[index] ?? redactDisplayPositional(positional); - if (replacement !== positional) { - sanitized = sanitized.split(positional).join(replacement); - } - } + const replacements = action.positionals.flatMap((positional, index) => { + const replacement = buildPositionalTextReplacement(positional, displayPositionals[index]); + return replacement ? [replacement] : []; + }); const resultText = action.result?.text; - if (typeof resultText === 'string' && resultText.length > 0) { - sanitized = sanitized.split(resultText).join(hiddenValue('text', resultText)); + return typeof resultText === 'string' && resultText.length > 0 + ? [{ raw: resultText, display: hiddenValue('text', resultText) }, ...replacements] + : replacements; +} + +function buildPositionalTextReplacement( + raw: string, + display: string | undefined, +): ActionTextReplacement | undefined { + if (!raw) return undefined; + const replacement = display ?? redactDisplayPositional(raw); + return replacement === raw ? undefined : { raw, display: replacement }; +} + +function uniqueActionTextReplacements( + candidates: ActionTextReplacement[], +): ActionTextReplacement[] { + const seen = new Set(); + const replacements: ActionTextReplacement[] = []; + for (const { raw, display } of candidates) { + if (seen.has(raw)) continue; + seen.add(raw); + replacements.push({ raw, display }); } - return sanitized; + return replacements; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function readActionTargetLabel(action: SessionAction): string | undefined {