From bf7551a7e5f629c2c363b4e58acfad9f8f461350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 7 Jun 2026 11:05:07 +0200 Subject: [PATCH] fix: scope runner diagnostics to sessions --- AGENTS.md | 5 +- src/client-normalizers.ts | 2 + src/client-shared.ts | 4 + src/client-types.ts | 4 + .../__tests__/request-execution-scope.test.ts | 107 +++++++++++++++++- .../__tests__/request-router-open.test.ts | 6 + src/daemon/handlers/session-inventory.ts | 35 +++--- src/daemon/handlers/session-open-surface.ts | 15 ++- src/daemon/handlers/session-open.ts | 13 ++- src/daemon/request-execution-scope.ts | 45 ++++++-- src/daemon/request-router.ts | 9 +- src/daemon/session-store.ts | 14 +++ src/platforms/ios/runner-transport.ts | 5 +- src/utils/__tests__/args.test.ts | 6 +- src/utils/cli-command-overrides.ts | 2 +- src/utils/cli-help.ts | 7 +- src/utils/diagnostics.ts | 36 +++++- website/docs/docs/commands.md | 3 + website/docs/docs/debugging-profiling.md | 2 + website/docs/docs/sessions.md | 10 +- 20 files changed, 285 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a1ca9cd5c..1d1db0f2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,11 +159,14 @@ Command-only flags (like `find --first`) that do not flow to the platform layer ## Logs Contract - Logs backend/source of truth is `src/daemon/app-log.ts`. - `session.ts` should orchestrate only (start/stop/path/doctor/mark), not duplicate backend logic. +- App logs are distinct from runner/platform output. Keep app/device log capture in `app.log`; Apple runner and `xcodebuild` subprocess output belongs in the session-scoped `runner.log`. - Preserve external grep/tail workflow in docs/skills. ## Diagnostics & Errors - Diagnostics source of truth: `src/utils/diagnostics.ts` - - `withDiagnosticsScope`, `emitDiagnostic`, `withDiagnosticTimer`, `flushDiagnosticsToSessionFile` + - `withDiagnosticsScope`, `updateDiagnosticsScope`, `emitDiagnostic`, `withDiagnosticTimer`, `flushDiagnosticsToSessionFile` +- Request diagnostics belong in `sessions//requests/.ndjson` once the effective session is resolved. The top-level daemon log is for daemon lifecycle/startup and pre-session failures. +- Session artifact paths are centralized in `src/daemon/session-store.ts`; do not hand-build session log paths in handlers. - Do not add ad-hoc stderr/file logging where diagnostics helpers apply. - Normalize user-facing failures via `src/utils/errors.ts` (`normalizeError`). - Failure payload contract: `code`, `message`, `hint`, `diagnosticId`, `logPath`, `details`. diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index e77b0089d..76ae05af0 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -111,6 +111,8 @@ export function normalizeSession(value: unknown): AgentDeviceSession { return { name, createdAt: readRequiredNumber(record, 'createdAt'), + sessionStateDir: readOptionalString(record, 'sessionStateDir'), + runnerLogPath: readOptionalString(record, 'runnerLogPath'), device: { platform, target, diff --git a/src/client-shared.ts b/src/client-shared.ts index 8cd2675f3..13b3b5d68 100644 --- a/src/client-shared.ts +++ b/src/client-shared.ts @@ -67,6 +67,8 @@ function serializeSessionDevice( export function serializeSessionListEntry(session: AgentDeviceSession): Record { return { name: session.name, + ...(session.sessionStateDir ? { sessionStateDir: session.sessionStateDir } : {}), + ...(session.runnerLogPath ? { runnerLogPath: session.runnerLogPath } : {}), ...serializeSessionDevice(session.device, { includeAndroidSerial: false }), createdAt: session.createdAt, }; @@ -141,6 +143,8 @@ export function serializeOpenResult(result: AppOpenResult): Record { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const cwd = fs.mkdtempSync(path.join(TEST_ROOT, 'cwd-scope-')); + fs.mkdirSync(path.join(cwd, '.git')); + + const scope = await withDiagnosticsScope( + { command: 'snapshot', requestId: 'request-logs-1', logPath: LOG_PATH }, + async () => + await createRequestExecutionScope({ + req: makeRequest({ meta: { cwd, requestId: 'request-logs-1' } }), + sessionStore, + leaseRegistry: new LeaseRegistry(), + }), + ); + + expect(scope.sessionName).toMatch(/^cwd:[a-f0-9]{16}:default$/); + expect(scope.requestLogPath).toMatch( + /cwd_[a-f0-9]{16}_default\/requests\/request-logs-1\.ndjson$/, + ); + expect(scope.runnerLogPath).toMatch(/cwd_[a-f0-9]{16}_default\/runner\.log$/); +}); + +test('request diagnostics flush into the effective session request log', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const cwd = fs.mkdtempSync(path.join(TEST_ROOT, 'diag-scope-')); + fs.mkdirSync(path.join(cwd, '.git')); + + const result = await withDiagnosticsScope( + { command: 'snapshot', requestId: 'request-diag-1', logPath: LOG_PATH }, + async () => { + const scope = await createRequestExecutionScope({ + req: makeRequest({ meta: { cwd, requestId: 'request-diag-1' } }), + sessionStore, + leaseRegistry: new LeaseRegistry(), + }); + return { + expectedPath: scope.requestLogPath, + flushedPath: flushDiagnosticsToSessionFile({ force: true }), + }; + }, + ); + + expect(result.flushedPath).toBe(result.expectedPath); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toContain('"phase":"request_start"'); +}); + test('createRequestExecutionScope rejects tenant requests without an active lease', async () => { await expect( createRequestExecutionScope({ @@ -67,6 +114,40 @@ test('createRequestExecutionScope rejects tenant requests without an active leas ).rejects.toThrow(/Lease is not active/); }); +test('tenant lease rejection flushes diagnostics into the effective session request log', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const requestId = 'tenant-lease-rejection'; + let flushedPath: string | null = null; + + await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => { + await expect( + createRequestExecutionScope({ + req: makeRequest({ + session: 'default', + command: 'snapshot', + meta: { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: '0'.repeat(32), + sessionIsolation: 'tenant', + requestId, + }, + }), + sessionStore, + leaseRegistry: new LeaseRegistry(), + }), + ).rejects.toThrow(/Lease is not active/); + flushedPath = flushDiagnosticsToSessionFile({ force: true }); + }); + + const expectedPath = resolveSessionRequestLogPath( + sessionStore.resolveSessionDir('tenant-a:default'), + requestId, + ); + expect(flushedPath).toBe(expectedPath); + expect(fs.readFileSync(expectedPath, 'utf8')).toContain('"phase":"request_start"'); +}); + test('prepareLockedRequestScope preserves existing-session selector validation', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.set('default', makeAndroidSession('default')); @@ -84,7 +165,6 @@ test('prepareLockedRequestScope preserves existing-session selector validation', expect(() => prepareLockedRequestScope({ scope, - logPath: LOG_PATH, sessionStore, trackDownloadableArtifact: () => 'artifact-id', }), @@ -116,7 +196,6 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor const result = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, async () => prepareLockedRequestScope({ scope, - logPath: LOG_PATH, sessionStore, trackDownloadableArtifact: () => 'artifact-id', }), @@ -131,6 +210,28 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor } }); +test('prepareLockedRequestScope passes the session runner log path into handler context', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + sessionStore.set('default', makeIosSession('default')); + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry: new LeaseRegistry(), + }); + + const result = prepareLockedRequestScope({ + scope, + sessionStore, + trackDownloadableArtifact: () => 'artifact-id', + }); + + expect(result.type).toBe('scope'); + if (result.type === 'scope') { + expect(result.scope.logPath).toBe(scope.runnerLogPath); + expect(result.scope.contextFromFlags(undefined).logPath).toBe(scope.runnerLogPath); + } +}); + test('runLocked rejects a canceled request before executing work', async () => { const requestId = 'request-scope-canceled-before-lock'; const scope = await createRequestExecutionScope({ diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 26b6e7f30..fc2683176 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -71,6 +71,12 @@ test('open returns and creates the session state directory', async () => { if (response.ok) { expect(response.data?.session).toBe('session-a'); expect(response.data?.sessionStateDir).toEqual(expect.stringContaining('session-a')); + expect(response.data?.runnerLogPath).toEqual( + path.join(String(response.data?.sessionStateDir), 'runner.log'), + ); + expect(response.data?.requestLogPath).toEqual( + path.join(String(response.data?.sessionStateDir), 'requests', 'req-open-state.ndjson'), + ); expect(fs.existsSync(String(response.data?.sessionStateDir))).toBe(true); } }); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 578c4d634..4d87f6560 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -13,7 +13,7 @@ import { resolveIosSimulatorDeviceSetPath, } from '../../utils/device-isolation.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; -import { SessionStore } from '../session-store.ts'; +import { resolveSessionRunnerLogPath, SessionStore } from '../session-store.ts'; import { listAndroidApps } from '../../platforms/android/app-lifecycle.ts'; import { listIosApps } from '../../platforms/ios/apps.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; @@ -35,20 +35,25 @@ export async function handleSessionInventoryCommands(params: { sessions: sessionStore .toArray() .filter((session) => sessionMatchesScope(session, scope)) - .map((session) => ({ - name: session.name, - platform: session.device.platform, - target: session.device.target ?? 'mobile', - surface: session.surface ?? 'app', - device: session.device.name, - id: session.device.id, - device_id: session.device.id, - createdAt: session.createdAt, - ...(session.device.platform === 'ios' && { - device_udid: session.device.id, - ios_simulator_device_set: session.device.simulatorSetPath ?? null, - }), - })), + .map((session) => { + const sessionStateDir = sessionStore.resolveSessionDir(session.name); + return { + name: session.name, + sessionStateDir, + runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), + platform: session.device.platform, + target: session.device.target ?? 'mobile', + surface: session.surface ?? 'app', + device: session.device.name, + id: session.device.id, + device_id: session.device.id, + createdAt: session.createdAt, + ...(session.device.platform === 'ios' && { + device_udid: session.device.id, + ios_simulator_device_set: session.device.simulatorSetPath ?? null, + }), + }; + }), }, }; } diff --git a/src/daemon/handlers/session-open-surface.ts b/src/daemon/handlers/session-open-surface.ts index 6c8350c65..3753278af 100644 --- a/src/daemon/handlers/session-open-surface.ts +++ b/src/daemon/handlers/session-open-surface.ts @@ -8,7 +8,9 @@ import type { StartupPerfSample } from './session-startup-metrics.ts'; export function buildOpenResult(params: { sessionName: string; - sessionStateDir?: string; + sessionStateDir: string; + runnerLogPath: string; + requestLogPath: string; appName?: string; appBundleId?: string; surface: SessionSurface; @@ -21,6 +23,8 @@ export function buildOpenResult(params: { const { sessionName, sessionStateDir, + runnerLogPath, + requestLogPath, appName, appBundleId, surface, @@ -30,8 +34,13 @@ export function buildOpenResult(params: { runtime, runtimeHintCount, } = params; - const result: Record = { session: sessionName, surface }; - if (sessionStateDir) result.sessionStateDir = sessionStateDir; + const result: Record = { + session: sessionName, + surface, + sessionStateDir, + runnerLogPath, + requestLogPath, + }; if (appName) result.appName = appName; if (appBundleId) result.appBundleId = appBundleId; if (startup) result.startup = startup; diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 124ffb5f0..83c367a1c 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -10,7 +10,11 @@ import { import { applyRuntimeHintsToApp } from '../runtime-hints.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse, SessionRuntimeHints, SessionState } from '../types.ts'; -import { SessionStore } from '../session-store.ts'; +import { + resolveSessionRequestLogPath, + resolveSessionRunnerLogPath, + SessionStore, +} from '../session-store.ts'; import { IOS_SIMULATOR_POST_CLOSE_SETTLE_MS, IOS_SIMULATOR_POST_OPEN_SETTLE_MS, @@ -24,6 +28,7 @@ import { buildNextOpenSession, buildOpenResult } from './session-open-surface.ts import { markAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts'; import { resetAndroidFramePerfStats } from '../../platforms/android/perf.ts'; import { withKeyedLock } from '../../utils/keyed-lock.ts'; +import { getDiagnosticsMeta } from '../../utils/diagnostics.ts'; import { inferAndroidPackageAfterOpen } from './session-open-target.ts'; import { invalidOpenArgs, @@ -255,10 +260,16 @@ async function completeOpenCommand(params: { setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtime); } const sessionStateDir = sessionStore.ensureSessionDir(sessionName); + const requestLogPath = resolveSessionRequestLogPath( + sessionStateDir, + req.meta?.requestId ?? getDiagnosticsMeta().requestId, + ); timing.totalDurationMs = Math.max(0, Date.now() - openCommandStartedAtMs); const openResult = buildOpenResult({ sessionName: nextSession.name, sessionStateDir, + runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), + requestLogPath, appName, appBundleId: sessionAppBundleId, surface, diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 5acc0012d..115edb418 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -1,6 +1,10 @@ import type { CommandFlags } from '../core/dispatch.ts'; import { withKeyedLock } from '../utils/keyed-lock.ts'; -import { emitDiagnostic, getDiagnosticsMeta } from '../utils/diagnostics.ts'; +import { + emitDiagnostic, + getDiagnosticsMeta, + updateDiagnosticsScope, +} from '../utils/diagnostics.ts'; import { applyCommandDefaults } from '../utils/command-schema.ts'; import type { DaemonCommandContext } from './context.ts'; import { contextFromFlags as contextFromFlagsWithLog } from './context.ts'; @@ -21,7 +25,11 @@ import { shouldValidateSessionSelector, } from './daemon-command-registry.ts'; import type { LeaseRegistry } from './lease-registry.ts'; -import type { SessionStore } from './session-store.ts'; +import { + resolveSessionRequestLogPath, + resolveSessionRunnerLogPath, + type SessionStore, +} from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps @@ -32,6 +40,8 @@ export type RequestExecutionScope = { req: DaemonRequest; command: string; sessionName: string; + requestLogPath: string; + runnerLogPath: string; runLocked(task: () => Promise): Promise; throwIfCanceled(): void; }; @@ -39,6 +49,7 @@ export type RequestExecutionScope = { export type LockedRequestScope = { req: DaemonRequest; sessionName: string; + logPath: string; existingSession: SessionState | undefined; finalize(response: DaemonResponse): DaemonResponse; contextFromFlags( @@ -64,21 +75,34 @@ export async function createRequestExecutionScope(params: { }): Promise { const { sessionStore, leaseRegistry } = params; const scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); + + const command = scopedReq.command; + const sessionName = resolveEffectiveSessionName(scopedReq, sessionStore); + const diagnosticsMeta = getDiagnosticsMeta(); + const sessionDir = sessionStore.resolveSessionDir(sessionName); + const requestLogPath = resolveSessionRequestLogPath( + sessionDir, + scopedReq.meta?.requestId ?? diagnosticsMeta.requestId, + ); + const runnerLogPath = resolveSessionRunnerLogPath(sessionDir); + updateDiagnosticsScope({ + session: sessionName, + logPath: requestLogPath, + }); emitDiagnostic({ level: 'info', phase: 'request_start', data: { - session: scopedReq.session, + publicSession: scopedReq.session, + effectiveSession: sessionName, command: scopedReq.command, tenant: scopedReq.meta?.tenantId, isolation: scopedReq.meta?.sessionIsolation, + requestLogPath, + runnerLogPath, }, }); - - const command = scopedReq.command; assertRequestLeaseAdmission(scopedReq, leaseRegistry); - - const sessionName = resolveEffectiveSessionName(scopedReq, sessionStore); const executionLockKeys = shouldLockSessionExecution(command) ? await resolveRequestExecutionLockKeys({ req: scopedReq, sessionName, sessionStore }) : []; @@ -88,6 +112,8 @@ export async function createRequestExecutionScope(params: { req: scopedReq, command, sessionName, + requestLogPath, + runnerLogPath, throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), runLocked: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); @@ -127,7 +153,6 @@ function applyRequestCommandDefaults(req: DaemonRequest): DaemonRequest { export function prepareLockedRequestScope(params: { scope: RequestExecutionScope; - logPath: string; sessionStore: SessionStore; trackDownloadableArtifact: (opts: { artifactPath: string; @@ -135,7 +160,8 @@ export function prepareLockedRequestScope(params: { fileName?: string; }) => string; }): LockedRequestScopeResult { - const { scope, logPath, sessionStore, trackDownloadableArtifact } = params; + const { scope, sessionStore, trackDownloadableArtifact } = params; + const logPath = scope.runnerLogPath; scope.throwIfCanceled(); let existingSession = sessionStore.get(scope.sessionName); if (existingSession) { @@ -188,6 +214,7 @@ export function prepareLockedRequestScope(params: { scope: { req: lockedReq, sessionName: scope.sessionName, + logPath, existingSession, finalize, contextFromFlags, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index be9c27748..ae729879b 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -110,7 +110,6 @@ export function createRequestHandler( const run = async (): Promise => { const locked = prepareLockedRequestScope({ scope, - logPath, sessionStore, trackDownloadableArtifact, }); @@ -154,7 +153,7 @@ export function createRequestHandler( const handlerResponse = await runRequestHandlerChain({ req: lockedScope.req, sessionName: lockedScope.sessionName, - logPath, + logPath: lockedScope.logPath, sessionStore, leaseRegistry, invoke: handleRequest, @@ -166,7 +165,11 @@ export function createRequestHandler( }); if (handlerResponse) return lockedScope.finalize(handlerResponse); - return await dispatchGenericForLockedScope({ lockedScope, logPath, sessionStore }); + return await dispatchGenericForLockedScope({ + lockedScope, + logPath: lockedScope.logPath, + sessionStore, + }); } function createReplayScopedActionInvoker( diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 3df94f4b8..d74ae703e 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -94,3 +94,17 @@ export class SessionStore { return expandSessionPath(filePath, cwd); } } + +/** Path to session-scoped platform subprocess output, such as Apple runner xcodebuild logs. */ +export function resolveSessionRunnerLogPath(sessionDir: string): string { + return path.join(sessionDir, 'runner.log'); +} + +/** Path to request-scoped daemon diagnostics for this session. */ +export function resolveSessionRequestLogPath( + sessionDir: string, + requestId: string | undefined, +): string { + const safeRequestId = safeSessionName(requestId && requestId.length > 0 ? requestId : 'unknown'); + return path.join(sessionDir, 'requests', `${safeRequestId}.ndjson`); +} diff --git a/src/platforms/ios/runner-transport.ts b/src/platforms/ios/runner-transport.ts index 4c71a088b..b8bf1b178 100644 --- a/src/platforms/ios/runner-transport.ts +++ b/src/platforms/ios/runner-transport.ts @@ -586,7 +586,10 @@ function appendLogChunk(logPath: string, chunk: string): void { const previous = logAppendQueues.get(logPath) ?? Promise.resolve(); const next = previous .catch(() => {}) - .then(() => fs.promises.appendFile(logPath, chunk)) + .then(async () => { + await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); + await fs.promises.appendFile(logPath, chunk); + }) .catch(() => {}); const queued = next.finally(() => { if (logAppendQueues.get(logPath) === queued) { diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index c74da8e02..86404e455 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -972,8 +972,8 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /After mutation: refs are stale/); assert.match(usageText, /use its selector directly; otherwise refresh with snapshot -i/); assert.match(usageText, /app-owned back uses back/); + assert.match(usageText, /Session state contains request diagnostics and runner\.log/); assert.match(usageText, /logs clear --restart\/mark\/path/); - assert.match(usageText, /trace start \.\/path; trace stop \.\/path/); assert.match(usageText, /network dump --include headers/); assert.match(usageText, /Full operating guide: agent-device help workflow/); assert.match(usageText, /Exploratory QA: agent-device help dogfood/); @@ -1043,6 +1043,7 @@ test('usageForCommand documents prepare ios-runner', () => { assert.match(help, /Prepare platform helper infrastructure/); assert.match(help, /--timeout /); assert.match(help, /XCTest runner/); + assert.match(help, /Runner build\/start output is written to the session runner\.log/); }); test('usageForCommand resolves workflow help topic', () => { @@ -1134,6 +1135,9 @@ test('usageForCommand resolves debugging help topic', () => { assert.match(help, /iOS support is runner-derived/); assert.match(help, /resolved app executable/); assert.match(help, /--launch-console is only for direct iOS simulator app launches/); + assert.match(help, /runnerLogPath and requestLogPath/); + assert.match(help, /requests\/\.ndjson holds daemon request diagnostics/); + assert.match(help, /daemon\.log is global daemon lifecycle evidence/); assert.match(help, /Do not use settings permission to answer a dialog already on screen/); }); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 9eff15bbd..a0c2d0deb 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -69,7 +69,7 @@ const CLI_COMMAND_OVERRIDES = { usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', listUsageOverride: 'prepare ios-runner --platform ios|macos', helpDescription: - 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test.', + 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', summary: 'Prepare platform helpers', positionalArgs: ['ios-runner'], allowedFlags: ['timeoutMs'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 0687b2554..f67ea4a39 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -55,7 +55,7 @@ const AGENT_QUICKSTART_LINES = [ 'Batch JSON steps use "command" and structured "input"; legacy "positionals"/"flags" steps still run in CLI but are deprecated until the next major version.', 'Navigation: app-owned back uses back; system back uses back --system.', 'Verification commands must name the expected text/selector; bare screenshots/snapshots are not enough.', - 'Debug evidence: logs clear --restart/mark/path; trace start ./path; trace stop ./path; network dump --include headers.', + 'Debug evidence: Session state contains request diagnostics and runner.log; use logs clear --restart/mark/path, trace, and network dump --include headers for app evidence.', 'Use agent-device commands in final plans; raw platform tools, pseudo commands, and helper prose are wrong.', 'Full operating guide: agent-device help workflow. Exploratory QA: agent-device help dogfood.', ] as const; @@ -123,7 +123,7 @@ Bootstrap: If app id is unknown, plan devices, apps, then open . Discovery is not enough when the task asks to open/start the app. Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install. In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner, health-checks it with a lightweight command, and retries one stuck/non-connecting runner launch before the first snapshot pays that setup cost. - CI may cache ~/.agent-device/ios-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. + CI may cache ~/.agent-device/ios-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. Runner build/start output is written to the session's runner.log; daemon.log is for daemon lifecycle/startup issues. Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop. Snapshots and refs: @@ -287,6 +287,9 @@ Alerts: Diagnostics and traces: Use --debug for CLI/daemon diagnostic ids and log paths. + Open output includes Session state; JSON also includes runnerLogPath and requestLogPath. + Session requests/.ndjson holds daemon request diagnostics; session runner.log holds Apple runner/xcodebuild output. + daemon.log is global daemon lifecycle evidence, not the primary per-run log. Use trace for low-level session diagnostics around one repro: agent-device trace start ./traces/diagnostics.trace agent-device press 'id="load-diagnostics"' diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index eedeb3036..491bbf7cc 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -30,6 +30,7 @@ type DiagnosticsScopeOptions = { type DiagnosticsScope = DiagnosticsScopeOptions & { diagnosticId: string; events: DiagnosticEvent[]; + liveWrittenEventCount: number; }; const diagnosticsStorage = new AsyncLocalStorage(); @@ -50,10 +51,17 @@ export async function withDiagnosticsScope( ...options, diagnosticId: createDiagnosticId(), events: [], + liveWrittenEventCount: 0, }; return await diagnosticsStorage.run(scope, fn); } +export function updateDiagnosticsScope(options: DiagnosticsScopeOptions): void { + const scope = diagnosticsStorage.getStore(); + if (!scope) return; + Object.assign(scope, options); +} + export function getDiagnosticsMeta(): { diagnosticId?: string; requestId?: string; @@ -92,15 +100,18 @@ export function emitDiagnostic(event: { }; scope.events.push(payload); if (!scope.debug) return; - const line = `[agent-device][diag] ${JSON.stringify(payload)}\n`; + const fileLine = `${JSON.stringify(payload)}\n`; try { if (scope.logPath) { - fs.appendFile(scope.logPath, line, () => {}); + appendDiagnosticLine(scope.logPath, fileLine); + scope.liveWrittenEventCount = scope.events.length; } if (scope.traceLogPath) { - fs.appendFile(scope.traceLogPath, line, () => {}); + appendDiagnosticLine(scope.traceLogPath, fileLine); + } + if (!scope.logPath && !scope.traceLogPath) { + process.stderr.write(`[agent-device][diag] ${fileLine}`); } - if (!scope.logPath && !scope.traceLogPath) process.stderr.write(line); } catch { // Best-effort diagnostics should not break request flow. } @@ -142,6 +153,18 @@ export function flushDiagnosticsToSessionFile(options: { force?: boolean } = {}) if (scope.events.length === 0) return null; try { + if (scope.logPath) { + const pendingEvents = scope.events.slice(scope.liveWrittenEventCount); + if (pendingEvents.length > 0) { + const lines = pendingEvents.map((entry) => JSON.stringify(redactDiagnosticData(entry))); + appendDiagnosticLine(scope.logPath, `${lines.join('\n')}\n`); + } + const logPath = scope.logPath; + scope.events = []; + scope.liveWrittenEventCount = 0; + return logPath; + } + const sessionDir = sanitizePathPart(scope.session ?? 'default'); const dayDir = new Date().toISOString().slice(0, 10); const baseDir = path.join(os.homedir(), '.agent-device', 'logs', sessionDir, dayDir); @@ -160,3 +183,8 @@ export function flushDiagnosticsToSessionFile(options: { force?: boolean } = {}) function sanitizePathPart(value: string): string { return value.replace(/[^a-zA-Z0-9._-]/g, '_'); } + +function appendDiagnosticLine(logPath: string, line: string): void { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, line, 'utf8'); +} diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 9a0ee76be..a9b8ddd6b 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -136,6 +136,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild. - CI may cache `~/.agent-device/ios-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version. - Avoid broad `restore-keys` fallbacks for runner caches. Reusing runner artifacts across Agent Device or Xcode versions can restore stale `.xctestrun` products; `prepare ios-runner` already handles bad exact-cache artifacts and one retryable non-connecting runner launch. +- Runner build/start output is written to the session's `runner.log`. The top-level `daemon.log` is reserved for daemon lifecycle/startup issues. ## TV targets @@ -696,6 +697,8 @@ agent-device network dump 25 --include all # Include parsed headers/body when av - 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. - `network dump [limit] [summary|headers|body|all]` parses recent HTTP(s) entries from `app.log`; `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/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 8fc804d1f..1e76f8603 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -52,6 +52,8 @@ agent-device logs path Use this flow when you need a clean repro window with logs, recent network activity, and a quick perf sample from the active app session. +`open` prints `Session state: `. Inspect that directory for per-run artifacts: `requests/.ndjson` contains daemon request diagnostics, `runner.log` contains Apple runner/`xcodebuild` output, and `app.log` contains app/device logs when log capture is active. The top-level daemon log is for daemon lifecycle/startup issues. + On iOS simulators, `logs` scope by bundle id and the resolved app executable. For launch-time stdout/stderr, capture the direct app launch console instead of starting raw `simctl` streams: ```bash diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index a09de37c6..0196c44a4 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -15,7 +15,15 @@ agent-device close The implicit `default` session is scoped to the caller's git worktree or current working directory. Independent agents in different worktrees do not attach to each other's default session. -When a session is established, human output includes a `Session state: ` line and JSON output includes `sessionStateDir`; this is the per-session artifact directory that can be inspected or removed after the run. +When a session is established, human output includes a `Session state: ` line and JSON output includes `sessionStateDir`; this is the per-session artifact directory that can be inspected or removed after the run. JSON output also includes `runnerLogPath` and `requestLogPath` when available. + +Session artifact directories contain per-run evidence for concurrent agents: + +- `requests/.ndjson` - daemon request diagnostics for this session. +- `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. + +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: