diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 690df344ea..0ce8f7e7f5 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -92,6 +92,15 @@ process primitives outside the shared host-command port. R11 applies these rules dynamic, and re-export edges; package-owned tests may import their own public façade. Contracts may depend on kernel vocabulary but never on concrete platform packages or daemon implementation types. +Durable-capture mechanics shared by more than one implementation live in the private +`@agent-device/capture-kit` workspace package, with the enforced direction +`kernel < contracts < capture-kit < platform/provider/daemon`. Contracts retains pure vocabulary and +plan models; process supervision, live-handle implementations, recovery helpers, runtime codecs, and +capture parsers do not live there. `capture-kit` is a domain package for durable capture, not a generic +platform-common package, and it preserves the package façades' implementation-lazy loading boundary. +Its introduction carries the normal workspace-package compliance surface: `check:affected` +selection, R11/R13 package enumeration, and the composite typecheck project list. + Canonical family, `AppleOS`, public-leaf, and selector identity remain declared in `@agent-device/kernel/device`. Platform-module metadata references one canonical family; during coexistence the legacy plugin registry derives its family identity from the same declaration rather @@ -330,11 +339,14 @@ but reattachment never scans telemetry to rebuild state. Every authoritative home exposes a deterministic facet-owned lookup or enumeration path after process loss. Its neutral record carries session/device identity, the exact runtime-owner reference, -descriptor and metadata, an ownership/fence token, and a lifecycle state sufficient to distinguish -starting, active, completing, completed, and cleanup-pending recovery. A new handle is not exposed -until the persisted ownership fence is acquired. Every finish/cleanup attempt holds that ownership -guard through destructive work and the persisted transition, or delegates to an operation that -atomically enforces the token, so a prior owner cannot later terminate a transferred resource. +descriptor and metadata, an ownership/fence token, and one of two persisted lifecycle states: +`open` or `completed`. In-progress distinctions such as starting, active, completing, and +cleanup-pending are phase metadata on the open record, not additional lifecycle states. The fence and +the descriptor remain authoritative across every open phase; cleanup uncertainty therefore cannot be +encoded as a terminal lifecycle. A new handle is not exposed until the persisted ownership fence is +acquired. Every finish/cleanup attempt holds that ownership guard through destructive work and the +persisted transition, or delegates to an operation that atomically enforces the token, so a prior +owner cannot later terminate a transferred resource. Persisting a descriptor does not make external-resource start and descriptor write atomic. A platform whose native tool cannot close that crash window retains a platform-owned orphan marker or diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index bd81e6ddcb..1213fc7ced 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -117,11 +117,6 @@ "count": 1 } }, - "src/daemon/app-log.ts": { - "complexity_high": { - "count": 1 - } - }, "src/daemon/client/daemon-client-lifecycle.ts": { "complexity_high": { "count": 1 @@ -596,7 +591,6 @@ "src/utils/rect-center.ts:high impact", "src/platforms/apple/core/app-launch.ts:complexity", "src/utils/parsing.ts:high impact", - "src/daemon/app-log-process.ts:high impact", "src/daemon/daemon-command-registry.ts:high impact", "src/replay/script.ts:complexity", "src/daemon/handlers/session-doctor-output.ts:high impact", diff --git a/package.json b/package.json index 67899eb7e1..7ff606db5e 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/run.test.ts", "check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts", "check:coverage-changed:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts", - "check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts", + "check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/logs-runtime-cutover-policy.test.ts scripts/layering/contracts-implementation-policy.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts", "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts", "depgraph:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", @@ -149,7 +149,7 @@ "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm package:npm", - "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/capture-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", "test-app:install": "pnpm install --dir examples/test-app", "test-app:start": "pnpm --dir examples/test-app start", "test-app:ios": "pnpm --dir examples/test-app ios", @@ -252,6 +252,7 @@ "yauzl": "^3.4.0" }, "devDependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/ad-replay": "workspace:*", "@agent-device/ad-script": "workspace:*", "@agent-device/contracts": "workspace:*", diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json new file mode 100644 index 0000000000..002b5a0b1d --- /dev/null +++ b/packages/capture-kit/package.json @@ -0,0 +1,17 @@ +{ + "name": "@agent-device/capture-kit", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Private durable-capture mechanics shared by platform runtimes, providers, and daemon orchestration.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/capture-kit/src/app-log-live-handle.ts b/packages/capture-kit/src/app-log-live-handle.ts new file mode 100644 index 0000000000..a21fab0eb8 --- /dev/null +++ b/packages/capture-kit/src/app-log-live-handle.ts @@ -0,0 +1,69 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { + isConfirmedCleanup, + type AppLogCompletion, + type AppLogLiveHandle, + type AppLogLiveSnapshot, + type CleanupOutcome, + type FinishOutcome, +} from '@agent-device/contracts/platform'; + +type AppLogLiveHandleImplementation = Readonly<{ + inspect(): AppLogLiveSnapshot; + finish(): Promise>; + forceCleanup(): Promise; +}>; + +/** Internal idempotent adapter underlying the narrower public handle factories. */ +export function createAppLogLiveHandle( + implementation: AppLogLiveHandleImplementation, +): AppLogLiveHandle { + let finish: Promise> | undefined; + let cleanup: Promise | undefined; + let disposal: Promise | undefined; + const forceCleanup = (): Promise => (cleanup ??= implementation.forceCleanup()); + return Object.freeze({ + inspect: () => implementation.inspect(), + finish: () => (finish ??= implementation.finish()), + forceCleanup, + [Symbol.asyncDispose]: async () => { + disposal ??= forceCleanup().then(assertConfirmedCleanup); + await disposal; + }, + }); +} + +/** Derives forced cleanup from an idempotent finish transaction. */ +export function createAppLogLiveHandleFromFinish( + implementation: Readonly<{ + inspect(): AppLogLiveSnapshot; + finish(): Promise>; + }>, +): AppLogLiveHandle { + return createAppLogLiveHandle({ + inspect: implementation.inspect, + finish: implementation.finish, + forceCleanup: async () => { + const outcome = await implementation.finish(); + return outcome.status === 'completed' + ? { status: 'cleaned' } + : { status: 'cleanup-pending', reason: outcome.reason, message: outcome.message }; + }, + }); +} + +function assertConfirmedCleanup(outcome: CleanupOutcome): void { + if (isConfirmedCleanup(outcome)) return; + throw new AppError( + 'COMMAND_FAILED', + outcome.message ?? 'Durable resource cleanup could not be confirmed', + { + reason: outcome.reason, + retriable: outcome.reason !== 'ownership-fence-lost', + hint: + outcome.reason === 'ownership-fence-lost' + ? 'Use the current resource owner or recovery record before retrying cleanup.' + : 'Keep the recovery record and retry cleanup through the exact runtime owner.', + }, + ); +} diff --git a/packages/capture-kit/src/app-log-pid-monitor.test.ts b/packages/capture-kit/src/app-log-pid-monitor.test.ts new file mode 100644 index 0000000000..ee266d9317 --- /dev/null +++ b/packages/capture-kit/src/app-log-pid-monitor.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { AppLogBackgroundProcess } from '@agent-device/contracts/platform'; +import { monitorPidScopedProcess } from './app-log-pid-monitor.ts'; +import { controlledSleeps, deferred, processFixture } from './app-log-pid-process.fixtures.ts'; + +describe('PID-scoped app-log monitor', () => { + test('rotates a live stream when the app PID changes', async () => { + const first = processFixture(deferred<{ stdout: string; stderr: string; exitCode: number }>()); + const second = processFixture(deferred<{ stdout: string; stderr: string; exitCode: number }>()); + const sleeps = controlledSleeps(); + const resolvePid = vi.fn(async () => '456'); + const startProcess = vi.fn(async () => second.process); + let stopped = false; + let active: AppLogBackgroundProcess | undefined; + const monitor = monitorPidScopedProcess({ + initialProcess: { pid: '123', process: first.process }, + stopped: () => stopped, + setActive: (process) => { + active = process; + }, + setState: vi.fn(), + resolvePid, + startProcess, + sleep: sleeps.sleep, + }); + + await vi.waitFor(() => expect(sleeps.pending()).toBe(1)); + sleeps.releaseNext(); + await vi.waitFor(() => expect(first.terminate).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(sleeps.pending()).toBe(1)); + sleeps.releaseNext(); + await vi.waitFor(() => expect(startProcess).toHaveBeenCalledWith('456')); + await vi.waitFor(() => expect(sleeps.pending()).toBe(1)); + + stopped = true; + await active?.terminate(); + await expect(monitor).resolves.toBeUndefined(); + expect(resolvePid).toHaveBeenCalledTimes(2); + expect(first.dispose).toHaveBeenCalledOnce(); + expect(second.terminate).toHaveBeenCalledOnce(); + expect(second.dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/capture-kit/src/app-log-pid-monitor.ts b/packages/capture-kit/src/app-log-pid-monitor.ts new file mode 100644 index 0000000000..cfb01e37d1 --- /dev/null +++ b/packages/capture-kit/src/app-log-pid-monitor.ts @@ -0,0 +1,181 @@ +import type { AppLogBackgroundProcess, AppLogLiveSnapshot } from '@agent-device/contracts/platform'; + +export type PidScopedProcess = Readonly<{ + pid: string; + process: AppLogBackgroundProcess; +}>; + +export type PidScopedProcessMonitor = Readonly<{ + initialProcess: PidScopedProcess | undefined; + stopped(): boolean; + setActive(process: AppLogBackgroundProcess | undefined): void; + setState(state: AppLogLiveSnapshot['state']): void; + resolvePid(): Promise; + startProcess(pid: string): Promise; + sleep(milliseconds: number, signal?: AbortSignal): Promise; +}>; + +type SubsequentProcessStart = + | Readonly<{ status: 'stopped' }> + | Readonly<{ status: 'waiting' }> + | Readonly<{ status: 'started'; process: PidScopedProcess }>; + +/** Monitors a PID-scoped stream and rotates it when the application process changes. */ +export async function monitorPidScopedProcess(input: PidScopedProcessMonitor): Promise { + let process = input.initialProcess; + while (!input.stopped()) { + if (process) { + await settleActiveProcess(input, process); + process = undefined; + await pauseBeforeProcessRestart(input); + continue; + } + + const started = await startAndAdoptSubsequentProcess(input); + if (started.status === 'stopped') return; + if (started.status === 'waiting') continue; + process = started.process; + } +} + +async function settleActiveProcess( + input: PidScopedProcessMonitor, + scopedProcess: PidScopedProcess, +): Promise { + const { process } = scopedProcess; + input.setActive(process); + try { + input.setState('active'); + await observeActiveProcess(input, scopedProcess); + } finally { + await disposeAdoptedProcess(input, process); + } +} + +async function observeActiveProcess( + input: PidScopedProcessMonitor, + scopedProcess: PidScopedProcess, +): Promise { + const wait = observeProcessWait(scopedProcess.process); + try { + await pollActiveProcessPid(input, scopedProcess, wait); + await terminateStoppedProcess(input, scopedProcess.process, wait); + await assertProcessWaitSucceeded(wait.outcome); + } finally { + wait.controller.abort(); + } +} + +type ProcessWaitObservation = Readonly<{ + controller: AbortController; + outcome: Promise | Readonly<{ status: 'failed'; error: unknown }>>; + settled(): boolean; +}>; + +function observeProcessWait(process: AppLogBackgroundProcess): ProcessWaitObservation { + const controller = new AbortController(); + let settled = false; + const finish = () => { + settled = true; + controller.abort(); + }; + const outcome = process.wait.then( + () => { + finish(); + return { status: 'exited' } as const; + }, + (error: unknown) => { + finish(); + return { status: 'failed', error } as const; + }, + ); + return { controller, outcome, settled: () => settled }; +} + +async function pollActiveProcessPid( + input: PidScopedProcessMonitor, + scopedProcess: PidScopedProcess, + wait: ProcessWaitObservation, +): Promise { + while (!input.stopped() && !wait.settled()) { + if (!(await waitForPidPoll(input, wait))) return; + const observedPid = await input.resolvePid(); + if (input.stopped()) return; + if (observedPid === scopedProcess.pid) continue; + await scopedProcess.process.terminate(); + return; + } +} + +async function waitForPidPoll( + input: PidScopedProcessMonitor, + wait: ProcessWaitObservation, +): Promise { + try { + await input.sleep(500, wait.controller.signal); + } catch (error) { + if (!wait.settled()) throw error; + } + return !wait.settled(); +} + +async function terminateStoppedProcess( + input: PidScopedProcessMonitor, + process: AppLogBackgroundProcess, + wait: ProcessWaitObservation, +): Promise { + if (input.stopped() && !wait.settled()) await process.terminate(); +} + +async function assertProcessWaitSucceeded( + outcome: ProcessWaitObservation['outcome'], +): Promise { + const settled = await outcome; + if (settled.status === 'failed') throw settled.error; +} + +async function pauseBeforeProcessRestart(input: PidScopedProcessMonitor): Promise { + if (input.stopped()) return; + input.setState('recovering'); + await input.sleep(500); +} + +async function startAndAdoptSubsequentProcess( + input: PidScopedProcessMonitor, +): Promise { + const pid = await input.resolvePid(); + if (input.stopped()) return { status: 'stopped' }; + if (!pid) { + input.setState('recovering'); + await input.sleep(1_000); + return { status: 'waiting' }; + } + const process = await input.startProcess(pid); + input.setActive(process); + if (!input.stopped()) return { status: 'started', process: { pid, process } }; + await stopAdoptedProcess(input, process); + return { status: 'stopped' }; +} + +async function stopAdoptedProcess( + input: PidScopedProcessMonitor, + process: AppLogBackgroundProcess, +): Promise { + try { + await process.terminate(); + await process.wait; + } finally { + await disposeAdoptedProcess(input, process); + } +} + +async function disposeAdoptedProcess( + input: PidScopedProcessMonitor, + process: AppLogBackgroundProcess, +): Promise { + try { + await process[Symbol.asyncDispose](); + } finally { + input.setActive(undefined); + } +} diff --git a/packages/capture-kit/src/app-log-pid-process.fixtures.ts b/packages/capture-kit/src/app-log-pid-process.fixtures.ts new file mode 100644 index 0000000000..14677054eb --- /dev/null +++ b/packages/capture-kit/src/app-log-pid-process.fixtures.ts @@ -0,0 +1,49 @@ +import { vi } from 'vitest'; +import type { AppLogBackgroundProcess } from '@agent-device/contracts/platform'; + +export function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +export function processFixture( + wait: ReturnType>, +) { + const terminate = vi.fn(async () => wait.resolve({ stdout: '', stderr: '', exitCode: 0 })); + const dispose = vi.fn(async () => {}); + return { + terminate, + dispose, + process: { + wait: wait.promise, + terminate, + [Symbol.asyncDispose]: dispose, + } satisfies AppLogBackgroundProcess, + }; +} + +export function controlledSleeps() { + const releases: (() => void)[] = []; + const sleep = (_milliseconds: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + const release = () => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }; + const onAbort = () => { + const index = releases.indexOf(release); + if (index >= 0) releases.splice(index, 1); + reject(signal?.reason); + }; + releases.push(release); + signal?.addEventListener('abort', onAbort, { once: true }); + }); + return { + sleep, + pending: () => releases.length, + releaseNext: () => releases.shift()?.(), + }; +} diff --git a/packages/capture-kit/src/app-log-pid-process.test.ts b/packages/capture-kit/src/app-log-pid-process.test.ts new file mode 100644 index 0000000000..d2f326fbcd --- /dev/null +++ b/packages/capture-kit/src/app-log-pid-process.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { + AppLogBackgroundProcess, + AppLogOutputSink, + AppLogRuntimeHost, +} from '@agent-device/contracts/platform'; +import { deferred, processFixture } from './app-log-pid-process.fixtures.ts'; +import { createPidScopedAppLogProcess } from './app-log-pid-process.ts'; + +describe('PID-scoped app-log process lifecycle', () => { + test('cleans a later process that resolves after finish begins', async () => { + const initialExit = deferred<{ stdout: string; stderr: string; exitCode: number }>(); + const laterExit = deferred<{ stdout: string; stderr: string; exitCode: number }>(); + const laterStart = deferred(); + const initial = processFixture(initialExit); + const later = processFixture(laterExit); + const output = outputFixture(); + const starts = vi + .fn() + .mockResolvedValueOnce(initial.process) + .mockImplementationOnce(async () => await laterStart.promise); + const host = hostFixture(output.sink); + const handle = await createPidScopedAppLogProcess({ + host, + backend: 'android', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + processStart: starts, + setupSignal: new AbortController().signal, + resolvePid: async () => (starts.mock.calls.length === 0 ? '123' : '456'), + command: (pid) => ({ + kind: 'host', + request: { executable: 'adb', args: ['logcat', '--pid', pid] }, + }), + cleanupFailureMessage: 'cleanup failed', + }); + + initialExit.resolve({ stdout: '', stderr: '', exitCode: 0 }); + await vi.waitFor(() => expect(starts).toHaveBeenCalledTimes(2)); + const finishing = handle.finish(); + laterStart.resolve(later.process); + + await expect(finishing).resolves.toMatchObject({ status: 'completed' }); + expect(later.terminate).toHaveBeenCalledOnce(); + expect(later.dispose).toHaveBeenCalledOnce(); + expect(output.dispose).toHaveBeenCalledOnce(); + }); + + test('disposes output and rejects when an empty-PID setup is cancelled during open', async () => { + const controller = new AbortController(); + const reason = new Error('cancelled during output open'); + const output = outputFixture(); + const host = hostFixture(output.sink, () => controller.abort(reason)); + const start = vi.fn(); + const resolvePid = vi.fn(async () => ''); + + await expect( + createPidScopedAppLogProcess({ + host, + backend: 'android', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + processStart: start, + setupSignal: controller.signal, + resolvePid, + command: () => ({ + kind: 'host', + request: { executable: 'adb', args: ['logcat'] }, + }), + cleanupFailureMessage: 'cleanup failed', + }), + ).rejects.toBe(reason); + expect(output.dispose).toHaveBeenCalledOnce(); + expect(start).not.toHaveBeenCalled(); + expect(resolvePid).toHaveBeenCalledOnce(); + expect(host.clock.sleep).not.toHaveBeenCalled(); + }); + + test('re-probes the app PID after an empty recovering observation', async () => { + const exit = deferred<{ stdout: string; stderr: string; exitCode: number }>(); + const started = processFixture(exit); + const output = outputFixture(); + const resolvePid = vi.fn().mockResolvedValueOnce('').mockResolvedValueOnce('456'); + const start = vi.fn(async () => started.process); + const handle = await createPidScopedAppLogProcess({ + host: hostFixture(output.sink), + backend: 'android', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + processStart: start, + setupSignal: new AbortController().signal, + resolvePid, + command: (pid) => ({ + kind: 'host', + request: { executable: 'adb', args: ['logcat', '--pid', pid] }, + }), + cleanupFailureMessage: 'cleanup failed', + }); + + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + expect(resolvePid.mock.calls.length).toBeGreaterThanOrEqual(2); + await handle.finish(); + expect(started.terminate).toHaveBeenCalledOnce(); + }); + + test('settles an initially started process before disposing output after cancellation', async () => { + const controller = new AbortController(); + const reason = new Error('cancelled after process start'); + const order: string[] = []; + const process: AppLogBackgroundProcess = { + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }).then((result) => { + order.push('wait'); + return result; + }), + terminate: async () => { + order.push('terminate'); + }, + [Symbol.asyncDispose]: async () => { + order.push('process-dispose'); + }, + }; + const output: AppLogOutputSink = { + write: async () => {}, + [Symbol.asyncDispose]: async () => { + order.push('output-dispose'); + }, + }; + const host = hostFixture(output); + + await expect( + createPidScopedAppLogProcess({ + host, + backend: 'android', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + processStart: async (_request, signal) => { + expect(signal?.aborted).toBe(false); + controller.abort(reason); + expect(signal?.reason).toBe(reason); + return process; + }, + setupSignal: controller.signal, + resolvePid: async () => '123', + command: () => ({ + kind: 'host', + request: { executable: 'adb', args: ['logcat'] }, + }), + cleanupFailureMessage: 'cleanup failed', + }), + ).rejects.toBe(reason); + expect(order).toEqual(['wait', 'terminate', 'process-dispose', 'output-dispose']); + }); +}); + +function outputFixture() { + const dispose = vi.fn(async () => {}); + return { + dispose, + sink: { + write: async () => {}, + [Symbol.asyncDispose]: dispose, + } satisfies AppLogOutputSink, + }; +} + +function hostFixture(output: AppLogOutputSink, onOpen?: () => void): AppLogRuntimeHost { + const start = vi.fn(); + return { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => { + throw new Error('unused'); + }, + }, + toolchains: { prepare: async () => undefined }, + artifacts: { resolveSession: () => ({ outputPath: '/tmp/app.log', pidPath: '/tmp/pid' }) }, + commands: { + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + outputs: { + openAppend: async () => { + onOpen?.(); + return output; + }, + readTail: async () => '', + }, + processTransports: { resolve: async () => ({ mode: 'local', start }) }, + processes: { + start, + readMarker: async () => ({ status: 'missing' }), + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + clock: { now: () => 0, sleep: vi.fn(testSleep) }, + }; +} + +async function testSleep(_milliseconds: number, signal?: AbortSignal): Promise { + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timeout); + reject(signal?.reason); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, 1); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/packages/capture-kit/src/app-log-pid-process.ts b/packages/capture-kit/src/app-log-pid-process.ts new file mode 100644 index 0000000000..0c199b1f76 --- /dev/null +++ b/packages/capture-kit/src/app-log-pid-process.ts @@ -0,0 +1,154 @@ +import type { LogBackend } from '@agent-device/contracts/observability'; +import type { + AppLogBackgroundProcess, + AppLogLiveHandle, + AppLogLiveSnapshot, + AppLogProcessStart, + AppLogProcessCommand, + AppLogRuntimeHost, +} from '@agent-device/contracts/platform'; +import { createAppLogLiveHandleFromFinish } from './app-log-live-handle.ts'; +import { monitorPidScopedProcess } from './app-log-pid-monitor.ts'; + +export type PidScopedAppLogProcessOptions = Readonly<{ + host: AppLogRuntimeHost; + backend: LogBackend; + outputPath: string; + pidPath: string; + processStart: AppLogProcessStart; + setupSignal: AbortSignal; + resolvePid(signal?: AbortSignal): Promise; + command(pid: string): AppLogProcessCommand; + cleanupFailureMessage: string; +}>; + +/** Owns the neutral retry and cleanup lifecycle for PID-scoped local log streams. */ +export async function createPidScopedAppLogProcess( + options: PidScopedAppLogProcessOptions, +): Promise { + const initialPid = await options.resolvePid(options.setupSignal); + options.setupSignal.throwIfAborted(); + const output = await options.host.outputs.openAppend(options.outputPath); + let state: AppLogLiveSnapshot['state'] = 'recovering'; + let stopped = false; + let active: AppLogBackgroundProcess | undefined; + try { + options.setupSignal.throwIfAborted(); + if (initialPid) { + options.setupSignal.throwIfAborted(); + const setupController = new AbortController(); + const forwardAbort = () => setupController.abort(options.setupSignal.reason); + options.setupSignal.addEventListener('abort', forwardAbort, { once: true }); + try { + active = await options.processStart( + { + command: options.command(initialPid), + output, + markerPath: options.pidPath, + }, + setupController.signal, + ); + } finally { + options.setupSignal.removeEventListener('abort', forwardAbort); + } + options.setupSignal.throwIfAborted(); + state = 'active'; + } + } catch (error) { + await settleCleanupSteps([ + async () => await active?.terminate(), + async () => { + await active?.wait; + }, + async () => await active?.[Symbol.asyncDispose](), + async () => await output[Symbol.asyncDispose](), + ]); + throw error; + } + const monitor = monitorPidScopedProcess({ + initialProcess: active && initialPid ? { pid: initialPid, process: active } : undefined, + stopped: () => stopped, + setActive: (process) => { + active = process; + }, + setState: (next) => { + state = next; + }, + resolvePid: async () => await options.resolvePid(), + startProcess: async (pid) => + await options.processStart({ + command: options.command(pid), + output, + markerPath: options.pidPath, + }), + sleep: async (milliseconds, signal) => await options.host.clock.sleep(milliseconds, signal), + }).catch(() => { + state = 'failed'; + }); + let finishPromise: ReturnType | undefined; + const finish = async () => + (finishPromise ??= finishPidScopedProcess({ + options, + monitor, + active: () => active, + stop: () => { + stopped = true; + }, + setState: (next) => { + state = next; + }, + output, + })); + const startedAt = options.host.clock.now(); + return createAppLogLiveHandleFromFinish({ + inspect: () => ({ backend: options.backend, state, startedAt }), + finish, + }); +} + +async function finishPidScopedProcess( + input: Readonly<{ + options: PidScopedAppLogProcessOptions; + monitor: Promise; + active(): AppLogBackgroundProcess | undefined; + stop(): void; + setState(state: AppLogLiveSnapshot['state']): void; + output: Awaited>; + }>, +) { + input.stop(); + const failures = await settleCleanupSteps([ + async () => await input.active()?.terminate(), + async () => await input.monitor, + async () => await input.output[Symbol.asyncDispose](), + ]); + if (failures.length > 0) { + input.setState('failed'); + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: input.options.cleanupFailureMessage, + } as const; + } + input.setState('ended'); + return { + status: 'completed', + result: { + backend: input.options.backend, + outputPath: input.options.outputPath, + completedAt: input.options.host.clock.now(), + }, + } as const; +} + +async function settleCleanupSteps(steps: readonly (() => Promise)[]): Promise { + const failures: unknown[] = []; + for (const step of steps) { + try { + await step(); + } catch (error) { + failures.push(error); + } + } + return failures; +} diff --git a/packages/capture-kit/src/app-log-pid-runtime.test.ts b/packages/capture-kit/src/app-log-pid-runtime.test.ts new file mode 100644 index 0000000000..ed028093e1 --- /dev/null +++ b/packages/capture-kit/src/app-log-pid-runtime.test.ts @@ -0,0 +1,171 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { expect, test, vi } from 'vitest'; +import { + type AppLogBackgroundProcess, + type AppLogRuntimeHost, + type DurableDescriptorCodec, +} from '@agent-device/contracts/platform'; +import { + createDurableResourceEnvelope, + encodeDurableDescriptor, +} from './durable-resource-envelope.ts'; +import { createPidScopedAppLogRuntimeOwner } from './app-log-pid-runtime.ts'; + +type TestDescriptor = Readonly<{ + transport: 'test-local' | 'test-provider'; + outputPath: string; + pidPath: string; +}>; + +const descriptorCodec: DurableDescriptorCodec = { + resourceKind: 'app-log', + version: 1, + encode: (descriptor) => ({ ...descriptor }), + decode: (body) => + typeof body.outputPath === 'string' && typeof body.pidPath === 'string' + ? { + status: 'decoded', + descriptor: { + transport: body.transport === 'test-provider' ? 'test-provider' : 'test-local', + outputPath: body.outputPath, + pidPath: body.pidPath, + }, + } + : { status: 'invalid', message: 'invalid test descriptor' }, +}; + +const device: DeviceInfo = { + platform: 'android', + id: 'device-1', + name: 'Device', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +test('PID-scoped runtime owner derives canonical artifacts and publishes one complete binding', async () => { + const fixture = hostFixture(); + const owner = createPidScopedAppLogRuntimeOwner(fixture.host, { + family: 'android', + backend: 'android', + codec: descriptorCodec, + label: 'Android', + startUnavailableHint: 'No app-log transport.', + cleanupFailureMessage: 'cleanup failed', + doctor: async () => ({ backend: 'android', checks: {}, notes: [] }), + process: async ({ device: selected }) => ({ + resolvePid: async () => '123', + command: (pid) => ({ + kind: 'android-adb', + serial: selected.id, + args: ['logcat', '--pid', pid], + }), + }), + descriptor: ({ artifacts, transport }) => ({ + transport: transport.mode === 'local' ? 'test-local' : 'test-provider', + ...artifacts, + }), + envelope: ({ input, device: selected, owner: selectedOwner, descriptor }) => + createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: input.sessionId, + device: { + id: selected.id, + family: 'android', + kind: selected.kind, + target: selected.target, + }, + owner: selectedOwner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(descriptorCodec, descriptor), + }), + }); + const binding = await owner.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + const started = await binding.operations.appLogStart?.({ + sessionId: 'session-1', + appBundleId: 'com.example.app', + outputPath: '/sessions/session-1/app.log', + fence: { token: 'fence', generation: 1 }, + }); + + expect(binding.facts.device.providerMode).toBe('local'); + expect(started?.envelope.descriptor.body).toEqual({ + transport: 'test-local', + outputPath: '/sessions/session-1/app.log', + pidPath: '/sessions/session-1/app-log.pid', + }); + expect(fixture.commands).toEqual([ + { kind: 'android-adb', serial: 'device-1', args: ['logcat', '--pid', '123'] }, + ]); + await started?.pendingHandle.transfer().finish(); +}); + +function hostFixture() { + const commands: unknown[] = []; + let resolveWait: (() => void) | undefined; + const wait = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { + resolveWait = () => resolve({ stdout: '', stderr: '', exitCode: 0 }); + }); + const process: AppLogBackgroundProcess = { + wait, + terminate: async () => resolveWait?.(), + [Symbol.asyncDispose]: async () => {}, + }; + const start: AppLogRuntimeHost['processes']['start'] = vi.fn(async ({ command }) => { + commands.push(command); + return process; + }); + const host: AppLogRuntimeHost = { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => { + throw new Error('unused'); + }, + }, + toolchains: { prepare: async () => {} }, + artifacts: { + resolveSession: (sessionId) => ({ + outputPath: `/sessions/${sessionId}/app.log`, + pidPath: `/sessions/${sessionId}/app-log.pid`, + }), + }, + commands: { + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + outputs: { + openAppend: async () => ({ + write: async () => {}, + [Symbol.asyncDispose]: async () => {}, + }), + readTail: async () => '', + }, + processTransports: { resolve: async () => ({ mode: 'local', start }) }, + processes: { + start, + readMarker: async () => ({ status: 'missing' }), + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + clock: { now: () => 10, sleep: waitForMonitorWake }, + }; + return { host, commands }; +} + +async function waitForMonitorWake(_milliseconds: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return; + await new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(), { once: true }); + }); +} diff --git a/packages/capture-kit/src/app-log-pid-runtime.ts b/packages/capture-kit/src/app-log-pid-runtime.ts new file mode 100644 index 0000000000..0d2e1ae940 --- /dev/null +++ b/packages/capture-kit/src/app-log-pid-runtime.ts @@ -0,0 +1,245 @@ +import type { DeviceInfo, Platform } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + AppLogProcessCommand, + AppLogProcessTransport, + AppLogRuntimeHost, + AppLogRuntimeOperations, + AppLogSessionArtifacts, + AppLogStartInput, + AppLogStartResult, + DeviceBinding, + DeviceRuntimeOwner, + DurableDescriptorCodec, + DurableResourceEnvelope, + HostCommandRequest, + RuntimeFacts, + RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { localRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform'; +import type { LogBackend } from '@agent-device/contracts/observability'; +import { createAppLogRecoveryOperations, createAppLogStartResult } from './app-log-runtime.ts'; +import { + cleanupManagedAppLogProcess, + reattachCleanupOnlyAppLogProcess, +} from './app-log-process-recovery.ts'; +import { createPidScopedAppLogProcess } from './app-log-pid-process.ts'; +import { + appLogSessionArtifactsMatch, + assertAppLogSessionArtifacts, +} from './app-log-session-artifacts.ts'; + +type PidScopedDescriptorPaths = Readonly<{ + outputPath: string; + pidPath: string; +}>; + +export type PidScopedAppLogRuntimeContext = Readonly<{ + host: AppLogRuntimeHost; + device: DeviceInfo; + signal: AbortSignal; +}>; + +export type PidScopedAppLogStartContext = PidScopedAppLogRuntimeContext & + Readonly<{ + input: AppLogStartInput; + artifacts: AppLogSessionArtifacts; + transport: AppLogProcessTransport; + }>; + +export type PidScopedAppLogProcessPlan = Readonly<{ + resolvePid(signal?: AbortSignal): Promise; + command(pid: string): AppLogProcessCommand; +}>; + +type PidScopedAppLogEnvelopeInput = Readonly<{ + input: AppLogStartInput; + device: DeviceInfo; + owner: RuntimeOwnerRef; + descriptor: Descriptor; +}>; + +export type PidScopedAppLogRuntimeOptions< + Family extends Platform, + Descriptor extends PidScopedDescriptorPaths, +> = Readonly<{ + family: Family; + backend: LogBackend; + label: string; + codec: DurableDescriptorCodec; + startUnavailableHint: string; + cleanupFailureMessage: string; + doctor( + context: PidScopedAppLogRuntimeContext, + appBundleId: string | undefined, + ): ReturnType; + validateStart?(context: PidScopedAppLogStartContext): void; + process(context: PidScopedAppLogStartContext): Promise; + descriptor(input: { + artifacts: AppLogSessionArtifacts; + transport: AppLogProcessTransport; + }): Descriptor; + envelope(input: PidScopedAppLogEnvelopeInput): DurableResourceEnvelope<'app-log'>; +}>; + +const available = Object.freeze({ available: true } as const); + +export async function resolveFirstNumericAppLogPid( + host: AppLogRuntimeHost, + request: HostCommandRequest, + signal?: AbortSignal, +): Promise { + const result = await host.commands.run(request, signal); + const pid = result.stdout.trim().split(/\s+/)[0] ?? ''; + return /^\d+$/.test(pid) ? pid : ''; +} + +/** Owns binding, facts, canonical artifacts, process lifecycle, and cleanup-only recovery. */ +export function createPidScopedAppLogRuntimeOwner< + Family extends Platform, + Descriptor extends PidScopedDescriptorPaths, +>( + host: AppLogRuntimeHost, + options: PidScopedAppLogRuntimeOptions, +): DeviceRuntimeOwner { + const owner = localRuntimeOwner(options.family); + return Object.freeze({ + owner, + ownsDevice: (device) => device.platform === options.family, + bind: async (request) => { + if (request.intent.kind === 'exact-owner' && !sameRuntimeOwner(request.intent.owner, owner)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `${options.label} app-log owner identity does not match`, + ); + } + if (request.device.platform !== options.family) { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + `${options.label} app-log owner cannot bind ${request.device.platform}`, + ); + } + const transport = await host.processTransports.resolve(request.device); + return createBinding(host, request.device, request.scope.signal, transport, owner, options); + }, + shutdown: async () => undefined, + }); +} + +function createBinding( + host: AppLogRuntimeHost, + device: DeviceInfo, + signal: AbortSignal, + transport: AppLogProcessTransport, + owner: RuntimeOwnerRef, + options: PidScopedAppLogRuntimeOptions, +): DeviceBinding { + const recovery = createAppLogRecoveryOperations({ + codec: options.codec, + reattach: async (descriptor, context) => { + if (!appLogSessionArtifactsMatch(host, context.sessionId, descriptor)) { + return { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: `${options.label} app-log descriptor paths do not match the owning session`, + }; + } + return await reattachCleanupOnlyAppLogProcess(host, descriptor.pidPath); + }, + cleanup: async (descriptor, context) => { + if (!appLogSessionArtifactsMatch(host, context.sessionId, descriptor)) { + return { + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + message: `${options.label} app-log descriptor paths do not match the owning session`, + } as const; + } + return await cleanupManagedAppLogProcess(host, descriptor.pidPath); + }, + }); + const operations: DeviceBinding['operations'] = Object.freeze({ + appLogInspect: async () => ({ backend: options.backend }), + appLogDoctor: async ({ appBundleId }) => + await options.doctor({ host, device, signal }, appBundleId), + ...(transport.start + ? { + appLogStart: async (input: AppLogStartInput) => + await startPidScopedAppLogs(host, device, signal, transport, owner, input, options), + } + : {}), + ...recovery, + }); + return Object.freeze({ + device, + owner, + facts: createFacts(device, transport, options), + operations, + [Symbol.asyncDispose]: async () => undefined, + }); +} + +function createFacts( + device: DeviceInfo, + transport: AppLogProcessTransport, + options: PidScopedAppLogRuntimeOptions, +): RuntimeFacts { + const start = transport.start + ? available + : ({ + available: false, + reason: 'owner-capability-missing', + hint: options.startUnavailableHint, + } as const); + return Object.freeze({ + device: { + family: options.family, + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + providerMode: transport.mode, + }, + operations: { + appLogInspect: available, + appLogDoctor: available, + appLogStart: start, + appLogReattach: available, + appLogCleanup: available, + }, + }); +} + +async function startPidScopedAppLogs< + Family extends Platform, + Descriptor extends PidScopedDescriptorPaths, +>( + host: AppLogRuntimeHost, + device: DeviceInfo, + signal: AbortSignal, + transport: AppLogProcessTransport, + owner: RuntimeOwnerRef, + input: AppLogStartInput, + options: PidScopedAppLogRuntimeOptions, +): Promise { + if (!transport.start) { + throw new AppError('UNSUPPORTED_OPERATION', options.startUnavailableHint); + } + assertAppLogSessionArtifacts(host, input); + const artifacts = host.artifacts.resolveSession(input.sessionId); + const context = { host, device, signal, input, artifacts, transport } as const; + options.validateStart?.(context); + signal.throwIfAborted(); + const process = await options.process(context); + signal.throwIfAborted(); + const handle = await createPidScopedAppLogProcess({ + host, + backend: options.backend, + outputPath: artifacts.outputPath, + pidPath: artifacts.pidPath, + processStart: transport.start, + setupSignal: signal, + resolvePid: process.resolvePid, + command: process.command, + cleanupFailureMessage: options.cleanupFailureMessage, + }); + const descriptor = options.descriptor({ artifacts, transport }); + return createAppLogStartResult(handle, options.envelope({ input, device, owner, descriptor })); +} diff --git a/packages/capture-kit/src/app-log-probe.ts b/packages/capture-kit/src/app-log-probe.ts new file mode 100644 index 0000000000..6e975b4985 --- /dev/null +++ b/packages/capture-kit/src/app-log-probe.ts @@ -0,0 +1,16 @@ +/** Runs an optional app-log probe without converting request cancellation into a false verdict. */ +export async function bestEffortAppLogCheck( + check: () => Promise, + signal?: AbortSignal, +): Promise { + try { + return await check(); + } catch { + if (signal?.aborted) throw signal.reason; + return false; + } +} + +export function appLogCommandSucceeded(result: { exitCode: number | null }): boolean { + return result.exitCode === 0; +} diff --git a/packages/capture-kit/src/app-log-process-recovery.test.ts b/packages/capture-kit/src/app-log-process-recovery.test.ts new file mode 100644 index 0000000000..fd1f7108da --- /dev/null +++ b/packages/capture-kit/src/app-log-process-recovery.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import type { + AppLogProcessMarkerReadOutcome, + AppLogProcessOwnership, +} from '@agent-device/contracts/platform'; +import { + cleanupManagedAppLogProcess, + reattachCleanupOnlyAppLogProcess, +} from './app-log-process-recovery.ts'; + +const decoded = { + status: 'decoded', + marker: { pid: 42, startTime: 'started', command: 'adb logcat' }, +} as const; + +test.each([ + [ + undefined, + 'owned-alive', + { + status: 'unreattachable', + reason: 'ownership-fence-lost', + message: 'App-log process marker path is missing', + }, + ], + [{ status: 'missing' }, 'owned-alive', { status: 'missing' }], + [ + { status: 'invalid', message: 'corrupt' }, + 'owned-alive', + { status: 'unreattachable', reason: 'ownership-fence-lost', message: 'corrupt' }, + ], + [decoded, 'missing', { status: 'missing' }], + [decoded, 'ownership-lost', { status: 'unreattachable', reason: 'ownership-fence-lost' }], + [decoded, 'owned-alive', { status: 'unreattachable', reason: 'transport-not-reattachable' }], +] as const)( + 'cleanup-only reattach preserves marker trust state %#', + async (read, ownership, expected) => { + const fixture = processHost(read, ownership); + assert.deepEqual( + await reattachCleanupOnlyAppLogProcess(fixture.host, read ? '/pid' : undefined), + expected, + ); + assert.equal(fixture.terminate.mock.calls.length, 0); + }, +); + +test('dead owned marker recovery clears terminal evidence before the next start', async () => { + let marker: AppLogProcessMarkerReadOutcome = decoded; + const clearMarker = vi.fn(async () => { + marker = { status: 'missing' }; + }); + const host = { + processes: { + readMarker: vi.fn(async () => marker), + clearMarker, + inspect: vi.fn(async () => 'missing' as const), + terminate: vi.fn(async () => 'already-missing' as const), + }, + }; + const startReplacement = vi.fn(async () => { + if (marker.status !== 'missing') throw new Error('stale marker still blocks replacement'); + return 'launched'; + }); + + assert.deepEqual(await reattachCleanupOnlyAppLogProcess(host, '/pid'), { status: 'missing' }); + assert.equal(await startReplacement(), 'launched'); + assert.equal(clearMarker.mock.calls.length, 1); +}); + +test.each([ + [ + undefined, + 'owned-alive', + 'terminated', + { + status: 'cleanup-pending', + reason: 'manual-recovery-required', + message: 'App-log process marker path is missing', + }, + ], + [{ status: 'missing' }, 'owned-alive', 'terminated', { status: 'already-missing' }], + [ + { status: 'invalid', message: 'corrupt' }, + 'owned-alive', + 'terminated', + { status: 'cleanup-pending', reason: 'manual-recovery-required', message: 'corrupt' }, + ], + [ + decoded, + 'ownership-lost', + 'terminated', + { status: 'cleanup-pending', reason: 'ownership-fence-lost' }, + ], + [decoded, 'missing', 'terminated', { status: 'already-missing' }], + [ + decoded, + 'owned-alive', + 'ownership-lost', + { status: 'cleanup-pending', reason: 'ownership-fence-lost' }, + ], + [decoded, 'owned-alive', 'already-missing', { status: 'already-missing' }], + [decoded, 'owned-alive', 'terminated', { status: 'cleaned' }], +] as const)( + 'managed cleanup preserves ownership certainty %#', + async (read, ownership, terminated, expected) => { + const fixture = processHost(read, ownership, terminated); + assert.deepEqual( + await cleanupManagedAppLogProcess(fixture.host, read ? '/pid' : undefined), + expected, + ); + assert.equal( + fixture.clearMarker.mock.calls.length, + (expected.status === 'already-missing' && read === decoded) || expected.status === 'cleaned' + ? 1 + : 0, + ); + }, +); + +function processHost( + read: AppLogProcessMarkerReadOutcome | undefined, + ownership: AppLogProcessOwnership, + terminated: 'terminated' | 'already-missing' | 'ownership-lost' = 'terminated', +) { + const clearMarker = vi.fn(async () => undefined); + const terminate = vi.fn(async () => terminated); + return { + host: { + processes: { + readMarker: vi.fn(async () => read!), + clearMarker, + inspect: vi.fn(async () => ownership), + terminate, + }, + }, + clearMarker, + terminate, + }; +} diff --git a/packages/capture-kit/src/app-log-process-recovery.ts b/packages/capture-kit/src/app-log-process-recovery.ts new file mode 100644 index 0000000000..6b1a4fa475 --- /dev/null +++ b/packages/capture-kit/src/app-log-process-recovery.ts @@ -0,0 +1,91 @@ +import type { + AppLogCompletion, + AppLogLiveHandle, + AppLogProcessMarkerReadOutcome, + AppLogRuntimeHost, + CleanupOutcome, + ReattachOutcome, +} from '@agent-device/contracts/platform'; + +type ManagedAppLogProcessHost = Readonly<{ + processes: Pick< + AppLogRuntimeHost['processes'], + 'readMarker' | 'clearMarker' | 'inspect' | 'terminate' + >; +}>; + +export type CleanupOnlyAppLogProcessReattachOutcome = Extract< + ReattachOutcome, + { status: 'missing' | 'unreattachable' } +>; + +/** A managed process can be cleaned after restart, but cannot reconstruct its live stream handle. */ +export async function reattachCleanupOnlyAppLogProcess( + host: ManagedAppLogProcessHost, + pidPath?: string, +): Promise { + const marker = await readManagedMarker(host, pidPath); + if (marker.status === 'missing') return { status: 'missing' }; + if (marker.status === 'invalid') { + return { + status: 'unreattachable', + reason: 'ownership-fence-lost', + message: marker.message, + }; + } + const ownership = await host.processes.inspect(marker.marker); + if (ownership === 'missing') { + if (pidPath) await host.processes.clearMarker(pidPath); + return { status: 'missing' }; + } + if (ownership === 'ownership-lost') { + return { status: 'unreattachable', reason: 'ownership-fence-lost' }; + } + return { status: 'unreattachable', reason: 'transport-not-reattachable' }; +} + +/** Cleans only complete, still-owned marker evidence; uncertain evidence remains fail-closed. */ +export async function cleanupManagedAppLogProcess( + host: ManagedAppLogProcessHost, + pidPath?: string, +): Promise { + if (!pidPath) { + return { + status: 'cleanup-pending', + reason: 'manual-recovery-required', + message: 'App-log process marker path is missing', + }; + } + const marker = await readManagedMarker(host, pidPath); + if (marker.status === 'missing') return { status: 'already-missing' }; + if (marker.status === 'invalid') { + return { + status: 'cleanup-pending', + reason: 'manual-recovery-required', + message: marker.message, + }; + } + const ownership = await host.processes.inspect(marker.marker); + if (ownership === 'ownership-lost') return ownershipLostCleanup(); + if (ownership === 'missing') { + await host.processes.clearMarker(pidPath); + return { status: 'already-missing' }; + } + const terminated = await host.processes.terminate(marker.marker); + if (terminated === 'ownership-lost') return ownershipLostCleanup(); + await host.processes.clearMarker(pidPath); + return { status: terminated === 'terminated' ? 'cleaned' : 'already-missing' }; +} + +async function readManagedMarker( + host: ManagedAppLogProcessHost, + pidPath: string | undefined, +): Promise { + return pidPath + ? await host.processes.readMarker(pidPath) + : { status: 'invalid', message: 'App-log process marker path is missing' }; +} + +function ownershipLostCleanup(): CleanupOutcome { + return { status: 'cleanup-pending', reason: 'ownership-fence-lost' }; +} diff --git a/packages/capture-kit/src/app-log-runtime.test.ts b/packages/capture-kit/src/app-log-runtime.test.ts new file mode 100644 index 0000000000..9fdb56b841 --- /dev/null +++ b/packages/capture-kit/src/app-log-runtime.test.ts @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + localRuntimeOwner, + providerRuntimeOwner, + type AppLogCompletion, + type AppLogBackgroundProcessRequest, + type AppLogProcessOwnership, + type AppLogProcessTransport, + type AppLogRuntimeHost, + type AppLogRuntimeProviderModule, + type DurableDescriptorCodec, +} from '@agent-device/contracts/platform'; +import { APP_LOG_ENVELOPE_FIXTURE } from './durable-resource-envelope.fixtures.ts'; +import { createAppLogLiveHandle } from './app-log-live-handle.ts'; +import { + createAppLogRecoveryOperations, + createAppLogStartResult, + decodeAppLogProcessMarker, +} from './app-log-runtime.ts'; + +const completion: AppLogCompletion = { + backend: 'android', + outputPath: '/tmp/app.log', + completedAt: 42, +}; + +function compileTimeProviderModuleProof(): void { + const invalid: AppLogRuntimeProviderModule = { + // @ts-expect-error Provider modules cannot advertise a local-family owner. + owner: localRuntimeOwner('apple'), + loadRuntime: async () => { + throw new Error('not loaded'); + }, + }; + void invalid; +} +void compileTimeProviderModuleProof; + +function compileTimeProcessTransportProof(): void { + const invalid: AppLogProcessTransport = { + // @ts-expect-error A direct provider runtime is an owner, not a narrow process transport. + mode: 'provider-runtime', + }; + void invalid; +} +void compileTimeProcessTransportProof; + +function compileTimeBackgroundCommandProof(): void { + const invalid: AppLogBackgroundProcessRequest = { + // @ts-expect-error Background commands require an explicit host/provider transport descriptor. + command: { executable: 'adb', args: ['-s', 'serial-1', 'logcat'] }, + output: { + write: async () => {}, + [Symbol.asyncDispose]: async () => {}, + }, + }; + void invalid; +} +void compileTimeBackgroundCommandProof; + +test('app-log live handle makes finish and forced async disposal idempotent', async () => { + const finish = vi.fn(async () => ({ status: 'completed', result: completion }) as const); + const cleanup = vi.fn(async () => ({ status: 'cleaned' }) as const); + const handle = createAppLogLiveHandle({ + inspect: () => ({ backend: 'android', state: 'active', startedAt: 12 }), + finish, + forceCleanup: cleanup, + }); + + assert.deepEqual(handle.inspect(), { backend: 'android', state: 'active', startedAt: 12 }); + assert.deepEqual(await handle.finish(), { status: 'completed', result: completion }); + assert.deepEqual(await handle.finish(), { status: 'completed', result: completion }); + await handle[Symbol.asyncDispose](); + await handle[Symbol.asyncDispose](); + + assert.equal(finish.mock.calls.length, 1); + assert.equal(cleanup.mock.calls.length, 1); +}); + +test('app-log async disposal rejects when forced cleanup remains unconfirmed', async () => { + const handle = createAppLogLiveHandle({ + inspect: () => ({ backend: 'android', state: 'failed', startedAt: 12 }), + finish: async () => ({ status: 'completed', result: completion }), + forceCleanup: async () => ({ + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + }), + }); + + await assert.rejects( + async () => await handle[Symbol.asyncDispose](), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'ownership-fence-lost', + ); +}); + +test('app-log start result retains pending ownership until explicit transfer', async () => { + let cleanupCount = 0; + const handle = createAppLogLiveHandle({ + inspect: () => ({ backend: 'android', state: 'active', startedAt: 12 }), + finish: async () => ({ status: 'completed', result: completion }), + forceCleanup: async () => { + cleanupCount += 1; + return { status: 'cleaned' }; + }, + }); + const result = createAppLogStartResult(handle, APP_LOG_ENVELOPE_FIXTURE); + + await result.pendingHandle[Symbol.asyncDispose](); + assert.equal(cleanupCount, 1); + + const adopted = createAppLogStartResult(handle, APP_LOG_ENVELOPE_FIXTURE); + assert.equal(adopted.pendingHandle.transfer(), handle); + await adopted.pendingHandle[Symbol.asyncDispose](); + assert.equal(cleanupCount, 1); +}); + +test('app-log recovery decodes only after exact-owner selection and fails closed', async () => { + type Descriptor = Readonly<{ pid: number }>; + const codec: DurableDescriptorCodec = { + resourceKind: 'app-log', + version: 2, + encode: ({ pid }) => ({ pid }), + decode: (body) => + typeof body.pid === 'number' + ? { status: 'decoded', descriptor: { pid: body.pid } } + : { status: 'invalid', message: 'pid is invalid' }, + }; + let reattachedDescriptor: Descriptor | undefined; + const reattach = vi.fn(async (descriptor: Descriptor, _context: unknown) => { + reattachedDescriptor = descriptor; + return { status: 'missing' } as const; + }); + const cleanup = vi.fn( + async (_descriptor: Descriptor) => ({ status: 'already-missing' }) as const, + ); + const operations = createAppLogRecoveryOperations({ codec, reattach, cleanup }); + + assert.deepEqual(await operations.appLogReattach({ envelope: APP_LOG_ENVELOPE_FIXTURE }), { + status: 'missing', + }); + assert.equal(reattachedDescriptor?.pid, 42); + assert.deepEqual(reattach.mock.calls[0]?.[1], { + sessionId: APP_LOG_ENVELOPE_FIXTURE.sessionId, + fence: APP_LOG_ENVELOPE_FIXTURE.fence, + }); + + const unsupported = { + ...APP_LOG_ENVELOPE_FIXTURE, + descriptor: { ...APP_LOG_ENVELOPE_FIXTURE.descriptor, version: 9 }, + }; + assert.deepEqual(await operations.appLogReattach({ envelope: unsupported }), { + status: 'unreattachable', + reason: 'descriptor-version-unsupported', + message: 'Unsupported app-log descriptor version: 9', + version: 9, + }); + assert.deepEqual(await operations.appLogCleanup({ envelope: unsupported }), { + status: 'cleanup-pending', + reason: 'manual-recovery-required', + message: 'Unsupported app-log descriptor version: 9', + }); + assert.equal(cleanup.mock.calls.length, 0); +}); + +test('app-log process marker decoding keeps incomplete or corrupt state distinct from missing', () => { + assert.deepEqual(decodeAppLogProcessMarker(undefined), { + status: 'invalid', + message: 'App-log process marker must be an object', + }); + assert.deepEqual(decodeAppLogProcessMarker({ pid: 42, command: 'adb logcat' }), { + status: 'invalid', + message: 'App-log process marker startTime must be a non-empty string', + }); + assert.deepEqual( + decodeAppLogProcessMarker({ pid: 42, startTime: '12345', command: 'adb logcat' }), + { + status: 'decoded', + marker: { pid: 42, startTime: '12345', command: 'adb logcat' }, + }, + ); +}); + +test('app-log ownership probe cannot collapse a mismatched live process into missing', () => { + const recoveryDecision = ( + ownership: AppLogProcessOwnership, + ): 'complete' | 'reattach' | 'retain' => { + switch (ownership) { + case 'missing': + return 'complete'; + case 'owned-alive': + return 'reattach'; + case 'ownership-lost': + return 'retain'; + } + }; + + assert.equal(recoveryDecision('missing'), 'complete'); + assert.equal(recoveryDecision('owned-alive'), 'reattach'); + assert.equal(recoveryDecision('ownership-lost'), 'retain'); +}); + +test('app-log output host exposes only a bounded session-confined tail read', () => { + type ReadTail = AppLogRuntimeHost['outputs']['readTail']; + const readTail: ReadTail = async (_path, _maxBytes) => 'existing suffix'; + + assert.equal(readTail.length, 2); +}); + +test('app-log process transport keeps narrow provider composition separate from runtime ownership', () => { + const transport = { + mode: 'transport-composed', + } satisfies AppLogProcessTransport; + + assert.equal(transport.mode, 'transport-composed'); + assert.equal('start' in transport, false); +}); + +test('app-log artifact authority resolves canonical paths from durable session identity', () => { + type ResolveSession = AppLogRuntimeHost['artifacts']['resolveSession']; + const resolveSession: ResolveSession = (sessionId) => ({ + outputPath: `/sessions/${sessionId}/app.log`, + pidPath: `/sessions/${sessionId}/app-log.pid`, + }); + + assert.deepEqual(resolveSession('session-a'), { + outputPath: '/sessions/session-a/app.log', + pidPath: '/sessions/session-a/app-log.pid', + }); +}); + +test('app-log provider module exposes inert exact-owner metadata without loading mechanics', () => { + const loadRuntime = vi.fn(async () => { + throw new Error('not loaded'); + }); + const module: AppLogRuntimeProviderModule = { + owner: providerRuntimeOwner('limrun', 'tenant-a'), + loadRuntime, + }; + + assert.deepEqual(module.owner, { + kind: 'provider-runtime', + provider: 'limrun', + instance: 'tenant-a', + }); + assert.equal(loadRuntime.mock.calls.length, 0); +}); diff --git a/packages/capture-kit/src/app-log-runtime.ts b/packages/capture-kit/src/app-log-runtime.ts new file mode 100644 index 0000000000..5075bab4ec --- /dev/null +++ b/packages/capture-kit/src/app-log-runtime.ts @@ -0,0 +1,93 @@ +import { + PendingTransferGuard, + type AppLogCompletion, + type AppLogLiveHandle, + type AppLogProcessMarkerReadOutcome, + type AppLogRuntimeOperations, + type AppLogStartResult, + type CleanupOutcome, + type DurableDescriptorCodec, + type DurableResourceEnvelope, + type ReattachOutcome, + type ResourceOwnershipFence, +} from '@agent-device/contracts/platform'; +import { decodeDurableDescriptor } from './durable-descriptor-codec.ts'; + +const APP_LOG_RESOURCE_KIND = 'app-log' as const; + +export function createAppLogStartResult( + handle: AppLogLiveHandle, + envelope: DurableResourceEnvelope, +): AppLogStartResult { + return Object.freeze({ pendingHandle: new PendingTransferGuard(handle), envelope }); +} + +type AppLogRecoveryContext = Readonly<{ + sessionId: string; + fence: ResourceOwnershipFence; +}>; + +export function createAppLogRecoveryOperations(implementation: { + codec: DurableDescriptorCodec; + reattach( + descriptor: Descriptor, + context: AppLogRecoveryContext, + ): Promise>; + cleanup(descriptor: Descriptor, context: AppLogRecoveryContext): Promise; +}): Pick { + return Object.freeze({ + appLogReattach: async ({ envelope }) => { + const decoded = decodeDurableDescriptor(envelope, implementation.codec); + if (decoded.status === 'unreattachable') return decoded; + return await implementation.reattach(decoded.descriptor, recoveryContext(envelope)); + }, + appLogCleanup: async ({ envelope }) => { + const decoded = decodeDurableDescriptor(envelope, implementation.codec); + if (decoded.status === 'decoded') { + return await implementation.cleanup(decoded.descriptor, recoveryContext(envelope)); + } + return { + status: 'cleanup-pending', + reason: 'manual-recovery-required', + message: decoded.message, + }; + }, + }); +} + +function recoveryContext( + envelope: DurableResourceEnvelope, +): AppLogRecoveryContext { + return Object.freeze({ sessionId: envelope.sessionId, fence: envelope.fence }); +} + +/** Validates a present marker without collapsing corrupt or incomplete data into absence. */ +export function decodeAppLogProcessMarker(value: unknown): AppLogProcessMarkerReadOutcome { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { status: 'invalid', message: 'App-log process marker must be an object' }; + } + const marker = value as Record; + if (!Number.isInteger(marker.pid) || Number(marker.pid) <= 0) { + return { status: 'invalid', message: 'App-log process marker pid must be a positive integer' }; + } + if (typeof marker.startTime !== 'string' || marker.startTime.trim().length === 0) { + return { + status: 'invalid', + message: 'App-log process marker startTime must be a non-empty string', + }; + } + if (typeof marker.command !== 'string' || marker.command.trim().length === 0) { + return { + status: 'invalid', + message: 'App-log process marker command must be a non-empty string', + }; + } + return { + status: 'decoded', + marker: Object.freeze({ + pid: Number(marker.pid), + startTime: marker.startTime, + command: marker.command, + }), + }; +} diff --git a/packages/capture-kit/src/app-log-session-artifacts.ts b/packages/capture-kit/src/app-log-session-artifacts.ts new file mode 100644 index 0000000000..5c6291680d --- /dev/null +++ b/packages/capture-kit/src/app-log-session-artifacts.ts @@ -0,0 +1,27 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { AppLogRuntimeHost, AppLogStartInput } from '@agent-device/contracts/platform'; + +export type AppLogArtifactPaths = Readonly<{ + outputPath: string; + pidPath?: string; +}>; + +export function appLogSessionArtifactsMatch( + host: AppLogRuntimeHost, + sessionId: string, + paths: AppLogArtifactPaths, +): boolean { + const expected = host.artifacts.resolveSession(sessionId); + return ( + paths.outputPath === expected.outputPath && + (paths.pidPath === undefined || paths.pidPath === expected.pidPath) + ); +} + +export function assertAppLogSessionArtifacts( + host: AppLogRuntimeHost, + input: AppLogStartInput, +): void { + if (appLogSessionArtifactsMatch(host, input.sessionId, input)) return; + throw new AppError('INVALID_ARGS', 'App-log paths do not match the owning session'); +} diff --git a/packages/capture-kit/src/app-log-unavailable-runtime.test.ts b/packages/capture-kit/src/app-log-unavailable-runtime.test.ts new file mode 100644 index 0000000000..5f4c6688fa --- /dev/null +++ b/packages/capture-kit/src/app-log-unavailable-runtime.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { localRuntimeOwner, providerRuntimeOwner } from '@agent-device/contracts/platform'; +import { + createUnavailableAppLogBinding, + createUnavailableAppLogRuntimeOwner, +} from './app-log-unavailable-runtime.ts'; + +const scope = { + signal: new AbortController().signal, + diagnostics: { emit: () => undefined }, + progress: { report: () => undefined }, +}; + +test('unavailable app-log owner binds only its exact family and publishes complete facts', async () => { + const runtime = createUnavailableAppLogRuntimeOwner('vega', { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'No app-log transport.', + }); + const device = { platform: 'vega', id: 'vega', name: 'Vega', kind: 'device' } as const; + const binding = await runtime.bind({ device, intent: { kind: 'ordinary' }, scope }); + + assert.equal(runtime.ownsDevice(device), true); + assert.deepEqual(binding.owner, localRuntimeOwner('vega')); + assert.deepEqual(Object.keys(binding.operations), []); + assert.deepEqual( + new Set(Object.values(binding.facts.operations)), + new Set([ + { available: false, reason: 'unsupported-platform-leaf', hint: 'No app-log transport.' }, + ]), + ); + await assert.doesNotReject(() => + runtime.bind({ + device, + intent: { + kind: 'exact-owner', + owner: localRuntimeOwner('vega'), + fence: { token: 'f', generation: 1 }, + }, + scope, + }), + ); + await assert.rejects( + () => + runtime.bind({ + device, + intent: { + kind: 'exact-owner', + owner: localRuntimeOwner('linux'), + fence: { token: 'f', generation: 1 }, + }, + scope, + }), + /identity does not match/, + ); + await assert.rejects( + () => + runtime.bind({ + device: { ...device, platform: 'linux' }, + intent: { kind: 'ordinary' }, + scope, + }), + /cannot bind linux/, + ); +}); + +test('unavailable app-log binding derives provider facts without exposing operations', async () => { + const device = { platform: 'android', id: 'remote', name: 'Remote', kind: 'emulator' } as const; + const owner = providerRuntimeOwner('remote', 'default'); + const binding = createUnavailableAppLogBinding(device, owner, { + available: false, + reason: 'unsupported-provider-mode', + }); + + assert.equal(binding.facts.device.providerMode, 'provider-runtime'); + assert.deepEqual(binding.facts.operations.appLogStart, { + available: false, + reason: 'unsupported-provider-mode', + }); + assert.deepEqual(binding.operations, {}); + await binding[Symbol.asyncDispose](); +}); diff --git a/packages/capture-kit/src/app-log-unavailable-runtime.ts b/packages/capture-kit/src/app-log-unavailable-runtime.ts new file mode 100644 index 0000000000..bef9a914a1 --- /dev/null +++ b/packages/capture-kit/src/app-log-unavailable-runtime.ts @@ -0,0 +1,70 @@ +import { deviceShape, type DeviceInfo, type Platform } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { + localRuntimeOwner, + sameRuntimeOwner, + type AppLogRuntimeOperations, + type DeviceBinding, + type DeviceRuntimeOwner, + type RuntimeFacts, + type RuntimeOperationUnavailability, + type RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; + +/** Builds an honest family owner for platforms with no app-log mechanics. */ +export function createUnavailableAppLogRuntimeOwner( + family: Platform, + fact: RuntimeOperationUnavailability, +): DeviceRuntimeOwner { + const owner = localRuntimeOwner(family); + const unavailable = Object.freeze({ ...fact }); + return Object.freeze({ + owner, + ownsDevice: (device) => device.platform === family, + bind: async (request) => { + if (request.intent.kind === 'exact-owner' && !sameRuntimeOwner(request.intent.owner, owner)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `${family} app-log owner identity does not match`, + ); + } + if (request.device.platform !== family) { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + `${family} app-log owner cannot bind ${request.device.platform}`, + ); + } + return createUnavailableAppLogBinding(request.device, owner, unavailable); + }, + shutdown: async () => undefined, + }); +} + +/** Builds the complete fact-only binding shared by unavailable local and provider owners. */ +export function createUnavailableAppLogBinding( + device: DeviceInfo, + owner: RuntimeOwnerRef, + fact: RuntimeOperationUnavailability, +): DeviceBinding { + const unavailable = Object.freeze({ ...fact }); + const facts: RuntimeFacts = Object.freeze({ + device: { + ...deviceShape(device), + providerMode: owner.kind === 'local-family' ? 'local' : 'provider-runtime', + }, + operations: { + appLogInspect: unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + }, + }); + return Object.freeze({ + device, + owner, + facts, + operations: Object.freeze({}), + [Symbol.asyncDispose]: async () => undefined, + }); +} diff --git a/packages/capture-kit/src/durable-descriptor-codec.ts b/packages/capture-kit/src/durable-descriptor-codec.ts new file mode 100644 index 0000000000..1be1d1e72b --- /dev/null +++ b/packages/capture-kit/src/durable-descriptor-codec.ts @@ -0,0 +1,51 @@ +import type { + DurableDescriptorCodec, + DurableResourceEnvelope, +} from '@agent-device/contracts/platform'; + +export type DurableDescriptorDecodeOutcome = + | Readonly<{ status: 'decoded'; descriptor: Descriptor }> + | Readonly<{ + status: 'unreattachable'; + reason: 'descriptor-invalid' | 'descriptor-version-unsupported'; + message: string; + version?: number; + }>; + +/** Invoke only after the envelope's exact owner has selected the facet codec. */ +export function decodeDurableDescriptor( + envelope: DurableResourceEnvelope, + codec: DurableDescriptorCodec, +): DurableDescriptorDecodeOutcome { + if (envelope.resourceKind !== codec.resourceKind) { + return { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: `Durable resource kind ${envelope.resourceKind} does not match ${codec.resourceKind}`, + }; + } + if (envelope.descriptor.version !== codec.version) { + return { + status: 'unreattachable', + reason: 'descriptor-version-unsupported', + message: `Unsupported ${codec.resourceKind} descriptor version: ${envelope.descriptor.version}`, + version: envelope.descriptor.version, + }; + } + try { + const decoded = codec.decode(envelope.descriptor.body); + return decoded.status === 'decoded' + ? decoded + : { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: decoded.message, + }; + } catch (error) { + return { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: error instanceof Error ? error.message : 'Descriptor codec rejected the body', + }; + } +} diff --git a/packages/capture-kit/src/durable-json.test.ts b/packages/capture-kit/src/durable-json.test.ts new file mode 100644 index 0000000000..f3531d5a20 --- /dev/null +++ b/packages/capture-kit/src/durable-json.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { freezeJsonObject, isBoundedJsonObject } from './durable-json.ts'; + +test('bounded durable JSON rejects cycles and excessive depth', () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + assert.equal(isBoundedJsonObject(cyclic), false); + + let nested: Record = {}; + for (let depth = 0; depth < 40; depth += 1) nested = { nested }; + assert.equal(isBoundedJsonObject(nested), false); +}); + +test('validated durable JSON freezes without recursively revalidating every subtree', () => { + let reads = 0; + const leaf = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { + reads += 1; + return 'leaf'; + }, + }); + const branch = Object.defineProperty({}, 'leaf', { + enumerable: true, + get: () => { + reads += 1; + return leaf; + }, + }); + const body = Object.defineProperty({}, 'branch', { + enumerable: true, + get: () => { + reads += 1; + return branch; + }, + }); + + assert.equal(isBoundedJsonObject(body), true); + freezeJsonObject(body); + assert.ok(reads <= 6, `expected one validation and one freeze read per node, observed ${reads}`); +}); diff --git a/packages/capture-kit/src/durable-json.ts b/packages/capture-kit/src/durable-json.ts new file mode 100644 index 0000000000..00ef784867 --- /dev/null +++ b/packages/capture-kit/src/durable-json.ts @@ -0,0 +1,79 @@ +import type { JsonObject, JsonValue } from '@agent-device/contracts/client'; + +type JsonValidationState = { + seen: WeakSet; + nodes: number; +}; + +const MAX_DESCRIPTOR_JSON_DEPTH = 32; +const MAX_DESCRIPTOR_JSON_NODES = 4_096; + +export function isBoundedJsonObject(value: unknown): value is JsonObject { + return validateObject(value, { seen: new WeakSet(), nodes: 0 }, 0); +} + +export function freezeJsonObject(value: JsonObject): JsonObject { + return Object.freeze( + Object.fromEntries(Object.entries(value).map(([key, item]) => [key, freezeJsonValue(item)])), + ); +} + +function validateObject( + value: unknown, + state: JsonValidationState, + depth: number, +): value is JsonObject { + if (!isPlainObject(value) || depth > MAX_DESCRIPTOR_JSON_DEPTH) return false; + if (state.seen.has(value)) return false; + state.seen.add(value); + state.nodes += 1; + const valid = + state.nodes <= MAX_DESCRIPTOR_JSON_NODES && + Object.values(value).every((item) => validateValue(item, state, depth + 1)); + state.seen.delete(value); + return valid; +} + +function validateValue( + value: unknown, + state: JsonValidationState, + depth: number, +): value is JsonValue { + if (isJsonScalar(value)) return true; + if (depth > MAX_DESCRIPTOR_JSON_DEPTH) return false; + if (Array.isArray(value)) return validateArray(value, state, depth); + return validateObject(value, state, depth); +} + +function validateArray( + value: unknown[], + state: JsonValidationState, + depth: number, +): value is JsonValue[] { + if (state.seen.has(value)) return false; + state.seen.add(value); + state.nodes += 1; + const valid = + state.nodes <= MAX_DESCRIPTOR_JSON_NODES && + value.every((item) => validateValue(item, state, depth + 1)); + state.seen.delete(value); + return valid; +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value) as unknown; + return prototype === Object.prototype || prototype === null; +} + +function isJsonScalar(value: unknown): value is null | string | boolean | number { + if (value === null) return true; + if (typeof value === 'number') return Number.isFinite(value); + return typeof value === 'string' || typeof value === 'boolean'; +} + +function freezeJsonValue(value: JsonValue): JsonValue { + if (Array.isArray(value)) return Object.freeze(value.map(freezeJsonValue)) as JsonValue[]; + if (value !== null && typeof value === 'object') return freezeJsonObject(value); + return value; +} diff --git a/packages/capture-kit/src/durable-resource-envelope.fixtures.ts b/packages/capture-kit/src/durable-resource-envelope.fixtures.ts new file mode 100644 index 0000000000..f51f1a7d32 --- /dev/null +++ b/packages/capture-kit/src/durable-resource-envelope.fixtures.ts @@ -0,0 +1,20 @@ +import { createDurableResourceEnvelope } from './durable-resource-envelope.ts'; + +export const APP_LOG_ENVELOPE_FIXTURE = createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: 'session-1', + device: { + id: 'emulator-5554', + family: 'android', + kind: 'emulator', + target: 'mobile', + }, + owner: { kind: 'local-family', family: 'android' }, + fence: { token: 'fence-2', generation: 2 }, + lifecycle: 'open', + descriptor: { + version: 2, + body: { pid: 42, outputPath: '/tmp/app.log' }, + }, + metadata: { startedAt: 1_700_000_000_000 }, +}); diff --git a/packages/capture-kit/src/durable-resource-envelope.test.ts b/packages/capture-kit/src/durable-resource-envelope.test.ts new file mode 100644 index 0000000000..045763bd66 --- /dev/null +++ b/packages/capture-kit/src/durable-resource-envelope.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { APP_LOG_ENVELOPE_FIXTURE } from './durable-resource-envelope.fixtures.ts'; +import { decodeDurableResourceEnvelope } from './durable-resource-envelope.ts'; + +test('neutral envelope decoding validates and freezes persisted JSON before facet selection', () => { + const result = decodeDurableResourceEnvelope( + JSON.parse(JSON.stringify(APP_LOG_ENVELOPE_FIXTURE)), + ); + assert.equal(result.status, 'decoded'); + if (result.status !== 'decoded') return; + + assert.deepEqual(result.envelope, APP_LOG_ENVELOPE_FIXTURE); + assert.ok(Object.isFrozen(result.envelope)); + assert.ok(Object.isFrozen(result.envelope.descriptor.body)); + assert.ok(Object.isFrozen(result.envelope.metadata)); +}); + +test('neutral envelope decoding retains invalid and unsupported-version evidence', () => { + const invalid = decodeDurableResourceEnvelope({ + ...APP_LOG_ENVELOPE_FIXTURE, + owner: { kind: 'provider-runtime', provider: 'webdriver', instance: ' ' }, + }); + assert.deepEqual(invalid, { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: 'Durable resource owner reference is invalid', + }); + + const unsupported = decodeDurableResourceEnvelope({ + ...APP_LOG_ENVELOPE_FIXTURE, + envelopeVersion: 7, + }); + assert.deepEqual(unsupported, { + status: 'unreattachable', + reason: 'descriptor-version-unsupported', + message: 'Unsupported durable resource envelope version: 7', + version: 7, + }); +}); + +test('neutral envelope accepts only open or completed persisted lifecycle states', () => { + for (const lifecycle of ['open', 'completed']) { + assert.equal( + decodeDurableResourceEnvelope({ ...APP_LOG_ENVELOPE_FIXTURE, lifecycle }).status, + 'decoded', + ); + } + for (const lifecycle of ['starting', 'active', 'completing', 'cleanup-pending']) { + assert.deepEqual(decodeDurableResourceEnvelope({ ...APP_LOG_ENVELOPE_FIXTURE, lifecycle }), { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: 'Durable resource lifecycle state is invalid', + }); + } +}); + +test.each([ + { + name: 'local owner for another family', + device: { + ...APP_LOG_ENVELOPE_FIXTURE.device, + family: 'apple', + appleOs: 'ios', + kind: 'device', + }, + owner: APP_LOG_ENVELOPE_FIXTURE.owner, + }, + { + name: 'Apple leaf on a non-Apple family', + device: { ...APP_LOG_ENVELOPE_FIXTURE.device, appleOs: 'ios' }, + }, + { + name: 'Apple physical backend on a non-Apple family', + device: { + ...APP_LOG_ENVELOPE_FIXTURE.device, + kind: 'device', + iosPhysicalDeviceBackend: 'coredevice', + }, + }, + { + name: 'Apple family without a leaf', + device: { + id: 'apple-1', + family: 'apple', + kind: 'device', + target: 'mobile', + }, + }, + { + name: 'physical backend on an Apple simulator', + device: { + id: 'simulator-1', + family: 'apple', + appleOs: 'ios', + kind: 'simulator', + target: 'mobile', + iosPhysicalDeviceBackend: 'xctest', + }, + }, +])('neutral envelope rejects impossible device/owner identity: $name', ({ device, owner }) => { + const outcome = decodeDurableResourceEnvelope({ + ...APP_LOG_ENVELOPE_FIXTURE, + device, + owner: owner ?? { kind: 'provider-runtime', provider: 'cloud', instance: 'tenant-1' }, + }); + + assert.equal(outcome.status, 'unreattachable'); + if (outcome.status === 'unreattachable') assert.equal(outcome.reason, 'descriptor-invalid'); +}); + +test('neutral envelope canonicalizes persisted provider owner identity', () => { + const outcome = decodeDurableResourceEnvelope({ + ...APP_LOG_ENVELOPE_FIXTURE, + owner: { + kind: 'provider-runtime', + provider: ' webdriver ', + instance: ' tenant-a ', + }, + }); + + assert.equal(outcome.status, 'decoded'); + if (outcome.status !== 'decoded') return; + assert.deepEqual(outcome.envelope.owner, { + kind: 'provider-runtime', + provider: 'webdriver', + instance: 'tenant-a', + }); +}); diff --git a/packages/capture-kit/src/durable-resource-envelope.ts b/packages/capture-kit/src/durable-resource-envelope.ts new file mode 100644 index 0000000000..868a456873 --- /dev/null +++ b/packages/capture-kit/src/durable-resource-envelope.ts @@ -0,0 +1,248 @@ +import { + isAppleOs, + isPlatform, + type AppleOS, + type DeviceIdentity, + type DeviceInfo, + type DeviceKind, + type DeviceTarget, + type Platform, +} from '@agent-device/kernel/device'; +import type { JsonObject } from '@agent-device/contracts/client'; +import { + localRuntimeOwner, + providerRuntimeOwner, + type DurableDescriptorCodec, + type DurableEnvelopeDecodeOutcome, + type DurableResourceEnvelope, + type DurableResourceLifecycleState, + type EncodedDurableDescriptor, + type ResourceOwnershipFence, + type RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { freezeJsonObject, isBoundedJsonObject } from './durable-json.ts'; + +const DURABLE_RESOURCE_ENVELOPE_VERSION = 1 as const; + +export function decodeDurableResourceEnvelope(value: unknown): DurableEnvelopeDecodeOutcome { + if (!isObject(value)) return invalidEnvelope('Durable resource envelope must be an object'); + + const envelopeVersion = value.envelopeVersion; + if (!isNonNegativeInteger(envelopeVersion)) { + return invalidEnvelope('Durable resource envelopeVersion must be a non-negative integer'); + } + if (envelopeVersion !== DURABLE_RESOURCE_ENVELOPE_VERSION) { + return { + status: 'unreattachable', + reason: 'descriptor-version-unsupported', + message: `Unsupported durable resource envelope version: ${envelopeVersion}`, + version: envelopeVersion, + }; + } + + if (!isNonEmptyString(value.resourceKind)) { + return invalidEnvelope('Durable resource resourceKind must be a non-empty string'); + } + if (!isNonEmptyString(value.sessionId)) { + return invalidEnvelope('Durable resource sessionId must be a non-empty string'); + } + const device = decodeDeviceIdentity(value.device); + if (!device) return invalidEnvelope('Durable resource device identity is invalid'); + const owner = decodeRuntimeOwnerRef(value.owner); + if (!owner) return invalidEnvelope('Durable resource owner reference is invalid'); + if (owner.kind === 'local-family' && owner.family !== device.family) { + return invalidEnvelope('Durable resource local owner does not match the device family'); + } + const fence = decodeFence(value.fence); + if (!fence) return invalidEnvelope('Durable resource ownership fence is invalid'); + if (!isLifecycleState(value.lifecycle)) { + return invalidEnvelope('Durable resource lifecycle state is invalid'); + } + const descriptor = decodeEncodedDescriptor(value.descriptor); + if (!descriptor) return invalidEnvelope('Durable resource descriptor is invalid'); + if (value.metadata !== undefined && !isBoundedJsonObject(value.metadata)) { + return invalidEnvelope('Durable resource metadata must be a JSON object'); + } + + return { + status: 'decoded', + envelope: Object.freeze({ + envelopeVersion: DURABLE_RESOURCE_ENVELOPE_VERSION, + resourceKind: value.resourceKind, + sessionId: value.sessionId, + device, + owner, + fence, + lifecycle: value.lifecycle, + descriptor, + ...(value.metadata === undefined ? {} : { metadata: freezeJsonObject(value.metadata) }), + }), + }; +} + +export function encodeDurableDescriptor( + codec: DurableDescriptorCodec, + descriptor: Descriptor, +): EncodedDurableDescriptor { + return Object.freeze({ + version: codec.version, + body: freezeJsonObject(codec.encode(descriptor)), + }); +} + +export function createDurableResourceEnvelope(input: { + resourceKind: ResourceKind; + sessionId: string; + device: DeviceIdentity; + owner: RuntimeOwnerRef; + fence: ResourceOwnershipFence; + lifecycle: DurableResourceLifecycleState; + descriptor: EncodedDurableDescriptor; + metadata?: JsonObject; +}): DurableResourceEnvelope { + const decoded = decodeDurableResourceEnvelope({ + envelopeVersion: DURABLE_RESOURCE_ENVELOPE_VERSION, + ...input, + }); + if (decoded.status !== 'decoded') { + throw new TypeError(decoded.message); + } + return decoded.envelope as DurableResourceEnvelope; +} + +function invalidEnvelope(message: string): DurableEnvelopeDecodeOutcome { + return { status: 'unreattachable', reason: 'descriptor-invalid', message }; +} + +function decodeRuntimeOwnerRef(value: unknown): RuntimeOwnerRef | null { + if (!isObject(value)) return null; + if (value.kind === 'local-family' && isPlatform(value.family)) { + return localRuntimeOwner(value.family); + } + if ( + value.kind === 'provider-runtime' && + isNonEmptyString(value.provider) && + isNonEmptyString(value.instance) + ) { + return providerRuntimeOwner(value.provider, value.instance); + } + return null; +} + +function decodeDeviceIdentity(value: unknown): DeviceIdentity | null { + if (!isObject(value)) return null; + const id = readDeviceIdentityId(value.id); + const family = readDeviceIdentityFamily(value.family); + const kind = readDeviceIdentityKind(value.kind); + const target = readDeviceIdentityTarget(value.target); + if (!id || !family || !kind || target === null) return null; + if (!hasCoherentAppleIdentity(value, family, kind)) return null; + return buildDeviceIdentity(value, id, family, kind, target); +} + +function buildDeviceIdentity( + value: Record & { + appleOs?: AppleOS; + iosPhysicalDeviceBackend?: DeviceInfo['iosPhysicalDeviceBackend']; + }, + id: string, + family: Platform, + kind: DeviceKind, + target: DeviceTarget | undefined, +): DeviceIdentity { + return Object.freeze({ + id, + family, + ...(value.appleOs === undefined ? {} : { appleOs: value.appleOs }), + kind, + ...(target === undefined ? {} : { target }), + ...(value.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: value.iosPhysicalDeviceBackend }), + }); +} + +function readDeviceIdentityId(value: unknown): string | null { + return isNonEmptyString(value) ? value : null; +} + +function readDeviceIdentityFamily(value: unknown): Platform | null { + return isPlatform(value) ? value : null; +} + +function readDeviceIdentityKind(value: unknown): DeviceKind | null { + return isDeviceKind(value) ? value : null; +} + +function readDeviceIdentityTarget(value: unknown): DeviceTarget | undefined | null { + if (value === undefined) return undefined; + return isDeviceTarget(value) ? value : null; +} + +function hasCoherentAppleIdentity( + value: Record, + family: Platform, + kind: DeviceKind, +): value is Record & { + appleOs?: AppleOS; + iosPhysicalDeviceBackend?: DeviceInfo['iosPhysicalDeviceBackend']; +} { + if (family === 'apple') { + if (!isAppleOs(value.appleOs)) return false; + } else if (value.appleOs !== undefined || value.iosPhysicalDeviceBackend !== undefined) { + return false; + } + return hasValidPhysicalAppleBackend(value.iosPhysicalDeviceBackend, kind); +} + +function hasValidPhysicalAppleBackend(value: unknown, kind: DeviceKind): boolean { + if (value === undefined) return true; + if (kind !== 'device') return false; + return value === 'coredevice' || value === 'xctest'; +} + +function decodeFence(value: unknown): ResourceOwnershipFence | null { + if ( + !isObject(value) || + !isNonEmptyString(value.token) || + !isNonNegativeInteger(value.generation) + ) { + return null; + } + return Object.freeze({ token: value.token, generation: value.generation }); +} + +function decodeEncodedDescriptor(value: unknown): EncodedDurableDescriptor | null { + if ( + !isObject(value) || + !isNonNegativeInteger(value.version) || + !isBoundedJsonObject(value.body) + ) { + return null; + } + return Object.freeze({ version: value.version, body: freezeJsonObject(value.body) }); +} + +function isLifecycleState(value: unknown): value is DurableResourceLifecycleState { + return value === 'open' || value === 'completed'; +} + +function isDeviceKind(value: unknown): value is DeviceKind { + return value === 'simulator' || value === 'emulator' || value === 'device'; +} + +function isDeviceTarget(value: unknown): value is DeviceTarget { + return value === 'mobile' || value === 'tv' || value === 'desktop'; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts new file mode 100644 index 0000000000..c9ed7ba1be --- /dev/null +++ b/packages/capture-kit/src/index.ts @@ -0,0 +1,28 @@ +export { + createDurableResourceEnvelope, + decodeDurableResourceEnvelope, + encodeDurableDescriptor, +} from './durable-resource-envelope.ts'; +export { + createAppLogRecoveryOperations, + createAppLogStartResult, + decodeAppLogProcessMarker, +} from './app-log-runtime.ts'; +export { createAppLogLiveHandle, createAppLogLiveHandleFromFinish } from './app-log-live-handle.ts'; +export { + cleanupManagedAppLogProcess, + reattachCleanupOnlyAppLogProcess, +} from './app-log-process-recovery.ts'; +export { + createPidScopedAppLogRuntimeOwner, + resolveFirstNumericAppLogPid, +} from './app-log-pid-runtime.ts'; +export { appLogCommandSucceeded, bestEffortAppLogCheck } from './app-log-probe.ts'; +export { + appLogSessionArtifactsMatch, + assertAppLogSessionArtifacts, +} from './app-log-session-artifacts.ts'; +export { + createUnavailableAppLogBinding, + createUnavailableAppLogRuntimeOwner, +} from './app-log-unavailable-runtime.ts'; diff --git a/packages/capture-kit/tsconfig.json b/packages/capture-kit/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/capture-kit/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/contracts/src/android-adb-failure.test.ts b/packages/contracts/src/android-adb-failure.test.ts deleted file mode 100644 index 85e059f7e1..0000000000 --- a/packages/contracts/src/android-adb-failure.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { classifyAndroidAdbFailure } from './android-adb-failure.ts'; - -test('Android ADB failures keep transport on stderr and install verdicts on stdout', () => { - assert.equal( - classifyAndroidAdbFailure("adb server version (40) doesn't match this client (41); killing...") - ?.reason, - 'server_version_mismatch', - ); - assert.equal(classifyAndroidAdbFailure('', 'log line: device offline detected'), undefined); - assert.equal( - classifyAndroidAdbFailure('', 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]')?.reason, - 'install_update_incompatible', - ); -}); diff --git a/packages/contracts/src/android-adb-failure.ts b/packages/contracts/src/android-adb-failure.ts deleted file mode 100644 index 3c10b1b0bf..0000000000 --- a/packages/contracts/src/android-adb-failure.ts +++ /dev/null @@ -1,117 +0,0 @@ -type AndroidAdbFailureReason = - | 'timeout' - | 'device_offline' - | 'device_unauthorized' - | 'device_not_found' - | 'multiple_devices' - | 'no_devices' - | 'connection_dropped' - | 'server_version_mismatch' - | 'install_insufficient_storage' - | 'install_update_incompatible' - | 'install_version_downgrade' - | 'install_failed'; - -export type AndroidAdbFailureClassification = Readonly<{ - /** Machine-readable failure family attached to error details as `adbFailure`. */ - reason: AndroidAdbFailureReason; - hint: string; - /** Present only when an unchanged retry can succeed. */ - retriable?: boolean; -}>; - -type AndroidAdbFailureMatcher = AndroidAdbFailureClassification & - Readonly<{ - pattern: RegExp; - /** Android package-manager install verdicts may be emitted on stdout. */ - matchStdout?: boolean; - }>; - -const ANDROID_ADB_FAILURE_MATCHERS: readonly AndroidAdbFailureMatcher[] = [ - { - reason: 'device_unauthorized', - pattern: /device unauthorized|device still authorizing/, - hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.', - }, - { - reason: 'device_offline', - pattern: /device offline/, - hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.', - retriable: true, - }, - { - reason: 'multiple_devices', - pattern: /more than one (?:device\/emulator|device and emulator)/, - hint: 'Multiple Android devices are connected — pass --serial (see adb devices) to select one.', - }, - { - reason: 'no_devices', - pattern: /no devices\/emulators found|no devices found/, - hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.', - }, - { - reason: 'device_not_found', - pattern: /device (?:'[^']*' )?not found/, - hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.', - retriable: true, - }, - { - reason: 'server_version_mismatch', - pattern: /adb server version \(\d+\) doesn't match this client/, - hint: 'Multiple adb installs conflict — adb restarts its server automatically, so retry; align PATH to a single adb to stop recurrences.', - retriable: true, - }, - { - reason: 'connection_dropped', - pattern: /transport error|connection reset|broken pipe|protocol fault/, - hint: 'The adb connection dropped — retry; if it persists, run adb kill-server and reconnect the device.', - retriable: true, - }, - { - reason: 'install_insufficient_storage', - pattern: /install_failed_insufficient_storage/, - hint: 'The device is out of storage — free up space or uninstall unused apps, then retry the install.', - matchStdout: true, - }, - { - reason: 'install_update_incompatible', - pattern: /install_failed_update_incompatible/, - hint: 'The installed app has an incompatible signature — uninstall the existing app first, then retry the install.', - matchStdout: true, - }, - { - reason: 'install_version_downgrade', - pattern: /install_failed_version_downgrade/, - hint: 'The APK is older than the installed app — uninstall the app first (or install with downgrade allowed), then retry.', - matchStdout: true, - }, - { - reason: 'install_failed', - pattern: /install_failed_\w+|install_parse_failed_\w+/, - hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.', - matchStdout: true, - }, -]; - -export const ANDROID_ADB_TIMEOUT_FAILURE: AndroidAdbFailureClassification = Object.freeze({ - reason: 'timeout', - hint: 'adb timed out — the adb server may be wedged. Run adb kill-server && adb start-server, check adb devices, then retry.', -}); - -/** - * Classifies transport failures from stderr. Package-manager install verdicts - * additionally inspect stdout because adb emits those semantic results there. - */ -export function classifyAndroidAdbFailure( - stderr: string, - stdout = '', -): AndroidAdbFailureClassification | undefined { - const stderrText = stderr.toLowerCase(); - const stdoutText = stdout.toLowerCase(); - for (const { pattern, matchStdout, ...classification } of ANDROID_ADB_FAILURE_MATCHERS) { - if (pattern.test(stderrText) || (matchStdout && pattern.test(stdoutText))) { - return classification; - } - } - return undefined; -} diff --git a/packages/contracts/src/android-device.test.ts b/packages/contracts/src/android-device.test.ts deleted file mode 100644 index aedd93b64c..0000000000 --- a/packages/contracts/src/android-device.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { isAndroidEmulatorSerial, normalizeAndroidDeviceName } from './android-device.ts'; - -test('Android device identity normalizes AVD names and recognizes running emulator serials', () => { - assert.equal(normalizeAndroidDeviceName(' Pixel_9 Pro '), 'pixel 9 pro'); - assert.equal(isAndroidEmulatorSerial('emulator-5554'), true); - assert.equal(isAndroidEmulatorSerial('R58M123ABC'), false); -}); diff --git a/packages/contracts/src/android-device.ts b/packages/contracts/src/android-device.ts deleted file mode 100644 index 9c89522e80..0000000000 --- a/packages/contracts/src/android-device.ts +++ /dev/null @@ -1,9 +0,0 @@ -const ANDROID_EMULATOR_SERIAL_PREFIX = 'emulator-'; - -export function isAndroidEmulatorSerial(serial: string): boolean { - return serial.startsWith(ANDROID_EMULATOR_SERIAL_PREFIX); -} - -export function normalizeAndroidDeviceName(value: string): string { - return value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); -} diff --git a/packages/contracts/src/app-log-runtime.ts b/packages/contracts/src/app-log-runtime.ts new file mode 100644 index 0000000000..568830c5ef --- /dev/null +++ b/packages/contracts/src/app-log-runtime.ts @@ -0,0 +1,196 @@ +import type { LogBackend } from './logs.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + AppleToolHost, + HostCommandRequest, + HostCommandResult, + HostCommandRunner, + HostToolchainPreparer, +} from './platform-runtime-host.ts'; +import type { + DeviceRuntimeOwner, + ResourceOwnershipFence, + RuntimeProviderMode, + RuntimeOwnerRef, + RuntimePlatformModule, +} from './platform-runtime.ts'; +import type { PendingTransferGuard } from './async-lifecycle.ts'; +import { + type CleanupOutcome, + type LiveResourceHandle, + type ReattachOutcome, +} from './durable-resource.ts'; +import type { DurableResourceEnvelope } from './durable-resource-envelope.ts'; + +export const APP_LOG_RESOURCE_KIND = 'app-log' as const; + +export type AppLogLiveState = 'active' | 'recovering' | 'ended' | 'failed'; + +type AppLogInspection = Readonly<{ backend: LogBackend }>; + +type AppLogDoctorInput = Readonly<{ appBundleId?: string }>; + +type AppLogDoctorResult = Readonly<{ + backend: LogBackend; + checks: Readonly>; + notes: readonly string[]; +}>; + +export type AppLogLiveSnapshot = Readonly<{ + backend: LogBackend; + state: AppLogLiveState; + startedAt: number; +}>; + +export type AppLogCompletion = Readonly<{ + backend: LogBackend; + outputPath: string; + completedAt: number; +}>; + +/** Neutral session projection of a failed app-log start or live resource. */ +export type AppLogFailure = Readonly<{ + backend?: LogBackend; + code: string; + message: string; + hint?: string; +}>; + +export type AppLogLiveHandle = LiveResourceHandle & + Readonly<{ + inspect(): AppLogLiveSnapshot; + }>; + +export type AppLogStartInput = Readonly<{ + sessionId: string; + appBundleId: string; + outputPath: string; + pidPath?: string; + fence: ResourceOwnershipFence; +}>; + +export type AppLogStartResult = Readonly<{ + pendingHandle: PendingTransferGuard; + envelope: DurableResourceEnvelope; +}>; + +type AppLogReattachInput = Readonly<{ + envelope: DurableResourceEnvelope; +}>; + +type AppLogCleanupInput = AppLogReattachInput; + +export type AppLogRuntimeOperations = Readonly<{ + appLogInspect(): Promise; + appLogDoctor(input: AppLogDoctorInput): Promise; + appLogStart(input: AppLogStartInput): Promise; + appLogReattach( + input: AppLogReattachInput, + ): Promise>; + appLogCleanup(input: AppLogCleanupInput): Promise; +}>; + +export type AppLogOutputSink = AsyncDisposable & + Readonly<{ + write(chunk: string | Uint8Array): Promise; + }>; + +export type AppLogProcessMarker = Readonly<{ + pid: number; + startTime: string; + command: string; +}>; + +export type AppLogProcessMarkerReadOutcome = + | Readonly<{ status: 'missing' }> + | Readonly<{ status: 'invalid'; message: string }> + | Readonly<{ status: 'decoded'; marker: AppLogProcessMarker }>; + +export type AppLogProcessOwnership = 'missing' | 'owned-alive' | 'ownership-lost'; + +export type AppLogBackgroundProcess = AsyncDisposable & + Readonly<{ + marker?: AppLogProcessMarker; + wait: Promise; + terminate(): Promise; + }>; + +export type AppLogProcessCommand = + | Readonly<{ + kind: 'host'; + request: HostCommandRequest; + }> + | Readonly<{ + kind: 'android-adb'; + serial: string; + args: readonly string[]; + options?: Pick; + }>; + +export type AppLogBackgroundProcessRequest = Readonly<{ + command: AppLogProcessCommand; + output: AppLogOutputSink; + markerPath?: string; +}>; + +export type AppLogProcessStart = ( + request: AppLogBackgroundProcessRequest, + signal?: AbortSignal, +) => Promise; + +/** Device-bound process transport selected without turning a narrow provider into a runtime owner. */ +export type AppLogProcessTransport = Readonly<{ + mode: Extract; + /** Absent when the selected narrow transport cannot stream app logs. */ + start?: AppLogProcessStart; +}>; + +/** Canonical daemon-owned artifact paths for one durable app-log session. */ +export type AppLogSessionArtifacts = Readonly<{ + outputPath: string; + pidPath: string; +}>; + +export type AppLogRuntimeHost = Readonly<{ + commands: HostCommandRunner; + appleTools: AppleToolHost; + toolchains: HostToolchainPreparer; + artifacts: Readonly<{ + resolveSession(sessionId: string): AppLogSessionArtifacts; + }>; + outputs: Readonly<{ + openAppend(path: string): Promise; + /** Reads a bounded suffix after the host has confined the path to its session root. */ + readTail(path: string, maxBytes: number): Promise; + }>; + processTransports: Readonly<{ + resolve(device: DeviceInfo): Promise; + }>; + processes: Readonly<{ + start( + request: AppLogBackgroundProcessRequest, + signal?: AbortSignal, + ): Promise; + readMarker(path: string): Promise; + clearMarker(path: string): Promise; + inspect(marker: AppLogProcessMarker): Promise; + terminate( + marker: AppLogProcessMarker, + ): Promise<'terminated' | 'already-missing' | 'ownership-lost'>; + }>; + clock: Readonly<{ + now(): number; + sleep(milliseconds: number, signal?: AbortSignal): Promise; + }>; +}>; + +export type AppLogRuntimePlatformModule = RuntimePlatformModule< + AppLogRuntimeOperations, + AppLogRuntimeHost +>; + +/** Cheap provider metadata stays eager; provider mechanics load only after selection. */ +export type AppLogRuntimeProviderModule = Readonly<{ + owner: Extract; + loadRuntime(host: AppLogRuntimeHost): Promise>; +}>; diff --git a/packages/contracts/src/async-lifecycle.test.ts b/packages/contracts/src/async-lifecycle.test.ts new file mode 100644 index 0000000000..0e4efe2f81 --- /dev/null +++ b/packages/contracts/src/async-lifecycle.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AsyncCleanupStack, PendingTransferGuard } from './async-lifecycle.ts'; + +function disposable(run: () => void | Promise): AsyncDisposable { + return { + [Symbol.asyncDispose]: async () => await run(), + }; +} + +test('async cleanup stack disposes once in reverse acquisition order', async () => { + const events: string[] = []; + const stack = new AsyncCleanupStack(); + stack.use( + disposable(() => { + events.push('first'); + }), + ); + stack.defer(async () => { + events.push('second'); + }); + + await stack[Symbol.asyncDispose](); + await stack[Symbol.asyncDispose](); + + assert.deepEqual(events, ['second', 'first']); + assert.equal(stack.disposed, true); + assert.throws(() => stack.defer(async () => {}), /after disposal/); +}); + +test('async cleanup stack attempts every cleanup and retains every failure', async () => { + const events: string[] = []; + const stack = new AsyncCleanupStack(); + stack.defer(async () => { + events.push('first'); + throw new Error('first failed'); + }); + stack.defer(async () => { + events.push('second'); + throw new Error('second failed'); + }); + + await assert.rejects( + async () => await stack[Symbol.asyncDispose](), + (error) => + error instanceof AggregateError && + error.errors.map(String).join('\n').includes('first failed') && + error.errors.map(String).join('\n').includes('second failed'), + ); + assert.deepEqual(events, ['second', 'first']); +}); + +test('pending transfer guard cleans only unadopted handles', async () => { + let pendingCleanupCount = 0; + const pending = new PendingTransferGuard( + disposable(() => { + pendingCleanupCount += 1; + }), + ); + await pending[Symbol.asyncDispose](); + await pending[Symbol.asyncDispose](); + assert.equal(pendingCleanupCount, 1); + assert.equal(pending.pending, false); + + let adoptedCleanupCount = 0; + const adopted = new PendingTransferGuard( + disposable(() => { + adoptedCleanupCount += 1; + }), + ); + const handle = adopted.transfer(); + await adopted[Symbol.asyncDispose](); + assert.equal(adoptedCleanupCount, 0); + await handle[Symbol.asyncDispose](); + assert.equal(adoptedCleanupCount, 1); + assert.throws(() => adopted.transfer(), /transferred state/); +}); diff --git a/packages/contracts/src/async-lifecycle.ts b/packages/contracts/src/async-lifecycle.ts new file mode 100644 index 0000000000..a1d0605d40 --- /dev/null +++ b/packages/contracts/src/async-lifecycle.ts @@ -0,0 +1,79 @@ +/** One cleanup transaction shared by request scopes and platform bind rollback. */ +export class AsyncCleanupStack implements AsyncDisposable { + readonly #cleanups: Array<() => Promise> = []; + #state: 'open' | 'disposing' | 'disposed' = 'open'; + + get disposed(): boolean { + return this.#state === 'disposed'; + } + + defer(cleanup: () => Promise): void { + this.#assertOpen(); + this.#cleanups.push(cleanup); + } + + use(value: Value): Value { + this.defer(async () => await value[Symbol.asyncDispose]()); + return value; + } + + async [Symbol.asyncDispose](): Promise { + if (this.#state === 'disposed') return; + if (this.#state === 'disposing') { + throw new TypeError('Async cleanup stack disposal is already in progress'); + } + this.#state = 'disposing'; + const failures: unknown[] = []; + while (this.#cleanups.length > 0) { + const cleanup = this.#cleanups.pop(); + if (!cleanup) continue; + try { + await cleanup(); + } catch (error) { + failures.push(error); + } + } + this.#state = 'disposed'; + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, 'Multiple asynchronous cleanups failed'); + } + } + + #assertOpen(): void { + if (this.#state !== 'open') { + throw new TypeError('Cannot register cleanup after disposal has begun'); + } + } +} + +/** + * Owns a newly published handle until command orchestration adopts it exactly once. + * Disposing a transferred guard is inert; disposing a pending guard cleans the handle. + */ +export class PendingTransferGuard implements AsyncDisposable { + readonly #value: Value; + #state: 'pending' | 'transferred' | 'disposed' = 'pending'; + + constructor(value: Value) { + this.#value = value; + } + + get pending(): boolean { + return this.#state === 'pending'; + } + + transfer(): Value { + if (this.#state !== 'pending') { + throw new TypeError(`Cannot transfer handle in ${this.#state} state`); + } + this.#state = 'transferred'; + return this.#value; + } + + async [Symbol.asyncDispose](): Promise { + if (this.#state !== 'pending') return; + this.#state = 'disposed'; + await this.#value[Symbol.asyncDispose](); + } +} diff --git a/packages/contracts/src/command-platform-execution.test.ts b/packages/contracts/src/command-platform-execution.test.ts index a0cf329923..608e8071de 100644 --- a/packages/contracts/src/command-platform-execution.test.ts +++ b/packages/contracts/src/command-platform-execution.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'vitest'; +import { appLogRuntimePlanUses } from './logs-runtime-plan.ts'; import { inventoryUse } from './platform-module.ts'; import { assertCommandPlatformExecution } from './command-platform-execution.ts'; @@ -22,4 +23,19 @@ describe('command platform execution declaration', () => { ])('rejects neither, mixed, widened, duplicate, or overlapping declarations: %j', (value) => { expect(() => assertCommandPlatformExecution(value)).toThrow(/exactly one/); }); + + test('accepts an exhaustive non-empty set of input-dependent runtime uses', () => { + expect(() => + assertCommandPlatformExecution({ kind: 'device-runtime', uses: appLogRuntimePlanUses }), + ).not.toThrow(); + }); + + test.each([ + { kind: 'device-runtime', uses: [] }, + { kind: 'device-runtime', use: appLogRuntimePlanUses[0], uses: appLogRuntimePlanUses }, + { kind: 'device-runtime', uses: [appLogRuntimePlanUses[0], appLogRuntimePlanUses[0]] }, + { kind: 'device-runtime', uses: [{ required: ['appLogStart'], preferred: ['appLogStart'] }] }, + ])('rejects empty, duplicate, overlapping, or both-form runtime uses: %j', (value) => { + expect(() => assertCommandPlatformExecution(value)).toThrow(/exactly one/); + }); }); diff --git a/packages/contracts/src/command-platform-execution.ts b/packages/contracts/src/command-platform-execution.ts index e28699eac1..65d6b6f9ef 100644 --- a/packages/contracts/src/command-platform-execution.ts +++ b/packages/contracts/src/command-platform-execution.ts @@ -1,15 +1,14 @@ import type { InventoryUse } from './platform-module.ts'; - -/** Serializable operation requirements owned by a runtime-backed command descriptor. */ -export type RuntimeUseDeclaration = Readonly<{ - required: readonly string[]; - preferred: readonly string[]; -}>; +import type { RuntimeUseDeclaration } from './platform-runtime.ts'; export type CommandPlatformExecution = | Readonly<{ kind: 'legacy' }> | Readonly<{ kind: 'inventory'; use: InventoryUse }> - | Readonly<{ kind: 'device-runtime'; use: RuntimeUseDeclaration }>; + | Readonly<{ kind: 'device-runtime'; use: RuntimeUseDeclaration }> + | Readonly<{ + kind: 'device-runtime'; + uses: readonly [RuntimeUseDeclaration, ...RuntimeUseDeclaration[]]; + }>; // The discriminated union cannot prove uniqueness or required/preferred disjointness inside // readonly arrays. Validate those declaration invariants where descriptors enter the registry. @@ -34,9 +33,32 @@ export function assertCommandPlatformExecution( ) { return; } + if ( + declaration['kind'] === 'device-runtime' && + sameKeys(keys, ['kind', 'uses']) && + hasRuntimeUseDeclarations(declaration['uses']) + ) { + return; + } throw invalidPlatformExecution(); } +function hasRuntimeUseDeclarations( + value: unknown, +): value is readonly [RuntimeUseDeclaration, ...RuntimeUseDeclaration[]] { + if (!Array.isArray(value) || value.length === 0) return false; + if (!value.every(hasRuntimeUseDeclaration)) return false; + const identities = value.map(runtimeUseIdentity); + return new Set(identities).size === identities.length; +} + +function runtimeUseIdentity(use: RuntimeUseDeclaration): string { + return JSON.stringify({ + required: [...use.required].sort(), + preferred: [...use.preferred].sort(), + }); +} + function hasExactInventoryUse(value: unknown): boolean { if (value === null || typeof value !== 'object') return false; const use = value as Record; diff --git a/packages/contracts/src/durable-resource-envelope.ts b/packages/contracts/src/durable-resource-envelope.ts new file mode 100644 index 0000000000..b7de846fcc --- /dev/null +++ b/packages/contracts/src/durable-resource-envelope.ts @@ -0,0 +1,46 @@ +import type { DeviceIdentity } from '@agent-device/kernel/device'; +import type { JsonObject } from './json.ts'; +import type { ResourceOwnershipFence, RuntimeOwnerRef } from './platform-runtime.ts'; + +export type DurableResourceLifecycleState = 'open' | 'completed'; + +export type EncodedDurableDescriptor = Readonly<{ + version: number; + body: JsonObject; +}>; + +/** Persisted neutral coordinates. Live handles and platform objects are deliberately absent. */ +export type DurableResourceEnvelope = Readonly<{ + envelopeVersion: 1; + resourceKind: ResourceKind; + sessionId: string; + device: DeviceIdentity; + owner: RuntimeOwnerRef; + fence: ResourceOwnershipFence; + lifecycle: DurableResourceLifecycleState; + descriptor: EncodedDurableDescriptor; + metadata?: JsonObject; +}>; + +export type DurableEnvelopeDecodeOutcome = + | Readonly<{ status: 'decoded'; envelope: DurableResourceEnvelope }> + | Readonly<{ + status: 'unreattachable'; + reason: 'descriptor-invalid' | 'descriptor-version-unsupported'; + message: string; + version?: number; + }>; + +export type DurableDescriptorCodec< + Descriptor extends object, + ResourceKind extends string, +> = Readonly<{ + resourceKind: ResourceKind; + version: number; + encode(descriptor: Descriptor): JsonObject; + decode(body: JsonObject): DurableDescriptorBodyDecodeOutcome; +}>; + +export type DurableDescriptorBodyDecodeOutcome = + | Readonly<{ status: 'decoded'; descriptor: Descriptor }> + | Readonly<{ status: 'invalid'; message: string }>; diff --git a/packages/contracts/src/durable-resource.test.ts b/packages/contracts/src/durable-resource.test.ts new file mode 100644 index 0000000000..b036a65fe5 --- /dev/null +++ b/packages/contracts/src/durable-resource.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { isConfirmedCleanup } from './durable-resource.ts'; + +test('cleanup confirmation distinguishes terminal cleanup from retained evidence', () => { + assert.equal(isConfirmedCleanup({ status: 'cleaned' }), true); + assert.equal( + isConfirmedCleanup({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + false, + ); +}); diff --git a/packages/contracts/src/durable-resource.ts b/packages/contracts/src/durable-resource.ts new file mode 100644 index 0000000000..04d58b7022 --- /dev/null +++ b/packages/contracts/src/durable-resource.ts @@ -0,0 +1,53 @@ +export type CleanupPendingReason = + | 'ownership-fence-lost' + | 'owner-unavailable' + | 'transport-failed' + | 'cleanup-unconfirmed' + | 'manual-recovery-required'; + +export type CleanupOutcome = + | Readonly<{ status: 'cleaned' }> + | Readonly<{ status: 'already-missing' }> + | Readonly<{ + status: 'cleanup-pending'; + reason: CleanupPendingReason; + message?: string; + }>; + +export type FinishOutcome = + | Readonly<{ status: 'completed'; result: Result; alreadyCompleted?: boolean }> + | Readonly<{ + status: 'cleanup-pending'; + reason: CleanupPendingReason; + message?: string; + }>; + +/** Common lifecycle shape implemented by a facet-specific live handle contract. */ +export type LiveResourceHandle = AsyncDisposable & + Readonly<{ + finish(): Promise>; + forceCleanup(): Promise; + }>; + +export type ResourceUnreattachableReason = + | 'descriptor-invalid' + | 'descriptor-version-unsupported' + | 'owner-unavailable' + | 'transport-not-reattachable' + | 'ownership-fence-lost'; + +export type ReattachOutcome = + | Readonly<{ status: 'active'; handle: Handle }> + | Readonly<{ status: 'completed'; result: Result }> + | Readonly<{ status: 'missing' }> + | Readonly<{ + status: 'unreattachable'; + reason: ResourceUnreattachableReason; + message?: string; + }>; + +export function isConfirmedCleanup( + outcome: CleanupOutcome, +): outcome is Extract { + return outcome.status === 'cleaned' || outcome.status === 'already-missing'; +} diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index f0ae86763a..d8f9d7047e 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -1,7 +1,4 @@ export type { TriggerAppEventCommandResult } from '../app-events.ts'; -export { ANDROID_ADB_TIMEOUT_FAILURE, classifyAndroidAdbFailure } from '../android-adb-failure.ts'; -export type { AndroidAdbFailureClassification } from '../android-adb-failure.ts'; -export { isAndroidEmulatorSerial, normalizeAndroidDeviceName } from '../android-device.ts'; export { DEFAULT_APPS_FILTER, assertResolvedAppsFilter, diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index 42bf50eac0..7d23384bf1 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -34,10 +34,82 @@ export { } from '../audio-probe-support.ts'; export type { PlatformPlugin } from '../platform-plugin.ts'; export { assertCommandPlatformExecution } from '../command-platform-execution.ts'; +export type { CommandPlatformExecution } from '../command-platform-execution.ts'; +export { AsyncCleanupStack, PendingTransferGuard } from '../async-lifecycle.ts'; +export { + localRuntimeOwner, + narrowDeviceBinding, + providerRuntimeOwner, + runtimeOwnerKey, + runtimeUse, + sameRuntimeOwner, +} from '../platform-runtime.ts'; export type { - CommandPlatformExecution, + BoundDeviceRuntime, + DeviceBinding, + DeviceBindingIntent, + DeviceBindingRequest, + DeviceRuntimeGateway, + DeviceRuntimeOwner, + ResourceOwnershipFence, + RuntimeDeviceShape, + RuntimeFacts, + RuntimeOperationFact, + RuntimeOperationKey, + RuntimeOperationUnavailability, + RuntimeOwnerRef, + RuntimePlatformModule, + RuntimeProviderMode, + RuntimeUse, RuntimeUseDeclaration, -} from '../command-platform-execution.ts'; +} from '../platform-runtime.ts'; +export type { + DurableDescriptorBodyDecodeOutcome, + DurableDescriptorCodec, + DurableEnvelopeDecodeOutcome, + DurableResourceEnvelope, + DurableResourceLifecycleState, + EncodedDurableDescriptor, +} from '../durable-resource-envelope.ts'; +export { isConfirmedCleanup } from '../durable-resource.ts'; +export type { + CleanupOutcome, + CleanupPendingReason, + FinishOutcome, + LiveResourceHandle, + ReattachOutcome, + ResourceUnreattachableReason, +} from '../durable-resource.ts'; +export { APP_LOG_RESOURCE_KIND } from '../app-log-runtime.ts'; +export type { + AppLogBackgroundProcess, + AppLogBackgroundProcessRequest, + AppLogCompletion, + AppLogFailure, + AppLogLiveHandle, + AppLogLiveSnapshot, + AppLogLiveState, + AppLogOutputSink, + AppLogProcessMarker, + AppLogProcessMarkerReadOutcome, + AppLogProcessOwnership, + AppLogProcessCommand, + AppLogProcessStart, + AppLogProcessTransport, + AppLogRuntimeHost, + AppLogRuntimeOperations, + AppLogRuntimePlatformModule, + AppLogRuntimeProviderModule, + AppLogSessionArtifacts, + AppLogStartInput, + AppLogStartResult, +} from '../app-log-runtime.ts'; +export { + appLogAdmissionUse, + appLogRuntimePlanUses, + resolveLogsRuntimePlan, +} from '../logs-runtime-plan.ts'; +export type { LogsRuntimePlan, LogsRuntimePlanInput } from '../logs-runtime-plan.ts'; export type { PlatformGatedProviderResolverKey } from '../platform-providers.ts'; export type { RunnerLogicalLeaseContext } from '../runner-lease-context.ts'; export type { diff --git a/packages/contracts/src/logs-runtime-cutover.test.ts b/packages/contracts/src/logs-runtime-cutover.test.ts new file mode 100644 index 0000000000..4249db40ef --- /dev/null +++ b/packages/contracts/src/logs-runtime-cutover.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { assertLogsRuntimeExecution } from './logs-runtime-cutover.ts'; +import { appLogRuntimePlanUses, resolveLogsRuntimePlan } from './logs-runtime-plan.ts'; + +const appLogInspectUse = resolveLogsRuntimePlan({ action: 'path' }).use; +const appLogDoctorUse = resolveLogsRuntimePlan({ action: 'doctor' }).use; +const appLogStartUse = resolveLogsRuntimePlan({ action: 'start' }).use; + +test('logs descriptor execution joins exactly to the seven normalized plans', () => { + assert.doesNotThrow(() => + assertLogsRuntimeExecution({ kind: 'device-runtime', uses: appLogRuntimePlanUses }), + ); +}); + +test.each([ + { name: 'static superset', execution: { kind: 'device-runtime', use: appLogStartUse } }, + { + name: 'missing plan use', + execution: { + kind: 'device-runtime', + uses: [appLogInspectUse, appLogStartUse], + }, + }, + { + name: 'unreferenced use', + execution: { + kind: 'device-runtime', + uses: [ + appLogInspectUse, + appLogDoctorUse, + appLogStartUse, + { required: ['appLogCleanup'], preferred: [] }, + ], + }, + }, +])('rejects a planted $name declaration', ({ execution }) => { + assert.throws(() => assertLogsRuntimeExecution(execution), /seven plans/); +}); diff --git a/packages/contracts/src/logs-runtime-cutover.ts b/packages/contracts/src/logs-runtime-cutover.ts new file mode 100644 index 0000000000..7977619c67 --- /dev/null +++ b/packages/contracts/src/logs-runtime-cutover.ts @@ -0,0 +1,32 @@ +import { + assertCommandPlatformExecution, + type CommandPlatformExecution, +} from './command-platform-execution.ts'; +import { appLogRuntimePlanUses } from './logs-runtime-plan.ts'; +import type { RuntimeUseDeclaration } from './platform-runtime.ts'; + +/** Joins the input-dependent logs plans to one exhaustive descriptor declaration. */ +export function assertLogsRuntimeExecution( + value: unknown, +): asserts value is Extract { + assertCommandPlatformExecution(value); + if (value.kind !== 'device-runtime' || !('uses' in value)) throw invalidLogsExecution(); + const declared = new Set(value.uses.map(runtimeUseIdentity)); + const planned = new Set(appLogRuntimePlanUses.map(runtimeUseIdentity)); + if (declared.size !== planned.size || [...declared].some((identity) => !planned.has(identity))) { + throw invalidLogsExecution(); + } +} + +function runtimeUseIdentity(use: RuntimeUseDeclaration): string { + return JSON.stringify({ + required: [...use.required].sort(), + preferred: [...use.preferred].sort(), + }); +} + +function invalidLogsExecution(): TypeError { + return new TypeError( + 'Logs runtime execution must declare exactly the distinct uses selected by its seven plans', + ); +} diff --git a/packages/contracts/src/logs-runtime-plan.test.ts b/packages/contracts/src/logs-runtime-plan.test.ts new file mode 100644 index 0000000000..f30fa0dce1 --- /dev/null +++ b/packages/contracts/src/logs-runtime-plan.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + appLogAdmissionUse, + appLogRuntimePlanUses, + resolveLogsRuntimePlan, +} from './logs-runtime-plan.ts'; + +const appLogInspectUse = resolveLogsRuntimePlan({ action: 'path' }).use; +const appLogDoctorUse = resolveLogsRuntimePlan({ action: 'doctor' }).use; + +test('normalizes all seven logs plans with their exact non-superset runtime use', () => { + const startUse = resolveLogsRuntimePlan({ action: 'start' }).use; + assert.deepEqual(resolveLogsRuntimePlan({}), { + kind: 'path', + requiresAppSession: false, + use: appLogInspectUse, + }); + assert.deepEqual(resolveLogsRuntimePlan({ action: 'start' }), { + kind: 'start', + requiresAppSession: true, + use: startUse, + }); + assert.deepEqual(resolveLogsRuntimePlan({ action: 'stop' }), { + kind: 'stop', + requiresAppSession: false, + use: appLogInspectUse, + }); + assert.deepEqual(resolveLogsRuntimePlan({ action: 'doctor' }), { + kind: 'doctor', + requiresAppSession: false, + use: appLogDoctorUse, + }); + assert.deepEqual(resolveLogsRuntimePlan({ action: 'mark', marker: 'checkpoint' }), { + kind: 'mark', + marker: 'checkpoint', + requiresAppSession: false, + use: appLogInspectUse, + }); + assert.deepEqual(resolveLogsRuntimePlan({ action: 'clear' }), { + kind: 'clear', + requiresAppSession: false, + use: appLogInspectUse, + }); + assert.deepEqual(resolveLogsRuntimePlan({ action: 'clear', restart: true }), { + kind: 'clear-restart', + requiresAppSession: true, + use: startUse, + }); + assert.deepEqual(appLogRuntimePlanUses, [appLogInspectUse, appLogDoctorUse, startUse]); + assert.deepEqual(appLogDoctorUse, { + required: ['appLogInspect', 'appLogDoctor'], + preferred: [], + }); + assert.deepEqual(startUse, { + required: ['appLogInspect', 'appLogStart'], + preferred: [], + }); +}); + +test('keeps fact-derived admission separate from every execution plan', () => { + assert.deepEqual(appLogAdmissionUse, { + required: [], + preferred: ['appLogInspect'], + }); + assert.equal((appLogRuntimePlanUses as readonly object[]).includes(appLogAdmissionUse), false); +}); + +test('rejects unknown actions and restart outside clear with typed invalid arguments', () => { + for (const input of [ + { action: 'wat' }, + { action: 'path', restart: true }, + { action: 'start', restart: true }, + ]) { + assert.throws( + () => resolveLogsRuntimePlan(input), + (error) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); + } +}); diff --git a/packages/contracts/src/logs-runtime-plan.ts b/packages/contracts/src/logs-runtime-plan.ts new file mode 100644 index 0000000000..1e4a529472 --- /dev/null +++ b/packages/contracts/src/logs-runtime-plan.ts @@ -0,0 +1,76 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { AppLogRuntimeOperations } from './app-log-runtime.ts'; +import { runtimeUse } from './platform-runtime.ts'; + +const appLogUse = runtimeUse(); + +const appLogInspectUse = appLogUse({ required: ['appLogInspect'] }); +const appLogDoctorUse = appLogUse({ required: ['appLogInspect', 'appLogDoctor'] }); +const appLogStartUse = appLogUse({ required: ['appLogInspect', 'appLogStart'] }); + +/** Fact-only admission probe; it is deliberately not an execution-plan variant. */ +export const appLogAdmissionUse = appLogUse({ + required: [], + preferred: ['appLogInspect'], +}); + +/** The exhaustive distinct use set declared by the input-dependent logs descriptor. */ +export const appLogRuntimePlanUses = Object.freeze([ + appLogInspectUse, + appLogDoctorUse, + appLogStartUse, +] as const); + +export type LogsRuntimePlan = + | Readonly<{ kind: 'path'; requiresAppSession: false; use: typeof appLogInspectUse }> + | Readonly<{ kind: 'start'; requiresAppSession: true; use: typeof appLogStartUse }> + | Readonly<{ kind: 'stop'; requiresAppSession: false; use: typeof appLogInspectUse }> + | Readonly<{ kind: 'doctor'; requiresAppSession: false; use: typeof appLogDoctorUse }> + | Readonly<{ + kind: 'mark'; + marker: string; + requiresAppSession: false; + use: typeof appLogInspectUse; + }> + | Readonly<{ kind: 'clear'; requiresAppSession: false; use: typeof appLogInspectUse }> + | Readonly<{ kind: 'clear-restart'; requiresAppSession: true; use: typeof appLogStartUse }>; + +export type LogsRuntimePlanInput = Readonly<{ + action?: string; + restart?: boolean; + marker?: string; +}>; + +export function resolveLogsRuntimePlan(input: LogsRuntimePlanInput): LogsRuntimePlan { + const action = (input.action ?? 'path').toLowerCase(); + if (input.restart && action !== 'clear') { + throw new AppError('INVALID_ARGS', 'logs --restart is only supported with logs clear'); + } + switch (action) { + case 'path': + return Object.freeze({ kind: 'path', requiresAppSession: false, use: appLogInspectUse }); + case 'start': + return Object.freeze({ kind: 'start', requiresAppSession: true, use: appLogStartUse }); + case 'stop': + return Object.freeze({ kind: 'stop', requiresAppSession: false, use: appLogInspectUse }); + case 'doctor': + return Object.freeze({ kind: 'doctor', requiresAppSession: false, use: appLogDoctorUse }); + case 'mark': + return Object.freeze({ + kind: 'mark', + marker: input.marker ?? '', + requiresAppSession: false, + use: appLogInspectUse, + }); + case 'clear': + return input.restart + ? Object.freeze({ + kind: 'clear-restart', + requiresAppSession: true, + use: appLogStartUse, + }) + : Object.freeze({ kind: 'clear', requiresAppSession: false, use: appLogInspectUse }); + default: + throw new AppError('INVALID_ARGS', 'logs requires path, start, stop, doctor, mark, or clear'); + } +} diff --git a/packages/contracts/src/platform-plugin.ts b/packages/contracts/src/platform-plugin.ts index fadc072eff..efb86badbb 100644 --- a/packages/contracts/src/platform-plugin.ts +++ b/packages/contracts/src/platform-plugin.ts @@ -1,5 +1,4 @@ import type { DeviceInfo, Platform, PlatformSelector } from '@agent-device/kernel/device'; -import type { LogBackend } from './logs.ts'; import type { RecordingBackendTag } from './recording.ts'; import type { PerfMetricsSamplerTag } from './perf.ts'; import type { PlatformGatedProviderResolverKey } from './platform-providers.ts'; @@ -25,8 +24,6 @@ type CapabilityBucket = 'apple' | 'android' | 'harmonyos' | 'vega' | 'linux' | ' * populated by wrapping the existing daemon branch AND pinned by a table-equivalence * parity test before a real call-site routes through it. A facet's type stays * PLATFORM-NEUTRAL and daemon-owned (never the iOS-simulator-shaped provider seam): - * {@link PlatformPlugin.appLog} carries the neutral {@link LogBackend} resolver - * (wraps `resolveLogBackend`, pinned by the daemon app-log routing parity test); * {@link PlatformPlugin.perf} carries the neutral perf-metrics support predicate * (wraps `supportsPlatformPerfMetrics`) plus the neutral {@link PerfMetricsSamplerTag} * resolver (wraps the per-platform metrics-sampling branch formerly open-coded in @@ -73,17 +70,6 @@ export type PlatformPlugin = { Record string | undefined> >; }; - /** - * The daemon app-log facet (issue #974). `resolveBackend` wraps the platform - * branch of `src/daemon/app-log.ts`'s `resolveLogBackend`, returning the neutral - * {@link LogBackend} tag for `device`. Present only on families that have an - * app-log backend (Apple + Android); left `undefined` for linux/web, where the - * hand branch historically fell through to the `'android'` default — the daemon - * lookup preserves that fallthrough, and the parity test pins the equivalence. - */ - readonly appLog?: { - resolveBackend(device: DeviceInfo): LogBackend; - }; /** * The daemon perf facet (issue #974). `supportsMetrics` wraps the platform * predicate `supportsPlatformPerfMetrics` in @@ -114,7 +100,7 @@ export type PlatformPlugin = { * per-platform branch of `resolveRecordingBackendForDevice` * (src/daemon/handlers/record-trace-recording-backends.ts), returning the neutral * {@link RecordingBackendTag} for `device` (a DATA-ONLY string, type-only in the - * plugin — exactly like {@link appLog}'s {@link LogBackend}). The daemon maps the tag + * plugin). The daemon maps the tag * back to its own {@link RecordingBackend} instance, so core/platforms never construct * the daemon-owned backend objects. Present on families with a recording backend * (Apple + Android + web); left `undefined` for linux, where the hand branch fell @@ -132,9 +118,9 @@ export type PlatformPlugin = { * src/daemon/request-platform-providers.ts. The daemon still OWNS the resolver * functions, their wrapper composition, and the request-scope concurrency isolation; * this facet supplies only the per-family gate (a plain string list, the keys - * type-only in the plugin). The ungated resolvers (`appLogProvider` / - * `recordingProvider`, which apply on every platform) are intentionally NOT part of - * the facet and stay ungated in the daemon. Every family carries this facet (each + * type-only in the plugin). The ungated `recordingProvider`, which applies on every + * platform, is intentionally NOT part of the facet and stays ungated in the daemon. + * Every family carries this facet (each * owns at least one platform-specific resolver); a device on an unregistered platform * resolves to no gated resolvers, matching the former hand gate. Pinned by the * providers routing parity test. diff --git a/packages/contracts/src/platform-runtime.test.ts b/packages/contracts/src/platform-runtime.test.ts new file mode 100644 index 0000000000..daf12cec25 --- /dev/null +++ b/packages/contracts/src/platform-runtime.test.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + localRuntimeOwner, + narrowDeviceBinding, + providerRuntimeOwner, + runtimeOwnerKey, + runtimeUse, + sameRuntimeOwner, + type BoundDeviceRuntime, + type DeviceBinding, +} from './platform-runtime.ts'; + +type TestOperations = { + inspect: (input: Readonly<{ depth: number }>) => Promise>; + inspectFast: () => Promise>; + mutate: (input: Readonly<{ value: string }>) => Promise; +}; + +const inspectUse = runtimeUse()({ + required: ['inspect'], + preferred: ['inspectFast'], +}); + +function compileTimeNarrowingProof(runtime: BoundDeviceRuntime): void { + const required: TestOperations['inspect'] = runtime.operations.inspect; + const preferred: TestOperations['inspectFast'] | undefined = runtime.operations.inspectFast; + void required; + void preferred; + + // @ts-expect-error An undeclared sibling operation cannot cross the selected projection. + void runtime.operations.mutate; + // @ts-expect-error The selected runtime is intentionally not disposable by a handler. + void runtime[Symbol.asyncDispose]; +} +void compileTimeNarrowingProof; + +function compileTimeDisjointProof(): void { + // @ts-expect-error Required and preferred keys are statically disjoint. + runtimeUse()({ required: ['inspect'], preferred: ['inspect'] }); +} +void compileTimeDisjointProof; + +test('runtime use freezes declarations and rejects dynamic overlap or duplicates', () => { + assert.deepEqual(inspectUse, { + required: ['inspect'], + preferred: ['inspectFast'], + }); + assert.ok(Object.isFrozen(inspectUse)); + assert.ok(Object.isFrozen(inspectUse.required)); + assert.ok(Object.isFrozen(inspectUse.preferred)); + + const dynamic = runtimeUse(); + assert.throws( + () => + dynamic({ + required: ['inspect'], + preferred: ['inspect'] as unknown as readonly ['inspectFast'], + }), + /both required and preferred/, + ); + assert.throws( + () => + dynamic({ + required: ['inspect', 'inspect'] as const, + }), + /duplicate required/, + ); +}); + +test('runtime owner keys distinguish local families and configured provider instances', () => { + assert.equal(runtimeOwnerKey(localRuntimeOwner('apple')), 'local:apple'); + assert.notEqual( + runtimeOwnerKey(providerRuntimeOwner('webdriver', 'tenant-a')), + runtimeOwnerKey(providerRuntimeOwner('webdriver', 'tenant-b')), + ); + assert.throws(() => providerRuntimeOwner('webdriver', ' '), /non-empty/); + assert.notEqual( + runtimeOwnerKey(providerRuntimeOwner('a:b', 'c')), + runtimeOwnerKey(providerRuntimeOwner('a', 'b:c')), + ); + assert.equal( + sameRuntimeOwner( + providerRuntimeOwner('webdriver', 'tenant-a'), + providerRuntimeOwner('webdriver', 'tenant-a'), + ), + true, + ); +}); + +test('binding narrowing proves required operations and omits unavailable preferred operations', () => { + const binding = testBinding({ + inspect: { available: true }, + inspectFast: { available: false, reason: 'owner-capability-missing' }, + mutate: { available: true }, + }); + const runtime = narrowDeviceBinding(binding, inspectUse); + + assert.equal(runtime.operations.inspect, binding.operations.inspect); + assert.equal(runtime.operations.inspectFast, undefined); + assert.deepEqual(runtime.facts.inspectFast, { + available: false, + reason: 'owner-capability-missing', + }); + assert.equal('mutate' in runtime.operations, false); +}); + +test('binding narrowing fails closed on unsupported or falsely advertised required operations', () => { + const unsupported = testBinding({ + inspect: { available: false, reason: 'unsupported-device-kind' }, + inspectFast: { available: false, reason: 'owner-capability-missing' }, + mutate: { available: true }, + }); + assert.throws( + () => narrowDeviceBinding(unsupported, inspectUse), + (error) => error instanceof AppError && error.code === 'UNSUPPORTED_OPERATION', + ); + + const missing = testBinding({ + inspect: { available: true }, + inspectFast: { available: false, reason: 'owner-capability-missing' }, + mutate: { available: true }, + }); + delete (missing.operations as Partial).inspect; + assert.throws( + () => narrowDeviceBinding(missing, inspectUse), + (error) => error instanceof AppError && error.details?.reason === 'runtime-contract-invalid', + ); +}); + +function testBinding( + facts: DeviceBinding['facts']['operations'], +): DeviceBinding { + return { + device: { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, + }, + owner: localRuntimeOwner('android'), + facts: { + device: { family: 'android', kind: 'emulator', providerMode: 'local' }, + operations: facts, + }, + operations: { + inspect: async () => ({ nodes: 1 }), + inspectFast: async () => ({ nodes: 1 }), + mutate: async () => undefined, + }, + [Symbol.asyncDispose]: async () => undefined, + }; +} diff --git a/packages/contracts/src/platform-runtime.ts b/packages/contracts/src/platform-runtime.ts new file mode 100644 index 0000000000..31b1413055 --- /dev/null +++ b/packages/contracts/src/platform-runtime.ts @@ -0,0 +1,326 @@ +import { + isPlatform, + type AppleOS, + type DeviceInfo, + type DeviceKind, + type DeviceTarget, + type Platform, +} from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { PlatformModuleMetadata } from './platform-module.ts'; +import type { PlatformRequestScope } from './platform-runtime-host.ts'; + +type RuntimeOperation = (...args: never[]) => unknown; + +/** String keys whose catalog entries are concrete semantic operation functions. */ +export type RuntimeOperationKey = Extract< + { + [Key in keyof Operations]: Operations[Key] extends RuntimeOperation ? Key : never; + }[keyof Operations], + string +>; + +declare const runtimeOperations: unique symbol; + +export type RuntimeUseDeclaration = Readonly<{ + required: readonly string[]; + preferred: readonly string[]; +}>; + +export type RuntimeUse< + Operations extends object, + Required extends readonly RuntimeOperationKey[], + Preferred extends readonly Exclude, Required[number]>[], +> = RuntimeUseDeclaration & + Readonly<{ + required: Required; + preferred: Preferred; + /** Type-only link to the operation catalog; never emitted in descriptor metadata. */ + readonly [runtimeOperations]?: Operations; + }>; + +type RuntimeUseInput< + Operations extends object, + Required extends readonly RuntimeOperationKey[], + Preferred extends readonly Exclude, Required[number]>[], +> = Readonly<{ + required: Required; + preferred?: Preferred; +}>; + +/** + * Define one descriptor's non-widened required/preferred operation declaration. + * Runtime validation keeps declarations built from dynamic data fail-closed too. + */ +export function runtimeUse() { + return < + const Required extends readonly RuntimeOperationKey[], + const Preferred extends readonly Exclude, Required[number]>[] = + readonly [], + >( + input: RuntimeUseInput, + ): RuntimeUse => { + const required = freezeUniqueKeys(input.required, 'required'); + const preferred = freezeUniqueKeys( + input.preferred ?? ([] as unknown as Preferred), + 'preferred', + ); + const overlap = preferred.find((key) => required.includes(key)); + if (overlap !== undefined) { + throw new TypeError(`Runtime operation cannot be both required and preferred: ${overlap}`); + } + return Object.freeze({ required, preferred }) as RuntimeUse; + }; +} + +function freezeUniqueKeys(keys: Keys, label: string): Keys { + const copy = [...keys]; + if (new Set(copy).size !== copy.length) { + throw new TypeError(`Runtime use contains duplicate ${label} operations`); + } + return Object.freeze(copy) as unknown as Keys; +} + +export type RuntimeOwnerRef = + | Readonly<{ kind: 'local-family'; family: Platform }> + | Readonly<{ kind: 'provider-runtime'; provider: string; instance: string }>; + +export function localRuntimeOwner(family: Platform): RuntimeOwnerRef { + if (!isPlatform(family)) { + throw new TypeError(`Unknown local runtime family: ${String(family)}`); + } + return Object.freeze({ kind: 'local-family', family }); +} + +export function providerRuntimeOwner( + provider: string, + instance: string, +): Extract { + const normalizedProvider = provider.trim(); + const normalizedInstance = instance.trim(); + if (normalizedProvider.length === 0 || normalizedInstance.length === 0) { + throw new TypeError( + 'Provider runtime owner requires non-empty provider and instance identities', + ); + } + return Object.freeze({ + kind: 'provider-runtime', + provider: normalizedProvider, + instance: normalizedInstance, + }); +} + +export function runtimeOwnerKey(owner: RuntimeOwnerRef): string { + return owner.kind === 'local-family' + ? `local:${owner.family}` + : `provider:${JSON.stringify([owner.provider, owner.instance])}`; +} + +export function sameRuntimeOwner(left: RuntimeOwnerRef, right: RuntimeOwnerRef): boolean { + return runtimeOwnerKey(left) === runtimeOwnerKey(right); +} + +export type RuntimeProviderMode = 'local' | 'transport-composed' | 'provider-runtime'; + +export type RuntimeDeviceShape = Readonly<{ + family: Platform; + appleOs?: AppleOS; + kind: DeviceKind; + target?: DeviceTarget; + iosPhysicalDeviceBackend?: DeviceInfo['iosPhysicalDeviceBackend']; + providerMode: RuntimeProviderMode; +}>; + +export type RuntimeOperationUnavailability = Readonly<{ + available: false; + reason: + | 'unsupported-platform-leaf' + | 'unsupported-device-kind' + | 'unsupported-device-backend' + | 'unsupported-provider-mode' + | 'owner-capability-missing'; + hint?: string; +}>; + +export type RuntimeOperationFact = Readonly<{ available: true }> | RuntimeOperationUnavailability; + +export type RuntimeFacts = Readonly<{ + device: RuntimeDeviceShape; + operations: Readonly<{ + [Key in RuntimeOperationKey]: RuntimeOperationFact; + }>; +}>; + +export type DeviceBinding = AsyncDisposable & + Readonly<{ + device: DeviceInfo; + owner: RuntimeOwnerRef; + facts: RuntimeFacts; + operations: Readonly>>>; + }>; + +export type ResourceOwnershipFence = Readonly<{ + token: string; + generation: number; +}>; + +export type DeviceBindingIntent = + | Readonly<{ kind: 'ordinary' }> + | Readonly<{ + kind: 'exact-owner'; + owner: RuntimeOwnerRef; + fence: ResourceOwnershipFence; + }>; + +export type DeviceBindingRequest = Readonly<{ + device: DeviceInfo; + intent: DeviceBindingIntent; + scope: PlatformRequestScope; +}>; + +/** Provider-first request gateway. Ordinary and exact-owner selection stay inside this module. */ +export type DeviceRuntimeGateway = Readonly<{ + bind(request: DeviceBindingRequest): Promise>; + shutdown(): Promise; +}>; + +/** One selectable local or provider owner; narrow providers do not implement this interface. */ +export type DeviceRuntimeOwner = DeviceRuntimeGateway & + Readonly<{ + owner: RuntimeOwnerRef; + ownsDevice(device: DeviceInfo): boolean; + }>; + +/** A family module gains this interface only with an honest package-owned runtime. */ +export type RuntimePlatformModule = PlatformModuleMetadata & + Readonly<{ + loadRuntime(host: Host): Promise>; + }>; + +type OperationsOf = + Use extends RuntimeUse ? Operations : never; + +type RequiredOf = + Use extends RuntimeUse + ? Required[number] + : never; + +type PreferredOf = + Use extends RuntimeUse + ? Preferred[number] + : never; + +/** The non-disposable operation projection returned to a specialized handler. */ +export type BoundDeviceRuntime = Readonly<{ + device: DeviceInfo; + owner: RuntimeOwnerRef; + facts: Readonly< + Pick>['operations'], RequiredOf | PreferredOf> + >; + operations: Readonly< + Pick, RequiredOf> & Partial, PreferredOf>> + >; +}>; + +/** The single trust choke point from a broad owner binding to a selected handler projection. */ +export function narrowDeviceBinding< + Operations extends object, + const Required extends readonly RuntimeOperationKey[], + const Preferred extends readonly Exclude, Required[number]>[], +>( + binding: DeviceBinding, + use: RuntimeUse, +): BoundDeviceRuntime> { + const selectedFacts: Record = {}; + const selectedOperations: Record = {}; + + for (const key of use.required) { + const fact = requireRuntimeFact(binding.facts.operations, key); + selectedFacts[key] = fact; + if (!fact.available) throw unsupportedRuntimeOperation(key, fact); + selectedOperations[key] = requireRuntimeOperation(binding.operations, key); + } + + for (const key of use.preferred) { + const fact = requireRuntimeFact(binding.facts.operations, key); + selectedFacts[key] = fact; + if (fact.available) { + selectedOperations[key] = requireRuntimeOperation(binding.operations, key); + } + } + + return Object.freeze({ + device: binding.device, + owner: binding.owner, + facts: Object.freeze(selectedFacts), + operations: Object.freeze(selectedOperations), + }) as BoundDeviceRuntime>; +} + +function requireRuntimeFact( + facts: RuntimeFacts['operations'], + key: RuntimeOperationKey, +): RuntimeOperationFact { + const fact: unknown = facts[key]; + if (!hasRuntimeAvailability(fact)) { + throw invalidRuntimeContract(`Runtime owner omitted the ${key} fact`); + } + const normalized = normalizeRuntimeFact(fact); + if (normalized) return normalized; + throw invalidRuntimeContract(`Runtime owner returned an invalid ${key} fact`); +} + +type RuntimeFactInput = Readonly<{ available: unknown; reason?: unknown; hint?: unknown }>; + +function hasRuntimeAvailability(value: unknown): value is RuntimeFactInput { + return value !== null && typeof value === 'object' && 'available' in value; +} + +function normalizeRuntimeFact(value: RuntimeFactInput): RuntimeOperationFact | undefined { + if (value.available === true) return Object.freeze({ available: true }); + if (value.available !== false) return undefined; + if (!isRuntimeOperationUnavailabilityReason(value.reason)) return undefined; + if (value.hint !== undefined && typeof value.hint !== 'string') return undefined; + return Object.freeze({ + available: false, + reason: value.reason, + ...(value.hint === undefined ? {} : { hint: value.hint }), + }); +} + +function requireRuntimeOperation( + operations: DeviceBinding['operations'], + key: RuntimeOperationKey, +): RuntimeOperation { + const operation: unknown = operations[key]; + if (typeof operation !== 'function') { + throw invalidRuntimeContract(`Runtime owner advertised ${key} without an implementation`); + } + return operation as RuntimeOperation; +} + +function isRuntimeOperationUnavailabilityReason( + value: unknown, +): value is RuntimeOperationUnavailability['reason'] { + return ( + value === 'unsupported-platform-leaf' || + value === 'unsupported-device-kind' || + value === 'unsupported-device-backend' || + value === 'unsupported-provider-mode' || + value === 'owner-capability-missing' + ); +} + +function unsupportedRuntimeOperation(key: string, fact: RuntimeOperationUnavailability): AppError { + return new AppError('UNSUPPORTED_OPERATION', `Runtime operation ${key} is unavailable`, { + reason: fact.reason, + hint: fact.hint, + }); +} + +function invalidRuntimeContract(message: string): AppError { + return new AppError('COMMAND_FAILED', message, { + reason: 'runtime-contract-invalid', + hint: 'This is an agent-device runtime contract bug; report the selected device and command.', + }); +} diff --git a/packages/kernel/src/device-identity.test.ts b/packages/kernel/src/device-identity.test.ts new file mode 100644 index 0000000000..caae3ae147 --- /dev/null +++ b/packages/kernel/src/device-identity.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest'; +import { + deviceIdentity, + deviceIdentityKey, + deviceShape, + sameDeviceIdentity, + sameDeviceShape, + type DeviceInfo, +} from './device.ts'; + +const DEVICE: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'device-1', + name: 'iPhone', + kind: 'device', + target: 'mobile', + iosPhysicalDeviceBackend: 'coredevice', + booted: true, + simulatorSetPath: '/tmp/simulators', +}; + +describe('canonical device identity', () => { + test('projects every ownership-qualified identity field', () => { + expect(deviceIdentity(DEVICE)).toEqual({ + id: 'device-1', + family: 'apple', + appleOs: 'ios', + kind: 'device', + target: 'mobile', + iosPhysicalDeviceBackend: 'coredevice', + }); + expect(deviceShape(DEVICE)).toEqual({ + family: 'apple', + appleOs: 'ios', + kind: 'device', + target: 'mobile', + iosPhysicalDeviceBackend: 'coredevice', + }); + }); + + test.each([ + ['family', { ...DEVICE, platform: 'android' as const }], + ['apple OS', { ...DEVICE, appleOs: 'ipados' as const }], + ['id', { ...DEVICE, id: 'device-2' }], + ['kind', { ...DEVICE, kind: 'simulator' as const }], + ['target', { ...DEVICE, target: 'tv' as const }], + ['physical backend', { ...DEVICE, iosPhysicalDeviceBackend: 'xctest' as const }], + ])('treats %s as identity-bearing', (_field, changed) => { + const canonical = deviceIdentity(DEVICE); + const other = deviceIdentity(changed); + expect(sameDeviceIdentity(canonical, other)).toBe(false); + expect(deviceIdentityKey(canonical)).not.toBe(deviceIdentityKey(other)); + }); + + test('excludes presentation and observation fields', () => { + const changed: DeviceInfo = { + ...DEVICE, + name: 'Renamed iPhone', + booted: false, + simulatorSetPath: '/tmp/another-set', + }; + expect(sameDeviceIdentity(deviceIdentity(DEVICE), deviceIdentity(changed))).toBe(true); + expect(sameDeviceShape(deviceShape(DEVICE), deviceShape(changed))).toBe(true); + }); +}); diff --git a/packages/kernel/src/device-identity.ts b/packages/kernel/src/device-identity.ts new file mode 100644 index 0000000000..903dda9cd4 --- /dev/null +++ b/packages/kernel/src/device-identity.ts @@ -0,0 +1,56 @@ +import type { AppleOS, DeviceInfo, DeviceKind, DeviceTarget, Platform } from './device.ts'; + +export type DeviceShape = Readonly<{ + family: Platform; + appleOs?: AppleOS; + kind: DeviceKind; + target?: DeviceTarget; + iosPhysicalDeviceBackend?: DeviceInfo['iosPhysicalDeviceBackend']; +}>; + +export type DeviceIdentity = DeviceShape & Readonly<{ id: string }>; + +type DeviceIdentitySource = Pick< + DeviceInfo, + 'platform' | 'appleOs' | 'id' | 'kind' | 'target' | 'iosPhysicalDeviceBackend' +>; + +/** Canonical ownership-qualified device shape, excluding presentation and observation fields. */ +export function deviceShape(device: DeviceIdentitySource): DeviceShape { + return Object.freeze({ + family: device.platform, + ...(device.appleOs === undefined ? {} : { appleOs: device.appleOs }), + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + ...(device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), + }); +} + +/** Canonical identity shared by runtime binding, facts, and durable-resource ownership. */ +export function deviceIdentity(device: DeviceIdentitySource): DeviceIdentity { + return Object.freeze({ id: device.id, ...deviceShape(device) }); +} + +export function deviceIdentityKey(identity: DeviceIdentity): string { + return JSON.stringify([identity.id, ...deviceShapeParts(identity)]); +} + +export function sameDeviceIdentity(left: DeviceIdentity, right: DeviceIdentity): boolean { + return deviceIdentityKey(left) === deviceIdentityKey(right); +} + +export function sameDeviceShape(left: DeviceShape, right: DeviceShape): boolean { + return JSON.stringify(deviceShapeParts(left)) === JSON.stringify(deviceShapeParts(right)); +} + +function deviceShapeParts(shape: DeviceShape): readonly unknown[] { + return [ + shape.family, + shape.appleOs ?? null, + shape.kind, + shape.target ?? null, + shape.iosPhysicalDeviceBackend ?? null, + ]; +} diff --git a/packages/kernel/src/device.ts b/packages/kernel/src/device.ts index c3bc20f96b..4546e24cbb 100644 --- a/packages/kernel/src/device.ts +++ b/packages/kernel/src/device.ts @@ -1,5 +1,14 @@ import { AppError } from './errors.ts'; +export { + deviceIdentity, + deviceIdentityKey, + deviceShape, + sameDeviceIdentity, + sameDeviceShape, + type DeviceIdentity, +} from './device-identity.ts'; + // Legacy Apple leaf platforms. Retained ONLY as accepted `--platform` / read-path // input aliases (approach b back-compat) and as the PUBLIC leaf strings the daemon // still emits; the internal `Platform` no longer carries them — every Apple OS diff --git a/packages/platform-android/package.json b/packages/platform-android/package.json index ac59d0d4a2..f9418c3c8b 100644 --- a/packages/platform-android/package.json +++ b/packages/platform-android/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Android-family platform runtime metadata and implementations for agent-device.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*" }, diff --git a/packages/platform-android/src/adb-failure.test.ts b/packages/platform-android/src/adb-failure.test.ts index c1162c835f..21508872e4 100644 --- a/packages/platform-android/src/adb-failure.test.ts +++ b/packages/platform-android/src/adb-failure.test.ts @@ -1,6 +1,19 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { androidDiscoveryCommandError } from './adb-failure.ts'; +import { androidDiscoveryCommandError, classifyAndroidAdbFailure } from './adb-failure.ts'; + +test('ADB failure classification keeps transport on stderr and install verdicts on stdout', () => { + assert.equal( + classifyAndroidAdbFailure("adb server version (40) doesn't match this client (41); killing...") + ?.reason, + 'server_version_mismatch', + ); + assert.equal(classifyAndroidAdbFailure('', 'log line: device offline detected'), undefined); + assert.equal( + classifyAndroidAdbFailure('', 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]')?.reason, + 'install_update_incompatible', + ); +}); test('ADB discovery classifies transport failures from stderr without trusting stdout', () => { const stdoutOnly = androidDiscoveryCommandError( diff --git a/packages/platform-android/src/adb-failure.ts b/packages/platform-android/src/adb-failure.ts index 74fcb6a4b4..f4c080ccf6 100644 --- a/packages/platform-android/src/adb-failure.ts +++ b/packages/platform-android/src/adb-failure.ts @@ -1,7 +1,136 @@ -import { - ANDROID_ADB_TIMEOUT_FAILURE, - classifyAndroidAdbFailure, -} from '@agent-device/contracts/device'; +type AndroidAdbFailureReason = + | 'timeout' + | 'device_offline' + | 'device_unauthorized' + | 'device_not_found' + | 'multiple_devices' + | 'no_devices' + | 'connection_dropped' + | 'server_version_mismatch' + | 'install_insufficient_storage' + | 'install_update_incompatible' + | 'install_version_downgrade' + | 'install_failed'; + +export type AndroidAdbFailureClassification = Readonly<{ + reason: AndroidAdbFailureReason; + hint: string; + retriable?: boolean; +}>; + +type AndroidAdbFailureMatcher = readonly [ + pattern: RegExp, + failure: AndroidAdbFailureClassification, + matchStdout?: true, +]; + +const ANDROID_ADB_FAILURE_MATCHERS = [ + [ + /device unauthorized|device still authorizing/, + { + reason: 'device_unauthorized', + hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.', + }, + ], + [ + /device offline/, + { + reason: 'device_offline', + hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.', + retriable: true, + }, + ], + [ + /more than one (?:device\/emulator|device and emulator)/, + { + reason: 'multiple_devices', + hint: 'Multiple Android devices are connected — pass --serial (see adb devices) to select one.', + }, + ], + [ + /no devices\/emulators found|no devices found/, + { + reason: 'no_devices', + hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.', + }, + ], + [ + /device (?:'[^']*' )?not found/, + { + reason: 'device_not_found', + hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.', + retriable: true, + }, + ], + [ + /adb server version \(\d+\) doesn't match this client/, + { + reason: 'server_version_mismatch', + hint: 'Multiple adb installs conflict — adb restarts its server automatically, so retry; align PATH to a single adb to stop recurrences.', + retriable: true, + }, + ], + [ + /transport error|connection reset|broken pipe|protocol fault/, + { + reason: 'connection_dropped', + hint: 'The adb connection dropped — retry; if it persists, run adb kill-server and reconnect the device.', + retriable: true, + }, + ], + [ + /install_failed_insufficient_storage/, + { + reason: 'install_insufficient_storage', + hint: 'The device is out of storage — free up space or uninstall unused apps, then retry the install.', + }, + true, + ], + [ + /install_failed_update_incompatible/, + { + reason: 'install_update_incompatible', + hint: 'The installed app has an incompatible signature — uninstall the existing app first, then retry the install.', + }, + true, + ], + [ + /install_failed_version_downgrade/, + { + reason: 'install_version_downgrade', + hint: 'The APK is older than the installed app — uninstall the app first (or install with downgrade allowed), then retry.', + }, + true, + ], + [ + /install_failed_\w+|install_parse_failed_\w+/, + { + reason: 'install_failed', + hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.', + }, + true, + ], +] as const satisfies readonly AndroidAdbFailureMatcher[]; + +const ANDROID_ADB_TIMEOUT_FAILURE: AndroidAdbFailureClassification = Object.freeze({ + reason: 'timeout', + hint: 'adb timed out — the adb server may be wedged. Run adb kill-server && adb start-server, check adb devices, then retry.', +}); + +export function classifyAndroidAdbFailure( + stderr: string, + stdout = '', +): AndroidAdbFailureClassification | undefined { + const stderrText = stderr.toLowerCase(); + const stdoutText = stdout.toLowerCase(); + for (const [pattern, classification, matchStdout] of ANDROID_ADB_FAILURE_MATCHERS) { + if (pattern.test(stderrText) || (matchStdout && pattern.test(stdoutText))) { + return classification; + } + } + return undefined; +} + import { AppError } from '@agent-device/kernel/errors'; import type { HostCommandResult } from '@agent-device/contracts/platform'; diff --git a/packages/platform-android/src/index.ts b/packages/platform-android/src/index.ts index 2864e19815..c5dc7b9377 100644 --- a/packages/platform-android/src/index.ts +++ b/packages/platform-android/src/index.ts @@ -1,4 +1,5 @@ import type { + AppLogRuntimePlatformModule, InventoryPlatformModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -10,6 +11,14 @@ const metadata = Object.freeze({ export type { AndroidInventoryConfig } from './inventory-config.ts'; +export const runtimeModule = Object.freeze({ + ...metadata, + loadRuntime: async (host) => { + const { createAndroidAppLogRuntime } = await import('./logs/runtime.ts'); + return createAndroidAppLogRuntime(host); + }, +} satisfies AppLogRuntimePlatformModule); + export function createAndroidInventoryModule( config: AndroidInventoryConfig, ): InventoryPlatformModule<'android'> { diff --git a/packages/platform-android/src/inventory-parsers.test.ts b/packages/platform-android/src/inventory-parsers.test.ts index ca3086fcb4..1c7c4cd44e 100644 --- a/packages/platform-android/src/inventory-parsers.test.ts +++ b/packages/platform-android/src/inventory-parsers.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { + isAndroidEmulatorSerial, + normalizeAndroidDeviceName, parseAndroidAvdList, parseAndroidDeviceEntries, parseAndroidEmulatorAvdNameOutput, @@ -8,6 +10,12 @@ import { parseAndroidTargetFromCharacteristics, } from './inventory-parsers.ts'; +test('Android inventory identity normalizes AVD names and recognizes emulator serials', () => { + assert.equal(normalizeAndroidDeviceName(' Pixel_9 Pro '), 'pixel 9 pro'); + assert.equal(isAndroidEmulatorSerial('emulator-5554'), true); + assert.equal(isAndroidEmulatorSerial('R58M123ABC'), false); +}); + test('Android inventory parsers preserve device, AVD-name, and TV detection semantics', () => { assert.deepEqual( parseAndroidDeviceEntries( diff --git a/packages/platform-android/src/inventory-parsers.ts b/packages/platform-android/src/inventory-parsers.ts index 9816a840cd..7c17936ee2 100644 --- a/packages/platform-android/src/inventory-parsers.ts +++ b/packages/platform-android/src/inventory-parsers.ts @@ -1,4 +1,12 @@ -import { normalizeAndroidDeviceName } from '@agent-device/contracts/device'; +const ANDROID_EMULATOR_SERIAL_PREFIX = 'emulator-'; + +export function isAndroidEmulatorSerial(serial: string): boolean { + return serial.startsWith(ANDROID_EMULATOR_SERIAL_PREFIX); +} + +export function normalizeAndroidDeviceName(value: string): string { + return value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); +} export type AndroidDeviceEntry = Readonly<{ serial: string; diff --git a/packages/platform-android/src/inventory.ts b/packages/platform-android/src/inventory.ts index f5fbbc2b3a..63d0bce13c 100644 --- a/packages/platform-android/src/inventory.ts +++ b/packages/platform-android/src/inventory.ts @@ -7,15 +7,13 @@ import type { HostCommandResult, PlatformRequestScope, } from '@agent-device/contracts/platform'; -import { - isAndroidEmulatorSerial, - normalizeAndroidDeviceName, - type DeviceInventoryRequest, -} from '@agent-device/contracts/device'; +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; import { androidDiscoveryCommandError, attachAndroidDiscoveryTimeout } from './adb-failure.ts'; import type { AndroidInventoryConfig } from './inventory-config.ts'; import { inferAndroidAvdTarget, + isAndroidEmulatorSerial, + normalizeAndroidDeviceName, parseAndroidAvdList, parseAndroidDeviceEntries, parseAndroidEmulatorAvdNameOutput, diff --git a/packages/platform-android/src/logs/descriptor.ts b/packages/platform-android/src/logs/descriptor.ts new file mode 100644 index 0000000000..1551c77e13 --- /dev/null +++ b/packages/platform-android/src/logs/descriptor.ts @@ -0,0 +1,70 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + DurableDescriptorCodec, + DurableResourceEnvelope, + RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { APP_LOG_RESOURCE_KIND } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit'; + +export type AndroidAppLogDescriptor = Readonly<{ + transport: 'android-logcat-local' | 'android-logcat-transport-composed'; + outputPath: string; + pidPath: string; +}>; + +export const androidAppLogDescriptorCodec: DurableDescriptorCodec< + AndroidAppLogDescriptor, + typeof APP_LOG_RESOURCE_KIND +> = Object.freeze({ + resourceKind: APP_LOG_RESOURCE_KIND, + version: 1, + encode: (descriptor: AndroidAppLogDescriptor) => ({ ...descriptor }), + decode: (body) => { + if ( + (body.transport !== 'android-logcat-local' && + body.transport !== 'android-logcat-transport-composed') || + !isNonEmptyString(body.outputPath) || + !isNonEmptyString(body.pidPath) + ) { + return { status: 'invalid', message: 'Invalid Android app-log descriptor' } as const; + } + return { + status: 'decoded', + descriptor: Object.freeze({ + transport: body.transport, + outputPath: body.outputPath, + pidPath: body.pidPath, + }), + } as const; + }, +}); + +export function createAndroidAppLogEnvelope(input: { + sessionId: string; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: { token: string; generation: number }; + descriptor: AndroidAppLogDescriptor; +}): DurableResourceEnvelope<'app-log'> { + const decoded = androidAppLogDescriptorCodec.decode(input.descriptor); + if (decoded.status !== 'decoded') throw new TypeError(decoded.message); + return createDurableResourceEnvelope({ + resourceKind: APP_LOG_RESOURCE_KIND, + sessionId: input.sessionId, + device: { + id: input.device.id, + family: 'android', + kind: input.device.kind, + ...(input.device.target === undefined ? {} : { target: input.device.target }), + }, + owner: input.owner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(androidAppLogDescriptorCodec, decoded.descriptor), + }); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/packages/platform-android/src/logs/package-name.test.ts b/packages/platform-android/src/logs/package-name.test.ts new file mode 100644 index 0000000000..4b5e91e49d --- /dev/null +++ b/packages/platform-android/src/logs/package-name.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { assertAndroidLogPackageSafe } from './package-name.ts'; + +test('Android app-log package validation preserves the legacy safe grammar', () => { + assert.doesNotThrow(() => assertAndroidLogPackageSafe('com.example.app:worker')); + assert.throws( + () => assertAndroidLogPackageSafe('com.example.app;rm -rf /'), + /Invalid Android package name for logs/, + ); +}); diff --git a/packages/platform-android/src/logs/package-name.ts b/packages/platform-android/src/logs/package-name.ts new file mode 100644 index 0000000000..ec8a494169 --- /dev/null +++ b/packages/platform-android/src/logs/package-name.ts @@ -0,0 +1,7 @@ +import { AppError } from '@agent-device/kernel/errors'; + +export function assertAndroidLogPackageSafe(appBundleId: string): void { + if (!/^[a-zA-Z0-9._:-]+$/.test(appBundleId)) { + throw new AppError('INVALID_ARGS', `Invalid Android package name for logs: ${appBundleId}`); + } +} diff --git a/packages/platform-android/src/logs/runtime.test.ts b/packages/platform-android/src/logs/runtime.test.ts new file mode 100644 index 0000000000..0c41d4a527 --- /dev/null +++ b/packages/platform-android/src/logs/runtime.test.ts @@ -0,0 +1,335 @@ +import type { + AppLogBackgroundProcess, + AppLogOutputSink, + AppLogRuntimeHost, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { describe, expect, test } from 'vitest'; +import { androidAppLogDescriptorCodec, createAndroidAppLogEnvelope } from './descriptor.ts'; +import { createAndroidAppLogRuntime } from './runtime.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + +describe('Android app-log runtime', () => { + test('decodes only marker-backed local and transport-composed descriptors', () => { + for (const transport of [ + 'android-logcat-local', + 'android-logcat-transport-composed', + ] as const) { + expect( + androidAppLogDescriptorCodec.decode({ + transport, + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + }), + ).toMatchObject({ status: 'decoded', descriptor: { transport } }); + } + expect( + androidAppLogDescriptorCodec.decode({ + transport: 'android-logcat-transport-composed', + outputPath: '/tmp/app.log', + }), + ).toMatchObject({ status: 'invalid' }); + expect( + androidAppLogDescriptorCodec.decode({ + transport: 'android-logcat', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + }), + ).toMatchObject({ status: 'invalid' }); + }); + + test('binds complete local facts and preserves logcat recovery command parity', async () => { + const fixture = hostFixture(); + const owner = createAndroidAppLogRuntime(fixture.host); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + + expect(binding.facts.operations.appLogStart).toEqual({ available: true }); + const started = await binding.operations.appLogStart?.({ + sessionId: 'session-1', + appBundleId: 'com.example.app', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + fence: { token: 'fence', generation: 1 }, + }); + expect(started?.envelope.descriptor.body).toEqual({ + transport: 'android-logcat-local', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + }); + expect(fixture.commands).toEqual([ + ['adb', '-s', 'emulator-5554', 'shell', 'pidof', 'com.example.app'], + ]); + expect(fixture.backgroundCommands).toEqual([ + ['adb', '-s', 'emulator-5554', 'logcat', '-v', 'time', '--pid', '123'], + ]); + + const handle = started?.pendingHandle.transfer(); + expect(handle?.inspect()).toMatchObject({ backend: 'android', state: 'active' }); + expect(await handle?.finish()).toMatchObject({ + status: 'completed', + result: { backend: 'android', outputPath: '/tmp/app.log' }, + }); + expect(fixture.terminated()).toBe(true); + expect(fixture.outputDisposed()).toBe(true); + }); + + test('keeps unsafe package names out of adb arguments', async () => { + const fixture = hostFixture(); + const binding = await createAndroidAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope, + }); + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session-1', + appBundleId: 'com.example; reboot', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(fixture.commands).toEqual([]); + }); + + test('preserves the exact request cancellation reason during doctor probes', async () => { + const controller = new AbortController(); + const reason = new Error('request cancelled'); + const fixture = hostFixture({ + commandRun: async () => { + controller.abort(reason); + throw reason; + }, + }); + const binding = await createAndroidAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { ...scope, signal: controller.signal }, + }); + await expect( + binding.operations.appLogDoctor?.({ appBundleId: 'com.example.app' }), + ).rejects.toBe(reason); + }); + + test('rejects cross-session start and recovery paths before host authority is used', async () => { + const fixture = hostFixture(); + const runtimeOwner = createAndroidAppLogRuntime(fixture.host); + const binding = await runtimeOwner.bind({ device, intent: { kind: 'ordinary' }, scope }); + const envelope = createAndroidAppLogEnvelope({ + sessionId: 'session-1', + device, + owner: runtimeOwner.owner, + fence: { token: 'fence', generation: 1 }, + descriptor: { + transport: 'android-logcat-local', + outputPath: '/tmp/app.log', + pidPath: '/tmp/other/app-log.pid', + }, + }); + + await expect(binding.operations.appLogReattach?.({ envelope })).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'descriptor-invalid', + }); + await expect(binding.operations.appLogCleanup?.({ envelope })).resolves.toMatchObject({ + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + }); + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session-1', + appBundleId: 'com.example.app', + outputPath: '/tmp/other/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(fixture.markerReads()).toBe(0); + expect(fixture.outputOpens()).toBe(0); + }); + + test('classifies a captured narrow provider transport while preserving marker authority', async () => { + const fixture = hostFixture({ processTransportMode: 'transport-composed' }); + const binding = await createAndroidAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope, + }); + + expect(binding.facts.device.providerMode).toBe('transport-composed'); + expect(binding.facts.operations.appLogStart).toEqual({ available: true }); + const started = await binding.operations.appLogStart?.({ + sessionId: 'session-1', + appBundleId: 'com.example.app', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + fence: { token: 'fence', generation: 1 }, + }); + expect(started?.envelope.descriptor.body).toEqual({ + transport: 'android-logcat-transport-composed', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + }); + expect(fixture.markerPaths).toEqual(['/tmp/app-log.pid']); + await started?.pendingHandle.transfer().finish(); + }); + + test('fails closed when a narrow provider transport cannot spawn logcat', async () => { + const fixture = hostFixture({ + processTransportMode: 'transport-composed', + processStartAvailable: false, + }); + const binding = await createAndroidAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope, + }); + + expect(binding.facts.operations.appLogStart).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + }); + expect(binding.operations.appLogStart).toBeUndefined(); + }); + + test('rejects before envelope adoption when initial managed process publication fails', async () => { + const failure = new Error('marker publication failed'); + const fixture = hostFixture({ processStartError: failure }); + const binding = await createAndroidAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope, + }); + + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session-1', + appBundleId: 'com.example.app', + outputPath: '/tmp/app.log', + pidPath: '/tmp/app-log.pid', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toBe(failure); + expect(fixture.outputDisposed()).toBe(true); + }); +}); + +function hostFixture( + options: { + commandRun?: AppLogRuntimeHost['commands']['run']; + processTransportMode?: 'local' | 'transport-composed'; + processStartAvailable?: boolean; + processStartError?: Error; + } = {}, +) { + const commands: string[][] = []; + const backgroundCommands: string[][] = []; + let terminated = false; + let outputDisposed = false; + let outputOpens = 0; + let markerReads = 0; + const markerPaths: Array = []; + let resolveWait: + | ((result: { stdout: string; stderr: string; exitCode: number }) => void) + | undefined; + const wait = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { + resolveWait = resolve; + }); + const output: AppLogOutputSink = { + write: async () => {}, + [Symbol.asyncDispose]: async () => { + outputDisposed = true; + }, + }; + const process: AppLogBackgroundProcess = { + marker: { pid: 42, startTime: 'started', command: 'adb logcat --pid 123' }, + wait, + terminate: async () => { + terminated = true; + resolveWait?.({ stdout: '', stderr: '', exitCode: 0 }); + }, + [Symbol.asyncDispose]: async () => {}, + }; + const startProcess: AppLogRuntimeHost['processes']['start'] = async ({ command, markerPath }) => { + if (command.kind !== 'android-adb') throw new Error('Expected a typed Android adb command'); + backgroundCommands.push(['adb', '-s', command.serial, ...command.args]); + markerPaths.push(markerPath); + if (options.processStartError) throw options.processStartError; + return process; + }; + const host: AppLogRuntimeHost = { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => { + throw new Error('unused'); + }, + }, + toolchains: { prepare: async () => undefined }, + artifacts: { + resolveSession: () => ({ outputPath: '/tmp/app.log', pidPath: '/tmp/app-log.pid' }), + }, + commands: { + which: async () => 'adb', + run: async (request, signal) => { + if (options.commandRun) return await options.commandRun(request, signal); + const { executable, args } = request; + commands.push([executable, ...args]); + return { stdout: '123\n', stderr: '', exitCode: 0 }; + }, + }, + outputs: { + openAppend: async () => { + outputOpens += 1; + return output; + }, + readTail: async () => '', + }, + processTransports: { + resolve: async () => ({ + mode: options.processTransportMode ?? 'local', + ...(options.processStartAvailable === false ? {} : { start: startProcess }), + }), + }, + processes: { + start: startProcess, + readMarker: async () => { + markerReads += 1; + return { status: 'missing' }; + }, + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + clock: { now: () => 100, sleep: waitForMonitorWake }, + }; + return { + host, + commands, + backgroundCommands, + terminated: () => terminated, + outputDisposed: () => outputDisposed, + outputOpens: () => outputOpens, + markerReads: () => markerReads, + markerPaths, + }; +} + +async function waitForMonitorWake(_milliseconds: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return; + await new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(), { once: true }); + }); +} diff --git a/packages/platform-android/src/logs/runtime.ts b/packages/platform-android/src/logs/runtime.ts new file mode 100644 index 0000000000..beb838794f --- /dev/null +++ b/packages/platform-android/src/logs/runtime.ts @@ -0,0 +1,109 @@ +import type { AppLogRuntimeHost } from '@agent-device/contracts/platform'; +import { + appLogCommandSucceeded, + bestEffortAppLogCheck, + createPidScopedAppLogRuntimeOwner, + resolveFirstNumericAppLogPid, +} from '@agent-device/capture-kit'; +import { assertAndroidLogPackageSafe } from './package-name.ts'; +import { androidAppLogDescriptorCodec, createAndroidAppLogEnvelope } from './descriptor.ts'; + +const START_UNAVAILABLE_HINT = + 'The selected Android transport cannot start a background adb logcat stream.'; + +export function createAndroidAppLogRuntime(host: AppLogRuntimeHost) { + return createPidScopedAppLogRuntimeOwner(host, { + family: 'android', + backend: 'android', + label: 'Android', + codec: androidAppLogDescriptorCodec, + startUnavailableHint: START_UNAVAILABLE_HINT, + cleanupFailureMessage: 'Android app-log cleanup did not settle every owned resource', + doctor: async ({ host: runtimeHost, device, signal }, appBundleId) => + await doctorAndroidAppLogs(runtimeHost, device.id, appBundleId, signal), + validateStart: ({ input }) => assertAndroidLogPackageSafe(input.appBundleId), + process: async ({ host: runtimeHost, device, input }) => ({ + resolvePid: async (signal) => + await resolveFirstNumericAppLogPid( + runtimeHost, + androidPidRequest(device.id, input.appBundleId), + signal, + ), + command: (pid) => ({ + kind: 'android-adb', + serial: device.id, + args: ['logcat', '-v', 'time', '--pid', pid], + options: { allowFailure: true }, + }), + }), + descriptor: ({ artifacts, transport }) => + ({ + transport: + transport.mode === 'transport-composed' + ? 'android-logcat-transport-composed' + : 'android-logcat-local', + ...artifacts, + }) as const, + envelope: ({ input, device, owner, descriptor }) => + createAndroidAppLogEnvelope({ + sessionId: input.sessionId, + device, + owner, + fence: input.fence, + descriptor, + }), + }); +} + +async function doctorAndroidAppLogs( + host: AppLogRuntimeHost, + deviceId: string, + appBundleId: string | undefined, + signal: AbortSignal, +) { + const checks: Record = {}; + checks.adbAvailable = await bestEffortAppLogCheck( + async () => + appLogCommandSucceeded( + await host.commands.run( + { + executable: 'adb', + args: ['-s', deviceId, 'shell', 'echo', 'ok'], + allowFailure: true, + timeoutMs: 1_000, + }, + signal, + ), + ), + signal, + ); + if (appBundleId) { + checks.androidPidVisible = await bestEffortAppLogCheck( + async () => + Boolean( + await resolveFirstNumericAppLogPid( + host, + androidPidRequest(deviceId, appBundleId), + signal, + ), + ), + signal, + ); + } + return { + backend: 'android' as const, + checks, + notes: appBundleId + ? [] + : ['No app bundle is tracked in this session. Run open first for app-scoped logs.'], + }; +} + +function androidPidRequest(deviceId: string, appBundleId: string) { + return { + executable: 'adb', + args: ['-s', deviceId, 'shell', 'pidof', appBundleId], + allowFailure: true, + timeoutMs: 5_000, + } as const; +} diff --git a/packages/platform-android/src/runtime-facade.test.ts b/packages/platform-android/src/runtime-facade.test.ts new file mode 100644 index 0000000000..64a1686ce4 --- /dev/null +++ b/packages/platform-android/src/runtime-facade.test.ts @@ -0,0 +1,17 @@ +import { expect, test, vi } from 'vitest'; + +const mechanics = vi.hoisted(() => ({ evaluations: 0 })); + +vi.mock('./logs/runtime.ts', async (loadOriginal) => { + mechanics.evaluations += 1; + return await loadOriginal(); +}); + +import { runtimeModule } from './index.ts'; + +test('defers Android app-log mechanics until runtime load', async () => { + expect(mechanics.evaluations).toBe(0); + expect(runtimeModule.family).toBe('android'); + await runtimeModule.loadRuntime({} as never); + expect(mechanics.evaluations).toBe(1); +}); diff --git a/packages/platform-apple/package.json b/packages/platform-apple/package.json index a7dfb671be..ddfca3cfdc 100644 --- a/packages/platform-apple/package.json +++ b/packages/platform-apple/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Apple-family platform runtime metadata and implementations for agent-device.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*" }, diff --git a/packages/platform-apple/src/index.ts b/packages/platform-apple/src/index.ts index a452b6734f..c6a17eb820 100644 --- a/packages/platform-apple/src/index.ts +++ b/packages/platform-apple/src/index.ts @@ -1,4 +1,5 @@ import type { + AppLogRuntimePlatformModule, InventoryPlatformModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -7,6 +8,14 @@ const metadata = Object.freeze({ family: 'apple', } satisfies PlatformModuleMetadata); +export const runtimeModule = Object.freeze({ + ...metadata, + loadRuntime: async (host) => { + const { createAppleAppLogRuntime } = await import('./logs/runtime.ts'); + return createAppleAppLogRuntime(host); + }, +} satisfies AppLogRuntimePlatformModule); + export const inventoryModule = Object.freeze({ ...metadata, loadInventory: async (host) => { diff --git a/packages/platform-apple/src/logs/backend.ts b/packages/platform-apple/src/logs/backend.ts new file mode 100644 index 0000000000..44833eec18 --- /dev/null +++ b/packages/platform-apple/src/logs/backend.ts @@ -0,0 +1,11 @@ +import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; + +export const APPLE_XCTEST_LOGS_HINT = + 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.'; + +export function backendForAppleDevice( + device: DeviceInfo, +): 'ios-simulator' | 'ios-device' | 'macos' { + if (isMacOs(device)) return 'macos'; + return device.kind === 'device' ? 'ios-device' : 'ios-simulator'; +} diff --git a/packages/platform-apple/src/logs/coredevice-console.test.ts b/packages/platform-apple/src/logs/coredevice-console.test.ts new file mode 100644 index 0000000000..c02927a163 --- /dev/null +++ b/packages/platform-apple/src/logs/coredevice-console.test.ts @@ -0,0 +1,41 @@ +import { expect, test, vi } from 'vitest'; +import { checkCoreDeviceConsoleCaptureSupport } from './coredevice-console.ts'; +import { hostFixture } from './runtime.fixtures.ts'; + +test('CoreDevice support uses the exact focused devicectl help probe', async () => { + const run = vi.fn(async () => ({ + stdout: 'USAGE: devicectl device process launch --console --terminate-existing', + stderr: '', + exitCode: 0, + })); + const fixture = hostFixture({ appleToolRun: run }); + const signal = new AbortController().signal; + + await expect(checkCoreDeviceConsoleCaptureSupport(fixture.host, signal)).resolves.toEqual({ + supported: true, + }); + expect(run).toHaveBeenCalledWith( + { + tool: 'devicectl', + args: ['device', 'process', 'launch', '--help'], + allowFailure: true, + timeoutMs: 5_000, + }, + signal, + ); +}); + +test('CoreDevice support preserves the exact Apple-tool cancellation reason', async () => { + const controller = new AbortController(); + const reason = new Error('cancelled'); + const fixture = hostFixture({ + appleToolRun: async () => { + controller.abort(reason); + throw reason; + }, + }); + + await expect(checkCoreDeviceConsoleCaptureSupport(fixture.host, controller.signal)).rejects.toBe( + reason, + ); +}); diff --git a/packages/platform-apple/src/logs/coredevice-console.ts b/packages/platform-apple/src/logs/coredevice-console.ts new file mode 100644 index 0000000000..5ffa3d316a --- /dev/null +++ b/packages/platform-apple/src/logs/coredevice-console.ts @@ -0,0 +1,49 @@ +import type { AppLogRuntimeHost } from '@agent-device/contracts/platform'; + +export const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED = { + message: 'iOS physical-device app console capture is not supported by the installed devicectl.', + hint: 'This devicectl does not expose process launch --console. Markers can still be written to app.log, but app output is not being captured. Use an iOS simulator for agent-device app logs or inspect physical-device logs in Console.app/Xcode until this Xcode toolchain exposes scriptable console capture.', +} as const; + +export const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED = { + message: 'Could not verify iOS physical-device app console capture support.', + hint: 'Retry logs clear --restart. If the probe keeps failing, run logs doctor and inspect the request diagnostics for the devicectl help command.', +} as const; + +export type CoreDeviceConsoleCaptureSupport = + | Readonly<{ supported: true; stderr?: string }> + | Readonly<{ supported: false; reason: 'unsupported' | 'probe-failed'; stderr?: string }>; + +export async function checkCoreDeviceConsoleCaptureSupport( + host: AppLogRuntimeHost, + signal?: AbortSignal, +): Promise { + try { + const result = await host.appleTools.run( + { + tool: 'devicectl', + args: ['device', 'process', 'launch', '--help'], + allowFailure: true, + timeoutMs: 5_000, + }, + signal, + ); + const stderr = result.stderr.trim() || undefined; + if (result.exitCode !== 0) return { supported: false, reason: 'probe-failed', stderr }; + const help = `${result.stdout}\n${result.stderr}`; + const supported = + /\bUSAGE:\s+devicectl device process launch\b/i.test(help) && + /--console\b/.test(help) && + /--terminate-existing\b/.test(help); + return supported + ? { supported: true, ...(stderr ? { stderr } : {}) } + : { supported: false, reason: 'unsupported', ...(stderr ? { stderr } : {}) }; + } catch (error) { + if (signal?.aborted) throw signal.reason; + return { + supported: false, + reason: 'probe-failed', + ...(error instanceof Error ? { stderr: error.message } : {}), + }; + } +} diff --git a/packages/platform-apple/src/logs/descriptor.test.ts b/packages/platform-apple/src/logs/descriptor.test.ts new file mode 100644 index 0000000000..b58892b4f1 --- /dev/null +++ b/packages/platform-apple/src/logs/descriptor.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from 'vitest'; +import { appleAppLogDescriptorCodec } from './descriptor.ts'; + +type DescriptorBody = Parameters[0]; + +const validBodies: readonly DescriptorBody[] = [ + { + transport: 'apple-log-stream', + backend: 'ios-simulator', + outputPath: '/sessions/one/app.log', + }, + { + transport: 'coredevice-console', + backend: 'ios-device', + outputPath: '/sessions/one/app.log', + pidPath: '/sessions/one/app-log.pid', + }, + { + transport: 'apple-log-stream', + backend: 'macos', + outputPath: '/sessions/one/app.log', + }, +]; + +test.each(validBodies)('decodes a coherent Apple app-log descriptor', (body) => { + expect(appleAppLogDescriptorCodec.decode(body)).toEqual({ + status: 'decoded', + descriptor: body, + }); +}); + +const invalidBodies: readonly DescriptorBody[] = [ + { + transport: 'unknown', + backend: 'ios-simulator', + outputPath: '/sessions/one/app.log', + }, + { + transport: 'apple-log-stream', + backend: 'unknown', + outputPath: '/sessions/one/app.log', + }, + { transport: 'apple-log-stream', backend: 'ios-simulator', outputPath: ' ' }, + { + transport: 'apple-log-stream', + backend: 'ios-simulator', + outputPath: '/sessions/one/app.log', + pidPath: '', + }, + { + transport: 'coredevice-console', + backend: 'ios-simulator', + outputPath: '/sessions/one/app.log', + }, + { + transport: 'apple-log-stream', + backend: 'ios-device', + outputPath: '/sessions/one/app.log', + }, +]; + +test.each(invalidBodies)('rejects an incoherent Apple app-log descriptor', (body) => { + expect(appleAppLogDescriptorCodec.decode(body)).toEqual({ + status: 'invalid', + message: 'Invalid Apple app-log descriptor', + }); +}); diff --git a/packages/platform-apple/src/logs/descriptor.ts b/packages/platform-apple/src/logs/descriptor.ts new file mode 100644 index 0000000000..2d5c8ea57c --- /dev/null +++ b/packages/platform-apple/src/logs/descriptor.ts @@ -0,0 +1,106 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + DurableDescriptorCodec, + DurableResourceEnvelope, + RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { APP_LOG_RESOURCE_KIND } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit'; + +export type AppleAppLogDescriptor = Readonly<{ + transport: 'apple-log-stream' | 'coredevice-console'; + backend: 'ios-simulator' | 'ios-device' | 'macos'; + outputPath: string; + pidPath?: string; +}>; + +type AppleAppLogDescriptorDecode = DurableDescriptorCodec< + AppleAppLogDescriptor, + typeof APP_LOG_RESOURCE_KIND +>['decode']; + +const decodeAppleAppLogDescriptor: AppleAppLogDescriptorDecode = (body) => { + if (!isAppleLogTransport(body.transport)) return invalidAppleAppLogDescriptor(); + if (!isAppleLogBackend(body.backend)) return invalidAppleAppLogDescriptor(); + if (!isNonEmptyString(body.outputPath)) return invalidAppleAppLogDescriptor(); + if (!isOptionalNonEmptyString(body.pidPath)) return invalidAppleAppLogDescriptor(); + if (!transportSupportsBackend(body.transport, body.backend)) { + return invalidAppleAppLogDescriptor(); + } + return { + status: 'decoded', + descriptor: Object.freeze({ + transport: body.transport, + backend: body.backend, + outputPath: body.outputPath, + ...(body.pidPath === undefined ? {} : { pidPath: body.pidPath }), + }), + }; +}; + +export const appleAppLogDescriptorCodec: DurableDescriptorCodec< + AppleAppLogDescriptor, + typeof APP_LOG_RESOURCE_KIND +> = Object.freeze({ + resourceKind: APP_LOG_RESOURCE_KIND, + version: 1, + encode: (descriptor: AppleAppLogDescriptor) => ({ ...descriptor }), + decode: decodeAppleAppLogDescriptor, +}); + +function invalidAppleAppLogDescriptor() { + return { status: 'invalid', message: 'Invalid Apple app-log descriptor' } as const; +} + +function isAppleLogTransport(value: unknown): value is AppleAppLogDescriptor['transport'] { + return value === 'apple-log-stream' || value === 'coredevice-console'; +} + +function isAppleLogBackend(value: unknown): value is AppleAppLogDescriptor['backend'] { + return value === 'ios-simulator' || value === 'ios-device' || value === 'macos'; +} + +function transportSupportsBackend( + transport: AppleAppLogDescriptor['transport'], + backend: AppleAppLogDescriptor['backend'], +): boolean { + return transport === 'coredevice-console' ? backend === 'ios-device' : backend !== 'ios-device'; +} + +function isOptionalNonEmptyString(value: unknown): value is string | undefined { + return value === undefined || isNonEmptyString(value); +} + +export function createAppleAppLogEnvelope(input: { + sessionId: string; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: { token: string; generation: number }; + descriptor: AppleAppLogDescriptor; +}): DurableResourceEnvelope<'app-log'> { + if (!input.device.appleOs) { + throw new TypeError('Apple app-log persistence requires an explicit appleOs identity'); + } + return createDurableResourceEnvelope({ + resourceKind: APP_LOG_RESOURCE_KIND, + sessionId: input.sessionId, + device: { + id: input.device.id, + family: 'apple', + appleOs: input.device.appleOs, + kind: input.device.kind, + ...(input.device.target === undefined ? {} : { target: input.device.target }), + ...(input.device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: input.device.iosPhysicalDeviceBackend }), + }, + owner: input.owner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(appleAppLogDescriptorCodec, input.descriptor), + }); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/packages/platform-apple/src/logs/doctor.test.ts b/packages/platform-apple/src/logs/doctor.test.ts new file mode 100644 index 0000000000..e07a0ebf8d --- /dev/null +++ b/packages/platform-apple/src/logs/doctor.test.ts @@ -0,0 +1,48 @@ +import { expect, test, vi } from 'vitest'; +import { doctorAppleAppLogs } from './doctor.ts'; +import { appleDevice, hostFixture } from './runtime.fixtures.ts'; + +test('simulator doctor routes the exact simctl probe through appleTools', async () => { + const appleToolRun = vi.fn(async () => ({ stdout: 'simctl help', stderr: '', exitCode: 0 })); + const fixture = hostFixture({ + appleToolRun, + commandRun: async (request) => { + if (request.executable === 'xcrun') throw new Error('raw xcrun must not run'); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }); + + await expect( + doctorAppleAppLogs(fixture.host, appleDevice(), 'com.example.app'), + ).resolves.toMatchObject({ checks: { simctlAvailable: true } }); + expect(appleToolRun).toHaveBeenCalledWith( + { tool: 'simctl', args: ['help'], allowFailure: true }, + undefined, + ); +}); + +test('CoreDevice doctor routes version discovery through appleTools', async () => { + const appleToolRun = vi.fn(async (request) => ({ + stdout: request.args.includes('--help') + ? 'USAGE: devicectl device process launch --console --terminate-existing' + : 'devicectl 1.0', + stderr: '', + exitCode: 0, + })); + const fixture = hostFixture({ appleToolRun }); + + await expect( + doctorAppleAppLogs( + fixture.host, + appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }), + 'com.example.app', + ), + ).resolves.toMatchObject({ + checks: { devicectlAvailable: true, devicectlConsoleCapture: true }, + }); + expect(appleToolRun).toHaveBeenNthCalledWith( + 1, + { tool: 'devicectl', args: ['--version'], allowFailure: true }, + undefined, + ); +}); diff --git a/packages/platform-apple/src/logs/doctor.ts b/packages/platform-apple/src/logs/doctor.ts new file mode 100644 index 0000000000..a93320c26a --- /dev/null +++ b/packages/platform-apple/src/logs/doctor.ts @@ -0,0 +1,68 @@ +import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import type { AppLogRuntimeHost } from '@agent-device/contracts/platform'; +import { appLogCommandSucceeded, bestEffortAppLogCheck } from '@agent-device/capture-kit'; +import { APPLE_XCTEST_LOGS_HINT, backendForAppleDevice } from './backend.ts'; +import { + checkCoreDeviceConsoleCaptureSupport, + IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED, + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, +} from './coredevice-console.ts'; + +export async function doctorAppleAppLogs( + host: AppLogRuntimeHost, + device: DeviceInfo, + appBundleId: string | undefined, + signal?: AbortSignal, +) { + const checks: Record = {}; + const notes = appBundleId + ? [] + : ['No app bundle is tracked in this session. Run open first for app-scoped logs.']; + if (isMacOs(device)) { + checks.logAvailable = Boolean(await host.commands.which('log')); + } else if (device.kind === 'simulator') { + checks.simctlAvailable = await bestEffortAppLogCheck( + async () => + appLogCommandSucceeded( + await host.appleTools.run( + { + tool: 'simctl', + args: ['help'], + allowFailure: true, + }, + signal, + ), + ), + signal, + ); + } else if (device.iosPhysicalDeviceBackend === 'xctest') { + checks.devicectlAvailable = false; + checks.devicectlConsoleCapture = false; + notes.push(APPLE_XCTEST_LOGS_HINT); + } else { + checks.devicectlAvailable = await bestEffortAppLogCheck( + async () => + appLogCommandSucceeded( + await host.appleTools.run( + { + tool: 'devicectl', + args: ['--version'], + allowFailure: true, + }, + signal, + ), + ), + signal, + ); + const support = await checkCoreDeviceConsoleCaptureSupport(host, signal); + checks.devicectlConsoleCapture = support.supported; + if (!support.supported) { + const message = + support.reason === 'unsupported' + ? IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED + : IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED; + notes.push(`${message.message} ${message.hint}`); + } + } + return { backend: backendForAppleDevice(device), checks, notes }; +} diff --git a/packages/platform-apple/src/logs/log-predicate.test.ts b/packages/platform-apple/src/logs/log-predicate.test.ts new file mode 100644 index 0000000000..8c3d607326 --- /dev/null +++ b/packages/platform-apple/src/logs/log-predicate.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + buildAppleLogPredicate, + buildIosDeviceConsoleLaunchArgs, + buildIosSimulatorLogStreamArgs, +} from './log-predicate.ts'; + +test('Apple app-log predicate covers bundle and executable provenance', () => { + const predicate = buildAppleLogPredicate('com.example."quoted"', 'ExampleExec'); + + assert.match(predicate, /subsystem == "com\.example\.\\"quoted\\""/); + assert.match(predicate, /subsystem CONTAINS "com\.example\.\\"quoted\\""/); + assert.match(predicate, /process == "ExampleExec"/); + assert.match(predicate, /processImagePath CONTAINS\[c\] "\/ExampleExec\.app\/"/); + assert.doesNotMatch(predicate, /eventMessage/); +}); + +test('Apple app-log arguments preserve simulator-set and CoreDevice launch semantics', () => { + assert.deepEqual( + buildIosSimulatorLogStreamArgs({ + deviceId: 'sim-1', + appBundleId: 'com.example.app', + executableName: 'ExampleExec', + simulatorSetPath: '/tmp/tenant-a/simulators', + }), + [ + 'simctl', + '--set', + '/tmp/tenant-a/simulators', + 'spawn', + 'sim-1', + 'log', + 'stream', + '--style', + 'compact', + '--level', + 'info', + '--predicate', + buildAppleLogPredicate('com.example.app', 'ExampleExec'), + ], + ); + assert.deepEqual(buildIosDeviceConsoleLaunchArgs('physical-1', 'com.example.app'), [ + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + 'physical-1', + '--console', + '--terminate-existing', + 'com.example.app', + ]); +}); diff --git a/packages/platform-apple/src/logs/log-predicate.ts b/packages/platform-apple/src/logs/log-predicate.ts new file mode 100644 index 0000000000..b1718ed4a9 --- /dev/null +++ b/packages/platform-apple/src/logs/log-predicate.ts @@ -0,0 +1,62 @@ +export function buildAppleLogPredicate(appBundleId: string, executableName?: string): string { + const escapedBundleId = escapePredicateString(appBundleId); + const clauses = [ + `subsystem == "${escapedBundleId}"`, + `subsystem CONTAINS "${escapedBundleId}"`, + `processImagePath ENDSWITH[c] "/${escapedBundleId}"`, + `senderImagePath ENDSWITH[c] "/${escapedBundleId}"`, + ]; + if (executableName) { + const escapedExecutable = escapePredicateString(executableName); + clauses.push( + `process == "${escapedExecutable}"`, + `processImagePath ENDSWITH[c] "/${escapedExecutable}"`, + `senderImagePath ENDSWITH[c] "/${escapedExecutable}"`, + `processImagePath CONTAINS[c] "/${escapedExecutable}.app/"`, + `senderImagePath CONTAINS[c] "/${escapedExecutable}.app/"`, + ); + } + return clauses.join(' OR '); +} + +export function buildIosSimulatorLogStreamArgs(params: { + deviceId: string; + appBundleId: string; + executableName?: string; + simulatorSetPath?: string; +}): string[] { + const simctlPrefix = params.simulatorSetPath + ? ['simctl', '--set', params.simulatorSetPath] + : ['simctl']; + return [ + ...simctlPrefix, + 'spawn', + params.deviceId, + 'log', + 'stream', + '--style', + 'compact', + '--level', + 'info', + '--predicate', + buildAppleLogPredicate(params.appBundleId, params.executableName), + ]; +} + +export function buildIosDeviceConsoleLaunchArgs(deviceId: string, appBundleId: string): string[] { + return [ + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + '--console', + '--terminate-existing', + appBundleId, + ]; +} + +function escapePredicateString(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); +} diff --git a/packages/platform-apple/src/logs/runtime.fixtures.ts b/packages/platform-apple/src/logs/runtime.fixtures.ts new file mode 100644 index 0000000000..794e19d73c --- /dev/null +++ b/packages/platform-apple/src/logs/runtime.fixtures.ts @@ -0,0 +1,98 @@ +import type { AppLogRuntimeHost, PlatformRequestScope } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { vi } from 'vitest'; + +export const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + +export function appleDevice(overrides: Partial = {}): DeviceInfo { + return { + platform: 'apple', + appleOs: 'ios', + id: 'apple-1', + name: 'iPhone', + kind: 'simulator', + target: 'mobile', + booted: true, + ...overrides, + }; +} + +export function hostFixture( + options: { + failProcessStart?: boolean; + commandRun?: AppLogRuntimeHost['commands']['run']; + appleToolRun?: AppLogRuntimeHost['appleTools']['run']; + } = {}, +) { + const backgroundCommands: string[][] = []; + let outputDisposed = false; + let outputOpens = 0; + let markerReads = 0; + const startProcess: AppLogRuntimeHost['processes']['start'] = vi.fn(async ({ command }) => { + if (command.kind !== 'host') throw new Error('Expected an Apple host command'); + backgroundCommands.push([command.request.executable, ...command.request.args]); + if (options.failProcessStart) throw new Error('start failed'); + return { + wait: new Promise(() => {}), + terminate: async () => {}, + [Symbol.asyncDispose]: async () => {}, + }; + }); + const host: AppLogRuntimeHost = { + appleTools: { + isXcrunAvailable: async () => true, + run: async (request, signal) => { + if (options.appleToolRun) return await options.appleToolRun(request, signal); + return request.args.includes('get_app_container') + ? { stdout: '', stderr: '', exitCode: 1 } + : { stdout: '', stderr: '', exitCode: 0 }; + }, + }, + toolchains: { prepare: async () => undefined }, + artifacts: { + resolveSession: () => ({ outputPath: '/tmp/app.log', pidPath: '/tmp/app-log.pid' }), + }, + commands: { + which: async () => undefined, + run: async (request, signal) => + options.commandRun + ? await options.commandRun(request, signal) + : { stdout: '', stderr: '', exitCode: 0 }, + }, + outputs: { + readTail: async () => '', + openAppend: async () => { + outputOpens += 1; + return { + write: async () => {}, + [Symbol.asyncDispose]: async () => { + outputDisposed = true; + }, + }; + }, + }, + processTransports: { resolve: async () => ({ mode: 'local', start: startProcess }) }, + processes: { + start: startProcess, + readMarker: async () => { + markerReads += 1; + return { status: 'missing' }; + }, + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + clock: { now: () => 10, sleep: async () => {} }, + }; + return { + host, + backgroundCommands, + outputDisposed: () => outputDisposed, + outputOpens: () => outputOpens, + markerReads: () => markerReads, + }; +} diff --git a/packages/platform-apple/src/logs/runtime.test.ts b/packages/platform-apple/src/logs/runtime.test.ts new file mode 100644 index 0000000000..382cc452b2 --- /dev/null +++ b/packages/platform-apple/src/logs/runtime.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from 'vitest'; +import { createAppleAppLogEnvelope } from './descriptor.ts'; +import { createAppleAppLogRuntime } from './runtime.ts'; +import { appleDevice, hostFixture, scope } from './runtime.fixtures.ts'; + +describe('Apple app-log runtime', () => { + test.each([ + { + name: 'iOS simulator', + device: appleDevice(), + available: true, + hint: undefined, + }, + { + name: 'iOS physical CoreDevice', + device: appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }), + available: true, + hint: undefined, + }, + { + name: 'iOS physical XCTest', + device: appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'xctest' }), + available: false, + hint: 'CoreDevice-backed physical iOS device', + }, + { + name: 'iPadOS simulator', + device: appleDevice({ appleOs: 'ipados' }), + available: true, + hint: undefined, + }, + { + name: 'tvOS simulator', + device: appleDevice({ appleOs: 'tvos', target: 'tv' }), + available: true, + hint: undefined, + }, + { + name: 'macOS host', + device: appleDevice({ appleOs: 'macos', kind: 'device', target: 'desktop' }), + available: true, + hint: undefined, + }, + { + name: 'visionOS simulator', + device: appleDevice({ appleOs: 'visionos' }), + available: true, + hint: undefined, + }, + { + name: 'watchOS sentinel', + device: appleDevice({ appleOs: 'watchos' }), + available: false, + hint: 'watchOS app logs are not supported', + }, + ])('classifies the $name leaf explicitly', async ({ device, available, hint }) => { + const binding = await createAppleAppLogRuntime(hostFixture().host).bind({ + device, + intent: { kind: 'ordinary' }, + scope, + }); + expect(binding.facts.device.providerMode).toBe('local'); + for (const operation of ['appLogInspect', 'appLogDoctor', 'appLogStart'] as const) { + const fact = binding.facts.operations[operation]; + expect(fact.available).toBe(available); + if (!available && hint) expect(fact).toHaveProperty('hint', expect.stringContaining(hint)); + } + }); + + test('rejects the XCTest physical backend with the established CoreDevice hint', async () => { + const binding = await createAppleAppLogRuntime(hostFixture().host).bind({ + device: appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'xctest' }), + intent: { kind: 'ordinary' }, + scope, + }); + expect(binding.facts.operations.appLogStart).toMatchObject({ + available: false, + reason: 'unsupported-device-backend', + }); + expect(binding.facts.operations.appLogInspect).toEqual(binding.facts.operations.appLogStart); + expect(binding.facts.operations.appLogStart).toHaveProperty( + 'hint', + expect.stringContaining('CoreDevice-backed physical iOS device'), + ); + }); + + test('fails closed for the reserved watchOS leaf', async () => { + const binding = await createAppleAppLogRuntime(hostFixture().host).bind({ + device: appleDevice({ appleOs: 'watchos' }), + intent: { kind: 'ordinary' }, + scope, + }); + expect(binding.facts.operations.appLogInspect).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + }); + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.app', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_PLATFORM' }); + }); + + test('rolls back the output sink if simulator process startup fails', async () => { + const fixture = hostFixture({ failProcessStart: true }); + const binding = await createAppleAppLogRuntime(fixture.host).bind({ + device: appleDevice(), + intent: { kind: 'ordinary' }, + scope, + }); + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.app', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toThrow('start failed'); + expect(fixture.outputDisposed()).toBe(true); + expect(fixture.backgroundCommands[0]).toEqual([ + 'xcrun', + 'simctl', + 'spawn', + 'apple-1', + 'log', + 'stream', + '--style', + 'compact', + '--level', + 'info', + '--predicate', + expect.stringContaining('com.example.app'), + ]); + }); + + test('rejects cross-session start and recovery paths before host authority is used', async () => { + const fixture = hostFixture(); + const runtimeOwner = createAppleAppLogRuntime(fixture.host); + const binding = await runtimeOwner.bind({ + device: appleDevice(), + intent: { kind: 'ordinary' }, + scope, + }); + const envelope = createAppleAppLogEnvelope({ + sessionId: 'session', + device: appleDevice(), + owner: runtimeOwner.owner, + fence: { token: 'fence', generation: 1 }, + descriptor: { + transport: 'apple-log-stream', + backend: 'ios-simulator', + outputPath: '/tmp/app.log', + pidPath: '/tmp/other/app-log.pid', + }, + }); + + await expect(binding.operations.appLogReattach?.({ envelope })).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'descriptor-invalid', + }); + await expect(binding.operations.appLogCleanup?.({ envelope })).resolves.toMatchObject({ + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + }); + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.app', + outputPath: '/tmp/other/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(fixture.markerReads()).toBe(0); + expect(fixture.outputOpens()).toBe(0); + }); + + test.each([ + { name: 'simulator doctor', device: appleDevice(), operation: 'doctor' as const }, + { + name: 'CoreDevice start probe', + device: appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }), + operation: 'start' as const, + }, + ])('preserves the exact cancellation reason for $name', async ({ device, operation }) => { + const controller = new AbortController(); + const reason = new Error('request cancelled'); + const fixture = hostFixture({ + appleToolRun: async () => { + controller.abort(reason); + throw reason; + }, + }); + const binding = await createAppleAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { ...scope, signal: controller.signal }, + }); + const request = + operation === 'doctor' + ? binding.operations.appLogDoctor?.({ appBundleId: 'com.example.app' }) + : binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.app', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }); + await expect(request).rejects.toBe(reason); + }); +}); diff --git a/packages/platform-apple/src/logs/runtime.ts b/packages/platform-apple/src/logs/runtime.ts new file mode 100644 index 0000000000..227fdcb383 --- /dev/null +++ b/packages/platform-apple/src/logs/runtime.ts @@ -0,0 +1,146 @@ +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + AppLogRuntimeHost, + AppLogRuntimeOperations, + DeviceBinding, + DeviceRuntimeOwner, + RuntimeFacts, +} from '@agent-device/contracts/platform'; +import { + appLogSessionArtifactsMatch, + cleanupManagedAppLogProcess, + createAppLogRecoveryOperations, + reattachCleanupOnlyAppLogProcess, +} from '@agent-device/capture-kit'; +import { localRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform'; +import { APPLE_XCTEST_LOGS_HINT, backendForAppleDevice } from './backend.ts'; +import { appleAppLogDescriptorCodec } from './descriptor.ts'; +import { doctorAppleAppLogs } from './doctor.ts'; +import { startAppleAppLogs } from './start.ts'; + +const owner = localRuntimeOwner('apple'); +const available = Object.freeze({ available: true } as const); + +export function createAppleAppLogRuntime( + host: AppLogRuntimeHost, +): DeviceRuntimeOwner { + return Object.freeze({ + owner, + ownsDevice: (device) => device.platform === 'apple', + bind: async (request) => { + if (request.intent.kind === 'exact-owner' && !sameRuntimeOwner(request.intent.owner, owner)) { + throw new AppError('UNSUPPORTED_OPERATION', 'Apple app-log owner identity does not match'); + } + return bindAppleAppLogs(host, request.device, request.scope.signal); + }, + shutdown: async () => undefined, + }); +} + +function bindAppleAppLogs( + host: AppLogRuntimeHost, + device: DeviceInfo, + signal: AbortSignal, +): DeviceBinding { + if (device.platform !== 'apple') { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + `Apple app-log owner cannot bind ${device.platform}`, + ); + } + const recovery = createAppLogRecoveryOperations({ + codec: appleAppLogDescriptorCodec, + reattach: async (descriptor, context) => { + if ( + descriptor.backend !== backendForAppleDevice(device) || + !appLogSessionArtifactsMatch(host, context.sessionId, descriptor) + ) { + return { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: 'Apple app-log descriptor does not match the bound device or owning session', + }; + } + return await reattachCleanupOnlyAppLogProcess(host, descriptor.pidPath); + }, + cleanup: async (descriptor, context) => + descriptor.backend === backendForAppleDevice(device) && + appLogSessionArtifactsMatch(host, context.sessionId, descriptor) + ? await cleanupManagedAppLogProcess(host, descriptor.pidPath) + : { + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + message: 'Apple app-log descriptor does not match the bound device or owning session', + }, + }); + const operations: AppLogRuntimeOperations = Object.freeze({ + appLogInspect: async () => ({ backend: backendForAppleDevice(device) }), + appLogDoctor: async ({ appBundleId }) => + await doctorAppleAppLogs(host, device, appBundleId, signal), + appLogStart: async (input) => await startAppleAppLogs(host, device, input, owner, signal), + ...recovery, + }); + return Object.freeze({ + device, + owner, + facts: appleAppLogFacts(device), + operations, + [Symbol.asyncDispose]: async () => undefined, + }); +} + +function appleAppLogFacts(device: DeviceInfo): RuntimeFacts { + if (device.appleOs === 'watchos') return unavailableAppleFacts(device); + const admitted = + isIosFamily(device) && device.kind === 'device' && device.iosPhysicalDeviceBackend === 'xctest' + ? ({ + available: false, + reason: 'unsupported-device-backend', + hint: APPLE_XCTEST_LOGS_HINT, + } as const) + : available; + return Object.freeze({ + device: { + family: 'apple', + appleOs: device.appleOs, + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + ...(device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), + providerMode: 'local', + }, + operations: { + appLogInspect: admitted, + appLogDoctor: admitted, + appLogStart: admitted, + appLogReattach: available, + appLogCleanup: available, + }, + }); +} + +function unavailableAppleFacts(device: DeviceInfo): RuntimeFacts { + const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'watchOS app logs are not supported.', + } as const); + return Object.freeze({ + device: { + family: 'apple', + appleOs: 'watchos', + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + providerMode: 'local', + }, + operations: { + appLogInspect: unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + }, + }); +} diff --git a/packages/platform-apple/src/logs/start.test.ts b/packages/platform-apple/src/logs/start.test.ts new file mode 100644 index 0000000000..8fcf5e68dc --- /dev/null +++ b/packages/platform-apple/src/logs/start.test.ts @@ -0,0 +1,57 @@ +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { expect, test, vi } from 'vitest'; +import { startAppleAppLogs } from './start.ts'; +import { appleDevice, hostFixture } from './runtime.fixtures.ts'; + +test('simulator start resolves its app container through appleTools and keeps plutil generic', async () => { + const appleToolRun = vi.fn(async () => ({ + stdout: '/tmp/Example.app\n', + stderr: '', + exitCode: 0, + })); + const commandRun = vi.fn(async (request) => { + if (request.executable === 'xcrun') throw new Error('raw foreground xcrun must not run'); + return { stdout: 'Example\n', stderr: '', exitCode: 0 }; + }); + const fixture = hostFixture({ appleToolRun, commandRun, failProcessStart: true }); + + await expect( + startAppleAppLogs( + fixture.host, + appleDevice(), + { + sessionId: 'session', + appBundleId: 'com.example.app', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }, + localRuntimeOwner('apple'), + ), + ).rejects.toThrow('start failed'); + + expect(appleToolRun).toHaveBeenCalledWith( + { + tool: 'simctl', + args: ['get_app_container', 'apple-1', 'com.example.app', 'app'], + allowFailure: true, + timeoutMs: 4_000, + }, + undefined, + ); + expect(commandRun).toHaveBeenCalledWith( + { + executable: 'plutil', + args: ['-extract', 'CFBundleExecutable', 'raw', '-o', '-', '/tmp/Example.app/Info.plist'], + allowFailure: true, + timeoutMs: 4_000, + }, + undefined, + ); + expect(fixture.backgroundCommands[0]?.slice(0, 5)).toEqual([ + 'xcrun', + 'simctl', + 'spawn', + 'apple-1', + 'log', + ]); +}); diff --git a/packages/platform-apple/src/logs/start.ts b/packages/platform-apple/src/logs/start.ts new file mode 100644 index 0000000000..284b3adb39 --- /dev/null +++ b/packages/platform-apple/src/logs/start.ts @@ -0,0 +1,259 @@ +import path from 'node:path'; +import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + AppLogBackgroundProcess, + AppLogLiveSnapshot, + AppLogRuntimeHost, + AppLogStartInput, + AppLogStartResult, + FinishOutcome, + RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import type { LogBackend } from '@agent-device/contracts/observability'; +import { AsyncCleanupStack } from '@agent-device/contracts/platform'; +import { + assertAppLogSessionArtifacts, + createAppLogLiveHandleFromFinish, + createAppLogStartResult, +} from '@agent-device/capture-kit'; +import { APPLE_XCTEST_LOGS_HINT, backendForAppleDevice } from './backend.ts'; +import { + checkCoreDeviceConsoleCaptureSupport, + IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED, + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, +} from './coredevice-console.ts'; +import { createAppleAppLogEnvelope, type AppleAppLogDescriptor } from './descriptor.ts'; +import { + buildAppleLogPredicate, + buildIosDeviceConsoleLaunchArgs, + buildIosSimulatorLogStreamArgs, +} from './log-predicate.ts'; + +export async function startAppleAppLogs( + host: AppLogRuntimeHost, + device: DeviceInfo, + input: AppLogStartInput, + owner: RuntimeOwnerRef, + signal?: AbortSignal, +): Promise { + assertAppleLogStartSupported(device); + assertAppLogSessionArtifacts(host, input); + const backend = backendForAppleDevice(device); + const command = await commandForAppleAppLogs(host, device, input.appBundleId, signal); + const rollback = new AsyncCleanupStack(); + let adopted = false; + try { + const output = await host.outputs.openAppend(input.outputPath); + rollback.defer(async () => { + if (!adopted) await output[Symbol.asyncDispose](); + }); + signal?.throwIfAborted(); + const backgroundProcess = await startCheckedAppleProcess( + host, + device, + command, + output, + input.pidPath, + signal, + ); + rollback.defer(async () => { + if (!adopted) await backgroundProcess[Symbol.asyncDispose](); + }); + const handle = createAppleProcessHandle( + host, + backgroundProcess, + output, + backend, + input.outputPath, + ); + const descriptor: AppleAppLogDescriptor = { + transport: backend === 'ios-device' ? 'coredevice-console' : 'apple-log-stream', + backend, + outputPath: input.outputPath, + ...(input.pidPath === undefined ? {} : { pidPath: input.pidPath }), + }; + const result = createAppLogStartResult( + handle, + createAppleAppLogEnvelope({ + sessionId: input.sessionId, + device, + owner, + fence: input.fence, + descriptor, + }), + ); + adopted = true; + return result; + } finally { + await rollback[Symbol.asyncDispose](); + } +} + +function assertAppleLogStartSupported(device: DeviceInfo): void { + if (device.appleOs === 'watchos') { + throw new AppError('UNSUPPORTED_PLATFORM', 'watchOS app logs are not supported'); + } + if ( + isIosFamily(device) && + device.kind === 'device' && + device.iosPhysicalDeviceBackend === 'xctest' + ) { + throw new AppError('UNSUPPORTED_OPERATION', APPLE_XCTEST_LOGS_HINT, { + hint: APPLE_XCTEST_LOGS_HINT, + }); + } +} + +async function startCheckedAppleProcess( + host: AppLogRuntimeHost, + device: DeviceInfo, + command: { executable: string; args: readonly string[]; allowFailure: true }, + output: Awaited>, + pidPath: string | undefined, + signal?: AbortSignal, +): Promise { + if (isIosFamily(device) && device.kind === 'device') { + const support = await checkCoreDeviceConsoleCaptureSupport(host, signal); + if (!support.supported) { + const message = + support.reason === 'unsupported' + ? IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED + : IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED; + throw new AppError( + support.reason === 'unsupported' ? 'UNSUPPORTED_OPERATION' : 'COMMAND_FAILED', + message.message, + { backend: 'ios-device', hint: message.hint, stderr: support.stderr }, + ); + } + } + signal?.throwIfAborted(); + return await host.processes.start({ + command: { kind: 'host', request: command }, + output, + ...(pidPath ? { markerPath: pidPath } : {}), + }); +} + +async function commandForAppleAppLogs( + host: AppLogRuntimeHost, + device: DeviceInfo, + appBundleId: string, + signal?: AbortSignal, +) { + if (isMacOs(device)) { + return { + executable: 'log', + args: ['stream', '--style', 'compact', '--predicate', buildAppleLogPredicate(appBundleId)], + allowFailure: true, + } as const; + } + if (device.kind === 'device') { + return { + executable: 'xcrun', + args: buildIosDeviceConsoleLaunchArgs(device.id, appBundleId), + allowFailure: true, + } as const; + } + const executableName = await resolveSimulatorExecutable(host, device, appBundleId, signal); + return { + executable: 'xcrun', + args: buildIosSimulatorLogStreamArgs({ + deviceId: device.id, + appBundleId, + executableName, + simulatorSetPath: device.simulatorSetPath, + }), + allowFailure: true, + } as const; +} + +async function resolveSimulatorExecutable( + host: AppLogRuntimeHost, + device: DeviceInfo, + appBundleId: string, + signal?: AbortSignal, +): Promise { + const prefix = device.simulatorSetPath ? ['--set', device.simulatorSetPath] : []; + const container = await host.appleTools.run( + { + tool: 'simctl', + args: [...prefix, 'get_app_container', device.id, appBundleId, 'app'], + allowFailure: true, + timeoutMs: 4_000, + }, + signal, + ); + const appPath = container.exitCode === 0 ? container.stdout.trim() : ''; + if (!appPath) return undefined; + const plist = await host.commands.run( + { + executable: 'plutil', + args: ['-extract', 'CFBundleExecutable', 'raw', '-o', '-', path.join(appPath, 'Info.plist')], + allowFailure: true, + timeoutMs: 4_000, + }, + signal, + ); + return plist.exitCode === 0 ? plist.stdout.trim() || undefined : undefined; +} + +function createAppleProcessHandle( + host: AppLogRuntimeHost, + backgroundProcess: AppLogBackgroundProcess, + output: Awaited>, + backend: LogBackend, + outputPath: string, +) { + const startedAt = host.clock.now(); + let state: AppLogLiveSnapshot['state'] = 'active'; + void backgroundProcess.wait.then( + (result) => { + state = result.exitCode === 0 ? 'ended' : 'failed'; + }, + () => { + state = 'failed'; + }, + ); + let finishPromise: + | Promise> + | undefined; + const finish = async () => + (finishPromise ??= (async () => { + const failures = await cleanupInOrder([ + async () => await backgroundProcess.terminate(), + async () => await backgroundProcess.wait.then(() => undefined), + async () => await backgroundProcess[Symbol.asyncDispose](), + async () => await output[Symbol.asyncDispose](), + ]); + if (failures.length > 0) { + state = 'failed'; + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: 'Apple app-log cleanup did not settle every owned resource', + } as const; + } + state = 'ended'; + return { + status: 'completed', + result: { backend, outputPath, completedAt: host.clock.now() }, + } as const; + })()); + return createAppLogLiveHandleFromFinish({ + inspect: () => ({ backend, state, startedAt }), + finish, + }); +} + +async function cleanupInOrder(steps: readonly (() => Promise)[]): Promise { + const failures: unknown[] = []; + for (const step of steps) { + try { + await step(); + } catch (error) { + failures.push(error); + } + } + return failures; +} diff --git a/packages/platform-harmonyos/package.json b/packages/platform-harmonyos/package.json index 598d45bfcd..d992cda5b1 100644 --- a/packages/platform-harmonyos/package.json +++ b/packages/platform-harmonyos/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "HarmonyOS-family platform runtime metadata and implementations for agent-device.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*" }, diff --git a/packages/platform-harmonyos/src/index.ts b/packages/platform-harmonyos/src/index.ts index 1da93c6c8d..51e4910752 100644 --- a/packages/platform-harmonyos/src/index.ts +++ b/packages/platform-harmonyos/src/index.ts @@ -1,4 +1,5 @@ import type { + AppLogRuntimePlatformModule, InventoryPlatformModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -8,6 +9,14 @@ const metadata = Object.freeze({ family: 'harmonyos', } satisfies PlatformModuleMetadata); +export const runtimeModule = Object.freeze({ + ...metadata, + loadRuntime: async (host) => { + const { createHarmonyAppLogRuntime } = await import('./logs/runtime.ts'); + return createHarmonyAppLogRuntime(host); + }, +} satisfies AppLogRuntimePlatformModule); + export type { HarmonyInventoryConfig } from './inventory-config.ts'; export function createHarmonyInventoryModule( diff --git a/packages/platform-harmonyos/src/logs/descriptor.ts b/packages/platform-harmonyos/src/logs/descriptor.ts new file mode 100644 index 0000000000..b6eec5d29d --- /dev/null +++ b/packages/platform-harmonyos/src/logs/descriptor.ts @@ -0,0 +1,67 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + DurableDescriptorCodec, + DurableResourceEnvelope, + RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { APP_LOG_RESOURCE_KIND } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit'; + +export type HarmonyAppLogDescriptor = Readonly<{ + transport: 'harmony-hilog'; + outputPath: string; + pidPath: string; +}>; + +export const harmonyAppLogDescriptorCodec: DurableDescriptorCodec< + HarmonyAppLogDescriptor, + typeof APP_LOG_RESOURCE_KIND +> = Object.freeze({ + resourceKind: APP_LOG_RESOURCE_KIND, + version: 1, + encode: (descriptor: HarmonyAppLogDescriptor) => ({ ...descriptor }), + decode: (body) => { + if ( + body.transport !== 'harmony-hilog' || + !isNonEmptyString(body.outputPath) || + !isNonEmptyString(body.pidPath) + ) { + return { status: 'invalid', message: 'Invalid HarmonyOS app-log descriptor' } as const; + } + return { + status: 'decoded', + descriptor: Object.freeze({ + transport: 'harmony-hilog', + outputPath: body.outputPath, + pidPath: body.pidPath, + }), + } as const; + }, +}); + +export function createHarmonyAppLogEnvelope(input: { + sessionId: string; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: { token: string; generation: number }; + descriptor: HarmonyAppLogDescriptor; +}): DurableResourceEnvelope<'app-log'> { + return createDurableResourceEnvelope({ + resourceKind: APP_LOG_RESOURCE_KIND, + sessionId: input.sessionId, + device: { + id: input.device.id, + family: 'harmonyos', + kind: input.device.kind, + ...(input.device.target === undefined ? {} : { target: input.device.target }), + }, + owner: input.owner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(harmonyAppLogDescriptorCodec, input.descriptor), + }); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/packages/platform-harmonyos/src/logs/runtime.test.ts b/packages/platform-harmonyos/src/logs/runtime.test.ts new file mode 100644 index 0000000000..fc3e5ac09f --- /dev/null +++ b/packages/platform-harmonyos/src/logs/runtime.test.ts @@ -0,0 +1,283 @@ +import type { + AppLogBackgroundProcess, + AppLogRuntimeHost, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { expect, test, vi } from 'vitest'; +import { createHarmonyAppLogEnvelope, harmonyAppLogDescriptorCodec } from './descriptor.ts'; +import { createHarmonyAppLogRuntime } from './runtime.ts'; + +const device: DeviceInfo = { + platform: 'harmonyos', + id: 'harmony-1', + name: 'Harmony', + kind: 'device', + target: 'mobile', + booted: true, +}; +const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + +test('rejects durable HarmonyOS descriptors without the canonical process marker', () => { + expect( + harmonyAppLogDescriptorCodec.decode({ + transport: 'harmony-hilog', + outputPath: '/tmp/app.log', + }), + ).toMatchObject({ status: 'invalid' }); +}); + +test('starts HarmonyOS hilog with the resolved application pid', async () => { + const fixture = hostFixture(); + const binding = await createHarmonyAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope, + }); + const started = await binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.harmony', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }); + expect(fixture.backgroundCommands).toEqual([ + ['hdc', '-t', 'harmony-1', 'shell', 'hilog', '-P', '456'], + ]); + expect(await started?.pendingHandle.transfer().finish()).toMatchObject({ + status: 'completed', + result: { backend: 'harmonyos' }, + }); +}); + +test('rejects cross-session start and recovery paths before host authority is used', async () => { + const fixture = hostFixture(); + const runtimeOwner = createHarmonyAppLogRuntime(fixture.host); + const binding = await runtimeOwner.bind({ device, intent: { kind: 'ordinary' }, scope }); + const envelope = createHarmonyAppLogEnvelope({ + sessionId: 'session', + device, + owner: runtimeOwner.owner, + fence: { token: 'fence', generation: 1 }, + descriptor: { + transport: 'harmony-hilog', + outputPath: '/tmp/app.log', + pidPath: '/tmp/other/app-log.pid', + }, + }); + + await expect(binding.operations.appLogReattach?.({ envelope })).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'descriptor-invalid', + }); + await expect(binding.operations.appLogCleanup?.({ envelope })).resolves.toMatchObject({ + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + }); + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.harmony', + outputPath: '/tmp/other/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(fixture.markerReads()).toBe(0); + expect(fixture.outputOpens()).toBe(0); +}); + +test('rejects a missing HDC tool before opening output or starting a process', async () => { + const fixture = hostFixture({ whichHdc: async () => undefined }); + const binding = await createHarmonyAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope, + }); + + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.harmony', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toMatchObject({ + code: 'TOOL_MISSING', + message: 'hdc not found in PATH', + details: { + hint: 'Install HarmonyOS Command Line Tools, then add its sdk/default/openharmony/toolchains directory to PATH.', + }, + }); + expect(fixture.outputOpens()).toBe(0); + expect(fixture.backgroundCommands).toEqual([]); +}); + +test('provider-selected HarmonyOS logs prepare configured HDC without local inventory discovery', async () => { + const events: string[] = []; + const prepare = vi.fn(async () => { + events.push('prepare'); + }); + const whichHdc = vi.fn(async () => { + expect(events).toEqual(['prepare']); + events.push('which'); + return '/configured/toolchains/hdc'; + }); + const fixture = hostFixture({ prepare, whichHdc }); + const binding = await createHarmonyAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope, + }); + + const started = await binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.harmony', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }); + + expect(prepare).toHaveBeenCalledWith('harmonyos'); + expect(whichHdc).toHaveBeenCalledWith('hdc'); + expect(events).toEqual(['prepare', 'which']); + expect(fixture.backgroundCommands).toEqual([ + ['/configured/toolchains/hdc', '-t', 'harmony-1', 'shell', 'hilog', '-P', '456'], + ]); + await started?.pendingHandle.transfer().finish(); +}); + +test('HarmonyOS log preflight preserves cancellation when toolchain preparation rejects differently', async () => { + const controller = new AbortController(); + const reason = new Error('cancelled'); + const transportError = new Error('toolchain preparation failed'); + const whichHdc = vi.fn(async () => 'hdc'); + const fixture = hostFixture({ + prepare: async () => { + controller.abort(reason); + throw transportError; + }, + whichHdc, + }); + const binding = await createHarmonyAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { ...scope, signal: controller.signal }, + }); + + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.harmony', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toBe(reason); + expect(whichHdc).not.toHaveBeenCalled(); + expect(fixture.outputOpens()).toBe(0); + expect(fixture.backgroundCommands).toEqual([]); +}); + +test('HarmonyOS log preflight preserves cancellation when HDC lookup rejects differently', async () => { + const controller = new AbortController(); + const reason = new Error('cancelled'); + const transportError = new Error('tool lookup failed'); + const fixture = hostFixture({ + whichHdc: async () => { + controller.abort(reason); + throw transportError; + }, + }); + const binding = await createHarmonyAppLogRuntime(fixture.host).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { ...scope, signal: controller.signal }, + }); + + await expect( + binding.operations.appLogStart?.({ + sessionId: 'session', + appBundleId: 'com.example.harmony', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toBe(reason); + expect(fixture.outputOpens()).toBe(0); + expect(fixture.backgroundCommands).toEqual([]); +}); + +function hostFixture( + options: { + prepare?: AppLogRuntimeHost['toolchains']['prepare']; + whichHdc?: AppLogRuntimeHost['commands']['which']; + } = {}, +) { + const backgroundCommands: string[][] = []; + let outputOpens = 0; + let markerReads = 0; + let resolveWait: (() => void) | undefined; + const wait = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { + resolveWait = () => resolve({ stdout: '', stderr: '', exitCode: 0 }); + }); + const process: AppLogBackgroundProcess = { + wait, + terminate: async () => resolveWait?.(), + [Symbol.asyncDispose]: async () => {}, + }; + const startProcess: AppLogRuntimeHost['processes']['start'] = async ({ command }) => { + if (command.kind !== 'host') throw new Error('Expected a HarmonyOS host command'); + backgroundCommands.push([command.request.executable, ...command.request.args]); + return process; + }; + const host: AppLogRuntimeHost = { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => { + throw new Error('unused'); + }, + }, + toolchains: { prepare: options.prepare ?? (async () => undefined) }, + artifacts: { + resolveSession: () => ({ outputPath: '/tmp/app.log', pidPath: '/tmp/app-log.pid' }), + }, + commands: { + which: options.whichHdc ?? (async () => 'hdc'), + run: async () => ({ stdout: '456\n', stderr: '', exitCode: 0 }), + }, + outputs: { + readTail: async () => '', + openAppend: async () => { + outputOpens += 1; + return { write: async () => {}, [Symbol.asyncDispose]: async () => {} }; + }, + }, + processTransports: { + resolve: async () => ({ mode: 'local', start: startProcess }), + }, + processes: { + start: startProcess, + readMarker: async () => { + markerReads += 1; + return { status: 'missing' }; + }, + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + clock: { now: () => 10, sleep: waitForMonitorWake }, + }; + return { + host, + backgroundCommands, + markerReads: () => markerReads, + outputOpens: () => outputOpens, + }; +} + +async function waitForMonitorWake(_milliseconds: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return; + await new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(), { once: true }); + }); +} diff --git a/packages/platform-harmonyos/src/logs/runtime.ts b/packages/platform-harmonyos/src/logs/runtime.ts new file mode 100644 index 0000000000..4c8b292290 --- /dev/null +++ b/packages/platform-harmonyos/src/logs/runtime.ts @@ -0,0 +1,90 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { AppLogRuntimeHost } from '@agent-device/contracts/platform'; +import { + createPidScopedAppLogRuntimeOwner, + resolveFirstNumericAppLogPid, +} from '@agent-device/capture-kit'; +import { createHarmonyAppLogEnvelope, harmonyAppLogDescriptorCodec } from './descriptor.ts'; + +const START_UNAVAILABLE_HINT = + 'The selected HarmonyOS transport cannot start a background hilog stream.'; + +export function createHarmonyAppLogRuntime(host: AppLogRuntimeHost) { + return createPidScopedAppLogRuntimeOwner(host, { + family: 'harmonyos', + backend: 'harmonyos', + label: 'HarmonyOS', + codec: harmonyAppLogDescriptorCodec, + startUnavailableHint: START_UNAVAILABLE_HINT, + cleanupFailureMessage: 'HarmonyOS app-log cleanup did not settle every owned resource', + doctor: async (_context, appBundleId) => ({ + backend: 'harmonyos', + checks: {}, + notes: appBundleId + ? [] + : ['No app bundle is tracked in this session. Run open first for app-scoped logs.'], + }), + process: async ({ host: runtimeHost, device, input, signal }) => { + const hdc = await prepareHarmonyLogs(runtimeHost, signal); + return { + resolvePid: async (pidSignal) => + await resolveFirstNumericAppLogPid( + runtimeHost, + { + executable: hdc, + args: ['-t', device.id, 'shell', 'pidof', input.appBundleId], + allowFailure: true, + timeoutMs: 5_000, + }, + pidSignal, + ), + command: (pid) => ({ + kind: 'host', + request: { + executable: hdc, + args: ['-t', device.id, 'shell', 'hilog', '-P', pid], + allowFailure: true, + }, + }), + }; + }, + descriptor: ({ artifacts }) => ({ transport: 'harmony-hilog', ...artifacts }) as const, + envelope: ({ input, device, owner, descriptor }) => + createHarmonyAppLogEnvelope({ + sessionId: input.sessionId, + device, + owner, + fence: input.fence, + descriptor, + }), + }); +} + +async function prepareHarmonyLogs(host: AppLogRuntimeHost, signal: AbortSignal): Promise { + signal.throwIfAborted(); + await preserveHarmonyCancellation(async () => await host.toolchains.prepare('harmonyos'), signal); + signal.throwIfAborted(); + const hdc = await preserveHarmonyCancellation( + async () => await host.commands.which('hdc'), + signal, + ); + signal.throwIfAborted(); + if (!hdc) { + throw new AppError('TOOL_MISSING', 'hdc not found in PATH', { + hint: 'Install HarmonyOS Command Line Tools, then add its sdk/default/openharmony/toolchains directory to PATH.', + }); + } + return hdc; +} + +async function preserveHarmonyCancellation( + operation: () => Promise, + signal: AbortSignal, +): Promise { + try { + return await operation(); + } catch (error) { + signal.throwIfAborted(); + throw error; + } +} diff --git a/packages/platform-linux/package.json b/packages/platform-linux/package.json index 5ddd4534ba..e4151b40ea 100644 --- a/packages/platform-linux/package.json +++ b/packages/platform-linux/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Linux-family platform runtime metadata and implementations for agent-device.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*" }, diff --git a/packages/platform-linux/src/index.ts b/packages/platform-linux/src/index.ts index 404a7fe6cc..bd00111262 100644 --- a/packages/platform-linux/src/index.ts +++ b/packages/platform-linux/src/index.ts @@ -1,4 +1,5 @@ import type { + AppLogRuntimePlatformModule, InventoryPlatformModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -7,6 +8,14 @@ const metadata = Object.freeze({ family: 'linux', } satisfies PlatformModuleMetadata); +export const runtimeModule = Object.freeze({ + ...metadata, + loadRuntime: async (_host) => { + const { createLinuxAppLogRuntime } = await import('./logs/runtime.ts'); + return createLinuxAppLogRuntime(); + }, +} satisfies AppLogRuntimePlatformModule); + export const inventoryModule: InventoryPlatformModule<'linux'> = Object.freeze({ ...metadata, loadInventory: async (host) => { diff --git a/packages/platform-linux/src/logs/runtime.ts b/packages/platform-linux/src/logs/runtime.ts new file mode 100644 index 0000000000..8de8e0b181 --- /dev/null +++ b/packages/platform-linux/src/logs/runtime.ts @@ -0,0 +1,11 @@ +import type { AppLogRuntimeOperations, DeviceRuntimeOwner } from '@agent-device/contracts/platform'; +import { createUnavailableAppLogRuntimeOwner } from '@agent-device/capture-kit'; + +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +export function createLinuxAppLogRuntime(): DeviceRuntimeOwner { + return createUnavailableAppLogRuntimeOwner('linux', unavailable); +} diff --git a/packages/platform-vega/package.json b/packages/platform-vega/package.json index bbcda342bc..6e04fb89a1 100644 --- a/packages/platform-vega/package.json +++ b/packages/platform-vega/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Vega-family platform runtime metadata and implementations for agent-device.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*" }, diff --git a/packages/platform-vega/src/index.ts b/packages/platform-vega/src/index.ts index 4759dd000f..144226a753 100644 --- a/packages/platform-vega/src/index.ts +++ b/packages/platform-vega/src/index.ts @@ -1,4 +1,5 @@ import type { + AppLogRuntimePlatformModule, InventoryPlatformModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -7,6 +8,14 @@ const metadata = Object.freeze({ family: 'vega', } satisfies PlatformModuleMetadata); +export const runtimeModule = Object.freeze({ + ...metadata, + loadRuntime: async (_host) => { + const { createVegaAppLogRuntime } = await import('./logs/runtime.ts'); + return createVegaAppLogRuntime(); + }, +} satisfies AppLogRuntimePlatformModule); + export const inventoryModule: InventoryPlatformModule<'vega'> = Object.freeze({ ...metadata, loadInventory: async (host) => { diff --git a/packages/platform-vega/src/logs/runtime.ts b/packages/platform-vega/src/logs/runtime.ts new file mode 100644 index 0000000000..e2663c7700 --- /dev/null +++ b/packages/platform-vega/src/logs/runtime.ts @@ -0,0 +1,11 @@ +import type { AppLogRuntimeOperations, DeviceRuntimeOwner } from '@agent-device/contracts/platform'; +import { createUnavailableAppLogRuntimeOwner } from '@agent-device/capture-kit'; + +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +export function createVegaAppLogRuntime(): DeviceRuntimeOwner { + return createUnavailableAppLogRuntimeOwner('vega', unavailable); +} diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index 190b4a4b20..26aee4edea 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Web-family platform runtime metadata and implementations for agent-device.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*" }, "exports": { diff --git a/packages/platform-web/src/index.ts b/packages/platform-web/src/index.ts index c7932eb4fc..0424338ea6 100644 --- a/packages/platform-web/src/index.ts +++ b/packages/platform-web/src/index.ts @@ -1,4 +1,5 @@ import type { + AppLogRuntimePlatformModule, InventoryPlatformModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -7,6 +8,14 @@ const metadata = Object.freeze({ family: 'web', } satisfies PlatformModuleMetadata); +export const runtimeModule = Object.freeze({ + ...metadata, + loadRuntime: async (_host) => { + const { createWebAppLogRuntime } = await import('./logs/runtime.ts'); + return createWebAppLogRuntime(); + }, +} satisfies AppLogRuntimePlatformModule); + export const inventoryModule: InventoryPlatformModule<'web'> = Object.freeze({ ...metadata, loadInventory: async () => { diff --git a/packages/platform-web/src/logs/runtime.ts b/packages/platform-web/src/logs/runtime.ts new file mode 100644 index 0000000000..d70b131c1d --- /dev/null +++ b/packages/platform-web/src/logs/runtime.ts @@ -0,0 +1,11 @@ +import type { AppLogRuntimeOperations, DeviceRuntimeOwner } from '@agent-device/contracts/platform'; +import { createUnavailableAppLogRuntimeOwner } from '@agent-device/capture-kit'; + +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +export function createWebAppLogRuntime(): DeviceRuntimeOwner { + return createUnavailableAppLogRuntimeOwner('web', unavailable); +} diff --git a/packages/provider-limrun/package.json b/packages/provider-limrun/package.json index c3f5ab8c67..c578b2895a 100644 --- a/packages/provider-limrun/package.json +++ b/packages/provider-limrun/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Limrun provider runtime for agent-device. Internal workspace package bundled into the published artifact.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*", "@limrun/api": "^0.24.5" diff --git a/packages/provider-limrun/src/app-log-descriptor.ts b/packages/provider-limrun/src/app-log-descriptor.ts new file mode 100644 index 0000000000..ef28144e53 --- /dev/null +++ b/packages/provider-limrun/src/app-log-descriptor.ts @@ -0,0 +1,83 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + DurableDescriptorCodec, + DurableResourceEnvelope, + RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { APP_LOG_RESOURCE_KIND } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit'; + +export type LimrunAppLogDescriptor = Readonly<{ + transport: 'limrun-log-poller'; + platform: 'ios' | 'android'; + leaseId: string; + instanceId: string; + appBundleId: string; + outputPath: string; +}>; + +export const limrunAppLogDescriptorCodec: DurableDescriptorCodec< + LimrunAppLogDescriptor, + typeof APP_LOG_RESOURCE_KIND +> = Object.freeze({ + resourceKind: APP_LOG_RESOURCE_KIND, + version: 1, + encode: (descriptor) => ({ ...descriptor }), + decode: (body) => { + if ( + body.transport !== 'limrun-log-poller' || + (body.platform !== 'ios' && body.platform !== 'android') || + !isNonEmptyString(body.leaseId) || + !isNonEmptyString(body.instanceId) || + !isNonEmptyString(body.appBundleId) || + !isNonEmptyString(body.outputPath) + ) { + return { status: 'invalid', message: 'Invalid Limrun app-log descriptor' }; + } + return { + status: 'decoded', + descriptor: Object.freeze({ + transport: 'limrun-log-poller', + platform: body.platform, + leaseId: body.leaseId, + instanceId: body.instanceId, + appBundleId: body.appBundleId, + outputPath: body.outputPath, + }), + }; + }, +}); + +export function createLimrunAppLogEnvelope(input: { + sessionId: string; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: { token: string; generation: number }; + descriptor: LimrunAppLogDescriptor; +}): DurableResourceEnvelope<'app-log'> { + if (input.device.platform === 'apple' && !input.device.appleOs) { + throw new TypeError('Limrun Apple app-log persistence requires an explicit appleOs identity'); + } + return createDurableResourceEnvelope({ + resourceKind: APP_LOG_RESOURCE_KIND, + sessionId: input.sessionId, + device: { + id: input.device.id, + family: input.device.platform, + ...(input.device.appleOs === undefined ? {} : { appleOs: input.device.appleOs }), + kind: input.device.kind, + ...(input.device.target === undefined ? {} : { target: input.device.target }), + ...(input.device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: input.device.iosPhysicalDeviceBackend }), + }, + owner: input.owner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(limrunAppLogDescriptorCodec, input.descriptor), + }); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/packages/provider-limrun/src/app-log-poller.test.ts b/packages/provider-limrun/src/app-log-poller.test.ts new file mode 100644 index 0000000000..a86c6b0809 --- /dev/null +++ b/packages/provider-limrun/src/app-log-poller.test.ts @@ -0,0 +1,227 @@ +import type { AppLogRuntimeHost } from '@agent-device/contracts/platform'; +import { describe, expect, test, vi } from 'vitest'; +import { startLimrunAppLogPoller, type LimrunAppLogReader } from './app-log-poller.ts'; + +describe('Limrun app-log poller', () => { + test('deduplicates the persisted tail and stops before disposing its resources', async () => { + const sleeps = deferredSleeps(); + const writes: string[] = []; + let outputDisposed = false; + let readerDisposed = false; + const reader: LimrunAppLogReader = { + platform: 'ios', + leaseId: 'lease-1', + instanceId: 'instance-1', + readLogs: vi.fn(async () => 'old line\nshared\nnew line\n'), + [Symbol.asyncDispose]: async () => { + readerDisposed = true; + }, + }; + const handle = await startLimrunAppLogPoller({ + host: pollerHost({ + existingTail: 'old line\nshared\n[agent-device][mark][time] checkpoint\n', + writes, + sleeps, + onOutputDispose: () => { + outputDisposed = true; + }, + }), + reader, + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }); + await vi.waitFor(() => expect(writes).toEqual(['new line\n'])); + + const finishing = handle.finish(); + expect(readerDisposed).toBe(false); + expect(outputDisposed).toBe(false); + sleeps.resolveNext(1_000); + await finishing; + expect(reader.readLogs).toHaveBeenCalledTimes(1); + expect(readerDisposed).toBe(true); + expect(outputDisposed).toBe(true); + }); + + test.each(['readTail', 'openAppend'] as const)( + 'rolls back the reader when %s fails during acquisition', + async (failure) => { + const dispose = vi.fn(async () => {}); + const reader: LimrunAppLogReader = { + platform: 'ios', + leaseId: 'lease-1', + instanceId: 'instance-1', + readLogs: async () => '', + [Symbol.asyncDispose]: dispose, + }; + const host = pollerHost({ + existingTail: '', + writes: [], + sleeps: deferredSleeps(), + failure, + }); + await expect( + startLimrunAppLogPoller({ + host, + reader, + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }), + ).rejects.toThrow(`${failure === 'readTail' ? 'tail' : 'open'} failed`); + expect(dispose).toHaveBeenCalledOnce(); + }, + ); + + test('uses linear overlap matching for a near-limit tail without overlap', async () => { + const sleeps = deferredSleeps(); + const writes: string[] = []; + const reader: LimrunAppLogReader = { + platform: 'ios', + leaseId: 'lease-1', + instanceId: 'instance-1', + readLogs: async () => `${'b'.repeat(240_000)}\n`, + [Symbol.asyncDispose]: async () => {}, + }; + const handle = await startLimrunAppLogPoller({ + host: pollerHost({ existingTail: `${'a'.repeat(240_000)}\n`, writes, sleeps }), + reader, + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }); + await vi.waitFor(() => expect(writes[0]?.length).toBe(240_001)); + const finishing = handle.finish(); + sleeps.resolveNext(1_000); + await finishing; + }); + + test('still disposes the output when reader cleanup rejects', async () => { + const sleeps = deferredSleeps(); + let outputDisposed = false; + const reader: LimrunAppLogReader = { + platform: 'ios', + leaseId: 'lease-1', + instanceId: 'instance-1', + readLogs: async () => '', + [Symbol.asyncDispose]: async () => { + throw new Error('reader cleanup failed'); + }, + }; + const handle = await startLimrunAppLogPoller({ + host: pollerHost({ + existingTail: '', + writes: [], + sleeps, + onOutputDispose: () => { + outputDisposed = true; + }, + }), + reader, + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }); + await vi.waitFor(() => expect(sleeps.hasPending(1_000)).toBe(true)); + const finishing = handle.finish(); + sleeps.resolveNext(1_000); + await expect(finishing).resolves.toMatchObject({ status: 'cleanup-pending' }); + expect(outputDisposed).toBe(true); + }); + + test('allows one bounded read only and settles it before disposal', async () => { + const sleeps = deferredSleeps(); + let readerDisposed = false; + const reader: LimrunAppLogReader = { + platform: 'android', + leaseId: 'lease-1', + instanceId: 'instance-1', + readLogs: vi.fn(async () => await new Promise(() => {})), + [Symbol.asyncDispose]: async () => { + readerDisposed = true; + }, + }; + const handle = await startLimrunAppLogPoller({ + host: pollerHost({ existingTail: '', writes: [], sleeps }), + reader, + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }); + const finishing = handle.finish(); + expect(readerDisposed).toBe(false); + sleeps.resolveNext(5_000); + await finishing; + expect(reader.readLogs).toHaveBeenCalledTimes(1); + expect(readerDisposed).toBe(true); + }); +}); + +function pollerHost(options: { + existingTail: string; + writes: string[]; + sleeps: ReturnType; + onOutputDispose?: () => void; + failure?: 'readTail' | 'openAppend'; +}): AppLogRuntimeHost { + return { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => { + throw new Error('unused'); + }, + }, + toolchains: { prepare: async () => undefined }, + artifacts: { + resolveSession: () => ({ + outputPath: '/sessions/one/app.log', + pidPath: '/sessions/one/app-log.pid', + }), + }, + commands: { + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + outputs: { + readTail: async () => { + if (options.failure === 'readTail') throw new Error('tail failed'); + return options.existingTail; + }, + openAppend: async () => { + if (options.failure === 'openAppend') throw new Error('open failed'); + return { + write: async (chunk) => { + options.writes.push(String(chunk)); + }, + [Symbol.asyncDispose]: async () => options.onOutputDispose?.(), + }; + }, + }, + processTransports: { + resolve: async () => ({ mode: 'local' }), + }, + processes: { + start: async () => { + throw new Error('unused'); + }, + readMarker: async () => ({ status: 'missing' }), + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + clock: { + now: () => 100, + sleep: async (milliseconds) => await options.sleeps.wait(milliseconds), + }, + }; +} + +function deferredSleeps() { + const pending: Array<{ milliseconds: number; resolve: () => void }> = []; + return { + wait: async (milliseconds: number) => + await new Promise((resolve) => pending.push({ milliseconds, resolve })), + resolveNext: (milliseconds: number) => { + const index = pending.findIndex((entry) => entry.milliseconds === milliseconds); + if (index < 0) throw new Error(`No ${milliseconds}ms sleep is pending`); + pending.splice(index, 1)[0]?.resolve(); + }, + hasPending: (milliseconds: number) => + pending.some((entry) => entry.milliseconds === milliseconds), + }; +} diff --git a/packages/provider-limrun/src/app-log-poller.ts b/packages/provider-limrun/src/app-log-poller.ts new file mode 100644 index 0000000000..163755ee1e --- /dev/null +++ b/packages/provider-limrun/src/app-log-poller.ts @@ -0,0 +1,208 @@ +import type { + AppLogLiveHandle, + AppLogLiveSnapshot, + AppLogOutputSink, + AppLogRuntimeHost, + FinishOutcome, +} from '@agent-device/contracts/platform'; +import { AsyncCleanupStack } from '@agent-device/contracts/platform'; +import { createAppLogLiveHandleFromFinish } from '@agent-device/capture-kit'; +import type { LogBackend } from '@agent-device/contracts/observability'; + +export type LimrunAppLogReader = AsyncDisposable & + Readonly<{ + platform: 'ios' | 'android'; + leaseId: string; + instanceId: string; + readLogs(appBundleId: string, lineLimit: number): Promise; + }>; + +export async function startLimrunAppLogPoller(options: { + host: AppLogRuntimeHost; + reader: LimrunAppLogReader; + appBundleId: string; + outputPath: string; +}): Promise { + const rollback = new AsyncCleanupStack(); + let adopted = false; + rollback.defer(async () => { + if (!adopted) await options.reader[Symbol.asyncDispose](); + }); + try { + const existingTail = await options.host.outputs.readTail(options.outputPath, 256 * 1024); + const output = await options.host.outputs.openAppend(options.outputPath); + rollback.defer(async () => { + if (!adopted) await output[Symbol.asyncDispose](); + }); + const handle = createPollerHandle(options, output, remoteOnlyTail(existingTail)); + adopted = true; + return handle; + } finally { + await rollback[Symbol.asyncDispose](); + } +} + +function createPollerHandle( + options: { + host: AppLogRuntimeHost; + reader: LimrunAppLogReader; + appBundleId: string; + outputPath: string; + }, + output: AppLogOutputSink, + existingTail: string, +): AppLogLiveHandle { + const backend = backendForReader(options.reader); + const startedAt = options.host.clock.now(); + let state: AppLogLiveSnapshot['state'] = 'active'; + let stopped = false; + let previous = existingTail; + const polling = (async () => { + while (!stopped) { + try { + const read = await boundedRead(options); + if (read.status === 'timeout') { + state = 'failed'; + return; + } + if (stopped) return; + const delta = appendedTail(previous, read.text); + previous = read.text; + if (delta) await output.write(delta.endsWith('\n') ? delta : `${delta}\n`); + state = 'active'; + } catch { + if (stopped) return; + state = 'recovering'; + } + await options.host.clock.sleep(1_000); + } + })(); + let finishPromise: + | Promise> + | undefined; + const finish = async () => + (finishPromise ??= (async () => { + stopped = true; + await polling; + const failures = await disposeAll([options.reader, output]); + if (failures.length > 0) { + state = 'failed'; + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: 'Limrun app-log cleanup did not settle every owned resource', + } as const; + } + state = 'ended'; + return { + status: 'completed', + result: { + backend, + outputPath: options.outputPath, + completedAt: options.host.clock.now(), + }, + } as const; + })()); + return createAppLogLiveHandleFromFinish({ + inspect: () => ({ backend, state, startedAt }), + finish, + }); +} + +async function boundedRead(options: { + host: AppLogRuntimeHost; + reader: LimrunAppLogReader; + appBundleId: string; +}): Promise | Readonly<{ status: 'timeout' }>> { + const controller = new AbortController(); + const read = abortable( + options.reader.readLogs(options.appBundleId, 1_000), + controller.signal, + ).then((text) => ({ status: 'read' as const, text })); + try { + const result = await Promise.race([ + read, + options.host.clock + .sleep(5_000, controller.signal) + .then(() => ({ status: 'timeout' as const })), + ]); + if (result.status === 'timeout') { + controller.abort(); + await read.catch(() => undefined); + } + return result; + } finally { + controller.abort(); + } +} + +async function abortable(source: Promise, signal: AbortSignal): Promise { + return await new Promise((resolve, reject) => { + const aborted = () => reject(signal.reason ?? new Error('App-log provider read aborted')); + if (signal.aborted) { + aborted(); + return; + } + signal.addEventListener('abort', aborted, { once: true }); + void source.then( + (value) => { + signal.removeEventListener('abort', aborted); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', aborted); + reject(error); + }, + ); + }); +} + +function appendedTail(previous: string, current: string): string { + if (!previous || !current) return current; + const prefix = buildPrefixTable(current); + const maximum = Math.min(previous.length, current.length); + const suffix = previous.slice(previous.length - maximum); + return current.slice(suffixPrefixOverlap(suffix, current, prefix)); +} + +function buildPrefixTable(text: string): Uint32Array { + const prefix = new Uint32Array(text.length); + let matched = 0; + for (let index = 1; index < text.length; index += 1) { + while (matched > 0 && text[index] !== text[matched]) matched = prefix[matched - 1]!; + if (text[index] === text[matched]) matched += 1; + prefix[index] = matched; + } + return prefix; +} + +function suffixPrefixOverlap(suffix: string, current: string, prefix: Uint32Array): number { + let matched = 0; + for (let index = 0; index < suffix.length; index += 1) { + while (matched > 0 && suffix[index] !== current[matched]) matched = prefix[matched - 1]!; + if (suffix[index] === current[matched]) matched += 1; + if (matched === current.length && index < suffix.length - 1) matched = prefix[matched - 1]!; + } + return matched; +} + +function remoteOnlyTail(tail: string): string { + return tail + .split('\n') + .filter((line) => !line.startsWith('[agent-device][mark]')) + .join('\n'); +} + +async function disposeAll(resources: readonly AsyncDisposable[]): Promise { + const results = await Promise.allSettled( + resources.map(async (resource) => await resource[Symbol.asyncDispose]()), + ); + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + return failures; +} + +function backendForReader(reader: LimrunAppLogReader): 'ios-simulator' | 'android' { + return reader.platform === 'ios' ? 'ios-simulator' : 'android'; +} diff --git a/packages/provider-limrun/src/app-log-reconnect.test.ts b/packages/provider-limrun/src/app-log-reconnect.test.ts new file mode 100644 index 0000000000..aaa6311848 --- /dev/null +++ b/packages/provider-limrun/src/app-log-reconnect.test.ts @@ -0,0 +1,107 @@ +import { expect, test, vi } from 'vitest'; + +const iosClient = vi.hoisted(() => ({ + appLogTail: vi.fn(async () => 'provider line\n'), + disconnect: vi.fn(), +})); +const androidClient = vi.hoisted(() => ({ + startAdbTunnel: vi.fn(async () => { + throw new Error('tunnel failed'); + }), + disconnect: vi.fn(), +})); + +vi.mock('@limrun/api/ios-client', () => ({ + createInstanceClient: vi.fn(async () => iosClient), +})); +vi.mock('@limrun/api/instance-client', () => ({ + createInstanceClient: vi.fn(async () => androidClient), +})); + +import { reconnectLimrunAppLogReader } from './app-log-reconnect.ts'; +import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; + +test('reattaches an owned Limrun instance without persisting credentials', async () => { + const get = vi.fn(async () => ({ + metadata: { labels: { provider: 'limrun', leaseId: 'lease-a' } }, + status: { state: 'ready', apiUrl: 'https://instance', token: 'secret' }, + })); + const signal = new AbortController().signal; + const outcome = await reconnectLimrunAppLogReader({ + limrun: { + iosInstances: { + get, + }, + } as never, + descriptor: { + transport: 'limrun-log-poller', + platform: 'ios', + leaseId: 'lease-a', + instanceId: 'instance-a', + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }, + dependencies: {} as LimrunRuntimeDependencies, + signal, + }); + expect(get).toHaveBeenCalledWith('instance-a', { timeout: 5_000, maxRetries: 0, signal }); + expect(outcome.status).toBe('opened'); + if (outcome.status !== 'opened') return; + expect(await outcome.reader.readLogs('com.example.app', 20)).toBe('provider line\n'); + await outcome.reader[Symbol.asyncDispose](); + expect(iosClient.disconnect).toHaveBeenCalledOnce(); +}); + +test('fails closed when the instance labels do not match the descriptor lease', async () => { + const outcome = await reconnectLimrunAppLogReader({ + limrun: { + iosInstances: { + get: vi.fn(async () => ({ + metadata: { labels: { provider: 'limrun', leaseId: 'another-lease' } }, + status: { state: 'ready', apiUrl: 'https://instance', token: 'secret' }, + })), + }, + } as never, + descriptor: { + transport: 'limrun-log-poller', + platform: 'ios', + leaseId: 'lease-a', + instanceId: 'instance-a', + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }, + dependencies: {} as LimrunRuntimeDependencies, + }); + expect(outcome).toEqual({ status: 'ownership-lost' }); +}); + +test('disconnects the Android instance client when tunnel acquisition fails', async () => { + androidClient.disconnect.mockClear(); + await expect( + reconnectLimrunAppLogReader({ + limrun: { + androidInstances: { + get: vi.fn(async () => ({ + metadata: { labels: { provider: 'limrun', leaseId: 'lease-a' } }, + status: { + state: 'ready', + apiUrl: 'https://instance', + adbWebSocketUrl: 'wss://adb', + token: 'secret', + }, + })), + }, + } as never, + descriptor: { + transport: 'limrun-log-poller', + platform: 'android', + leaseId: 'lease-a', + instanceId: 'instance-a', + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }, + dependencies: {} as LimrunRuntimeDependencies, + }), + ).rejects.toThrow('tunnel failed'); + expect(androidClient.disconnect).toHaveBeenCalledOnce(); +}); diff --git a/packages/provider-limrun/src/app-log-reconnect.ts b/packages/provider-limrun/src/app-log-reconnect.ts new file mode 100644 index 0000000000..9cfaa9e170 --- /dev/null +++ b/packages/provider-limrun/src/app-log-reconnect.ts @@ -0,0 +1,154 @@ +import type Limrun from '@limrun/api'; +import { NotFoundError } from '@limrun/api'; +import { createInstanceClient as createAndroidInstanceClient } from '@limrun/api/instance-client'; +import { createInstanceClient as createIosInstanceClient } from '@limrun/api/ios-client'; +import { AsyncCleanupStack } from '@agent-device/contracts/platform'; +import type { LimrunAppLogDescriptor } from './app-log-descriptor.ts'; +import type { LimrunAppLogReader } from './app-log-poller.ts'; +import type { LimrunAppLogReconnectOutcome } from './app-log-runtime.ts'; +import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; + +export async function reconnectLimrunAppLogReader(options: { + limrun: Limrun; + descriptor: LimrunAppLogDescriptor; + dependencies: LimrunRuntimeDependencies; + signal?: AbortSignal; +}): Promise { + try { + return options.descriptor.platform === 'ios' + ? await reconnectIos(options) + : await reconnectAndroid(options); + } catch (error) { + if (error instanceof NotFoundError) return { status: 'missing' }; + throw error; + } +} + +async function reconnectIos(options: { + limrun: Limrun; + descriptor: LimrunAppLogDescriptor; + signal?: AbortSignal; +}): Promise { + const selected = await selectOwnedActiveInstance( + async () => + await options.limrun.iosInstances.get(options.descriptor.instanceId, { + timeout: 5_000, + maxRetries: 0, + signal: options.signal, + }), + options.descriptor, + ); + if (selected.status !== 'active') return selected; + const { instance } = selected; + const client = await createIosInstanceClient({ + apiUrl: selected.apiUrl, + token: instance.status.token, + logLevel: 'warn', + }); + const reader: LimrunAppLogReader = { + platform: 'ios', + leaseId: options.descriptor.leaseId, + instanceId: options.descriptor.instanceId, + readLogs: async (appBundleId, lineLimit) => await client.appLogTail(appBundleId, lineLimit), + [Symbol.asyncDispose]: async () => client.disconnect(), + }; + return { status: 'opened', reader }; +} + +async function reconnectAndroid(options: { + limrun: Limrun; + descriptor: LimrunAppLogDescriptor; + dependencies: LimrunRuntimeDependencies; + signal?: AbortSignal; +}): Promise { + const selected = await selectOwnedActiveInstance( + async () => + await options.limrun.androidInstances.get(options.descriptor.instanceId, { + timeout: 5_000, + maxRetries: 0, + signal: options.signal, + }), + options.descriptor, + ); + if (selected.status !== 'active') return selected; + const { instance } = selected; + if (!instance.status.adbWebSocketUrl) return { status: 'missing' }; + const client = await createAndroidInstanceClient({ + apiUrl: selected.apiUrl, + adbUrl: instance.status.adbWebSocketUrl, + token: instance.status.token, + logLevel: 'warn', + }); + const rollback = new AsyncCleanupStack(); + let adopted = false; + rollback.defer(async () => { + if (!adopted) client.disconnect(); + }); + try { + const tunnel = await client.startAdbTunnel(); + rollback.defer(async () => { + if (!adopted) tunnel.close(); + }); + const serial = `${tunnel.address.address}:${tunnel.address.port}`; + const adb = async ( + args: string[], + commandOptions?: Parameters[1], + ) => await options.dependencies.host.runAdb(['-s', serial, ...args], commandOptions); + const reader: LimrunAppLogReader = { + platform: 'android', + leaseId: options.descriptor.leaseId, + instanceId: options.descriptor.instanceId, + readLogs: async (_appBundleId, lineLimit) => + await options.dependencies.android.readLogs(adb, lineLimit), + [Symbol.asyncDispose]: async () => { + await options.dependencies.host + .runAdb(['disconnect', serial], { allowFailure: true, timeoutMs: 10_000 }) + .catch(() => undefined); + const results = await Promise.allSettled([ + Promise.resolve().then(() => tunnel.close()), + Promise.resolve().then(() => client.disconnect()), + ]); + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, 'Limrun Android app-log reader cleanup failed'); + } + }, + }; + adopted = true; + return { status: 'opened', reader }; + } finally { + await rollback[Symbol.asyncDispose](); + } +} + +type ReconnectableLimrunInstance = Readonly<{ + metadata: Readonly<{ labels?: Record }>; + status: Readonly<{ state: string; apiUrl?: string | null }>; +}>; + +async function selectOwnedActiveInstance( + load: () => Promise, + descriptor: LimrunAppLogDescriptor, +): Promise< + | Readonly<{ status: 'active'; instance: Instance; apiUrl: string }> + | Extract +> { + const instance = await load(); + if (!hasDescriptorOwnership(instance.metadata.labels, descriptor)) { + return { status: 'ownership-lost' }; + } + if (instance.status.state === 'terminated' || !instance.status.apiUrl) { + return { status: 'missing' }; + } + return { status: 'active', instance, apiUrl: instance.status.apiUrl }; +} + +function hasDescriptorOwnership( + labels: Record | undefined, + descriptor: LimrunAppLogDescriptor, +): boolean { + return labels?.provider === 'limrun' && labels.leaseId === descriptor.leaseId; +} diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts new file mode 100644 index 0000000000..4e6bbcee02 --- /dev/null +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -0,0 +1,187 @@ +import type { AppLogRuntimeHost, PlatformRequestScope } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { expect, test, vi } from 'vitest'; +import { createLimrunAppLogEnvelope } from './app-log-descriptor.ts'; +import { createLimrunAppLogRuntimeOwner } from './app-log-runtime.ts'; + +const device: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'limrun:ios:lease-a', + name: 'Limrun iOS', + kind: 'simulator', + target: 'mobile', + booted: true, +}; + +const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + +test('rejects a cross-platform durable descriptor before provider reconnection', async () => { + const reconnect = vi.fn(async () => ({ status: 'missing' as const })); + const owner = createLimrunAppLogRuntimeOwner({ + host: unusedHost(), + runtimeInstance: 'default', + ownsDevice: () => true, + openCurrent: async () => undefined, + reconnect, + }); + const binding = await owner.bind({ + device, + intent: { kind: 'ordinary' }, + scope, + }); + expect(binding.facts.device.providerMode).toBe('provider-runtime'); + const envelope = createLimrunAppLogEnvelope({ + sessionId: 'session', + device, + owner: owner.owner, + fence: { token: 'fence', generation: 1 }, + descriptor: { + transport: 'limrun-log-poller', + platform: 'android', + leaseId: 'lease-a', + instanceId: 'instance-a', + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }, + }); + await expect(binding.operations.appLogReattach?.({ envelope })).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'descriptor-invalid', + }); + await expect(binding.operations.appLogCleanup?.({ envelope })).resolves.toMatchObject({ + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + }); + expect(reconnect).not.toHaveBeenCalled(); +}); + +test('rejects cross-session paths before reconnecting or opening a provider reader', async () => { + const reconnect = vi.fn(async () => ({ status: 'missing' as const })); + const openCurrent = vi.fn(async () => undefined); + const owner = createLimrunAppLogRuntimeOwner({ + host: unusedHost(), + runtimeInstance: 'default', + ownsDevice: () => true, + openCurrent, + reconnect, + }); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + const envelope = createLimrunAppLogEnvelope({ + sessionId: 'one', + device, + owner: owner.owner, + fence: { token: 'fence', generation: 1 }, + descriptor: { + transport: 'limrun-log-poller', + platform: 'ios', + leaseId: 'lease-a', + instanceId: 'instance-a', + appBundleId: 'com.example.app', + outputPath: '/sessions/two/app.log', + }, + }); + + await expect(binding.operations.appLogReattach?.({ envelope })).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'descriptor-invalid', + }); + await expect( + binding.operations.appLogStart?.({ + sessionId: 'one', + appBundleId: 'com.example.app', + outputPath: '/sessions/two/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(reconnect).not.toHaveBeenCalled(); + expect(openCurrent).not.toHaveBeenCalled(); +}); + +test.each([ + { + name: 'HarmonyOS device carrying an Android-shaped Limrun id', + device: { + ...device, + platform: 'harmonyos' as const, + appleOs: undefined, + id: 'limrun:android:lease-a', + kind: 'device' as const, + }, + }, + { + name: 'non-iOS Apple leaf', + device: { ...device, appleOs: 'macos' as const, target: 'desktop' as const }, + }, + { + name: 'physical Apple kind', + device: { ...device, kind: 'device' as const }, + }, +])('rejects exact binding for an impossible Limrun $name', async ({ device: invalidDevice }) => { + const reconnect = vi.fn(async () => ({ status: 'missing' as const })); + const owner = createLimrunAppLogRuntimeOwner({ + host: unusedHost(), + runtimeInstance: 'default', + ownsDevice: () => true, + openCurrent: async () => undefined, + reconnect, + }); + + await expect( + owner.bind({ + device: invalidDevice, + intent: { + kind: 'exact-owner', + owner: owner.owner, + fence: { token: 'fence', generation: 1 }, + }, + scope, + }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_PLATFORM' }); + expect(reconnect).not.toHaveBeenCalled(); +}); + +function unusedHost(): AppLogRuntimeHost { + return { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => { + throw new Error('unused'); + }, + }, + toolchains: { prepare: async () => undefined }, + artifacts: { + resolveSession: (sessionId) => ({ + outputPath: `/sessions/${sessionId}/app.log`, + pidPath: `/sessions/${sessionId}/app-log.pid`, + }), + }, + commands: { + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + outputs: { + readTail: async () => '', + openAppend: async () => { + throw new Error('unused'); + }, + }, + processTransports: { + resolve: async () => ({ mode: 'local' }), + }, + processes: { + start: async () => { + throw new Error('unused'); + }, + readMarker: async () => ({ status: 'missing' }), + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + clock: { now: () => 1, sleep: async () => {} }, + }; +} diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts new file mode 100644 index 0000000000..f0384a4ced --- /dev/null +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -0,0 +1,245 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { parseLimrunDeviceId } from './device.ts'; +import type { + AppLogRuntimeHost, + AppLogRuntimeOperations, + DeviceBinding, + DeviceRuntimeOwner, + RuntimeFacts, +} from '@agent-device/contracts/platform'; +import { + appLogSessionArtifactsMatch, + assertAppLogSessionArtifacts, + createAppLogRecoveryOperations, + createAppLogStartResult, +} from '@agent-device/capture-kit'; +import { providerRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform'; +import { + createLimrunAppLogEnvelope, + limrunAppLogDescriptorCodec, + type LimrunAppLogDescriptor, +} from './app-log-descriptor.ts'; +import { startLimrunAppLogPoller, type LimrunAppLogReader } from './app-log-poller.ts'; + +export type LimrunAppLogReconnectOutcome = + | Readonly<{ status: 'opened'; reader: LimrunAppLogReader }> + | Readonly<{ status: 'missing' }> + | Readonly<{ status: 'ownership-lost' }>; + +export type LimrunAppLogRuntimeOwnerOptions = Readonly<{ + host: AppLogRuntimeHost; + runtimeInstance: string; + ownsDevice(device: DeviceInfo): boolean; + openCurrent(device: DeviceInfo): Promise; + reconnect( + descriptor: LimrunAppLogDescriptor, + signal?: AbortSignal, + ): Promise; +}>; + +const available = Object.freeze({ available: true } as const); + +export function createLimrunAppLogRuntimeOwner( + options: LimrunAppLogRuntimeOwnerOptions, +): DeviceRuntimeOwner { + const owner = providerRuntimeOwner('limrun', options.runtimeInstance); + return Object.freeze({ + owner, + ownsDevice: (device) => isSupportedLimrunAppLogDevice(device) && options.ownsDevice(device), + bind: async (request) => { + if (request.intent.kind === 'exact-owner' && !sameRuntimeOwner(request.intent.owner, owner)) { + throw new AppError('UNSUPPORTED_OPERATION', 'Limrun app-log owner identity does not match'); + } + if (!isSupportedLimrunAppLogDevice(request.device)) { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + 'Limrun app logs require an iOS simulator or Android emulator device identity', + ); + } + return bindLimrunAppLogs(options, owner, request.device, request.scope.signal); + }, + shutdown: async () => undefined, + }); +} + +function bindLimrunAppLogs( + options: LimrunAppLogRuntimeOwnerOptions, + owner: ReturnType, + device: DeviceInfo, + signal: AbortSignal, +): DeviceBinding { + const recovery = createAppLogRecoveryOperations({ + codec: limrunAppLogDescriptorCodec, + reattach: async (descriptor, context) => { + if ( + !descriptorMatchesDevice(descriptor, device) || + !appLogSessionArtifactsMatch(options.host, context.sessionId, descriptor) + ) { + return { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: 'Limrun app-log descriptor does not match the bound device or owning session', + }; + } + const reconnected = await options.reconnect(descriptor, signal); + if (reconnected.status === 'missing') return { status: 'missing' }; + if (reconnected.status === 'ownership-lost') { + return { status: 'unreattachable', reason: 'ownership-fence-lost' }; + } + return { + status: 'active', + handle: await startLimrunAppLogPoller({ + host: options.host, + reader: reconnected.reader, + appBundleId: descriptor.appBundleId, + outputPath: descriptor.outputPath, + }), + }; + }, + cleanup: async (descriptor, context) => + descriptorMatchesDevice(descriptor, device) && + appLogSessionArtifactsMatch(options.host, context.sessionId, descriptor) + ? { status: 'cleaned' } + : { + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + message: 'Limrun app-log descriptor does not match the bound device or owning session', + }, + }); + const operations = { + appLogInspect: async () => ({ backend: backendForDevice(device) }), + appLogDoctor: async () => ({ + backend: backendForDevice(device), + checks: { limrunSessionAvailable: await currentSessionAvailable(options, device, signal) }, + notes: [], + }), + appLogStart: async (input) => { + assertAppLogSessionArtifacts(options.host, input); + signal.throwIfAborted(); + const reader = await options.openCurrent(device); + if (!reader) { + throw new AppError('UNSUPPORTED_OPERATION', 'Limrun app logs require an active instance'); + } + const descriptor: LimrunAppLogDescriptor = { + transport: 'limrun-log-poller', + platform: reader.platform, + leaseId: reader.leaseId, + instanceId: reader.instanceId, + appBundleId: input.appBundleId, + outputPath: input.outputPath, + }; + let pollerOwnsReader = false; + try { + signal.throwIfAborted(); + const envelope = createLimrunAppLogEnvelope({ + sessionId: input.sessionId, + device, + owner, + fence: input.fence, + descriptor, + }); + pollerOwnsReader = true; + const handle = await startLimrunAppLogPoller({ + host: options.host, + reader, + appBundleId: input.appBundleId, + outputPath: input.outputPath, + }); + return createAppLogStartResult(handle, envelope); + } catch (error) { + if (!pollerOwnsReader) await reader[Symbol.asyncDispose](); + throw error; + } + }, + ...recovery, + } satisfies AppLogRuntimeOperations; + return Object.freeze({ + device, + owner, + facts: facts(device), + operations: Object.freeze(operations), + [Symbol.asyncDispose]: async () => undefined, + }); +} + +async function currentSessionAvailable( + options: LimrunAppLogRuntimeOwnerOptions, + device: DeviceInfo, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const reader = await options.openCurrent(device); + if (!reader) return false; + try { + signal.throwIfAborted(); + } finally { + await reader[Symbol.asyncDispose](); + } + return true; +} + +function facts(device: DeviceInfo): RuntimeFacts { + return Object.freeze({ + device: { + family: device.platform, + ...(device.appleOs === undefined ? {} : { appleOs: device.appleOs }), + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + ...(device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), + providerMode: 'provider-runtime', + }, + operations: { + appLogInspect: available, + appLogDoctor: available, + appLogStart: available, + appLogReattach: available, + appLogCleanup: available, + }, + }); +} + +function backendForDevice(device: DeviceInfo): 'ios-simulator' | 'android' { + return device.platform === 'apple' ? 'ios-simulator' : 'android'; +} + +function descriptorMatchesDevice(descriptor: LimrunAppLogDescriptor, device: DeviceInfo): boolean { + if (!isSupportedLimrunAppLogDevice(device)) return false; + const parsed = parseLimrunDeviceId(device.id); + return ( + parsed !== undefined && + parsed.leaseId === descriptor.leaseId && + parsed.platform === descriptor.platform && + (device.platform === 'apple' + ? descriptor.platform === 'ios' + : descriptor.platform === 'android') + ); +} + +function isSupportedLimrunAppLogDevice(device: DeviceInfo): boolean { + const parsed = parseLimrunDeviceId(device.id); + if (!parsed || device.target !== 'mobile') return false; + return parsed.platform === 'ios' + ? isSupportedLimrunIosDevice(device) + : isSupportedLimrunAndroidDevice(device); +} + +function isSupportedLimrunIosDevice(device: DeviceInfo): boolean { + return ( + device.platform === 'apple' && + device.appleOs === 'ios' && + device.kind === 'simulator' && + device.iosPhysicalDeviceBackend === undefined + ); +} + +function isSupportedLimrunAndroidDevice(device: DeviceInfo): boolean { + return ( + device.platform === 'android' && + device.appleOs === undefined && + device.kind === 'emulator' && + device.iosPhysicalDeviceBackend === undefined + ); +} diff --git a/packages/provider-limrun/src/runtime-instance.test.ts b/packages/provider-limrun/src/runtime-instance.test.ts new file mode 100644 index 0000000000..dbc7164087 --- /dev/null +++ b/packages/provider-limrun/src/runtime-instance.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from 'vitest'; +import { resolveLimrunRuntimeInstance } from './runtime-instance.ts'; + +test('derives a stable opaque identity without exposing the API key', () => { + const first = resolveLimrunRuntimeInstance({ apiKey: 'secret-key', region: ' EU ' }); + const same = resolveLimrunRuntimeInstance({ apiKey: 'secret-key', region: 'eu' }); + const changed = resolveLimrunRuntimeInstance({ apiKey: 'another-key', region: 'eu' }); + expect(first).toBe(same); + expect(changed).not.toBe(first); + expect(first).not.toContain('secret-key'); +}); + +test('uses and validates an explicit composition identity', () => { + expect(resolveLimrunRuntimeInstance({ apiKey: 'secret', runtimeInstance: ' account-a ' })).toBe( + 'account-a', + ); + expect(() => resolveLimrunRuntimeInstance({ apiKey: 'secret', runtimeInstance: ' ' })).toThrow( + 'non-empty', + ); +}); diff --git a/packages/provider-limrun/src/runtime-instance.ts b/packages/provider-limrun/src/runtime-instance.ts new file mode 100644 index 0000000000..6c728d7844 --- /dev/null +++ b/packages/provider-limrun/src/runtime-instance.ts @@ -0,0 +1,30 @@ +import { scryptSync } from 'node:crypto'; + +const RUNTIME_INSTANCE_KEY_LENGTH = 32; +const RUNTIME_INSTANCE_SCRYPT_COST = 16_384; +const RUNTIME_INSTANCE_SCRYPT_MAX_MEMORY = 64 * 1024 * 1024; +const RUNTIME_INSTANCE_SALT = 'agent-device:limrun-runtime-owner:v1'; + +export function resolveLimrunRuntimeInstance(options: { + apiKey: string; + region?: string; + runtimeInstance?: string; +}): string { + if (options.runtimeInstance !== undefined) { + const explicit = options.runtimeInstance.trim(); + if (!explicit) throw new TypeError('Limrun runtimeInstance must be a non-empty string'); + return explicit; + } + const principal = JSON.stringify({ + provider: 'limrun', + region: options.region?.trim().toLowerCase() || 'default', + apiKey: options.apiKey, + }); + const fingerprint = scryptSync(principal, RUNTIME_INSTANCE_SALT, RUNTIME_INSTANCE_KEY_LENGTH, { + N: RUNTIME_INSTANCE_SCRYPT_COST, + r: 8, + p: 1, + maxmem: RUNTIME_INSTANCE_SCRYPT_MAX_MEMORY, + }); + return `principal-${fingerprint.toString('hex')}`; +} diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 04bc4915d9..b98d569561 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -34,7 +34,17 @@ import { } from './ios.ts'; import { createLimrunDeviceSession, type LimrunDeviceSession } from './device-session.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; +import type { + AppLogRuntimeHost, + AppLogRuntimeOperations, + AppLogRuntimeProviderModule, + DeviceRuntimeOwner, +} from '@agent-device/contracts/platform'; +import { providerRuntimeOwner } from '@agent-device/contracts/platform'; +import type { LimrunAppLogDescriptor } from './app-log-descriptor.ts'; +import type { LimrunAppLogReader } from './app-log-poller.ts'; import { buildLimrunClientOptions, LIMRUN_CLIENT_HEADER } from './client-options.ts'; +import { resolveLimrunRuntimeInstance } from './runtime-instance.ts'; type LimrunInstance = { metadata: { id: string }; @@ -50,6 +60,7 @@ type LimrunRuntimeSession = LimrunIosSession | LimrunAndroidSession; export type LimrunRuntimeOptions = { apiKey: string; region?: string; + runtimeInstance?: string; }; export type LimrunRuntime = ProviderDeviceRuntime & { @@ -57,11 +68,37 @@ export type LimrunRuntime = ProviderDeviceRuntime & { getDeviceSession(device: DeviceInfo): LimrunDeviceSession | undefined; }; +export type LimrunRuntimeRegistration = Readonly<{ + runtime: LimrunRuntime; + appLogModule: AppLogRuntimeProviderModule; +}>; + +export function createLimrunRuntime( + options: LimrunRuntimeOptions, + dependencies: LimrunRuntimeDependencies, + mode: Readonly<{ includeAppLogModule: true }>, +): LimrunRuntimeRegistration; export function createLimrunRuntime( options: LimrunRuntimeOptions, dependencies: LimrunRuntimeDependencies, -): LimrunRuntime { - return new LimrunRuntimeImplementation(options, dependencies); +): LimrunRuntime; +export function createLimrunRuntime( + options: LimrunRuntimeOptions, + dependencies: LimrunRuntimeDependencies, + mode?: Readonly<{ includeAppLogModule: true }>, +): LimrunRuntime | LimrunRuntimeRegistration { + const runtime = new LimrunRuntimeImplementation(options, dependencies); + if (!mode?.includeAppLogModule) return runtime; + const owner = providerRuntimeOwner(LIMRUN_PROVIDER, resolveLimrunRuntimeInstance(options)); + if (owner.kind !== 'provider-runtime') throw new TypeError('Invalid Limrun runtime owner'); + return Object.freeze({ + runtime, + appLogModule: Object.freeze({ + owner, + loadRuntime: async (host: AppLogRuntimeHost) => + await loadLimrunAppLogRuntime(runtime, owner.instance, host), + }), + }); } class LimrunRuntimeImplementation implements ProviderDeviceRuntime { @@ -294,6 +331,32 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { return session?.platform === parsed.platform ? session : undefined; } + currentAppLogReader(device: DeviceInfo): LimrunAppLogReader | undefined { + const session = this.getSessionForDevice(device); + if (!session) return undefined; + const publicSession = createLimrunDeviceSession(session); + return { + platform: session.platform, + leaseId: session.lease.leaseId, + instanceId: session.instanceId, + readLogs: async (appBundleId, lineLimit) => + publicSession.platform === 'ios' + ? await publicSession.readLogs(appBundleId, lineLimit) + : await publicSession.readLogs(lineLimit), + [Symbol.asyncDispose]: async () => undefined, + }; + } + + async reconnectAppLogReader(descriptor: LimrunAppLogDescriptor, signal?: AbortSignal) { + const { reconnectLimrunAppLogReader } = await import('./app-log-reconnect.ts'); + return await reconnectLimrunAppLogReader({ + limrun: this.limrun, + descriptor, + dependencies: this.dependencies, + signal, + }); + } + private requireAndroidPortReverseSession(leaseId: string): LimrunAndroidSession | undefined { const session = this.sessions.get(leaseId); if (!session || session.platform === 'android') return session; @@ -304,6 +367,22 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { } } +async function loadLimrunAppLogRuntime( + runtime: LimrunRuntimeImplementation, + runtimeInstance: string, + host: AppLogRuntimeHost, +): Promise> { + const { createLimrunAppLogRuntimeOwner } = await import('./app-log-runtime.ts'); + return createLimrunAppLogRuntimeOwner({ + host, + runtimeInstance, + ownsDevice: (device) => runtime.ownsDevice(device), + openCurrent: async (device) => runtime.currentAppLogReader(device), + reconnect: async (descriptor, signal) => + await runtime.reconnectAppLogReader(descriptor, signal), + }); +} + function portReverseResult(options: ProviderPortReverseOptions): Record { return { leaseId: options.leaseId, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da0f604c25..02e0a8e5c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@agent-device/ad-script': specifier: workspace:* version: link:packages/ad-script + '@agent-device/capture-kit': + specifier: workspace:* + version: link:packages/capture-kit '@agent-device/contracts': specifier: workspace:* version: link:packages/contracts @@ -169,6 +172,15 @@ importers: specifier: workspace:* version: link:../kernel + packages/capture-kit: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + packages/contracts: dependencies: '@agent-device/kernel': @@ -191,6 +203,9 @@ importers: packages/platform-android: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts @@ -200,6 +215,9 @@ importers: packages/platform-apple: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts @@ -209,6 +227,9 @@ importers: packages/platform-harmonyos: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts @@ -218,6 +239,9 @@ importers: packages/platform-linux: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts @@ -227,6 +251,9 @@ importers: packages/platform-vega: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts @@ -236,12 +263,18 @@ importers: packages/platform-web: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts packages/provider-limrun: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts @@ -295,10 +328,10 @@ importers: devDependencies: '@callstack/rspress-preset': specifier: ^0.6.6 - version: 0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@rspress/core': specifier: ^2.0.12 - version: 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) + version: 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) packages: @@ -3661,7 +3694,7 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 @@ -3696,14 +3729,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) '@babel/traverse': 7.29.7(supports-color@7.2.0) semver: 6.3.1 transitivePeerDependencies: @@ -3711,24 +3744,24 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7': + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: @@ -3740,16 +3773,16 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) '@babel/helper-optimise-call-expression': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 @@ -3779,10 +3812,10 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: @@ -3803,7 +3836,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 @@ -3811,41 +3844,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -3881,11 +3914,11 @@ snapshots: '@braidai/lang@1.1.2': {} - '@callstack/rspress-preset@0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@callstack/rspress-preset@0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@callstack/rspress-theme': 0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) - '@rspress/plugin-sitemap': 2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2)) + '@callstack/rspress-theme': 0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + '@rspress/plugin-sitemap': 2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0)) '@vercel/analytics': 2.0.1(react@19.2.7) rsbuild-plugin-open-graph: 1.1.2(@rsbuild/core@2.0.11) zod: 4.3.6 @@ -3901,9 +3934,9 @@ snapshots: - vue - vue-router - '@callstack/rspress-theme@0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@callstack/rspress-theme@0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -4125,7 +4158,7 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 - '@mdx-js/mdx@3.1.1': + '@mdx-js/mdx@3.1.1(supports-color@7.2.0)': dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 @@ -4137,14 +4170,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.0 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6 + hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0 - remark-mdx: 3.1.1 - remark-parse: 11.0.0 + rehype-recma: 1.0.0(supports-color@7.2.0) + remark-mdx: 3.1.1(supports-color@7.2.0) + remark-parse: 11.0.0(supports-color@7.2.0) remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -4547,9 +4580,9 @@ snapshots: optionalDependencies: '@rspack/core': 2.0.6(@swc/helpers@0.5.23) - '@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2)': + '@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@mdx-js/mdx': 3.1.1 + '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@19.2.7) '@rsbuild/core': 2.0.11 '@rsbuild/plugin-react': 2.0.0(@rsbuild/core@2.0.11)(@rspack/core@2.0.6(@swc/helpers@0.5.23)) @@ -4562,9 +4595,9 @@ snapshots: copy-to-clipboard: 3.3.3 flexsearch: 0.8.212 hast-util-heading-rank: 3.0.0 - hast-util-to-jsx-runtime: 2.3.6 - mdast-util-mdx: 3.0.0 - mdast-util-mdxjs-esm: 2.0.1 + hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) + mdast-util-mdx: 3.0.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) medium-zoom: 1.1.0 nprogress: 0.2.0 react: 19.2.7 @@ -4575,11 +4608,11 @@ snapshots: react-router-dom: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) rehype-external-links: 3.0.0 rehype-raw: 7.0.0 - remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) - remark-cjk-friendly-gfm-strikethrough: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) - remark-gfm: 4.0.1 - remark-mdx: 3.1.1 - remark-parse: 11.0.0 + remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) + remark-cjk-friendly-gfm-strikethrough: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) + remark-gfm: 4.0.1(supports-color@7.2.0) + remark-mdx: 3.1.1(supports-color@7.2.0) + remark-parse: 11.0.0(supports-color@7.2.0) remark-stringify: 11.0.0 scroll-into-view-if-needed: 3.1.0 shiki: 4.0.2 @@ -4597,9 +4630,9 @@ snapshots: - micromark-util-types - supports-color - '@rspress/plugin-sitemap@2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))': + '@rspress/plugin-sitemap@2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))': dependencies: - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) '@rspress/shared@2.0.12': dependencies: @@ -4711,9 +4744,9 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/generator': 7.29.7 '@babel/parser': 7.29.3 - '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/util': 9.6.1 angular-html-parser: 10.4.0 @@ -5453,7 +5486,7 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-estree@3.1.3: + hast-util-to-estree@3.1.3(supports-color@7.2.0): dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 @@ -5463,9 +5496,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -5488,7 +5521,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6: + hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 @@ -5497,9 +5530,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -5716,14 +5749,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -5741,67 +5774,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: + mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0: + mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -5809,7 +5842,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -5818,23 +5851,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0: + mdast-util-mdx@3.0.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.3 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1: + mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -5893,11 +5926,11 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): + micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): dependencies: devlop: 1.1.0 get-east-asian-width: 1.5.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) micromark-util-character: 2.1.1 micromark-util-chunked: 2.0.1 @@ -5914,10 +5947,10 @@ snapshots: optionalDependencies: micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): + micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): dependencies: devlop: 1.1.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) micromark-util-chunked: 2.0.1 micromark-util-resolve-all: 2.0.1 @@ -6148,7 +6181,7 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@7.2.0): dependencies: '@types/debug': 4.1.13 debug: 4.4.3(supports-color@7.2.0) @@ -6467,17 +6500,17 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0: + rehype-recma@1.0.0(supports-color@7.2.0): dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 - hast-util-to-estree: 3.1.3 + hast-util-to-estree: 3.1.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - remark-cjk-friendly-gfm-strikethrough@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): + remark-cjk-friendly-gfm-strikethrough@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) + micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -6485,9 +6518,9 @@ snapshots: - micromark - micromark-util-types - remark-cjk-friendly@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): + remark-cjk-friendly@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) + micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -6495,28 +6528,28 @@ snapshots: - micromark - micromark-util-types - remark-gfm@4.0.1: + remark-gfm@4.0.1(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 + mdast-util-gfm: 3.1.0(supports-color@7.2.0) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@7.2.0) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1: + remark-mdx@3.1.1(supports-color@7.2.0): dependencies: - mdast-util-mdx: 3.0.0 + mdast-util-mdx: 3.0.0(supports-color@7.2.0) micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index aee2dd3311..2e9950826f 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -147,6 +147,7 @@ test('workspace package source selects static gates, fallow, layering, and the b for (const file of [ 'packages/kernel/src/errors.ts', 'packages/contracts/src/facades/device.ts', + 'packages/capture-kit/src/app-log-live-handle.ts', ]) { const result = plan([file]); assert.equal(result.failOpen, false, file); diff --git a/scripts/coverage-changed/run.test.ts b/scripts/coverage-changed/run.test.ts index 66de04a095..b16972d4bf 100644 --- a/scripts/coverage-changed/run.test.ts +++ b/scripts/coverage-changed/run.test.ts @@ -72,6 +72,19 @@ test('passes and prints n/a for a docs-only change without touching coverage', ( assert.match(out, /0\/0 \(n\/a\)/); }); +test('reads a large stacked diff beyond the subprocess default buffer', () => { + write('README.md', '# large stack\n' + 'documentation line\n'.repeat(80_000)); + git('add', '-A'); + git('commit', '-q', '-m', 'large docs stack'); + writeLcov('SF:src/base.ts\nDA:1,1\nend_of_record\n'); + + const { code, out } = capture(() => run(['--base', 'main'], repo)); + + assert.equal(code, 0); + assert.match(out, /Changed-line coverage gate: PASS/); + assert.match(out, /0\/0 \(n\/a\)/); +}); + test('fails when a changed source line is uncovered and names that line', () => { write('src/feature.ts', 'export const covered = 1;\nexport const uncovered = 2;\n'); git('add', '-A'); diff --git a/scripts/coverage-changed/run.ts b/scripts/coverage-changed/run.ts index 10f159f5b6..0802d63cc3 100644 --- a/scripts/coverage-changed/run.ts +++ b/scripts/coverage-changed/run.ts @@ -23,6 +23,7 @@ import { const USAGE = 'Usage: pnpm check:coverage-changed [--base ]\n'; const LCOV_PATH = 'coverage/lcov.info'; +const GIT_DIFF_MAX_BUFFER_BYTES = 16 * 1024 * 1024; function fmtPct(pct: number | null): string { return pct === null ? 'n/a' : `${pct.toFixed(2)}%`; @@ -73,6 +74,7 @@ export function run(argv: readonly string[], cwd?: string): number { const diff = runCmdSync('git', ['diff', '--unified=0', '--no-color', `${base}...HEAD`], { cwd: root, + maxBuffer: GIT_DIFF_MAX_BUFFER_BYTES, }).stdout; const result = computeChangedCoverage({ diffs: parseUnifiedDiff(diff), diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 8d89e4b356..5304acf3da 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -93,6 +93,13 @@ import { readTrackedPlatformPackageDeclarations, } from './platform-package-repository.ts'; import { policyLead, policyViolation, ZONE_POLICIES } from './zone-policy.ts'; +import { + logsLegacyRouteViolations, + logsRuntimeNarrowingViolations, + logsSessionStateOwnershipViolations, + sourceExecutedUsingDeclarationViolations, +} from './logs-runtime-cutover-policy.ts'; +import { contractsImplementationAuthorityViolations } from './contracts-implementation-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', @@ -170,6 +177,24 @@ function checkCycles(edges: readonly ResolvedImportEdge[]): LayeringViolation[] })); } +function checkLogsRuntimeCutover(sources: ReadonlyMap): LayeringViolation[] { + const production = [...sources].map(([file, source]) => ({ path: file, source })); + return [ + ...logsLegacyRouteViolations(production), + ...logsRuntimeNarrowingViolations(production), + ...logsSessionStateOwnershipViolations(production), + ...sourceExecutedUsingDeclarationViolations(production), + ]; +} + +function checkContractsImplementationAuthority( + sources: ReadonlyMap, +): LayeringViolation[] { + return contractsImplementationAuthorityViolations( + [...sources].map(([path, source]) => ({ path, source })), + ); +} + function checkBackEdges(edges: readonly ResolvedImportEdge[]): LayeringViolation[] { const seen = new Set(); return edges.flatMap((edge) => { @@ -582,6 +607,8 @@ export function main(): number { const violations = [ ...checkLayeringRules(edges), ...checkCycles(edges), + ...checkLogsRuntimeCutover(sources), + ...checkContractsImplementationAuthority(sources), ...checkBackEdges(edges), ...checkTypeInversions(edges), ...checkSessionStateOwnership(sources), diff --git a/scripts/layering/contracts-implementation-policy.test.ts b/scripts/layering/contracts-implementation-policy.test.ts new file mode 100644 index 0000000000..b7e0d67846 --- /dev/null +++ b/scripts/layering/contracts-implementation-policy.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { contractsImplementationAuthorityViolations } from './contracts-implementation-policy.ts'; + +function messages(source: string, path = 'packages/contracts/src/planted.ts'): string[] { + return contractsImplementationAuthorityViolations([{ path, source }]).map( + (violation) => violation.message, + ); +} + +test('contracts rejects host process and timer mechanics', () => { + for (const source of [ + "import fs from 'node:fs';", + "import { readFile } from 'fs/promises';", + "import { spawn } from 'node:child_process';", + "import { setTimeout as sleep } from 'node:timers/promises';", + 'setTimeout(work, 10);', + 'globalThis.setImmediate(work);', + 'clearInterval(timer);', + ]) { + assert.equal(messages(source).length, 1, source); + } +}); + +test('contracts policy ignores type vocabulary, prose, tests, and capture-kit mechanics', () => { + assert.deepEqual( + messages( + [ + 'export type Timeout = { setTimeout: number };', + 'const prose = "import fs from \'node:fs\'; setTimeout(work, 10)";', + '// clearInterval(timer);', + ].join('\n'), + ), + [], + ); + assert.deepEqual(messages("import fs from 'node:fs';", 'packages/contracts/src/a.test.ts'), []); + assert.deepEqual( + messages("import fs from 'node:fs';", 'packages/capture-kit/src/app-log-output.ts'), + [], + ); +}); diff --git a/scripts/layering/contracts-implementation-policy.ts b/scripts/layering/contracts-implementation-policy.ts new file mode 100644 index 0000000000..8d4aaea169 --- /dev/null +++ b/scripts/layering/contracts-implementation-policy.ts @@ -0,0 +1,117 @@ +import { parseSync } from 'oxc-parser'; +import type { LayeringViolation } from './model.ts'; + +export type ContractsProductionSource = Readonly<{ path: string; source: string }>; + +const RULE = 'R11 contracts-implementation-authority'; +const FORBIDDEN_HOST_MODULES = /^(?:node:)?(?:child_process|fs|timers)(?:\/|$)/; +const FORBIDDEN_TIMER_CALLS = new Set([ + 'clearImmediate', + 'clearInterval', + 'clearTimeout', + 'setImmediate', + 'setInterval', + 'setTimeout', +]); + +/** Contracts owns vocabulary. Host/process/timer mechanics belong in capture-kit or an adapter. */ +export function contractsImplementationAuthorityViolations( + sources: readonly ContractsProductionSource[], +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const file of sources) { + if (!isContractsProduction(file.path)) continue; + const parsed = parseSync(file.path, file.source); + for (const site of moduleSpecifiers(parsed.module, file.source)) { + if (!FORBIDDEN_HOST_MODULES.test(site.spec)) continue; + violations.push( + violation( + file.path, + site.line, + `contracts imports host implementation authority '${site.spec}'; move mechanics to @agent-device/capture-kit`, + ), + ); + } + visit(parsed.program, (node) => { + if (node.type !== 'CallExpression') return; + const timerName = timerCallName(node.callee); + if (!timerName) return; + violations.push( + violation( + file.path, + lineAt(file.source, Number(node.start ?? 0)), + `contracts calls timer primitive '${timerName}'; move lifecycle mechanics to @agent-device/capture-kit`, + ), + ); + }); + } + return violations; +} + +function moduleSpecifiers( + module: ReturnType['module'], + source: string, +): ReadonlyArray<{ spec: string; line: number }> { + const sites: Array<{ spec: string; line: number }> = []; + const add = (request: { value?: string; start?: number } | undefined): void => { + if (request?.value) + sites.push({ spec: request.value, line: lineAt(source, request.start ?? 0) }); + }; + for (const entry of module.staticImports) add(entry.moduleRequest); + for (const entry of module.staticExports) { + for (const exported of entry.entries) add(exported.moduleRequest); + } + for (const entry of module.dynamicImports) { + const raw = source.slice(entry.moduleRequest.start, entry.moduleRequest.end); + const literal = /^(['"])([^'"]*)\1$/.exec(raw); + if (literal) sites.push({ spec: literal[2]!, line: lineAt(source, entry.moduleRequest.start) }); + } + return sites; +} + +function timerCallName(value: unknown): string | undefined { + if (value === null || typeof value !== 'object') return undefined; + const callee = value as Record; + if (callee.type === 'Identifier' && FORBIDDEN_TIMER_CALLS.has(String(callee.name))) { + return String(callee.name); + } + if (callee.type !== 'MemberExpression' || callee.computed === true) return undefined; + const object = callee.object as Record | undefined; + const property = callee.property as Record | undefined; + if ( + object?.type !== 'Identifier' || + !['global', 'globalThis', 'window'].includes(String(object.name)) || + property?.type !== 'Identifier' || + !FORBIDDEN_TIMER_CALLS.has(String(property.name)) + ) { + return undefined; + } + return String(property.name); +} + +function isContractsProduction(file: string): boolean { + return ( + file.startsWith('packages/contracts/src/') && + !file.endsWith('.test.ts') && + !file.includes('/__tests__/') + ); +} + +function violation(file: string, line: number, message: string): LayeringViolation { + return { rule: RULE, file, line, message }; +} + +function lineAt(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +function visit(node: unknown, callback: (node: Record) => void): void { + if (node === null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visit(child, callback); + return; + } + const record = node as Record; + callback(record); + for (const value of Object.values(record)) visit(value, callback); +} diff --git a/scripts/layering/logs-runtime-cutover-policy.test.ts b/scripts/layering/logs-runtime-cutover-policy.test.ts new file mode 100644 index 0000000000..992dc323dc --- /dev/null +++ b/scripts/layering/logs-runtime-cutover-policy.test.ts @@ -0,0 +1,263 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + logsLegacyRouteViolations, + logsRuntimeNarrowingViolations, + logsSessionStateOwnershipViolations, + sourceExecutedUsingDeclarationViolations, +} from './logs-runtime-cutover-policy.ts'; + +function summaries(violations: readonly Readonly<{ file: string; message: string }>[]): string[] { + return violations.map(({ file, message }) => `${file}: ${message}`); +} + +test('legacy logs scan catches a planted provider route and plugin facet', () => { + assert.deepEqual( + summaries( + logsLegacyRouteViolations([ + { + path: 'src/daemon/planted.ts', + source: 'await withAppLogProvider(provider, task); await startAppLog(request);', + }, + { + path: 'src/platforms/planted/plugin.ts', + source: + 'export const plugin = { appLog: { resolveBackend() {} } } satisfies PlatformPlugin;', + }, + ]), + ), + [ + 'src/daemon/planted.ts: legacy logs route withAppLogProvider', + 'src/daemon/planted.ts: legacy logs route startAppLog', + 'src/platforms/planted/plugin.ts: legacy PlatformPlugin appLog facet', + ], + ); +}); + +test('legacy logs scan ignores retired names in comments and string data', () => { + assert.deepEqual( + summaries( + logsLegacyRouteViolations([ + { + path: 'src/daemon/planted.ts', + source: ` + // AppLogProvider and startAppLog were removed. + const migrationNote = 'withAppLogProvider and requireCommandSupported("logs")'; + const metadata = { note: 'appLogProvider' }; + `, + }, + { + path: 'src/platforms/apple/plugin.ts', + source: `const note = 'PUBLIC_COMMANDS.logs';`, + }, + ]), + ), + [], + ); +}); + +test('legacy logs scan retains type-import and computed executable route coverage', () => { + assert.deepEqual( + summaries( + logsLegacyRouteViolations([ + { + path: 'src/daemon/planted.ts', + source: ` + import type { AppLogProvider as LegacyProvider } from './old-app-log.ts'; + const route = handlers['startAppLog']; + `, + }, + ]), + ), + [ + 'src/daemon/planted.ts: legacy logs route AppLogProvider', + 'src/daemon/planted.ts: legacy logs route startAppLog', + ], + ); +}); + +test('legacy logs scan catches planted capability and dual-admission routes', () => { + const violations = logsLegacyRouteViolations([ + { + path: 'src/core/command-descriptor/registry.ts', + source: ` + const registry = [ + { name: 'logs', capability: { apple: {} }, platformExecution: runtime }, + { name: 'events', capability: { apple: {} } }, + ]; + `, + }, + { + path: 'src/daemon/handlers/planted.ts', + source: ` + const literal = requireCommandSupported('logs', device); + const symbolic = requireCommandSupported(PUBLIC_COMMANDS.logs, device); + `, + }, + { + path: 'src/platforms/apple/plugin.ts', + source: `const supports = { [PUBLIC_COMMANDS.logs]: supportsCoreDevice };`, + }, + { + path: 'src/core/capabilities.ts', + source: `const HARMONYOS_SUPPORTED_COMMANDS = new Set(['open', 'logs']);`, + }, + ]); + + assert.deepEqual(summaries(violations), [ + 'src/core/command-descriptor/registry.ts: logs descriptor retains legacy capability admission', + 'src/daemon/handlers/planted.ts: legacy logs capability admission requireCommandSupported', + 'src/daemon/handlers/planted.ts: legacy logs capability admission requireCommandSupported', + 'src/platforms/apple/plugin.ts: Apple plugin retains legacy logs support or hint closure', + 'src/core/capabilities.ts: HarmonyOS static command set retains logs admission', + ]); +}); + +test('legacy logs scan follows declaration roles after files move', () => { + const violations = logsLegacyRouteViolations([ + { + path: 'src/moved/command-declarations.ts', + source: `const descriptor = { name: 'logs', capability: { apple: {} } };`, + }, + { + path: 'src/moved/platform-contract.ts', + source: `export type PlatformPlugin = { readonly appLog?: { inspect(): void } };`, + }, + { + path: 'src/moved/apple-family.ts', + source: `const plugin = { appLog: { inspect() {} } } as const satisfies PlatformPlugin;`, + }, + { + path: 'src/moved/typed-plugin.ts', + source: `const plugin: PlatformPlugin = { appLog: { inspect() {} } };`, + }, + { + path: 'src/moved/capability-data.ts', + source: `const HARMONYOS_SUPPORTED_COMMANDS = new Set(['logs']);`, + }, + ]); + + assert.deepEqual( + violations.map(({ file, message }) => ({ file, message })), + [ + { + file: 'src/moved/command-declarations.ts', + message: 'logs descriptor retains legacy capability admission', + }, + { + file: 'src/moved/platform-contract.ts', + message: 'legacy PlatformPlugin appLog facet', + }, + { + file: 'src/moved/apple-family.ts', + message: 'legacy PlatformPlugin appLog facet', + }, + { + file: 'src/moved/typed-plugin.ts', + message: 'legacy PlatformPlugin appLog facet', + }, + { + file: 'src/moved/capability-data.ts', + message: 'HarmonyOS static command set retains logs admission', + }, + ], + ); +}); + +test('R14 violations retain their exact source line and punctuation', () => { + const [violation] = logsRuntimeNarrowingViolations([ + { + path: 'src/daemon/moved-handler.ts', + source: ` + const note = 'reason: retained'; + const widened = runtime as BoundDeviceRuntime; + `, + }, + ]); + + assert.deepEqual(violation, { + rule: 'R14 logs-runtime-cutover', + file: 'src/daemon/moved-handler.ts', + line: 3, + message: 'widened logs runtime access as BoundDeviceRuntime', + }); +}); + +test('narrowing scan catches planted assertions, non-null repair, and bracket access', () => { + const violations = logsRuntimeNarrowingViolations([ + { + path: 'src/daemon/handlers/planted.ts', + source: ` + const widened = runtime as BoundDeviceRuntime; + widened.operations.appLogStart!({}); + widened.operations['appLogDoctor']({}); + `, + }, + ]); + + assert.equal(violations.length, 3); + const messages = violations.map(({ message }) => message).join('\n'); + assert.match(messages, /BoundDeviceRuntime/); + assert.match(messages, /appLogStart!/); + assert.match(messages, /appLogDoctor/); +}); + +test('session state scan catches planted app-log record construction outside its owner', () => { + assert.deepEqual( + summaries( + logsSessionStateOwnershipViolations([ + { + path: 'src/daemon/handlers/planted.ts', + source: `sessionStore.set(name, { ...session, appLog: resource, appLogFailure: undefined });`, + }, + { + path: 'src/daemon/request-platform-providers.ts', + source: ` + type Scope = { appLog: { provider?: AppLogProvider } }; + const scope = { appLog: { provider } }; + `, + }, + { + path: 'src/daemon/app-log-session-resource.ts', + source: `sessionStore.set(name, { ...session, appLog: resource });`, + }, + { + path: 'src/daemon/session-teardown.ts', + source: `teardownSessionResources({ appLog: 'run' }); teardownSessionResources({ appLog: 'already-settled' });`, + }, + { + path: 'src/daemon/handlers/invalid-teardown.ts', + source: `teardownSessionResources({ appLog: 'skip' });`, + }, + ]), + ), + [ + 'src/daemon/handlers/planted.ts: session appLog record constructed outside its owner', + 'src/daemon/handlers/planted.ts: session appLogFailure record constructed outside its owner', + 'src/daemon/request-platform-providers.ts: session appLog record constructed outside its owner', + 'src/daemon/handlers/invalid-teardown.ts: session appLog record constructed outside its owner', + ], + ); +}); + +test('source-executed syntax scan rejects using declarations but ignores prose', () => { + assert.deepEqual( + summaries( + sourceExecutedUsingDeclarationViolations([ + { + path: 'packages/platform-apple/src/logs/planted.ts', + source: ` + // await using oldHandle = acquire(); + const migrationNote = 'using replacement = acquire()'; + async function run() { await using handle = acquire(); } + function runSync() { using cleanup = acquireSync(); } + `, + }, + ]), + ), + [ + 'packages/platform-apple/src/logs/planted.ts: source-executed TypeScript uses unsupported await using declaration', + 'packages/platform-apple/src/logs/planted.ts: source-executed TypeScript uses unsupported using declaration', + ], + ); +}); diff --git a/scripts/layering/logs-runtime-cutover-policy.ts b/scripts/layering/logs-runtime-cutover-policy.ts new file mode 100644 index 0000000000..34892dcc5c --- /dev/null +++ b/scripts/layering/logs-runtime-cutover-policy.ts @@ -0,0 +1,312 @@ +import { parseSync } from 'oxc-parser'; +import type { LayeringViolation } from './model.ts'; + +export type LogsRuntimeProductionSource = Readonly<{ path: string; source: string }>; + +const LOGS_RUNTIME_CUTOVER_RULE = 'R14 logs-runtime-cutover'; + +const LEGACY_LOG_ROUTE_NAMES = new Set([ + 'startAppLog', + 'stopAppLog', + 'runAppLogDoctor', + 'resolveLogBackend', + 'withAppLogProvider', + 'appLogProvider', + 'AppLogProviderResolver', + 'AppLogProvider', +]); +const RUNTIME_TYPE_ASSERTION = + /\bas\s+(?:BoundDeviceRuntime|AppLogRuntimeOperations|AppLogLiveHandle)\b/g; +const NON_NULL_RUNTIME_OPERATION = /\.operations(?:\.[A-Za-z_$][\w$]*|\[[^\]]+\])!/g; +const BRACKETED_RUNTIME_OPERATION = /\.operations\[['"]appLog[A-Za-z]+['"]\]/g; + +/** Legacy provider/tag execution is forbidden once the logs descriptor is runtime-backed. */ +export function logsLegacyRouteViolations( + sources: readonly LogsRuntimeProductionSource[], +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const file of sources) { + const parsed = parseSync(file.path, file.source); + const seenRoutes = new Set(); + visitAst(parsed.program, (node) => { + const route = legacyRouteName(node); + if (route) { + const identity = `${String(node.start ?? '')}:${route}`; + if (!seenRoutes.has(identity)) { + seenRoutes.add(identity); + violations.push(astViolation(file, node, `legacy logs route ${route}`)); + } + } + if (isLegacyLogsAdmission(node)) { + violations.push( + astViolation(file, node, 'legacy logs capability admission requireCommandSupported'), + ); + } + if (isLogsDescriptorWithCapability(node)) { + violations.push( + astViolation(file, node, 'logs descriptor retains legacy capability admission'), + ); + } + if (isAppleLogsCommandMember(node)) { + violations.push( + astViolation(file, node, 'Apple plugin retains legacy logs support or hint closure'), + ); + } + if (isHarmonyLogsCommandSetDeclaration(node)) { + violations.push( + astViolation(file, node, 'HarmonyOS static command set retains logs admission'), + ); + } + if (isPlatformPluginAppLogDeclaration(node)) { + violations.push(astViolation(file, node, 'legacy PlatformPlugin appLog facet')); + } + }); + } + return violations; +} + +function legacyRouteName(node: Record): string | undefined { + if (node.type === 'Identifier' && LEGACY_LOG_ROUTE_NAMES.has(String(node.name))) { + return String(node.name); + } + if (node.type === 'Property' || node.type === 'TSPropertySignature') { + const key = propertyName(node.key); + return key && LEGACY_LOG_ROUTE_NAMES.has(key) ? key : undefined; + } + if (node.type === 'MemberExpression' && node.computed === true) { + const key = propertyName(node.property); + return key && LEGACY_LOG_ROUTE_NAMES.has(key) ? key : undefined; + } + return undefined; +} + +function isLegacyLogsAdmission(node: Record): boolean { + if (node.type !== 'CallExpression') return false; + const callee = node.callee as Record | undefined; + const args = node.arguments as readonly Record[] | undefined; + return ( + callee?.type === 'Identifier' && + callee.name === 'requireCommandSupported' && + isLogsCommandExpression(args?.[0]) + ); +} + +function isLogsCommandExpression(node: Record | undefined): boolean { + if (node?.type === 'Literal') return node.value === 'logs'; + if (node?.type !== 'MemberExpression') return false; + const object = node.object as Record | undefined; + return ( + object?.type === 'Identifier' && + object.name === 'PUBLIC_COMMANDS' && + memberName(node) === 'logs' + ); +} + +function isLogsDescriptorWithCapability(node: Record): boolean { + if (node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) return false; + let nameIsLogs = false; + let hasCapability = false; + for (const property of node.properties as Record[]) { + if (property.type !== 'Property' || property.computed === true) continue; + const key = propertyName(property.key); + const value = property.value as Record | undefined; + if (key === 'name' && value?.type === 'Literal' && value.value === 'logs') { + nameIsLogs = true; + } + if (key === 'capability') hasCapability = true; + } + return nameIsLogs && hasCapability; +} + +function isAppleLogsCommandMember(node: Record): boolean { + if (node.type !== 'Property' || node.computed !== true) return false; + return isLogsCommandExpression(node.key as Record | undefined); +} + +function isHarmonyLogsCommandSetDeclaration(node: Record): boolean { + if (node.type !== 'VariableDeclarator') return false; + const id = node.id as Record | undefined; + return ( + id?.type === 'Identifier' && + id.name === 'HARMONYOS_SUPPORTED_COMMANDS' && + astContainsString(node.init, 'logs') + ); +} + +function isPlatformPluginAppLogDeclaration(node: Record): boolean { + if (node.type === 'TSTypeAliasDeclaration' || node.type === 'TSInterfaceDeclaration') { + const id = node.id as Record | undefined; + return id?.type === 'Identifier' && id.name === 'PlatformPlugin' && astContainsAppLog(node); + } + if (node.type === 'TSSatisfiesExpression' || node.type === 'TSAsExpression') { + return isNamedType(node.typeAnnotation, 'PlatformPlugin') && astContainsAppLog(node.expression); + } + if (node.type !== 'VariableDeclarator') return false; + const id = node.id as Record | undefined; + return isNamedType(id?.typeAnnotation, 'PlatformPlugin') && astContainsAppLog(node.init); +} + +function isNamedType(node: unknown, expected: string): boolean { + if (node === null || typeof node !== 'object') return false; + const record = node as Record; + if (record.type === 'TSTypeAnnotation') return isNamedType(record.typeAnnotation, expected); + if (record.type !== 'TSTypeReference') return false; + const name = record.typeName as Record | undefined; + return name?.type === 'Identifier' && name.name === expected; +} + +function astContainsAppLog(node: unknown): boolean { + let found = false; + visitAst(node, (candidate) => { + if ( + (candidate.type === 'Property' || candidate.type === 'TSPropertySignature') && + candidate.computed !== true && + propertyName(candidate.key) === 'appLog' + ) { + found = true; + } + }); + return found; +} + +function memberName(node: Record): string | undefined { + const property = node.property as Record | undefined; + if (!property) return undefined; + if (node.computed === true) return propertyName(property); + return property.type === 'Identifier' ? String(property.name) : undefined; +} + +function astContainsString(node: unknown, expected: string): boolean { + let found = false; + visitAst(node, (candidate) => { + if (candidate.type === 'Literal' && candidate.value === expected) found = true; + }); + return found; +} + +function visitAst(node: unknown, visitor: (node: Record) => void): void { + if (node === null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visitAst(child, visitor); + return; + } + const record = node as Record; + visitor(record); + for (const child of Object.values(record)) visitAst(child, visitor); +} + +/** Migrated daemon owners consume narrowed operations without manufacturing facet proof. */ +export function logsRuntimeNarrowingViolations( + sources: readonly LogsRuntimeProductionSource[], +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) { + for (const pattern of [ + RUNTIME_TYPE_ASSERTION, + NON_NULL_RUNTIME_OPERATION, + BRACKETED_RUNTIME_OPERATION, + ]) { + for (const match of file.source.matchAll(pattern)) { + violations.push( + offsetViolation(file, match.index, `widened logs runtime access ${match[0]}`), + ); + } + } + } + return violations; +} + +/** Session app-log state transitions have one whole-record replacement owner. */ +export function logsSessionStateOwnershipViolations( + sources: readonly LogsRuntimeProductionSource[], +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const file of sources) { + if ( + !file.path.startsWith('src/daemon/') || + file.path === 'src/daemon/app-log-session-resource.ts' || + file.path === 'src/daemon/types.ts' + ) { + continue; + } + const parsed = parseSync(file.path, file.source); + visitAst(parsed.program, (node) => { + if (node.type === 'Property' && node.kind === 'init' && node.computed !== true) { + const field = propertyName(node.key); + if ( + (field === 'appLog' || field === 'appLogFailure') && + !isAppLogTeardownDiscriminant(field, node.value) + ) { + violations.push( + astViolation(file, node, `session ${field} record constructed outside its owner`), + ); + } + } + }); + } + return violations; +} + +function isAppLogTeardownDiscriminant(field: string, valueNode: unknown): boolean { + if (field !== 'appLog' || !valueNode || typeof valueNode !== 'object') return false; + const value = valueNode as Record; + return value.type === 'Literal' && (value.value === 'run' || value.value === 'already-settled'); +} + +/** + * Source-executed TypeScript must remain parseable by the supported Node 22 + * type-stripping runtime. OXC identifies both declaration forms without + * mistaking comments or string data for executable syntax. + */ +export function sourceExecutedUsingDeclarationViolations( + sources: readonly LogsRuntimeProductionSource[], +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const file of sources) { + const parsed = parseSync(file.path, file.source); + visitAst(parsed.program, (node) => { + if ( + node.type === 'VariableDeclaration' && + (node.kind === 'using' || node.kind === 'await using') + ) { + violations.push( + astViolation( + file, + node, + `source-executed TypeScript uses unsupported ${String(node.kind)} declaration`, + ), + ); + } + }); + } + return violations; +} + +function astViolation( + file: LogsRuntimeProductionSource, + node: Record, + message: string, +): LayeringViolation { + return offsetViolation(file, typeof node.start === 'number' ? node.start : 0, message); +} + +function offsetViolation( + file: LogsRuntimeProductionSource, + offset: number, + message: string, +): LayeringViolation { + return { + rule: LOGS_RUNTIME_CUTOVER_RULE, + file: file.path, + line: file.source.slice(0, offset).split('\n').length, + message, + }; +} + +function propertyName(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as Record; + return record.type === 'Identifier' || record.type === 'Literal' + ? ((record.name as string | undefined) ?? (record.value as string | undefined)) + : undefined; +} diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 434580f482..fc8909c978 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -84,6 +84,7 @@ export function zoneRank(zone: string): number | null { export const UNRANKED_ZONES: ReadonlySet = new Set([ '(root)', 'kernel', + 'capture-kit', 'platform-apple', 'platform-android', 'platform-harmonyos', diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index cae5f71254..0aee8f1fb1 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -304,6 +304,19 @@ test('the real tree parses, declares, and passes R11', () => { assert.ok(contractsPackage, 'contracts package must exist'); assert.deepEqual([...contractsPackage.exportTargets.keys()].sort(), [...CONTRACT_EXPORTS].sort()); assert.deepEqual([...contractsPackage.workspaceDependencies], ['@agent-device/kernel']); + const captureKitPackage = packages.find((pkg) => pkg.name === '@agent-device/capture-kit'); + assert.ok(captureKitPackage, 'capture-kit package must exist'); + assert.equal( + JSON.parse(fs.readFileSync(path.join(repoRoot, 'packages/capture-kit/package.json'), 'utf8')) + .private, + true, + 'capture-kit stays a private implementation package', + ); + assert.deepEqual([...captureKitPackage.exportTargets.keys()], ['@agent-device/capture-kit']); + assert.deepEqual([...captureKitPackage.workspaceDependencies].sort(), [ + '@agent-device/contracts', + '@agent-device/kernel', + ]); const maestroPackage = packages.find((pkg) => pkg.name === '@agent-device/maestro'); assert.ok(maestroPackage, 'maestro package must exist'); assert.deepEqual([...maestroPackage.exportTargets.keys()], ['@agent-device/maestro']); @@ -451,6 +464,7 @@ test('the real tree parses, declares, and passes R11', () => { ['@agent-device/provider-limrun'], ); assert.deepEqual([...providerLimrunPackage.workspaceDependencies].sort(), [ + '@agent-device/capture-kit', '@agent-device/contracts', '@agent-device/kernel', ]); @@ -468,6 +482,10 @@ test('the real tree parses, declares, and passes R11', () => { assert.ok(xmlPackage, 'xml package must exist'); assert.deepEqual([...xmlPackage.exportTargets.keys()], ['@agent-device/xml']); assert.deepEqual([...xmlPackage.workspaceDependencies], []); + assert.ok( + rootWorkspaceDependencyNames(repoRoot).has('@agent-device/capture-kit'), + 'root must declare the capture-kit workspace dependency', + ); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/kernel'), 'root must declare the kernel workspace dependency', diff --git a/scripts/layering/platform-composition-policy.ts b/scripts/layering/platform-composition-policy.ts index bb0a2e69e9..018462074a 100644 --- a/scripts/layering/platform-composition-policy.ts +++ b/scripts/layering/platform-composition-policy.ts @@ -58,6 +58,8 @@ function isAllowedCompositionImport(specifier: string): boolean { return ( /^@agent-device\/contracts(?:\/|$)/.test(specifier) || /^@agent-device\/platform-[^/]+$/.test(specifier) || + specifier === './platform-runtime-app-log.ts' || + specifier === './platform-runtime-app-log-host.ts' || specifier === './platform-runtime-device-inventory.ts' || specifier === './platform-runtime-host.ts' || specifier.startsWith('./platform-runtime-host/') diff --git a/scripts/layering/platform-package-policy.test.ts b/scripts/layering/platform-package-policy.test.ts index e919f210f4..b4b4a3421d 100644 --- a/scripts/layering/platform-package-policy.test.ts +++ b/scripts/layering/platform-package-policy.test.ts @@ -72,7 +72,8 @@ test('the inventory substrate has six private lazy packages and one exact compos assert.deepEqual(checkPlatformPackagePolicy(validSources(), declarations()), []); }); -test('platform workspace packages are R11-owned unranked zones', () => { +test('capture-kit and platform workspace packages are R11-owned unranked zones', () => { + assert.equal(classifyZone('capture-kit'), 'unranked'); for (const family of CANONICAL_PLATFORM_FAMILIES) { assert.equal(classifyZone(`platform-${family}`), 'unranked', family); } @@ -157,7 +158,14 @@ test('platform packages cannot escape to root, daemon, siblings, or raw process } }); -test('platform packages cannot depend on any other workspace implementation package', () => { +test('platform packages may use capture-kit but no unrelated workspace implementation package', () => { + const allowed = validSources(); + allowed.set( + 'packages/platform-apple/src/probe.test.ts', + "import { createAppLogLiveHandle } from '@agent-device/capture-kit';", + ); + assert.deepEqual(checkPlatformPackagePolicy(allowed, declarations()), []); + for (const specifier of [ '@agent-device/selectors', '@agent-device/provider-webdriver', @@ -170,7 +178,7 @@ test('platform packages cannot depend on any other workspace implementation pack ); assert.match( messages(sources).join('\n'), - /may import workspace code only from contracts or kernel/, + /may import workspace code only from capture-kit, contracts, or kernel/, ); } }); @@ -246,9 +254,13 @@ test('composition imports are category-based for future contracts and host adapt [ composition(), "import type { PlatformRequestScope } from '@agent-device/contracts/platform';", + "import { createComposedAppLogRuntimeGateway } from './platform-runtime-app-log.ts';", + "import { createAppLogRuntimeHost } from './platform-runtime-app-log-host.ts';", "import { hostCommandRunner } from './platform-runtime-host/command.ts';", "import { createComposedDeviceInventoryGateways } from './platform-runtime-device-inventory.ts';", 'void hostCommandRunner;', + 'void createComposedAppLogRuntimeGateway;', + 'void createAppLogRuntimeHost;', 'void createComposedDeviceInventoryGateways;', 'export type Scope = PlatformRequestScope;', ].join('\n'), diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts index 5511c9d766..d08c85b74f 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -151,6 +151,8 @@ function checkSource(file: string, source: string): LayeringViolation[] { if ( site.spec.startsWith('@agent-device/') && !site.spec.startsWith('@agent-device/contracts/') && + site.spec !== '@agent-device/capture-kit' && + !site.spec.startsWith('@agent-device/capture-kit/') && !site.spec.startsWith('@agent-device/kernel/') && !isPackageOwnedFacadeTest(file, ownerFamily, site.spec) ) { @@ -158,7 +160,7 @@ function checkSource(file: string, source: string): LayeringViolation[] { violation( file, site.line, - `platform-${ownerFamily} may import workspace code only from contracts or kernel; found '${site.spec}'`, + `platform-${ownerFamily} may import workspace code only from capture-kit, contracts, or kernel; found '${site.spec}'`, ), ); } @@ -221,5 +223,5 @@ export function checkPlatformPackagePolicy( } export function platformPackagePolicySummary(): string { - return 'R13 holds six private implementation-lazy platform inventory packages behind one composition root'; + return 'R13 holds six private implementation-lazy platform packages above capture-kit behind one composition root'; } diff --git a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts index 74c5699b08..6952f9f828 100644 --- a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts +++ b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts @@ -72,7 +72,6 @@ const SUPPORTS_REF: Record boolean> = { install: supportsAppInstallation, reinstall: supportsAppInstallation, 'install-from-source': supportsAppInstallation, - logs: supportsCoreDevicePhysicalOperation, perf: supportsCoreDevicePhysicalOperation, record: supportsCoreDevicePhysicalOperation, push: isNotMacOs, @@ -105,7 +104,6 @@ const HINT_REF: Record string | undefined> = { install: coreDeviceOnlyPhysicalOperationHint, reinstall: coreDeviceOnlyPhysicalOperationHint, 'install-from-source': coreDeviceOnlyPhysicalOperationHint, - logs: coreDeviceOnlyPhysicalOperationHint, perf: coreDeviceOnlyPhysicalOperationHint, record: coreDeviceOnlyPhysicalOperationHint, 'tv-remote': (device) => { diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index 77e909b971..a16c6c4aae 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -1,23 +1,32 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { createDefaultProviderDeviceRuntimes } from '../provider-device-runtimes.ts'; +import { createDefaultProviderRuntimeComposition } from '../provider-device-runtimes.ts'; test('default provider runtimes skip Limrun when only the removed API key alias is configured', async () => { - const runtimes = await createDefaultProviderDeviceRuntimes({ LIM_API_KEY: 'lim_test_key' }); + const { runtimes, appLogModules } = await createDefaultProviderRuntimeComposition({ + LIM_API_KEY: 'lim_test_key', + }); assert.equal( runtimes.some((runtime) => runtime.provider === 'limrun'), false, ); + assert.equal(appLogModules.length, 0); await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); }); test('default provider runtimes load Limrun when a Limrun API key is configured', async () => { - const runtimes = await createDefaultProviderDeviceRuntimes({ LIMRUN_API_KEY: 'lim_test_key' }); + const { runtimes, appLogModules } = await createDefaultProviderRuntimeComposition({ + LIMRUN_API_KEY: 'lim_test_key', + }); assert.equal( runtimes.some((runtime) => runtime.provider === 'limrun'), true, ); + const limrun = runtimes.find((runtime) => runtime.provider === 'limrun'); + assert.equal(limrun ? 'loadRuntime' in limrun : true, false); + assert.equal(appLogModules.length, 1); + assert.equal(appLogModules[0]?.runtime, limrun); await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); }); diff --git a/src/__tests__/test-utils/app-log-live-handle.ts b/src/__tests__/test-utils/app-log-live-handle.ts new file mode 100644 index 0000000000..1a9d8b4d69 --- /dev/null +++ b/src/__tests__/test-utils/app-log-live-handle.ts @@ -0,0 +1,21 @@ +import type { + AppLogCompletion, + AppLogLiveHandle, + AppLogLiveSnapshot, + CleanupOutcome, + FinishOutcome, +} from '@agent-device/contracts/platform'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit'; + +type AppLogLiveHandleImplementation = Readonly<{ + inspect(): AppLogLiveSnapshot; + finish(): Promise>; + forceCleanup(): Promise; +}>; + +/** Test-only constructor for owners that need independently controlled finish and cleanup outcomes. */ +export function createTestAppLogLiveHandle( + implementation: AppLogLiveHandleImplementation, +): AppLogLiveHandle { + return createAppLogLiveHandle(implementation); +} diff --git a/src/cli-schema/command-schema-guards.test.ts b/src/cli-schema/command-schema-guards.test.ts index 2cfb42a4a5..701a42c89f 100644 --- a/src/cli-schema/command-schema-guards.test.ts +++ b/src/cli-schema/command-schema-guards.test.ts @@ -58,14 +58,16 @@ test('cli.ts command dispatch checks are recognized by parser-level unknown-comm test('schema capability mappings match capability source-of-truth', () => { const cliCommands = new Set(listCliCommandNames()); - const capabilityCheckedCommands = commandDescriptors + const capabilityCatalogCommands = commandDescriptors .filter( (descriptor) => - 'capability' in descriptor && descriptor.capability && cliCommands.has(descriptor.name), + (('capability' in descriptor && descriptor.capability !== undefined) || + descriptor.platformExecution.kind === 'device-runtime') && + cliCommands.has(descriptor.name), ) .map((descriptor) => descriptor.name) .sort(); - assert.deepEqual(capabilityCheckedCommands, listCapabilityCommands()); + assert.deepEqual(capabilityCatalogCommands, listCapabilityCommands()); }); function collectCliDispatchCommandLiterals(): Set { diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index eca8a9414a..05c753f113 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -253,11 +253,12 @@ test('viewport resizing is admitted only on web, where a backend exists', () => }); test('capabilities reject CoreDevice-only commands for XCTest-backed devices', () => { + // Runtime-backed logs admission is proven from exact device facts in + // session-capabilities.test.ts, never through this legacy matrix projection. const coreDeviceOnlyCommands = [ 'apps', 'install', 'install-from-source', - 'logs', 'perf', 'record', 'reinstall', @@ -405,7 +406,6 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'install', 'install-from-source', 'keyboard', - 'logs', 'network', 'perf', 'push', @@ -457,7 +457,6 @@ test('web supports only the initial browser interaction slice', () => { 'install', 'install-from-source', 'keyboard', - 'logs', 'longpress', 'perf', 'push', diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 431e2bb059..7574adee94 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -136,7 +136,6 @@ const SUPPORTS_REF: Record boolean> = { install: supportsAppInstallation, reinstall: supportsAppInstallation, 'install-from-source': supportsAppInstallation, - logs: supportsCoreDevicePhysicalOperation, perf: supportsCoreDevicePhysicalOperation, record: supportsCoreDevicePhysicalOperation, push: isNotMacOs, @@ -165,7 +164,6 @@ const HINT_REF: Record string | undefined> = { install: coreDeviceOnlyPhysicalOperationHint, reinstall: coreDeviceOnlyPhysicalOperationHint, 'install-from-source': coreDeviceOnlyPhysicalOperationHint, - logs: coreDeviceOnlyPhysicalOperationHint, perf: coreDeviceOnlyPhysicalOperationHint, record: coreDeviceOnlyPhysicalOperationHint, 'tv-remote': (device) => { @@ -211,7 +209,6 @@ const HARMONYOS_SUPPORTED_COMMANDS_REF = new Set([ 'keyboard', 'is', 'longpress', - 'logs', 'press', 'reinstall', 'screenshot', @@ -269,7 +266,7 @@ test('(b.1) plugin-bucket selection matches the platform -> bucket table', () => }); test('(b.1) isCommandSupportedOnDevice is unchanged across the command x device matrix', () => { - const commands = listCapabilityCommands(); + const commands = Object.keys(BASE_COMMAND_CAPABILITY_MATRIX); for (const command of commands) { for (const device of SAMPLE_DEVICES) { // BASE lacks the `web` augmentation, so the descriptor-fold reference is only @@ -286,7 +283,7 @@ test('(b.1) isCommandSupportedOnDevice is unchanged across the command x device }); test('HarmonyOS advertises only the current HDC-backed command subset', () => { - const availableCommands = listCapabilityCommands() + const availableCommands = Object.keys(BASE_COMMAND_CAPABILITY_MATRIX) .filter((command) => isCommandSupportedOnDevice(command, HARMONYOS_EMULATOR)) .sort(); @@ -306,7 +303,6 @@ test('HarmonyOS advertises only the current HDC-backed command subset', () => { 'install', 'is', 'keyboard', - 'logs', 'longpress', 'open', 'perf', @@ -323,7 +319,7 @@ test('HarmonyOS advertises only the current HDC-backed command subset', () => { }); test('(b.2) unsupportedHint closures are verbatim across the full device matrix', () => { - const commands = listCapabilityCommands(); + const commands = Object.keys(BASE_COMMAND_CAPABILITY_MATRIX); for (const command of commands) { const reference = HINT_REF[command]; for (const device of SAMPLE_DEVICES) { @@ -342,6 +338,11 @@ test('(b.2) unsupportedHint closures are verbatim across the full device matrix' } }); +test('the capability catalog includes runtime-backed commands without restoring legacy admission', () => { + assert.ok(listCapabilityCommands().includes('logs')); + assert.equal(BASE_COMMAND_CAPABILITY_MATRIX['logs'], undefined); +}); + test('(b.2) the Apple plugin carries exactly the relocated supports/hint closures', () => { // The relocation target: `supports()` / `unsupportedHint()` now live on the Apple // plugin (the family that owns every discriminating device). Pin the RELOCATED maps' diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index b16d8e6c11..dc2e566944 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -56,7 +56,6 @@ const HARMONYOS_SUPPORTED_COMMANDS = new Set([ 'keyboard', 'is', 'longpress', - 'logs', 'press', 'reinstall', 'screenshot', @@ -168,7 +167,14 @@ export function unsupportedHintForDevice(command: string, device: DeviceInfo): s } export function listCapabilityCommands(): string[] { - return Object.keys(COMMAND_CAPABILITY_MATRIX).sort(); + return commandDescriptors + .filter( + (descriptor) => + ('capability' in descriptor && descriptor.capability !== undefined) || + descriptor.platformExecution.kind === 'device-runtime', + ) + .map((descriptor) => descriptor.name) + .sort(); } /** diff --git a/src/core/command-descriptor/__tests__/logs-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/logs-runtime-execution.test.ts new file mode 100644 index 0000000000..3f8736d352 --- /dev/null +++ b/src/core/command-descriptor/__tests__/logs-runtime-execution.test.ts @@ -0,0 +1,11 @@ +import { appLogRuntimePlanUses } from '@agent-device/contracts/platform'; +import { expect, test } from 'vitest'; +import { commandDescriptors } from '../registry.ts'; + +test('logs descriptor declares exactly the distinct uses selected by all seven plans', () => { + const logs = commandDescriptors.find(({ name }) => name === 'logs'); + expect(logs?.platformExecution).toEqual({ + kind: 'device-runtime', + uses: appLogRuntimePlanUses, + }); +}); diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index c15bebd102..e560bf749c 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -41,8 +41,9 @@ const DAEMON_FUNCTION_TRAITS = [ // device mutation could be classified, so it is no longer unrouted.) const UNROUTED_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSource]); -// Public commands that intentionally carry no capability entry — pure control-plane -// or always-admitted commands, so the capability matrix has never covered them. +// Public commands that intentionally carry no legacy capability entry. Most are +// pure control-plane or always-admitted commands; logs is admitted from exact +// runtime facts and therefore belongs to the capability catalog without a matrix row. const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.appState, PUBLIC_COMMANDS.artifacts, @@ -51,6 +52,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.doctor, PUBLIC_COMMANDS.events, + PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.test, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 4f9b2d9508..43a11d93b2 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -12,7 +12,11 @@ import { } from './timeout-policy.ts'; import { resolvePostActionObservationSupport } from './post-action-observation.ts'; import type { PostActionObservationSupport } from './post-action-observation.ts'; -import { assertCommandPlatformExecution, inventoryUse } from '@agent-device/contracts/platform'; +import { + appLogRuntimePlanUses, + assertCommandPlatformExecution, + inventoryUse, +} from '@agent-device/contracts/platform'; import type { CommandCatalogGroup, CommandDescriptor, @@ -514,7 +518,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, recordsSessionAction: false, daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, + platformExecution: { kind: 'device-runtime', uses: appLogRuntimePlanUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, diff --git a/src/core/interactors/register-builtins.ts b/src/core/interactors/register-builtins.ts index fed858193e..1092876283 100644 --- a/src/core/interactors/register-builtins.ts +++ b/src/core/interactors/register-builtins.ts @@ -32,8 +32,6 @@ const androidPlugin = { device.target === 'tv' ? undefined : 'tv-remote is supported only on Android TV targets.', }, }, - // Wraps the Android arm of `resolveLogBackend`: every Android device -> 'android'. - appLog: { resolveBackend: () => 'android' }, // Wraps the Android arm of `supportsPlatformPerfMetrics`: every Android device // reports perf-metrics support. `metricsSamplerTag` wraps the Android arm of the // former `buildPerfResponseData` sampling branch: every supported Android device @@ -55,7 +53,6 @@ const harmonyosPlugin = { id: 'harmonyos', platforms: ['harmonyos'], capability: { bucket: 'harmonyos' }, - appLog: { resolveBackend: () => 'harmonyos' }, perf: { supportsMetrics: () => true, metricsSamplerTag: () => 'harmonyos' }, // HarmonyOS exposes the system recorder only on physical devices. The backend // validates its whole-screen-only scope before starting the service. diff --git a/src/daemon/__tests__/app-log-admission-ledger.test.ts b/src/daemon/__tests__/app-log-admission-ledger.test.ts new file mode 100644 index 0000000000..fb4626f583 --- /dev/null +++ b/src/daemon/__tests__/app-log-admission-ledger.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from 'vitest'; +import { deviceIdentity, type DeviceInfo } from '@agent-device/kernel/device'; +import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; +import { createNextAppLogFence } from '../app-log-start-preflight.ts'; +import { resolveAppLogResourcePath } from '../app-log-resource-store.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; + +const DEVICE: DeviceInfo = { + platform: 'android', + id: 'ledger-device', + name: 'Pixel', + kind: 'emulator', +}; + +const OTHER_DEVICE: DeviceInfo = { + platform: 'android', + id: 'other-ledger-device', + name: 'Other Pixel', + kind: 'emulator', +}; + +test('an undurable cleanup block is isolated to its daemon-owned admission ledger', () => { + const blockedLedger = createAppLogAdmissionLedger(); + const restartedDaemonLedger = createAppLogAdmissionLedger(); + const sessionStore = makeSessionStore('app-log-admission-ledger-'); + const resourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir('session')); + + blockedLedger.blockUndurableCleanup(DEVICE, 'cleanup could not be confirmed'); + + expect(() => + createNextAppLogFence({ ledger: blockedLedger, resourcePath, device: DEVICE }), + ).toThrow(/process-local/); + expect( + createNextAppLogFence({ ledger: restartedDaemonLedger, resourcePath, device: DEVICE }) + .generation, + ).toBe(1); +}); + +test('retained legacy markers fail closed only in the daemon ledger that observed them', () => { + const retainingLedger = createAppLogAdmissionLedger({ markerExists: () => true }); + const restartedDaemonLedger = createAppLogAdmissionLedger(); + const sessionStore = makeSessionStore('app-log-admission-legacy-ledger-'); + const resourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir('session')); + + retainingLedger.retainLegacyMarkers([ + { markerPath: '/sessions/legacy/app-log.pid', device: deviceIdentity(DEVICE) }, + ]); + + expect(() => + createNextAppLogFence({ ledger: retainingLedger, resourcePath, device: DEVICE }), + ).toThrow(/legacy app-log marker/); + expect( + createNextAppLogFence({ ledger: restartedDaemonLedger, resourcePath, device: DEVICE }) + .generation, + ).toBe(1); + expect( + createNextAppLogFence({ ledger: retainingLedger, resourcePath, device: OTHER_DEVICE }) + .generation, + ).toBe(1); +}); + +test('removing a retained legacy marker unblocks the matching device without a daemon restart', () => { + let markerExists = true; + const ledger = createAppLogAdmissionLedger({ markerExists: () => markerExists }); + const sessionStore = makeSessionStore('app-log-admission-removed-marker-'); + const resourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir('session')); + + ledger.retainLegacyMarkers([ + { markerPath: '/sessions/legacy/app-log.pid', device: deviceIdentity(DEVICE) }, + ]); + expect(() => createNextAppLogFence({ ledger, resourcePath, device: DEVICE })).toThrow( + /legacy app-log marker/, + ); + + markerExists = false; + expect(createNextAppLogFence({ ledger, resourcePath, device: DEVICE }).generation).toBe(1); +}); + +test('undurable cleanup blocks expire within the configured bound and report a diagnostic', () => { + let now = 1_000; + const expired: string[] = []; + const ledger = createAppLogAdmissionLedger({ + now: () => now, + undurableCleanupTtlMs: 500, + onUndurableCleanupExpired: ({ reason }) => expired.push(reason), + }); + const sessionStore = makeSessionStore('app-log-admission-expiry-'); + const resourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir('session')); + + ledger.blockUndurableCleanup(DEVICE, 'cleanup could not be confirmed'); + expect(() => createNextAppLogFence({ ledger, resourcePath, device: DEVICE })).toThrow( + /process-local/, + ); + + now += 501; + expect(createNextAppLogFence({ ledger, resourcePath, device: DEVICE }).generation).toBe(1); + expect(expired).toEqual(['cleanup could not be confirmed']); +}); diff --git a/src/daemon/__tests__/app-log-android.test.ts b/src/daemon/__tests__/app-log-android.test.ts deleted file mode 100644 index 8d12a3ccc7..0000000000 --- a/src/daemon/__tests__/app-log-android.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { test, expect, vi } from 'vitest'; -import assert from 'node:assert/strict'; -import { EventEmitter } from 'node:events'; -import fs from 'node:fs'; -import path from 'node:path'; -import { PassThrough } from 'node:stream'; -import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; - -vi.mock('../../utils/exec.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), - runCmdBackground: vi.fn(), - }; -}); -vi.mock('../app-log-stream.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, sleep: vi.fn(async () => {}) }; -}); - -import { runCmd, runCmdBackground } from '../../utils/exec.ts'; -import { readRecentAndroidLogcatForPackage, startAndroidAppLog } from '../app-log-android.ts'; - -const mockRunCmd = vi.mocked(runCmd); -const mockRunCmdBackground = vi.mocked(runCmdBackground); - -type MockChild = EventEmitter & { - stdout: PassThrough; - stderr: PassThrough; - pid?: number; - killed: boolean; - kill: (signal?: NodeJS.Signals) => boolean; -}; - -function makeMockChild(pid?: number): MockChild { - const child = new EventEmitter() as MockChild; - child.stdout = new PassThrough(); - child.stderr = new PassThrough(); - if (pid !== undefined) { - child.pid = pid; - } - child.killed = false; - child.kill = () => { - if (child.killed) return false; - child.killed = true; - queueMicrotask(() => child.emit('close', 0)); - return true; - }; - return child; -} - -function mockBackgroundChild(child: MockChild): ReturnType { - return { - child: child as unknown as ReturnType['child'], - wait: new Promise((resolve) => { - child.once('close', (code) => resolve({ stdout: '', stderr: '', exitCode: code ?? 0 })); - }), - }; -} - -test('startAndroidAppLog returns to active state after a successful reattach', async () => { - const logDir = mkdtempForTestSync('agent-device-android-log-'); - const stream = fs.createWriteStream(path.join(logDir, 'app.log')); - const firstChild = makeMockChild(1001); - const secondChild = makeMockChild(1002); - - mockRunCmd.mockReset(); - let pidLookupCount = 0; - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - pidLookupCount += 1; - return { - stdout: pidLookupCount === 1 ? '111\n' : '222\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - mockRunCmdBackground.mockReset(); - let spawnCount = 0; - mockRunCmdBackground.mockImplementation(() => { - spawnCount += 1; - if (spawnCount === 1) { - return mockBackgroundChild(firstChild); - } - return mockBackgroundChild(secondChild); - }); - - const appLog = await startAndroidAppLog('emulator-5554', 'com.example.app', stream, []); - await vi.waitFor(() => { - expect(mockRunCmdBackground).toHaveBeenCalledTimes(1); - }); - assert.equal(appLog.getState(), 'active'); - - firstChild.emit('close', 1); - await vi.waitFor(() => { - expect(mockRunCmdBackground).toHaveBeenCalledTimes(2); - }); - assert.equal(appLog.getState(), 'active'); - - await appLog.stop(); - await appLog.wait; -}); - -test('startAndroidAppLog reports active for provider streams without host pid', async () => { - const logDir = mkdtempForTestSync('agent-device-android-log-'); - const stream = fs.createWriteStream(path.join(logDir, 'app.log')); - const child = makeMockChild(); - - mockRunCmd.mockReset(); - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '111\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - mockRunCmdBackground.mockReset(); - mockRunCmdBackground.mockImplementation(() => mockBackgroundChild(child)); - - const appLog = await startAndroidAppLog('emulator-5554', 'com.example.app', stream, []); - await vi.waitFor(() => { - expect(mockRunCmdBackground).toHaveBeenCalledTimes(1); - }); - - assert.equal(appLog.getState(), 'active'); - - await appLog.stop(); - await appLog.wait; -}); - -test('readRecentAndroidLogcatForPackage keeps lines for package-associated prior pids', async () => { - mockRunCmd.mockReset(); - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { - return { - stdout: - '04-01 10:00:00.000 I/ActivityManager( 9999): Process com.example.app (pid 1234) has died\n' + - '04-01 10:00:00.500 D/GIBSDK (1234): POST https://api.example.com/v1/submit status=504 duration=15000\n' + - '04-01 10:00:01.000 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + - '04-01 10:00:01.500 D/GIBSDK (4321): GET https://api.example.com/v1/ping status=200\n' + - '04-01 10:00:02.000 D/OtherTag (7777): GET https://example.com/ignore status=200\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const result = await readRecentAndroidLogcatForPackage('emulator-5554', 'com.example.app'); - - assert.ok(result); - expect(result?.pid).toBe('4321'); - expect(result?.recoveredPids).toEqual(['4321', '1234']); - expect(result?.text).toContain('(1234): POST https://api.example.com/v1/submit'); - expect(result?.text).toContain('(4321): GET https://api.example.com/v1/ping'); - expect(result?.text).not.toContain('https://example.com/ignore'); -}); diff --git a/src/daemon/__tests__/app-log-resource-fence.test.ts b/src/daemon/__tests__/app-log-resource-fence.test.ts new file mode 100644 index 0000000000..2e2f60f823 --- /dev/null +++ b/src/daemon/__tests__/app-log-resource-fence.test.ts @@ -0,0 +1,83 @@ +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { withAppLogResourceFence } from '../app-log-resource-fence.ts'; +import { + readAppLogResourceRecord, + resolveAppLogResourcePath, + writeAppLogResourceRecord, +} from '../app-log-resource-store.ts'; + +test('ownership fence rejects a stale token before the native side effect', async () => { + const resourcePath = makeRecord(); + const sideEffect = vi.fn(async () => {}); + await expect( + withAppLogResourceFence({ + resourcePath, + expected: { token: 'stale', generation: 1 }, + run: sideEffect, + }), + ).rejects.toMatchObject({ details: { reason: 'ownership-fence-lost' } }); + expect(sideEffect).not.toHaveBeenCalled(); +}); + +test('ownership fence serializes validation, side effect, and persisted transition', async () => { + const resourcePath = makeRecord(); + const order: string[] = []; + let releaseFirst!: () => void; + let markFirstStarted!: () => void; + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve; + }); + const first = withAppLogResourceFence({ + resourcePath, + expected: { token: 'current', generation: 1 }, + run: async (lease) => { + order.push('first-start'); + markFirstStarted(); + await firstReleased; + lease.transition('open', { metadata: { phase: 'completing' } }); + order.push('first-end'); + }, + }); + const second = withAppLogResourceFence({ + resourcePath, + expected: { token: 'current', generation: 1 }, + run: async () => { + order.push('second'); + }, + }); + await firstStarted; + expect(order).toEqual(['first-start']); + releaseFirst(); + await Promise.all([first, second]); + expect(order).toEqual(['first-start', 'first-end', 'second']); + expect(readAppLogResourceRecord(resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open', metadata: { phase: 'completing' } }, + }); +}); + +function makeRecord(): string { + const resourcePath = resolveAppLogResourcePath( + path.join(mkdtempForTestSync('app-log-fence-'), 'session'), + ); + writeAppLogResourceRecord( + resourcePath, + createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: 'session', + device: { id: 'emulator-5554', family: 'android', kind: 'emulator' }, + owner: localRuntimeOwner('android'), + fence: { token: 'current', generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: {} }, + }), + ); + return resourcePath; +} diff --git a/src/daemon/__tests__/app-log-resource-recovery.test.ts b/src/daemon/__tests__/app-log-resource-recovery.test.ts new file mode 100644 index 0000000000..0604bd13d5 --- /dev/null +++ b/src/daemon/__tests__/app-log-resource-recovery.test.ts @@ -0,0 +1,359 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + type AppLogRuntimeOperations, + type CleanupOutcome, + type DeviceRuntimeGateway, + type ReattachOutcome, +} from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { recoverAppLogResourcesAfterDaemonLock } from '../app-log-resource-recovery.ts'; +import { + readAppLogResourceRecord, + resolveAppLogResourcePath, + writeAppLogResourceRecord, +} from '../app-log-resource-store.ts'; + +const scope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + +test('startup recovery retains invalid/schema-mismatched evidence without binding', async () => { + const sessionsDir = mkdtempForTestSync('app-log-recovery-invalid-'); + const resourcePath = resolveAppLogResourcePath(path.join(sessionsDir, 'session')); + fs.mkdirSync(path.dirname(resourcePath), { recursive: true }); + fs.writeFileSync(resourcePath, '{'); + const runtime = makeGateway(); + + expect( + await recoverAppLogResourcesAfterDaemonLock({ sessionsDir, gateway: runtime.gateway, scope }), + ).toEqual({ scanned: 1, recovered: 0, retained: 1 }); + expect(runtime.bind).not.toHaveBeenCalled(); + expect(fs.readFileSync(resourcePath, 'utf8')).toBe('{'); +}); + +test('startup recovery binds the exact owner/fence and cleans an active handle', async () => { + const { sessionsDir, resourcePath } = makeRecord(); + const runtime = makeGateway(); + const result = await recoverAppLogResourcesAfterDaemonLock({ + sessionsDir, + gateway: runtime.gateway, + scope, + }); + + expect(result).toEqual({ scanned: 1, recovered: 1, retained: 0 }); + expect(runtime.bind).toHaveBeenCalledWith( + expect.objectContaining({ + intent: { + kind: 'exact-owner', + owner: localRuntimeOwner('android'), + fence: { token: 'fence', generation: 1 }, + }, + }), + ); + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(runtime.bindingDispose).toHaveBeenCalledOnce(); + expect(readAppLogResourceRecord(resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed' }, + }); +}); + +test('facet schema mismatch remains unmodified and never starts a replacement cleanup', async () => { + const { sessionsDir, resourcePath } = makeRecord(); + const runtime = makeGateway({ + reattach: { + status: 'unreattachable', + reason: 'descriptor-version-unsupported', + message: 'future descriptor', + }, + }); + const before = fs.readFileSync(resourcePath, 'utf8'); + + expect( + await recoverAppLogResourcesAfterDaemonLock({ sessionsDir, gateway: runtime.gateway, scope }), + ).toEqual({ scanned: 1, recovered: 0, retained: 1 }); + expect(runtime.cleanup).not.toHaveBeenCalled(); + expect(fs.readFileSync(resourcePath, 'utf8')).toBe(before); +}); + +test('a manifest claiming a different session is retained before any runtime authority binds', async () => { + const { sessionsDir, resourcePath } = makeRecord({ + physicalSessionId: 'session-a', + envelopeSessionId: 'session-b', + }); + const runtime = makeGateway(); + const before = fs.readFileSync(resourcePath, 'utf8'); + + expect( + await recoverAppLogResourcesAfterDaemonLock({ sessionsDir, gateway: runtime.gateway, scope }), + ).toEqual({ scanned: 1, recovered: 0, retained: 1 }); + expect(runtime.bind).not.toHaveBeenCalled(); + expect(runtime.forceCleanup).not.toHaveBeenCalled(); + expect(runtime.cleanup).not.toHaveBeenCalled(); + expect(fs.readFileSync(resourcePath, 'utf8')).toBe(before); +}); + +test('uncertain active cleanup persists cleanup-pending for the next exact-owner recovery', async () => { + const { sessionsDir, resourcePath } = makeRecord(); + const runtime = makeGateway({ + forceCleanup: { status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }, + }); + expect( + await recoverAppLogResourcesAfterDaemonLock({ sessionsDir, gateway: runtime.gateway, scope }), + ).toEqual({ scanned: 1, recovered: 0, retained: 1 }); + expect(readAppLogResourceRecord(resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open', metadata: { phase: 'cleanup-pending' } }, + }); +}); + +test('startup recovery bounds a never-settling owner and retains its durable evidence', async () => { + vi.useFakeTimers(); + try { + const { sessionsDir, resourcePath } = makeRecord(); + const runtime = makeGateway({ + reattachImplementation: () => new Promise(() => {}), + }); + const diagnostics: Array<{ phase: string }> = []; + + const recovery = recoverAppLogResourcesAfterDaemonLock({ + sessionsDir, + gateway: runtime.gateway, + scope, + perRecordDeadlineMs: 25, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + await vi.advanceTimersByTimeAsync(25); + + await expect(recovery).resolves.toEqual({ scanned: 1, recovered: 0, retained: 1 }); + expect(runtime.boundSignals[0]?.aborted).toBe(true); + expect(runtime.forceCleanup).not.toHaveBeenCalled(); + expect(diagnostics).toContainEqual({ + phase: 'app_log_recovery_timed_out', + resourcePath, + data: { + deadlineMs: 25, + error: 'App-log recovery exceeded its 25ms deadline', + }, + }); + expect(readAppLogResourceRecord(resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open' }, + }); + } finally { + vi.useRealTimers(); + } +}); + +test('a late active reattach is disposed exactly once without escaping recovery authority', async () => { + vi.useFakeTimers(); + try { + const { sessionsDir } = makeRecord(); + let resolveReattach!: ( + outcome: ReattachOutcome, never>, + ) => void; + const reattach = new Promise, never>>( + (resolve) => { + resolveReattach = resolve; + }, + ); + const runtime = makeGateway({ reattachImplementation: () => reattach }); + + const recovery = recoverAppLogResourcesAfterDaemonLock({ + sessionsDir, + gateway: runtime.gateway, + scope, + perRecordDeadlineMs: 25, + }); + await vi.advanceTimersByTimeAsync(25); + await expect(recovery).resolves.toEqual({ scanned: 1, recovered: 0, retained: 1 }); + + resolveReattach({ status: 'active', handle: createHandle(runtime.forceCleanup) }); + await vi.advanceTimersByTimeAsync(0); + + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(runtime.bindingDispose).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } +}); + +test('late handle cleanup failure is secondary evidence and preserves the deadline outcome', async () => { + vi.useFakeTimers(); + try { + const { sessionsDir, resourcePath } = makeRecord(); + let resolveReattach!: ( + outcome: ReattachOutcome, never>, + ) => void; + const reattach = new Promise, never>>( + (resolve) => { + resolveReattach = resolve; + }, + ); + const runtime = makeGateway({ + reattachImplementation: () => reattach, + forceCleanupImplementation: async () => { + throw new Error('late cleanup failed'); + }, + }); + const diagnostics: Array<{ phase: string; data: Readonly> }> = []; + + const recovery = recoverAppLogResourcesAfterDaemonLock({ + sessionsDir, + gateway: runtime.gateway, + scope, + perRecordDeadlineMs: 25, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + await vi.advanceTimersByTimeAsync(25); + await expect(recovery).resolves.toEqual({ scanned: 1, recovered: 0, retained: 1 }); + + resolveReattach({ status: 'active', handle: createHandle(runtime.forceCleanup) }); + await vi.advanceTimersByTimeAsync(0); + + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(runtime.bindingDispose).toHaveBeenCalledOnce(); + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ phase: 'app_log_recovery_timed_out' }), + { + phase: 'app_log_recovery_late_handle_cleanup_failed', + resourcePath, + data: { + error: 'late cleanup failed', + primaryError: 'App-log recovery exceeded its 25ms deadline', + }, + }, + ]), + ); + } finally { + vi.useRealTimers(); + } +}); + +test('once exact-owner cleanup starts, recovery holds the daemon lock path until it settles', async () => { + vi.useFakeTimers(); + try { + const { sessionsDir } = makeRecord(); + let resolveCleanup!: (outcome: CleanupOutcome) => void; + const cleanup = new Promise((resolve) => { + resolveCleanup = resolve; + }); + const runtime = makeGateway({ forceCleanupImplementation: () => cleanup }); + let settled = false; + + const recovery = recoverAppLogResourcesAfterDaemonLock({ + sessionsDir, + gateway: runtime.gateway, + scope, + perRecordDeadlineMs: 25, + }).finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(25); + expect(settled).toBe(false); + + resolveCleanup({ status: 'cleaned' }); + await expect(recovery).resolves.toEqual({ scanned: 1, recovered: 1, retained: 0 }); + } finally { + vi.useRealTimers(); + } +}); + +function makeRecord(options: { physicalSessionId?: string; envelopeSessionId?: string } = {}) { + const sessionsDir = mkdtempForTestSync('app-log-recovery-'); + const physicalSessionId = options.physicalSessionId ?? 'session'; + const envelopeSessionId = options.envelopeSessionId ?? physicalSessionId; + const resourcePath = resolveAppLogResourcePath(path.join(sessionsDir, physicalSessionId)); + writeAppLogResourceRecord( + resourcePath, + createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: envelopeSessionId, + device: { id: 'emulator-5554', family: 'android', kind: 'emulator' }, + owner: localRuntimeOwner('android'), + fence: { token: 'fence', generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: { pid: 123 } }, + }), + ); + return { sessionsDir, resourcePath }; +} + +function makeGateway( + options: { + reattach?: ReattachOutcome, never>; + reattachImplementation?: () => Promise, never>>; + forceCleanup?: CleanupOutcome; + forceCleanupImplementation?: () => Promise; + } = {}, +) { + const forceCleanup = vi.fn( + options.forceCleanupImplementation ?? + (async (): Promise => options.forceCleanup ?? { status: 'cleaned' }), + ); + const handle = createHandle(forceCleanup); + const bindingDispose = vi.fn(async () => {}); + const cleanup = vi.fn(async () => ({ status: 'cleaned' as const })); + const operations: AppLogRuntimeOperations = { + appLogInspect: async () => ({ backend: 'android' }), + appLogDoctor: async () => ({ backend: 'android', checks: {}, notes: [] }), + appLogStart: async () => { + throw new Error('recovery must not start a replacement'); + }, + appLogReattach: + options.reattachImplementation ?? + (async () => options.reattach ?? { status: 'active', handle }), + appLogCleanup: cleanup, + }; + const boundSignals: AbortSignal[] = []; + const bind = vi.fn(async ({ device, scope: bindingScope }) => { + boundSignals.push(bindingScope.signal); + return { + device, + owner: localRuntimeOwner('android'), + facts: { + device: { family: 'android' as const, kind: device.kind, providerMode: 'local' as const }, + operations: { + appLogInspect: { available: true as const }, + appLogDoctor: { available: true as const }, + appLogStart: { available: true as const }, + appLogReattach: { available: true as const }, + appLogCleanup: { available: true as const }, + }, + }, + operations, + [Symbol.asyncDispose]: bindingDispose, + }; + }); + const gateway: DeviceRuntimeGateway = { + bind, + shutdown: async () => {}, + }; + return { gateway, bind, forceCleanup, cleanup, bindingDispose, boundSignals }; +} + +function createHandle( + forceCleanup: () => Promise = vi.fn( + async (): Promise => ({ status: 'cleaned' }), + ), +) { + return createTestAppLogLiveHandle({ + inspect: () => ({ backend: 'android', state: 'recovering', startedAt: 1 }), + finish: async () => ({ + status: 'completed', + result: { backend: 'android', outputPath: '/tmp/app.log', completedAt: 2 }, + }), + forceCleanup, + }); +} diff --git a/src/daemon/__tests__/app-log-resource-store.test.ts b/src/daemon/__tests__/app-log-resource-store.test.ts new file mode 100644 index 0000000000..358bf35b83 --- /dev/null +++ b/src/daemon/__tests__/app-log-resource-store.test.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { + listAppLogResourcePaths, + readAppLogResourceRecord, + resolveAppLogResourcePath, + writeAppLogResourceRecord, +} from '../app-log-resource-store.ts'; + +test('app-log resource records publish atomically with owner-only permissions', () => { + const resourcePath = resolveAppLogResourcePath( + path.join(mkdtempForTestSync('app-log-record-'), 'session'), + ); + writeAppLogResourceRecord(resourcePath, envelope('session')); + + expect(readAppLogResourceRecord(resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { resourceKind: 'app-log', lifecycle: 'open' }, + }); + expect(fs.statSync(resourcePath).mode & 0o777).toBe(0o600); + expect(fs.readdirSync(path.dirname(resourcePath))).toEqual(['app-log.resource.json']); +}); + +test.each([ + ['invalid JSON', '{'], + ['unsupported schema', JSON.stringify({ ...envelope('session'), envelopeVersion: 999 })], +] as const)('app-log resource reader classifies and retains %s', (_name, body) => { + const resourcePath = resolveAppLogResourcePath( + path.join(mkdtempForTestSync('app-log-unreattachable-'), 'session'), + ); + fs.mkdirSync(path.dirname(resourcePath), { recursive: true }); + fs.writeFileSync(resourcePath, body); + + expect(readAppLogResourceRecord(resourcePath).status).toBe('unreattachable'); + expect(fs.readFileSync(resourcePath, 'utf8')).toBe(body); +}); + +test('app-log resource listing is deterministic and ignores unrelated artifacts', () => { + const sessionsDir = mkdtempForTestSync('app-log-record-list-'); + for (const sessionName of ['zeta', 'alpha']) { + const resourcePath = resolveAppLogResourcePath(path.join(sessionsDir, sessionName)); + writeAppLogResourceRecord(resourcePath, envelope(sessionName)); + } + fs.writeFileSync(path.join(sessionsDir, 'unrelated.txt'), 'ignored'); + expect(listAppLogResourcePaths(sessionsDir)).toEqual([ + resolveAppLogResourcePath(path.join(sessionsDir, 'alpha')), + resolveAppLogResourcePath(path.join(sessionsDir, 'zeta')), + ]); +}); + +test('resource symlinks are retained as unreattachable evidence and never replaced on write', () => { + const root = mkdtempForTestSync('app-log-record-symlink-'); + const outsidePath = path.join(root, 'outside.json'); + const outsideBody = `${JSON.stringify(envelope('outside'))}\n`; + fs.writeFileSync(outsidePath, outsideBody); + const resourcePath = resolveAppLogResourcePath(path.join(root, 'sessions', 'session')); + fs.mkdirSync(path.dirname(resourcePath), { recursive: true }); + fs.symlinkSync(outsidePath, resourcePath); + + expect(readAppLogResourceRecord(resourcePath)).toMatchObject({ + status: 'unreattachable', + reason: 'descriptor-invalid', + message: expect.stringMatching(/regular file/), + }); + expect(() => writeAppLogResourceRecord(resourcePath, envelope('replacement'))).toThrow( + /symbolic link/, + ); + expect(fs.lstatSync(resourcePath).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(outsidePath, 'utf8')).toBe(outsideBody); +}); + +function envelope(sessionId: string) { + return createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId, + device: { id: 'emulator-5554', family: 'android', kind: 'emulator' }, + owner: localRuntimeOwner('android'), + fence: { token: `fence-${sessionId}`, generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: {} }, + }); +} diff --git a/src/daemon/__tests__/app-log-session-resource.test.ts b/src/daemon/__tests__/app-log-session-resource.test.ts new file mode 100644 index 0000000000..4462a6ff18 --- /dev/null +++ b/src/daemon/__tests__/app-log-session-resource.test.ts @@ -0,0 +1,321 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { localRuntimeOwner, type CleanupOutcome } from '@agent-device/contracts/platform'; +import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; +import { adoptStartedSessionAppLog } from '../app-log-session-resource.ts'; +import { createNextAppLogFence } from '../app-log-start-preflight.ts'; +import { readAppLogResourceRecord, resolveAppLogResourcePath } from '../app-log-resource-store.ts'; +import type { SessionState } from '../types.ts'; + +test('start persists open recovery truth before adopting the live handle', async () => { + const context = makeContext(); + const runtime = makeStartResult(context); + await adoptStartedSessionAppLog({ + ...context, + ...runtime.result, + throwIfCanceled: () => {}, + }); + expect(context.sessionStore.get(context.sessionName)?.appLog?.handle).toBe(runtime.handle); + expect(readAppLogResourceRecord(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open', metadata: { phase: 'active' } }, + }); +}); + +test('cancellation after native start cleans the pending handle and terminalizes the record', async () => { + const context = makeContext(); + const runtime = makeStartResult(context); + const primary = new AppError('CANCELED', 'request canceled'); + await expect( + adoptStartedSessionAppLog({ + ...context, + ...runtime.result, + throwIfCanceled: () => { + throw primary; + }, + }), + ).rejects.toBe(primary); + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(readAppLogResourceRecord(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed' }, + }); +}); + +test('rejecting canceled-start cleanup retains cleanup-pending truth and blocks replacement', async () => { + const context = makeContext(); + const runtime = makeStartResult(context, { + status: 'cleanup-pending', + reason: 'cleanup-unconfirmed', + }); + const primary = new AppError('CANCELED', 'request canceled'); + await expect( + adoptStartedSessionAppLog({ + ...context, + ...runtime.result, + throwIfCanceled: () => { + throw primary; + }, + }), + ).rejects.toBe(primary); + expect(readAppLogResourceRecord(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open', metadata: { phase: 'cleanup-pending' } }, + }); + expect(() => + createNextAppLogFence({ + ledger: context.admissionLedger, + resourcePath: context.resourcePath, + device: context.device, + }), + ).toThrow(/terminal state/); +}); + +test('SessionStore failure after transfer disposes the transferred handle and preserves primary error', async () => { + const context = makeContext(); + const runtime = makeStartResult(context); + const primary = new Error('store adoption failed'); + vi.spyOn(context.sessionStore, 'set').mockImplementationOnce(() => { + throw primary; + }); + await expect( + adoptStartedSessionAppLog({ + ...context, + ...runtime.result, + throwIfCanceled: () => {}, + }), + ).rejects.toBe(primary); + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(readAppLogResourceRecord(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed' }, + }); +}); + +test('incoherent start envelope with rejecting cleanup persists a blocking tombstone', async () => { + const context = makeContext(); + const runtime = makeStartResult(context, { + status: 'cleanup-pending', + reason: 'cleanup-unconfirmed', + }); + const incoherentEnvelope = createDurableResourceEnvelope({ + ...runtime.result.envelope, + sessionId: 'wrong-session', + }); + + await expect( + adoptStartedSessionAppLog({ + ...context, + pendingHandle: runtime.result.pendingHandle, + envelope: incoherentEnvelope, + throwIfCanceled: () => {}, + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'runtime-contract-invalid' }, + }); + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(readAppLogResourceRecord(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { + sessionId: context.sessionName, + lifecycle: 'open', + metadata: { phase: 'cleanup-pending', runtimeContractInvalid: true }, + }, + }); + expect(() => + createNextAppLogFence({ + ledger: context.admissionLedger, + resourcePath: context.resourcePath, + device: context.device, + }), + ).toThrow(/terminal state/); +}); + +test.each([ + { + label: 'Apple leaf', + device: { id: 'ios-device', family: 'apple', appleOs: 'macos', kind: 'device' } as const, + }, + { + label: 'physical-device backend', + device: { + id: 'ios-device', + family: 'apple', + appleOs: 'ios', + kind: 'device', + target: 'mobile', + iosPhysicalDeviceBackend: 'coredevice', + } as const, + }, +])('rejects a start envelope with a mismatched $label identity', async ({ device }) => { + const context = makeContext({ + platform: 'apple', + appleOs: 'ios', + id: 'ios-device', + name: 'iPhone', + kind: 'device', + target: 'mobile', + iosPhysicalDeviceBackend: 'xctest', + }); + const runtime = makeStartResult(context, { status: 'cleaned' }, device); + + await expect( + adoptStartedSessionAppLog({ + ...context, + ...runtime.result, + throwIfCanceled: () => {}, + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'runtime-contract-invalid' }, + }); + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(context.sessionStore.get(context.sessionName)?.appLog).toBeUndefined(); +}); + +test('unwritable tombstone plus rejecting cleanup blocks same-process replacement', async () => { + const context = makeContext(); + const runtime = makeStartResult(context, { + status: 'cleanup-pending', + reason: 'cleanup-unconfirmed', + }); + const incoherentEnvelope = createDurableResourceEnvelope({ + ...runtime.result.envelope, + sessionId: 'wrong-session', + }); + const resourceDir = path.dirname(context.resourcePath); + fs.mkdirSync(resourceDir, { recursive: true }); + fs.chmodSync(resourceDir, 0o500); + + try { + await expect( + adoptStartedSessionAppLog({ + ...context, + pendingHandle: runtime.result.pendingHandle, + envelope: incoherentEnvelope, + throwIfCanceled: () => {}, + }), + ).rejects.toMatchObject({ details: { reason: 'runtime-contract-invalid' } }); + } finally { + fs.chmodSync(resourceDir, 0o700); + } + + expect(readAppLogResourceRecord(context.resourcePath)).toEqual({ status: 'missing' }); + expect(() => + createNextAppLogFence({ + ledger: context.admissionLedger, + resourcePath: context.resourcePath, + device: context.device, + }), + ).toThrow(/process-local/); +}); + +test('lost durable record during failed adoption installs a same-device process block', async () => { + const context = makeContext({ + platform: 'android', + id: 'record-lost-device', + name: 'Pixel', + kind: 'emulator', + }); + const runtime = makeStartResult(context, { + status: 'cleanup-pending', + reason: 'cleanup-unconfirmed', + }); + const primary = new AppError('CANCELED', 'request canceled'); + + await expect( + adoptStartedSessionAppLog({ + ...context, + ...runtime.result, + throwIfCanceled: () => { + fs.rmSync(context.resourcePath); + throw primary; + }, + }), + ).rejects.toBe(primary); + + expect(readAppLogResourceRecord(context.resourcePath)).toEqual({ status: 'missing' }); + expect(() => + createNextAppLogFence({ + ledger: context.admissionLedger, + resourcePath: context.resourcePath, + device: context.device, + }), + ).toThrow(/process-local/); +}); + +function makeContext( + device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + }, +) { + const sessionStore = makeSessionStore('app-log-session-resource-'); + const sessionName = 'session'; + const session: SessionState = { + name: sessionName, + device, + createdAt: Date.now(), + actions: [], + }; + sessionStore.set(sessionName, session); + const resourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir(sessionName)); + return { + admissionLedger: createAppLogAdmissionLedger(), + session, + sessionName, + sessionStore, + resourcePath, + device: session.device, + owner: localRuntimeOwner(device.platform), + fence: { token: 'fence', generation: 1 }, + }; +} + +function makeStartResult( + context: ReturnType, + cleanup: CleanupOutcome = { status: 'cleaned' }, + envelopeDevice?: Parameters[0]['device'], +) { + const forceCleanup = vi.fn(async () => cleanup); + const handle = createTestAppLogLiveHandle({ + inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), + finish: async () => ({ + status: 'completed', + result: { backend: 'android', outputPath: '/tmp/app.log', completedAt: 2 }, + }), + forceCleanup, + }); + const envelope = createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: context.sessionName, + device: envelopeDevice ?? { + id: context.device.id, + family: context.device.platform, + ...(context.device.appleOs === undefined ? {} : { appleOs: context.device.appleOs }), + kind: context.device.kind, + ...(context.device.target === undefined ? {} : { target: context.device.target }), + ...(context.device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: context.device.iosPhysicalDeviceBackend }), + }, + owner: context.owner, + fence: context.fence, + lifecycle: 'open', + descriptor: { version: 1, body: { pid: 123 } }, + }); + return { + handle, + forceCleanup, + result: createAppLogStartResult(handle, envelope), + }; +} diff --git a/src/daemon/__tests__/app-log-start-preflight.test.ts b/src/daemon/__tests__/app-log-start-preflight.test.ts new file mode 100644 index 0000000000..3ce1f2c705 --- /dev/null +++ b/src/daemon/__tests__/app-log-start-preflight.test.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { deviceIdentity, type DeviceInfo } from '@agent-device/kernel/device'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; +import { createNextAppLogFence } from '../app-log-start-preflight.ts'; +import { resolveAppLogResourcePath, writeAppLogResourceRecord } from '../app-log-resource-store.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'cross-session-device', + name: 'Pixel', + kind: 'emulator', +}; + +test('nonterminal record from an old session blocks replacement on the same device', () => { + const ledger = createAppLogAdmissionLedger(); + const sessionStore = makeSessionStore('app-log-start-preflight-cross-session-'); + const oldResourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir('old-session')); + writeAppLogResourceRecord( + oldResourcePath, + createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: 'old-session', + device: { id: device.id, family: 'android', kind: 'emulator' }, + owner: localRuntimeOwner('android'), + fence: { token: 'old', generation: 1 }, + lifecycle: 'open', + metadata: { phase: 'cleanup-pending' }, + descriptor: { version: 1, body: {} }, + }), + ); + const newResourcePath = resolveAppLogResourcePath( + sessionStore.resolveSessionDir('replacement-session'), + ); + + expect(() => createNextAppLogFence({ ledger, resourcePath: newResourcePath, device })).toThrow( + /this device/, + ); +}); + +test('an undecodable manifest or retained legacy marker blocks all replacement starts', () => { + const ledger = createAppLogAdmissionLedger({ markerExists: () => true }); + const sessionStore = makeSessionStore('app-log-start-preflight-global-'); + const resourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir('new-session')); + const corruptPath = resolveAppLogResourcePath(sessionStore.resolveSessionDir('corrupt-session')); + fs.mkdirSync(path.dirname(corruptPath), { recursive: true }); + fs.writeFileSync(corruptPath, '{'); + + expect(() => createNextAppLogFence({ ledger, resourcePath, device })).toThrow(/unreattachable/); + + fs.rmSync(corruptPath); + ledger.retainLegacyMarkers([ + { markerPath: '/sessions/legacy/app-log.pid', device: deviceIdentity(device) }, + ]); + expect(() => createNextAppLogFence({ ledger, resourcePath, device })).toThrow( + /legacy app-log marker/, + ); +}); + +test('a symlinked manifest blocks replacement globally without touching its external target', () => { + const ledger = createAppLogAdmissionLedger(); + const sessionStore = makeSessionStore('app-log-start-preflight-symlink-'); + const sessionsDir = path.dirname(sessionStore.resolveSessionDir('unused')); + const outsidePath = path.join(path.dirname(sessionsDir), 'outside.json'); + const outsideBody = `${JSON.stringify( + createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: 'outside', + device: { id: 'other-device', family: 'android', kind: 'emulator' }, + owner: localRuntimeOwner('android'), + fence: { token: 'outside', generation: 1 }, + lifecycle: 'completed', + descriptor: { version: 1, body: {} }, + }), + )}\n`; + fs.writeFileSync(outsidePath, outsideBody); + const symlinkPath = resolveAppLogResourcePath(sessionStore.resolveSessionDir('symlink-session')); + fs.mkdirSync(path.dirname(symlinkPath), { recursive: true }); + fs.symlinkSync(outsidePath, symlinkPath); + const replacementPath = resolveAppLogResourcePath( + sessionStore.resolveSessionDir('replacement-session'), + ); + + expect(() => createNextAppLogFence({ ledger, resourcePath: replacementPath, device })).toThrow( + /unreattachable/, + ); + expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(outsidePath, 'utf8')).toBe(outsideBody); +}); diff --git a/src/daemon/__tests__/app-log.test.ts b/src/daemon/__tests__/app-log.test.ts index 92368f90d7..e7bdd4b7de 100644 --- a/src/daemon/__tests__/app-log.test.ts +++ b/src/daemon/__tests__/app-log.test.ts @@ -1,297 +1,43 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { finished } from 'node:stream/promises'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; -import { withAppleToolProvider } from '../../platforms/apple/core/tool-provider.ts'; -import type { ExecResult } from '../../utils/exec.ts'; -import { runAppLogDoctor, rotateAppLogIfNeeded } from '../app-log.ts'; -import { assertAndroidPackageArgSafe } from '../app-log-android.ts'; -import { - buildAppleLogPredicate, - buildIosDeviceConsoleLaunchArgs, - buildIosSimulatorLogStreamArgs, - startIosDeviceAppLog, -} from '../app-log-ios.ts'; -import { APP_LOG_PID_FILENAME, cleanupStaleAppLogProcesses } from '../app-log-process.ts'; +import { expect, test } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { appendAppLogMarker, clearAppLogFiles, getAppLogPathMetadata } from '../app-log.ts'; -const IOS_DEVICE_ID = '00008150-0000AAAA'; -const IOS_DEVICE: DeviceInfo = { - platform: 'apple', - appleOs: 'ios', - id: IOS_DEVICE_ID, - name: 'iPhone', - kind: 'device', -}; -const IOS_DEVICE_HELP_WITHOUT_CONSOLE_CAPTURE = - 'USAGE: devicectl device [--verbose] [--quiet] \n\nSUBCOMMANDS:\n info\n process\n'; -const IOS_DEVICE_CONSOLE_CAPTURE_HELP = `USAGE: devicectl device process launch [] --device +test('marker and clear operations keep app-log file ownership in the daemon', () => { + const root = mkdtempForTestSync('agent-device-app-log-files-'); + const outPath = path.join(root, 'session', 'app.log'); -COMMAND OPTIONS: - --console Attaches the application to the console and waits for it to exit. - --terminate-existing Terminates any already-running instances of the app prior to launch.`; + appendAppLogMarker(outPath, 'checkpoint'); + fs.writeFileSync(`${outPath}.1`, 'rotated'); -type FakeDevicectlRun = (args: string[]) => Promise; - -async function withFakeDevicectl( - run: FakeDevicectlRun, - fn: () => Promise, -): Promise<{ result: T; calls: string[][] }> { - const calls: string[][] = []; - const result = await withAppleToolProvider( - { - runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - devicectl: { - run: async (args) => { - calls.push(args); - return await run(args); - }, - }, - whichCommand: async () => false, - }, - fn, - ); - return { result, calls }; -} - -function makeAppLogWriteStream(prefix: string): fs.WriteStream { - const root = mkdtempForTestSync(prefix); - return fs.createWriteStream(path.join(root, 'app.log'), { flags: 'a' }); -} - -test('buildAppleLogPredicate includes bundle-aware filters', () => { - const predicate = buildAppleLogPredicate('com.example.app'); - assert.match(predicate, /subsystem == "com\.example\.app"/); - assert.match(predicate, /subsystem CONTAINS "com\.example\.app"/); - assert.match(predicate, /processImagePath ENDSWITH\[c\] "\/com\.example\.app"/); - assert.match(predicate, /senderImagePath ENDSWITH\[c\] "\/com\.example\.app"/); - assert.doesNotMatch(predicate, /eventMessage CONTAINS\[c\] "com\.example\.app"/); -}); - -test('buildAppleLogPredicate includes executable-aware filters when available', () => { - const predicate = buildAppleLogPredicate('com.example.app', 'ExampleExec'); - assert.match(predicate, /process == "ExampleExec"/); - assert.match(predicate, /processImagePath ENDSWITH\[c\] "\/ExampleExec"/); - assert.match(predicate, /processImagePath CONTAINS\[c\] "\/ExampleExec\.app\/"/); -}); - -test('assertAndroidPackageArgSafe rejects unsafe values', () => { - assert.doesNotThrow(() => assertAndroidPackageArgSafe('com.example.app')); - assert.throws( - () => assertAndroidPackageArgSafe('com.example.app;rm -rf /'), - /Invalid Android package/, - ); -}); - -test('rotateAppLogIfNeeded rotates and truncates oldest by configured max files', () => { - const root = mkdtempForTestSync('agent-device-app-log-rotate-'); - const outPath = path.join(root, 'app.log'); - fs.writeFileSync(outPath, 'a'.repeat(20)); - fs.writeFileSync(`${outPath}.1`, 'old1'); - fs.writeFileSync(`${outPath}.2`, 'old2'); - - rotateAppLogIfNeeded(outPath, { maxBytes: 10, maxRotatedFiles: 2 }); - - assert.equal(fs.existsSync(outPath), false); - assert.equal(fs.readFileSync(`${outPath}.1`, 'utf8').length, 20); - assert.equal(fs.readFileSync(`${outPath}.2`, 'utf8'), 'old1'); -}); - -test('cleanupStaleAppLogProcesses removes pid files even when pid is stale', () => { - const root = mkdtempForTestSync('agent-device-app-log-clean-'); - const sessionDir = path.join(root, 'default'); - fs.mkdirSync(sessionDir, { recursive: true }); - const pidPath = path.join(sessionDir, APP_LOG_PID_FILENAME); - fs.writeFileSync(pidPath, '999999\n'); - - cleanupStaleAppLogProcesses(root); - - assert.equal(fs.existsSync(pidPath), false); -}); - -test('buildIosDeviceConsoleLaunchArgs builds expected devicectl command args', () => { - assert.deepEqual(buildIosDeviceConsoleLaunchArgs(IOS_DEVICE_ID, 'com.example.app'), [ - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - IOS_DEVICE_ID, - '--console', - '--terminate-existing', - 'com.example.app', - ]); -}); - -test('startIosDeviceAppLog reports unsupported devicectl console capture before spawning', async () => { - const stream = makeAppLogWriteStream('agent-device-ios-device-log-'); - const { calls } = await withFakeDevicectl( - async () => ({ - stdout: IOS_DEVICE_HELP_WITHOUT_CONSOLE_CAPTURE, - stderr: '', - exitCode: 0, - }), - async () => { - await assert.rejects( - async () => await startIosDeviceAppLog(IOS_DEVICE_ID, 'com.example.app', stream, []), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.code, 'UNSUPPORTED_OPERATION'); - assert.match(error.message, /iOS physical-device app console capture is not supported/); - assert.equal(error.details?.backend, 'ios-device'); - return true; - }, - ); - }, - ); - - await finished(stream).catch(() => {}); - assert.deepEqual(calls, [['device', 'process', 'launch', '--help']]); -}); - -test('startIosDeviceAppLog reports retryable failure when devicectl support probe fails', async () => { - const stream = makeAppLogWriteStream('agent-device-ios-device-log-timeout-'); - - await withFakeDevicectl( - async () => { - throw new Error('xcrun timed out after 5000ms'); - }, - async () => { - await assert.rejects( - async () => await startIosDeviceAppLog(IOS_DEVICE_ID, 'com.example.app', stream, []), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.code, 'COMMAND_FAILED'); - assert.match(error.message, /Could not verify iOS physical-device app console capture/); - assert.equal(error.details?.stderr, 'xcrun timed out after 5000ms'); - return true; - }, - ); - }, - ); - - await finished(stream).catch(() => {}); -}); - -test('runAppLogDoctor reports supported iOS physical-device console capture', async () => { - const { result, calls } = await withFakeDevicectl( - async (args) => { - if (args.join(' ') === '--version') { - return { stdout: '506.6\n', stderr: '', exitCode: 0 }; - } - return { - stdout: IOS_DEVICE_CONSOLE_CAPTURE_HELP, - stderr: '', - exitCode: 0, - }; - }, - async () => await runAppLogDoctor(IOS_DEVICE, 'com.example.app'), - ); - - assert.deepEqual(calls, [['--version'], ['device', 'process', 'launch', '--help']]); - assert.equal(result.checks.devicectlAvailable, true); - assert.equal(result.checks.devicectlConsoleCapture, true); - assert.equal(result.notes.length, 0); -}); - -test('startIosDeviceAppLog marks clean devicectl console exit as ended', async () => { - const root = mkdtempForTestSync('agent-device-ios-device-console-'); - const fakeBinDir = path.join(root, 'bin'); - fs.mkdirSync(fakeBinDir); - const fakeXcrun = path.join(fakeBinDir, 'xcrun'); - fs.writeFileSync(fakeXcrun, '#!/bin/sh\nprintf "app output\\n"\nexit 0\n'); - fs.chmodSync(fakeXcrun, 0o755); - const previousPath = process.env.PATH; - process.env.PATH = `${fakeBinDir}${path.delimiter}${previousPath ?? ''}`; - const stream = fs.createWriteStream(path.join(root, 'app.log'), { flags: 'a' }); - - try { - await withFakeDevicectl( - async () => ({ stdout: IOS_DEVICE_CONSOLE_CAPTURE_HELP, stderr: '', exitCode: 0 }), - async () => { - const appLog = await startIosDeviceAppLog(IOS_DEVICE_ID, 'com.example.app', stream, []); - assert.equal(appLog.getState(), 'active'); - assert.equal((await appLog.wait).exitCode, 0); - assert.equal(appLog.getState(), 'ended'); - }, - ); - } finally { - if (previousPath === undefined) delete process.env.PATH; - else process.env.PATH = previousPath; - await finished(stream).catch(() => {}); - } -}); - -test('buildIosSimulatorLogStreamArgs streams logs inside the simulator at info level', () => { - assert.deepEqual( - buildIosSimulatorLogStreamArgs({ - deviceId: 'sim-1', - appBundleId: 'com.example.app', - executableName: 'ExampleExec', - }), - [ - 'simctl', - 'spawn', - 'sim-1', - 'log', - 'stream', - '--style', - 'compact', - '--level', - 'info', - '--predicate', - buildAppleLogPredicate('com.example.app', 'ExampleExec'), - ], - ); -}); - -test('buildIosSimulatorLogStreamArgs respects simulator device set scoping', () => { - assert.deepEqual( - buildIosSimulatorLogStreamArgs({ - deviceId: 'sim-1', - appBundleId: 'com.example.app', - simulatorSetPath: '/tmp/tenant-a/simulators', - }), - [ - 'simctl', - '--set', - '/tmp/tenant-a/simulators', - 'spawn', - 'sim-1', - 'log', - 'stream', - '--style', - 'compact', - '--level', - 'info', - '--predicate', - buildAppleLogPredicate('com.example.app'), - ], - ); -}); - -test('cleanupStaleAppLogProcesses removes legacy plain pid files safely', () => { - const root = mkdtempForTestSync('agent-device-app-log-clean-legacy-'); - const sessionDir = path.join(root, 'default'); - fs.mkdirSync(sessionDir, { recursive: true }); - const pidPath = path.join(sessionDir, APP_LOG_PID_FILENAME); - fs.writeFileSync(pidPath, '1\n'); - - cleanupStaleAppLogProcesses(root); - - assert.equal(fs.existsSync(pidPath), false); -}); - -test('runAppLogDoctor returns note when app bundle is missing', async () => { - const result = await runAppLogDoctor({ - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', + expect(fs.readFileSync(outPath, 'utf8')).toMatch(/\[agent-device\]\[mark\].* checkpoint/); + expect(getAppLogPathMetadata(outPath)).toMatchObject({ exists: true }); + expect(clearAppLogFiles(outPath)).toEqual({ + path: outPath, + cleared: true, + removedRotatedFiles: 1, }); - assert.equal(Array.isArray(result.notes), true); - assert.ok(result.notes.some((note) => note.includes('Run open first'))); -}); + expect(fs.readFileSync(outPath, 'utf8')).toBe(''); + expect(fs.existsSync(`${outPath}.1`)).toBe(false); +}); + +test.each(['metadata', 'mark', 'clear'] as const)( + 'rejects a final app.log symlink before %s touches its target', + (operation) => { + const root = mkdtempForTestSync(`agent-device-app-log-${operation}-`); + const outPath = path.join(root, 'session', 'app.log'); + const outsidePath = path.join(root, 'outside.log'); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outsidePath, 'outside'); + fs.symlinkSync(outsidePath, outPath); + + expect(() => { + if (operation === 'metadata') getAppLogPathMetadata(outPath); + else if (operation === 'mark') appendAppLogMarker(outPath, 'checkpoint'); + else clearAppLogFiles(outPath); + }).toThrow(); + expect(fs.readFileSync(outsidePath, 'utf8')).toBe('outside'); + expect(fs.lstatSync(outPath).isSymbolicLink()).toBe(true); + }, +); diff --git a/src/daemon/__tests__/applog-plugin-routing-parity.test.ts b/src/daemon/__tests__/applog-plugin-routing-parity.test.ts deleted file mode 100644 index f39eed17ad..0000000000 --- a/src/daemon/__tests__/applog-plugin-routing-parity.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { - isIosFamily, - isMacOs, - DEVICE_TARGETS, - PLATFORMS, - type DeviceInfo, - type DeviceKind, - type DeviceTarget, -} from '@agent-device/kernel/device'; -import { - ANDROID_EMULATOR, - ANDROID_TV_DEVICE, - IOS_DEVICE, - IOS_SIMULATOR, - LINUX_DEVICE, - MACOS_DEVICE, - TVOS_SIMULATOR, - WEB_DESKTOP_DEVICE, -} from '../../__tests__/test-utils/index.ts'; -import { getPlugin } from '../../core/platform-plugin-registry.ts'; -import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts'; -import { resolveLogBackend } from '../app-log.ts'; -import type { LogBackend } from '@agent-device/contracts/observability'; - -// Phase 3 step b.3 (issue #974) parity gate for the daemon app-log facet. The -// per-platform branch of `resolveLogBackend` now flows through the PlatformPlugin -// `appLog.resolveBackend` facet instead of a hand switch. An INDEPENDENT verbatim -// copy of the former branch below is the BEFORE oracle: a plugin-vs-branch -// disagreement on any sample device fails this test. (Mirrors the verbatim-copy -// discipline in core/__tests__/capability-plugin-routing-parity.test.ts.) - -registerBuiltinPlatformPlugins(); - -// --- INDEPENDENT verbatim copy of the former `resolveLogBackend` hand branch --- -function resolveLogBackendByHand(device: DeviceInfo): LogBackend { - if (isMacOs(device)) return 'macos'; - if (isIosFamily(device)) { - return device.kind === 'device' ? 'ios-device' : 'ios-simulator'; - } - if (device.platform === 'harmonyos') return 'harmonyos'; - return 'android'; -} - -// --- the exhaustive synthetic device matrix (every platform x kind x target) --- -const DEVICE_KINDS_ALL: DeviceKind[] = ['simulator', 'emulator', 'device']; -const DEVICE_TARGETS_ALL: (DeviceTarget | undefined)[] = [undefined, ...DEVICE_TARGETS]; - -function buildDeviceMatrix(): DeviceInfo[] { - const devices: DeviceInfo[] = []; - for (const platform of PLATFORMS) { - for (const kind of DEVICE_KINDS_ALL) { - for (const target of DEVICE_TARGETS_ALL) { - devices.push({ - platform, - id: `${platform}-${kind}-${target ?? 'none'}`, - name: `${platform} ${kind} ${target ?? 'none'}`, - kind, - ...(target ? { target } : {}), - booted: true, - }); - } - } - } - return devices; -} - -// The hand-authored fixtures (the real discovery shapes) plus the exhaustive -// synthetic cross-product, so every off-nominal combination is pinned too. -const SAMPLE_DEVICES: DeviceInfo[] = [ - ANDROID_EMULATOR, - ANDROID_TV_DEVICE, - IOS_DEVICE, - IOS_SIMULATOR, - LINUX_DEVICE, - MACOS_DEVICE, - TVOS_SIMULATOR, - WEB_DESKTOP_DEVICE, - ...buildDeviceMatrix(), -]; - -test('resolveLogBackend routed through the plugin is byte-identical to the former hand branch', () => { - for (const device of SAMPLE_DEVICES) { - assert.equal( - resolveLogBackend(device), - resolveLogBackendByHand(device), - `backend for ${device.id}`, - ); - } -}); - -test('only families with an app-log backend carry the appLog facet', () => { - // Apple owns ios + macos (SAME plugin instance); Android and HarmonyOS carry theirs. - assert.equal(getPlugin('apple'), getPlugin('apple')); - assert.ok(getPlugin('apple').appLog, 'apple plugin exposes appLog'); - assert.ok(getPlugin('android').appLog, 'android plugin exposes appLog'); - assert.ok(getPlugin('harmonyos').appLog, 'harmonyos plugin exposes appLog'); - // linux/web historically fell through to the `'android'` default; they get NO - // facet, and the daemon lookup preserves that fallthrough (asserted below). - assert.equal(getPlugin('linux').appLog, undefined, 'linux plugin has no appLog'); - assert.equal(getPlugin('web').appLog, undefined, 'web plugin has no appLog'); -}); - -test('each populated appLog facet resolves the backend its family owns', () => { - for (const device of SAMPLE_DEVICES.filter( - (d) => isIosFamily(d) || isMacOs(d) || d.platform === 'android' || d.platform === 'harmonyos', - )) { - assert.equal( - getPlugin(device.platform).appLog?.resolveBackend(device), - resolveLogBackendByHand(device), - `facet backend for ${device.id}`, - ); - } -}); - -test('the factless families fall through to the historical default', () => { - for (const device of SAMPLE_DEVICES.filter( - (d) => d.platform === 'linux' || d.platform === 'web', - )) { - assert.equal(getPlugin(device.platform).appLog, undefined); - assert.equal(resolveLogBackend(device), 'android', `fallthrough for ${device.id}`); - } -}); diff --git a/src/daemon/__tests__/daemon-runtime-app-log.test.ts b/src/daemon/__tests__/daemon-runtime-app-log.test.ts new file mode 100644 index 0000000000..b898bb6843 --- /dev/null +++ b/src/daemon/__tests__/daemon-runtime-app-log.test.ts @@ -0,0 +1,133 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { + recoverAppLogResourcesAfterDaemonLock, + type AppLogRecoveryDiagnostic, +} from '../app-log-resource-recovery.ts'; +import { readAppLogResourceRecord, resolveAppLogResourcePath } from '../app-log-resource-store.ts'; +import { + flushDaemonStartupDiagnostics, + teardownDaemonSessionForShutdown, +} from '../server/daemon-runtime.ts'; +import type { SessionState } from '../types.ts'; +import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; + +test('daemon startup awaits app-log recovery after acquiring the lock and before opening servers', () => { + const source = fs.readFileSync(new URL('../server/daemon-runtime.ts', import.meta.url), 'utf8'); + const acquiredLock = source.indexOf('if (!acquireDaemonLock('); + const legacyRecovery = source.indexOf('await recoverLegacyAppLogMarkersAfterDaemonLock('); + const recovery = source.indexOf('await recoverAppLogResourcesAfterDaemonLock('); + const openedServers = source.indexOf('const opened = await openDaemonServers()'); + + expect(acquiredLock).toBeGreaterThanOrEqual(0); + expect(legacyRecovery).toBeGreaterThan(acquiredLock); + expect(recovery).toBeGreaterThan(legacyRecovery); + expect(openedServers).toBeGreaterThan(recovery); +}); + +test('retained startup recovery evidence is flushed after daemon.log publication', async () => { + const root = mkdtempForTestSync('daemon-runtime-app-log-recovery-diagnostics-'); + const sessionsDir = path.join(root, 'sessions'); + const resourcePath = path.join(sessionsDir, 'session', 'app-log.resource.json'); + fs.mkdirSync(path.dirname(resourcePath), { recursive: true }); + fs.writeFileSync(resourcePath, '{'); + const diagnostics: AppLogRecoveryDiagnostic[] = []; + + await recoverAppLogResourcesAfterDaemonLock({ + sessionsDir, + gateway: unavailableDeviceRuntimeGateway, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + const logPath = path.join(root, 'daemon.log'); + fs.writeFileSync(logPath, 'pre-publication bytes are truncated'); + fs.writeFileSync(logPath, ''); + await flushDaemonStartupDiagnostics(logPath, diagnostics); + + const events = fs + .readFileSync(logPath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { phase: string; data?: Record }); + expect(events).toEqual([ + expect.objectContaining({ + phase: 'app_log_recovery_record_unreattachable', + data: expect.objectContaining({ resourcePath }), + }), + ]); +}); + +test('daemon shutdown settles fenced app-log cleanup before finalization can release ownership', async () => { + const sessionStore = makeSessionStore('daemon-runtime-app-log-shutdown-'); + const session: SessionState = { + name: 'session', + device: { platform: 'web', id: 'browser', name: 'Browser', kind: 'device' }, + createdAt: Date.now(), + actions: [], + }; + const fence = { token: 'fence', generation: 1 }; + const envelope = createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: session.name, + device: { id: session.device.id, family: 'web', kind: 'device' }, + owner: localRuntimeOwner('web'), + fence, + lifecycle: 'open', + descriptor: { version: 1, body: {} }, + }); + let releaseCleanup!: () => void; + let markCleanupStarted!: () => void; + const cleanupReleased = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const cleanupStarted = new Promise((resolve) => { + markCleanupStarted = resolve; + }); + const forceCleanup = vi.fn(async () => { + markCleanupStarted(); + await cleanupReleased; + return { status: 'cleaned' as const }; + }); + const handle = createTestAppLogLiveHandle({ + inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + forceCleanup, + }); + session.appLog = { handle, envelope }; + sessionStore.set(session.name, session); + const resourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir(session.name)); + fs.mkdirSync(sessionStore.resolveSessionDir(session.name), { recursive: true }); + fs.writeFileSync(resourcePath, `${JSON.stringify(envelope)}\n`); + const beforeDelete = vi.fn(async () => {}); + + const teardown = teardownDaemonSessionForShutdown({ + session, + sessionStore, + stderr: { write: () => {} }, + beforeDelete, + }); + await cleanupStarted; + + expect(beforeDelete).not.toHaveBeenCalled(); + expect(sessionStore.get(session.name)).toBeDefined(); + releaseCleanup(); + await teardown; + + expect(forceCleanup).toHaveBeenCalledOnce(); + expect(beforeDelete).toHaveBeenCalledOnce(); + expect(sessionStore.get(session.name)).toBeUndefined(); + expect(readAppLogResourceRecord(resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed' }, + }); +}); diff --git a/src/daemon/__tests__/providers-plugin-routing-parity.test.ts b/src/daemon/__tests__/providers-plugin-routing-parity.test.ts index 814088977f..5b357a15b4 100644 --- a/src/daemon/__tests__/providers-plugin-routing-parity.test.ts +++ b/src/daemon/__tests__/providers-plugin-routing-parity.test.ts @@ -41,7 +41,7 @@ import type { DaemonRequest } from '../types.ts'; registerBuiltinPlatformPlugins(); -// The platform-gated resolver keys, and the two ungated resolvers (no platform gate; +// The platform-gated resolver keys, and the ungated resolver (no platform gate; // they apply on every platform and are NOT part of the facet). const GATED_KEYS: PlatformGatedProviderResolverKey[] = [ 'androidAdbProvider', @@ -51,7 +51,7 @@ const GATED_KEYS: PlatformGatedProviderResolverKey[] = [ 'linuxToolProvider', 'webProvider', ]; -const UNGATED_KEYS = ['appLogProvider', 'recordingProvider'] as const; +const UNGATED_KEYS = ['recordingProvider'] as const; // --- INDEPENDENT verbatim copy of the former per-descriptor platform gates --- function gatedResolversByHand(device: DeviceInfo): Set { @@ -137,7 +137,7 @@ test('every family carries the providers facet with the resolvers it owns', () = // End-to-end routing proof: drive the REAL `withRequestPlatformProviderScope` with a // spy for every resolver and assert exactly the gated resolvers the former hand gate -// admitted are invoked (plus the two ungated resolvers, on every platform). Each spy +// admitted are invoked (plus the ungated resolver, on every platform). Each spy // returns `undefined`, so no wrapper is composed — but the resolver is still called iff // its gate passed, which is precisely what the former `device.platform === …` branch // decided. Breaking the facet flips which resolvers run and fails this test. @@ -155,7 +155,6 @@ test('withRequestPlatformProviderScope invokes exactly the resolvers the former vegaToolProvider: spy('vegaToolProvider'), linuxToolProvider: spy('linuxToolProvider'), webProvider: spy('webProvider'), - appLogProvider: spy('appLogProvider'), recordingProvider: spy('recordingProvider'), }; diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 46e29c6784..156a7d2db1 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -11,6 +11,7 @@ import { contextFromFlags } from '../context.ts'; import { handleLeaseCommands } from '../handlers/lease.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { runRequestHandlerChain } from '../request-handler-chain.ts'; +import { unavailableBindDevice } from './test-device-runtime-gateway.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; @@ -254,6 +255,8 @@ async function runCatalogCommandThroughHandlerChain( leaseRegistry, invoke: async () => ({ ok: true, data: {} }), androidAdbExecutor: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + bindDevice: unavailableBindDevice, + throwIfCanceled: () => {}, contextFromFlags: (flags, appBundleId, traceLogPath) => contextFromFlags( '/tmp/agent-device-catalog-route.log', diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index 2c38e2d008..7c606e5447 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -18,6 +18,7 @@ import { createLocalLinuxToolProvider, withLinuxToolProvider, } from '../../platforms/linux/tool-provider.ts'; +import { unavailableBindDevice } from './test-device-runtime-gateway.ts'; function makeRequest(command: string, positionals: string[] = []): DaemonRequest { return { @@ -40,6 +41,8 @@ function makeChainParams(req: DaemonRequest) { sessionStore, leaseRegistry: new LeaseRegistry(), invoke: async (): Promise => ({ ok: true, data: {} }), + bindDevice: unavailableBindDevice, + throwIfCanceled: () => {}, contextFromFlags: () => ({ logPath: '/tmp/agent-device-request-chain.log' }), }; } diff --git a/src/daemon/__tests__/request-router-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index fd84aae810..65a7109d94 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -18,7 +18,7 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { }; }); -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; diff --git a/src/daemon/__tests__/request-router-android-perf.test.ts b/src/daemon/__tests__/request-router-android-perf.test.ts index bf8955f3b9..be60ab6d4c 100644 --- a/src/daemon/__tests__/request-router-android-perf.test.ts +++ b/src/daemon/__tests__/request-router-android-perf.test.ts @@ -1,6 +1,6 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { expect, test } from 'vitest'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { SessionStore } from '../session-store.ts'; import { AppError } from '@agent-device/kernel/errors'; diff --git a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts index fbdfe6c45c..02361eedd2 100644 --- a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts +++ b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts @@ -1,6 +1,6 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { beforeEach, expect, test } from 'vitest'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { SessionStore } from '../session-store.ts'; import { AppError } from '@agent-device/kernel/errors'; diff --git a/src/daemon/__tests__/request-router-cost.test.ts b/src/daemon/__tests__/request-router-cost.test.ts index 3b7a88bd63..310a11f3c7 100644 --- a/src/daemon/__tests__/request-router-cost.test.ts +++ b/src/daemon/__tests__/request-router-cost.test.ts @@ -17,7 +17,7 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; diff --git a/src/daemon/__tests__/request-router-custom-action-flags.test.ts b/src/daemon/__tests__/request-router-custom-action-flags.test.ts index cc061933bc..5e092e1ea6 100644 --- a/src/daemon/__tests__/request-router-custom-action-flags.test.ts +++ b/src/daemon/__tests__/request-router-custom-action-flags.test.ts @@ -9,7 +9,7 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/de import { test, expect } from 'vitest'; import path from 'node:path'; import os from 'node:os'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; diff --git a/src/daemon/__tests__/request-router-events.test.ts b/src/daemon/__tests__/request-router-events.test.ts index a003d50556..363d779351 100644 --- a/src/daemon/__tests__/request-router-events.test.ts +++ b/src/daemon/__tests__/request-router-events.test.ts @@ -3,7 +3,7 @@ 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 { createRequestHandler } from './test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeIosSession } from '../../__tests__/test-utils/index.ts'; diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index da85caf00f..e58c1e79ff 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -20,7 +20,7 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 96941a1455..63c50e89bb 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -7,13 +7,17 @@ import { getResolveTargetDeviceMock } from './request-router-dispatch-mocks.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../session-teardown.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, stopAppleRunnerForClose: vi.fn(async () => {}) }; +}); vi.mock('../../utils/host-process.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readProcessStartTime: vi.fn(() => 'test-process-start') }; }); import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { resolveRequestExecutionLockKeys } from '../request-binding.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { ensureDeviceReady } from '../device-ready.ts'; diff --git a/src/daemon/__tests__/request-router-record-flags.test.ts b/src/daemon/__tests__/request-router-record-flags.test.ts index 283729af09..c6fe7eeb8a 100644 --- a/src/daemon/__tests__/request-router-record-flags.test.ts +++ b/src/daemon/__tests__/request-router-record-flags.test.ts @@ -9,7 +9,7 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/de import { test, expect } from 'vitest'; import path from 'node:path'; import os from 'node:os'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index 23e6883aee..3905f41001 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -23,7 +23,7 @@ import { dispatchGestureViewport, } from '../../core/dispatch.ts'; import { getRunnerSessionSnapshot } from '../../platforms/apple/core/runner/runner-client.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; diff --git a/src/daemon/__tests__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index 25a1b172bb..585e8da152 100644 --- a/src/daemon/__tests__/request-router-repair-expired.test.ts +++ b/src/daemon/__tests__/request-router-repair-expired.test.ts @@ -15,7 +15,7 @@ import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { LeaseRegistry } from '../lease-registry.ts'; diff --git a/src/daemon/__tests__/request-router-replay-env.test.ts b/src/daemon/__tests__/request-router-replay-env.test.ts index 612661f99a..01822bf34c 100644 --- a/src/daemon/__tests__/request-router-replay-env.test.ts +++ b/src/daemon/__tests__/request-router-replay-env.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { expect, test } from 'vitest'; import { makeSessionStore } from '../../__tests__/test-utils/index.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; function createHarness() { diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index 6fae867c40..d15081622e 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -34,7 +34,7 @@ import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { ensureDeviceReady } from '../device-ready.ts'; const mockDispatch = vi.mocked(dispatchCommand); diff --git a/src/daemon/__tests__/request-router-response-level.test.ts b/src/daemon/__tests__/request-router-response-level.test.ts index b5427fe138..2c6f151aac 100644 --- a/src/daemon/__tests__/request-router-response-level.test.ts +++ b/src/daemon/__tests__/request-router-response-level.test.ts @@ -33,7 +33,7 @@ vi.mock('../response-views.ts', async (importOriginal) => { }); import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index c6458c95ff..fdb3cf1998 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -19,7 +19,7 @@ vi.mock('../../platforms/android/app-lifecycle.ts', async (importOriginal) => { }); import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { dispatchScreenshotViaRuntime } from '../screenshot-runtime.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index c1bd5b5328..52ee3e4a2b 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -18,7 +18,7 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; diff --git a/src/daemon/__tests__/request-runtime-binding-router.test.ts b/src/daemon/__tests__/request-runtime-binding-router.test.ts new file mode 100644 index 0000000000..f59300d92b --- /dev/null +++ b/src/daemon/__tests__/request-runtime-binding-router.test.ts @@ -0,0 +1,136 @@ +import fs from 'node:fs'; +import { expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + type AppLogRuntimeOperations, + type DeviceRuntimeGateway, +} from '@agent-device/contracts/platform'; +import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +test('request binding disposes once after response while adopted app-log handle survives', async () => { + const runtime = makeGateway(); + const { handler, sessionStore } = makeHandler(runtime.gateway); + const response = await handler(request(['start'])); + + expect(response).toMatchObject({ ok: true, data: { started: true } }); + expect(runtime.bind).toHaveBeenCalledOnce(); + expect(runtime.bindingDispose).toHaveBeenCalledOnce(); + expect(runtime.forceCleanup).not.toHaveBeenCalled(); + expect(sessionStore.get('session')?.appLog?.handle).toBe(runtime.handle); +}); + +test('primary request failure survives a rejecting binding disposal', async () => { + const cleanupFailure = new Error('binding dispose failed'); + const runtime = makeGateway(cleanupFailure); + const { handler } = makeHandler(runtime.gateway); + const response = await handler(request(['invalid'])); + + expect(response).toMatchObject({ + ok: false, + error: { code: 'INVALID_ARGS', message: expect.stringContaining('logs requires') }, + }); + expect(runtime.bindingDispose).toHaveBeenCalledOnce(); + if (response.ok || !response.error.logPath) throw new Error('expected request diagnostics path'); + const phases = fs + .readFileSync(response.error.logPath, 'utf8') + .trim() + .split('\n') + .map((line) => (JSON.parse(line) as { phase: string }).phase); + expect(phases).toContain('request_failed'); + expect(phases).toContain('request_binding_cleanup_failed'); +}); + +function makeHandler(gateway: DeviceRuntimeGateway) { + const sessionStore = makeSessionStore('request-runtime-binding-router-'); + sessionStore.set('session', { + name: 'session', + device: { platform: 'android', id: 'emulator-5554', name: 'Pixel', kind: 'emulator' }, + appBundleId: 'com.example.app', + createdAt: Date.now(), + actions: [], + }); + return { + sessionStore, + handler: createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 'token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: gateway, + trackDownloadableArtifact: () => 'artifact', + }), + }; +} + +function request(positionals: string[]) { + return { + token: 'token', + session: 'session', + command: 'logs', + positionals, + flags: {}, + meta: { requestId: `logs-${positionals[0]}` }, + }; +} + +function makeGateway(disposeError?: Error) { + const owner = localRuntimeOwner('android'); + const forceCleanup = vi.fn(async () => ({ status: 'cleaned' as const })); + const handle = createTestAppLogLiveHandle({ + inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), + finish: async () => ({ + status: 'completed', + result: { backend: 'android', outputPath: '/tmp/app.log', completedAt: 2 }, + }), + forceCleanup, + }); + const operations: AppLogRuntimeOperations = { + appLogInspect: async () => ({ backend: 'android' }), + appLogDoctor: async () => ({ backend: 'android', checks: {}, notes: [] }), + appLogStart: async (input) => + createAppLogStartResult( + handle, + createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: input.sessionId, + device: { id: 'emulator-5554', family: 'android', kind: 'emulator' }, + owner, + fence: input.fence, + lifecycle: 'open', + descriptor: { version: 1, body: {} }, + }), + ), + appLogReattach: async () => ({ status: 'missing' }), + appLogCleanup: async () => ({ status: 'cleaned' }), + }; + const bindingDispose = vi.fn(async () => { + if (disposeError) throw disposeError; + }); + const bind = vi.fn(async ({ device }) => ({ + device, + owner, + facts: { + device: { family: 'android' as const, kind: device.kind, providerMode: 'local' as const }, + operations: { + appLogInspect: { available: true as const }, + appLogDoctor: { available: true as const }, + appLogStart: { available: true as const }, + appLogReattach: { available: true as const }, + appLogCleanup: { available: true as const }, + }, + }, + operations, + [Symbol.asyncDispose]: bindingDispose, + })); + const gateway: DeviceRuntimeGateway = { + bind, + shutdown: async () => {}, + }; + return { gateway, bind, bindingDispose, forceCleanup, handle }; +} diff --git a/src/daemon/__tests__/request-runtime-binding.test.ts b/src/daemon/__tests__/request-runtime-binding.test.ts new file mode 100644 index 0000000000..fa46e7226e --- /dev/null +++ b/src/daemon/__tests__/request-runtime-binding.test.ts @@ -0,0 +1,131 @@ +import { expect, test, vi } from 'vitest'; +import { + appLogAdmissionUse, + localRuntimeOwner, + resolveLogsRuntimePlan, + type AppLogRuntimeOperations, + type DeviceBinding, + type DeviceRuntimeGateway, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createRequestRuntimeBindings } from '../request-runtime-binding.ts'; + +const inspectPlan = resolveLogsRuntimePlan({ action: 'path' }); +const doctorPlan = resolveLogsRuntimePlan({ action: 'doctor' }); +if (inspectPlan.kind !== 'path' || doctorPlan.kind !== 'doctor') { + throw new TypeError('Expected inspect and doctor app-log plans'); +} +const appLogInspectUse = inspectPlan.use; +const appLogDoctorUse = doctorPlan.use; + +const scope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + +test('request runtime binding caches one broad owner and projects each declared use', async () => { + const runtime = makeGateway(); + const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + + const admission = await bindings.bindDevice(device('one'), appLogAdmissionUse); + const inspect = await bindings.bindDevice(device('one'), appLogInspectUse); + const doctor = await bindings.bindDevice(device('one'), appLogDoctorUse); + + expect(runtime.bind).toHaveBeenCalledOnce(); + expect(admission.operations.appLogInspect).toBe(runtime.operations.appLogInspect); + expect(Object.keys(inspect.operations)).toEqual(['appLogInspect']); + expect(Object.keys(doctor.operations)).toEqual(['appLogInspect', 'appLogDoctor']); + expect(Symbol.asyncDispose in doctor).toBe(false); + await bindings[Symbol.asyncDispose](); + expect(runtime.disposals).toEqual(['one']); +}); + +test('request binding disposes multiple owners in reverse adoption order', async () => { + const runtime = makeGateway(); + const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + await bindings.bindDevice(device('one'), appLogInspectUse); + await bindings.bindDevice(device('two'), appLogInspectUse); + await bindings[Symbol.asyncDispose](); + expect(runtime.disposals).toEqual(['two', 'one']); +}); + +test('concurrent uses share one in-flight broad binding for the device', async () => { + const runtime = makeGateway(); + const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + const selected = device('one'); + + await Promise.all([ + bindings.bindDevice(selected, appLogInspectUse), + bindings.bindDevice(selected, appLogDoctorUse), + ]); + + expect(runtime.bind).toHaveBeenCalledOnce(); + await bindings[Symbol.asyncDispose](); + expect(runtime.disposals).toEqual(['one']); +}); + +test('preferred absence is visible without failing while required absence fails typed', async () => { + const runtime = makeGateway({ inspectAvailable: false }); + const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + const admission = await bindings.bindDevice(device('one'), appLogAdmissionUse); + expect(admission.facts.appLogInspect).toMatchObject({ available: false }); + expect(admission.operations.appLogInspect).toBeUndefined(); + await expect(bindings.bindDevice(device('one'), appLogInspectUse)).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + }); + await bindings[Symbol.asyncDispose](); +}); + +function makeGateway(options: { inspectAvailable?: boolean } = {}) { + const disposals: string[] = []; + const operations: AppLogRuntimeOperations = { + appLogInspect: vi.fn(async () => ({ backend: 'android' as const })), + appLogDoctor: vi.fn(async () => ({ backend: 'android' as const, checks: {}, notes: [] })), + appLogStart: vi.fn(async () => { + throw new Error('not used'); + }), + appLogReattach: vi.fn(async () => ({ status: 'missing' as const })), + appLogCleanup: vi.fn(async () => ({ status: 'already-missing' as const })), + }; + const bind = vi.fn( + async ({ device: selected }): Promise> => ({ + device: selected, + owner: localRuntimeOwner('android'), + facts: { + device: { family: 'android', kind: 'emulator', providerMode: 'local' }, + operations: { + appLogInspect: + options.inspectAvailable === false + ? { available: false, reason: 'owner-capability-missing' } + : { available: true }, + appLogDoctor: { available: true }, + appLogStart: { available: true }, + appLogReattach: { available: true }, + appLogCleanup: { available: true }, + }, + }, + operations: + options.inspectAvailable === false + ? { + appLogDoctor: operations.appLogDoctor, + appLogStart: operations.appLogStart, + appLogReattach: operations.appLogReattach, + appLogCleanup: operations.appLogCleanup, + } + : operations, + [Symbol.asyncDispose]: async () => { + disposals.push(selected.id); + }, + }), + ); + const gateway: DeviceRuntimeGateway = { + bind, + shutdown: async () => {}, + }; + return { gateway, bind, operations, disposals }; +} + +function device(id: string): DeviceInfo { + return { platform: 'android', id, name: id, kind: 'emulator' }; +} diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index 782c5f4be3..ee81900ee5 100644 --- a/src/daemon/__tests__/request-save-script-transports.test.ts +++ b/src/daemon/__tests__/request-save-script-transports.test.ts @@ -17,7 +17,7 @@ import net from 'node:net'; import path from 'node:path'; import { afterEach, expect, test } from 'vitest'; import { LeaseRegistry } from '../lease-registry.ts'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { SessionStore } from '../session-store.ts'; import { createDaemonHttpServer } from '../server/http-server.ts'; import { createSocketServer, listenNetServer } from '../server/transport.ts'; diff --git a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts index de53dcc81c..28d9636f30 100644 --- a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts +++ b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts @@ -11,7 +11,7 @@ import { test, expect } from 'vitest'; import path from 'node:path'; import os from 'node:os'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { createRequestHandler } from '../request-router.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import type { SessionState } from '../types.ts'; diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts new file mode 100644 index 0000000000..674ab1c4f0 --- /dev/null +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -0,0 +1,68 @@ +import { + localRuntimeOwner, + narrowDeviceBinding, + type AppLogRuntimeOperations, + type DeviceRuntimeGateway, +} from '@agent-device/contracts/platform'; +import { + createRequestHandler as createProductionRequestHandler, + type RequestRouterDeps, +} from '../request-router.ts'; +import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; + +export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway = + Object.freeze({ + bind: async ({ device }) => ({ + device, + owner: localRuntimeOwner(device.platform), + facts: { + device: { + family: device.platform, + kind: device.kind, + providerMode: 'local', + ...(device.appleOs === undefined ? {} : { appleOs: device.appleOs }), + ...(device.target === undefined ? {} : { target: device.target }), + ...(device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), + }, + operations: { + appLogInspect: unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + }, + }, + operations: {}, + [Symbol.asyncDispose]: async () => {}, + }), + shutdown: async () => {}, + }); + +const unavailable = Object.freeze({ + available: false as const, + reason: 'owner-capability-missing' as const, +}); + +export const unavailableBindDevice: BindDeviceRuntime = async (device, use) => + narrowDeviceBinding( + await unavailableDeviceRuntimeGateway.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }), + use, + ); + +export function createRequestHandler( + deps: Omit & + Partial>, +) { + const { deviceRuntimeGateway = unavailableDeviceRuntimeGateway, ...rest } = deps; + return createProductionRequestHandler({ ...rest, deviceRuntimeGateway }); +} diff --git a/src/daemon/app-log-admission-ledger.ts b/src/daemon/app-log-admission-ledger.ts new file mode 100644 index 0000000000..baf7b918fe --- /dev/null +++ b/src/daemon/app-log-admission-ledger.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import { + deviceIdentity, + deviceIdentityKey, + type DeviceIdentity, + type DeviceInfo, +} from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +const DEFAULT_UNDURABLE_CLEANUP_TTL_MS = 5 * 60_000; + +export type RetainedLegacyAppLogMarker = Readonly<{ + markerPath: string; + device?: DeviceIdentity; +}>; + +export type AppLogAdmissionLedgerOptions = Readonly<{ + now?: () => number; + markerExists?: (markerPath: string) => boolean; + undurableCleanupTtlMs?: number; + onUndurableCleanupExpired?: (block: Readonly<{ device: DeviceIdentity; reason: string }>) => void; +}>; + +/** Process-lifetime admission evidence that is intentionally reset by daemon restart. */ +export type AppLogAdmissionLedger = Readonly<{ + retainLegacyMarkers(markers: readonly RetainedLegacyAppLogMarker[]): void; + blockUndurableCleanup(device: DeviceInfo, reason: string): void; + clearUndurableCleanup(device: DeviceInfo): void; + assertStartAllowed(device: DeviceInfo): void; +}>; + +function findRetainedLegacyMarker( + retainedMarkers: Map, + identityKey: string, + markerExists: (markerPath: string) => boolean, +): RetainedLegacyAppLogMarker | undefined { + let retained: RetainedLegacyAppLogMarker | undefined; + for (const [markerPath, marker] of retainedMarkers) { + const matchesDevice = + marker.device === undefined || deviceIdentityKey(marker.device) === identityKey; + if (!matchesDevice) continue; + if (!markerExists(markerPath)) { + retainedMarkers.delete(markerPath); + continue; + } + retained ??= marker; + } + return retained; +} + +export function createAppLogAdmissionLedger( + options: AppLogAdmissionLedgerOptions = {}, +): AppLogAdmissionLedger { + const now = options.now ?? Date.now; + const markerExists = options.markerExists ?? fs.existsSync; + const ttlMs = options.undurableCleanupTtlMs ?? DEFAULT_UNDURABLE_CLEANUP_TTL_MS; + const undurableCleanupBlocks = new Map< + string, + Readonly<{ device: DeviceIdentity; reason: string; expiresAt: number }> + >(); + const retainedLegacyMarkers = new Map(); + return Object.freeze({ + retainLegacyMarkers(markers: readonly RetainedLegacyAppLogMarker[]): void { + for (const marker of markers) retainedLegacyMarkers.set(marker.markerPath, marker); + }, + blockUndurableCleanup(device: DeviceInfo, reason: string): void { + const identity = deviceIdentity(device); + undurableCleanupBlocks.set(deviceIdentityKey(identity), { + device: identity, + reason, + expiresAt: now() + ttlMs, + }); + }, + clearUndurableCleanup(device: DeviceInfo): void { + undurableCleanupBlocks.delete(deviceIdentityKey(deviceIdentity(device))); + }, + assertStartAllowed(device: DeviceInfo): void { + const identity = deviceIdentity(device); + const identityKey = deviceIdentityKey(identity); + const retained = findRetainedLegacyMarker(retainedLegacyMarkers, identityKey, markerExists); + if (retained) { + throw new AppError( + 'COMMAND_FAILED', + 'A retained legacy app-log marker prevents replacement capture', + { + reason: 'cleanup-unconfirmed', + hint: 'Inspect and remove the retained app-log.pid only after manually confirming its process is gone.', + }, + ); + } + + const block = undurableCleanupBlocks.get(identityKey); + if (!block) return; + if (now() > block.expiresAt) { + undurableCleanupBlocks.delete(identityKey); + options.onUndurableCleanupExpired?.({ device: block.device, reason: block.reason }); + return; + } + throw new AppError( + 'COMMAND_FAILED', + 'The existing app-log resource has process-local unconfirmed ownership', + { + reason: 'cleanup-unconfirmed', + hint: `Do not start a replacement in this daemon process: ${block.reason}`, + }, + ); + }, + }); +} diff --git a/src/daemon/app-log-android.ts b/src/daemon/app-log-android.ts deleted file mode 100644 index 0fe0c85419..0000000000 --- a/src/daemon/app-log-android.ts +++ /dev/null @@ -1,196 +0,0 @@ -import fs from 'node:fs'; -import { - resolveAndroidAdbExecutor, - resolveAndroidAdbProvider, - type AndroidAdbProcess, -} from '../platforms/android/adb-executor.ts'; -import { androidDeviceForSerial } from '../platforms/android/adb.ts'; -import { - captureAndroidLogcatWithAdb, - streamAndroidLogcatWithAdb, -} from '../platforms/android/logcat.ts'; -import { AppError } from '@agent-device/kernel/errors'; -import { - clearPidFile, - readStoredAppLogProcessMeta, - writePidFile, - type AppLogResult, - type AppLogState, -} from './app-log-process.ts'; -import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts'; -import { sleep } from '../utils/timeouts.ts'; - -export function assertAndroidPackageArgSafe(appBundleId: string): void { - if (!/^[a-zA-Z0-9._:-]+$/.test(appBundleId)) { - throw new AppError('INVALID_ARGS', `Invalid Android package name for logs: ${appBundleId}`); - } -} - -export async function resolveAndroidPid( - deviceId: string, - appBundleId: string, -): Promise { - const pidResult = await resolveAndroidAdbExecutor(androidDeviceForSerial(deviceId))( - ['shell', 'pidof', appBundleId], - { allowFailure: true }, - ); - const pid = pidResult.stdout.trim().split(/\s+/)[0]; - if (!pid || !/^\d+$/.test(pid)) return null; - return pid; -} - -export function readTrackedAndroidLogcatPid(pidPath: string | undefined): string | null { - const command = readStoredAppLogProcessMeta(pidPath)?.command; - if (!command) return null; - const match = /(?:^|\s)--pid\s+(\d+)(?:\s|$)/.exec(command); - return match?.[1] ?? null; -} - -export async function readRecentAndroidLogcatForPackage( - deviceId: string, - appBundleId: string, -): Promise<{ pid: string | null; text: string; recoveredPids: string[] } | null> { - assertAndroidPackageArgSafe(appBundleId); - const pid = await resolveAndroidPid(deviceId, appBundleId); - const adb = resolveAndroidAdbExecutor(androidDeviceForSerial(deviceId)); - const text = await captureAndroidLogcatWithAdb(adb, { lines: 4000, timeoutMs: 3_000 }).catch( - () => '', - ); - if (text.trim().length === 0) { - return null; - } - const recoveredPids = collectAndroidPackagePids(text, appBundleId, pid); - if (recoveredPids.length === 0) { - return null; - } - const filteredText = filterAndroidLogcatToPids(text, appBundleId, recoveredPids); - if (filteredText.trim().length === 0) { - return null; - } - return { pid, text: filteredText, recoveredPids }; -} - -export async function startAndroidAppLog( - deviceId: string, - appBundleId: string, - stream: fs.WriteStream, - redactionPatterns: RegExp[], - pidPath?: string, -): Promise { - let state: AppLogState = 'recovering'; - let stopped = false; - let activeChild: AndroidAdbProcess | undefined; - let activeWait: ReturnType | undefined; - - const wait = (async () => { - try { - while (!stopped) { - const pid = await resolveAndroidPid(deviceId, appBundleId); - if (!pid) { - state = 'recovering'; - await sleep(1_000); - continue; - } - const provider = resolveAndroidAdbProvider(androidDeviceForSerial(deviceId)); - const child = streamAndroidLogcatWithAdb(provider, { pid }); - activeChild = child; - const writer = createLineWriter(stream, { redactionPatterns }); - activeWait = attachChildToStream(child, stream, { endStreamOnClose: false, writer }); - if (typeof child.pid === 'number') { - writePidFile(pidPath, child.pid); - } - state = 'active'; - await activeWait; - clearPidFile(pidPath); - activeChild = undefined; - activeWait = undefined; - if (stopped) return { stdout: '', stderr: '', exitCode: 0 }; - state = 'recovering'; - await sleep(500); - } - return { stdout: '', stderr: '', exitCode: 0 }; - } finally { - stream.end(); - clearPidFile(pidPath); - } - })(); - - return { - backend: 'android', - getState: () => state, - startedAt: Date.now(), - wait, - stop: async () => { - stopped = true; - if (activeChild && !activeChild.killed) { - activeChild.kill('SIGINT'); - } - if (activeWait) await waitForChildExit(activeWait); - if (activeChild && !activeChild.killed) { - activeChild.kill('SIGKILL'); - } - await waitForChildExit(wait); - clearPidFile(pidPath); - }, - }; -} - -function collectAndroidPackagePids( - content: string, - appBundleId: string, - currentPid: string | null, -): string[] { - const pids = new Set(); - if (currentPid) { - pids.add(currentPid); - } - const lines = content.split('\n'); - for (const line of lines) { - if (!line.includes(appBundleId)) continue; - for (const candidate of extractAndroidPidsFromPackageLine(line, appBundleId)) { - pids.add(candidate); - } - } - return [...pids]; -} - -function extractAndroidPidsFromPackageLine(line: string, appBundleId: string): string[] { - const escapedPackage = escapeRegExp(appBundleId); - const patterns = [ - new RegExp(`\\bStart proc\\s+(\\d+):${escapedPackage}(?:\\b|/)`, 'i'), - new RegExp(`\\b(\\d+):${escapedPackage}(?:\\b|/)`, 'i'), - new RegExp(`${escapedPackage}.*?\\bpid\\s*[=:]?\\s*(\\d+)\\b`, 'i'), - new RegExp(`\\bpid\\s*[=:]?\\s*(\\d+)\\b.*${escapedPackage}`, 'i'), - ]; - const results: string[] = []; - for (const pattern of patterns) { - const match = pattern.exec(line); - const pid = match?.[1]; - if (pid && /^\d+$/.test(pid)) { - results.push(pid); - } - } - return results; -} - -function filterAndroidLogcatToPids(content: string, appBundleId: string, pids: string[]): string { - const pidSet = new Set(pids); - return content - .split('\n') - .filter((line) => { - if (!line.trim()) return false; - if (line.includes(appBundleId)) return true; - const linePid = parseAndroidThreadtimePid(line); - return linePid ? pidSet.has(linePid) : false; - }) - .join('\n'); -} - -function parseAndroidThreadtimePid(line: string): string | null { - const match = /\(\s*(\d+)\)\s*:/.exec(line); - return match?.[1] ?? null; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/src/daemon/app-log-doctor.ts b/src/daemon/app-log-doctor.ts deleted file mode 100644 index b3bd1509b7..0000000000 --- a/src/daemon/app-log-doctor.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; -import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; -import { runAndroidAdb } from '../platforms/android/adb.ts'; -import { runCmd } from '../utils/exec.ts'; -import { - checkIosDeviceConsoleCaptureSupport, - IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_NOTE, - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, -} from './app-log-ios.ts'; -import { checkCoreDeviceAvailable } from '../platforms/apple/core/physical-device-console.ts'; - -export type AppLogDoctorResult = { - checks: Record; - notes: string[]; -}; - -export async function runAppLogDoctor( - device: DeviceInfo, - appBundleId?: string, -): Promise { - const checks: Record = {}; - const notes = buildAppLogDoctorNotes(appBundleId); - - if (device.platform === 'android') { - Object.assign(checks, await runAndroidAppLogDoctor(device, appBundleId)); - } - if (isIosFamily(device) && device.kind === 'simulator') { - Object.assign(checks, await runIosSimulatorAppLogDoctor()); - } - if (isIosFamily(device) && device.kind === 'device') { - const result = await runIosDeviceAppLogDoctor(); - Object.assign(checks, result.checks); - notes.push(...result.notes); - } - if (isMacOs(device)) { - Object.assign(checks, await runMacOsAppLogDoctor()); - } - return { checks, notes }; -} - -function buildAppLogDoctorNotes(appBundleId: string | undefined): string[] { - if (appBundleId) return []; - return ['No app bundle is tracked in this session. Run open first for app-scoped logs.']; -} - -async function runAndroidAppLogDoctor( - device: DeviceInfo, - appBundleId?: string, -): Promise> { - const checks: Record = {}; - checks.adbAvailable = await safeCheck(async () => { - const adb = await runAndroidAdb(device, ['shell', 'echo', 'ok'], { - allowFailure: true, - timeoutMs: 1_000, - }); - return adb.exitCode === 0; - }); - if (!appBundleId) return checks; - - checks.androidPidVisible = await safeCheck(async () => { - const pidof = await runAndroidAdb(device, ['shell', 'pidof', appBundleId], { - allowFailure: true, - timeoutMs: 1_000, - }); - return pidof.stdout.trim().length > 0; - }); - return checks; -} - -async function runIosSimulatorAppLogDoctor(): Promise> { - const simctlAvailable = await safeCheck(async () => { - const simctl = await runXcrun(['simctl', 'help'], { allowFailure: true }); - return simctl.exitCode === 0; - }); - return { simctlAvailable }; -} - -async function runIosDeviceAppLogDoctor(): Promise { - const checks: Record = {}; - const notes: string[] = []; - checks.devicectlAvailable = await safeCheck(checkCoreDeviceAvailable); - if (!checks.devicectlAvailable) return { checks, notes }; - - const consoleCapture = await checkIosDeviceConsoleCaptureSupport(); - checks.devicectlConsoleCapture = consoleCapture.supported; - if (!consoleCapture.supported) { - if (consoleCapture.reason === 'probe-failed') { - notes.push(IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_NOTE); - } else { - notes.push(IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE); - } - } - return { checks, notes }; -} - -async function runMacOsAppLogDoctor(): Promise> { - const logAvailable = await safeCheck(async () => { - const log = await runCmd('log', ['help'], { allowFailure: true }); - return log.exitCode === 0; - }); - return { logAvailable }; -} - -async function safeCheck(check: () => Promise): Promise { - try { - return await check(); - } catch { - return false; - } -} diff --git a/src/daemon/app-log-harmonyos.ts b/src/daemon/app-log-harmonyos.ts deleted file mode 100644 index a8db7d108c..0000000000 --- a/src/daemon/app-log-harmonyos.ts +++ /dev/null @@ -1,87 +0,0 @@ -import fs from 'node:fs'; -import { - harmonyDeviceForTarget, - ensureHdcAvailable, - runHarmonyHdc, -} from '../platforms/harmonyos/hdc.ts'; -import { runCmdBackground } from '../utils/exec.ts'; -import { sleep } from '../utils/timeouts.ts'; -import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts'; -import { - clearPidFile, - writePidFile, - type AppLogResult, - type AppLogState, -} from './app-log-process.ts'; - -async function resolveHarmonyPid(deviceId: string, bundleId: string): Promise { - const result = await runHarmonyHdc( - harmonyDeviceForTarget(deviceId, { name: deviceId, emulator: false }), - ['shell', 'pidof', bundleId], - { allowFailure: true }, - ); - const pid = result.stdout.trim().split(/\s+/)[0]; - return pid && /^\d+$/.test(pid) ? pid : null; -} - -export async function startHarmonyAppLog( - deviceId: string, - bundleId: string, - stream: fs.WriteStream, - redactionPatterns: RegExp[], - pidPath?: string, -): Promise { - await ensureHdcAvailable(); - let state: AppLogState = 'recovering'; - let stopped = false; - let active: ReturnType | undefined; - let activeWait: ReturnType | undefined; - const wait = (async () => { - try { - while (!stopped) { - const pid = await resolveHarmonyPid(deviceId, bundleId); - if (!pid) { - state = 'recovering'; - await sleep(1_000); - continue; - } - active = runCmdBackground('hdc', ['-t', deviceId, 'shell', 'hilog', '-P', pid], { - captureOutput: false, - allowFailure: true, - }); - activeWait = attachChildToStream(active.child, stream, { - endStreamOnClose: false, - writer: createLineWriter(stream, { redactionPatterns }), - }); - if (typeof active.child.pid === 'number') writePidFile(pidPath, active.child.pid); - state = 'active'; - await activeWait; - clearPidFile(pidPath); - active = undefined; - activeWait = undefined; - if (!stopped) { - state = 'recovering'; - await sleep(500); - } - } - return { stdout: '', stderr: '', exitCode: 0 }; - } finally { - stream.end(); - clearPidFile(pidPath); - } - })(); - return { - backend: 'harmonyos', - getState: () => state, - startedAt: Date.now(), - wait, - stop: async () => { - stopped = true; - if (active?.child && !active.child.killed) active.child.kill('SIGINT'); - if (activeWait) await waitForChildExit(activeWait); - if (active?.child && !active.child.killed) active.child.kill('SIGKILL'); - await waitForChildExit(wait); - clearPidFile(pidPath); - }, - }; -} diff --git a/src/daemon/app-log-ios.ts b/src/daemon/app-log-ios.ts deleted file mode 100644 index 27c236acb5..0000000000 --- a/src/daemon/app-log-ios.ts +++ /dev/null @@ -1,306 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { buildSimctlArgs } from '../platforms/apple/core/simctl.ts'; -import { AppError } from '@agent-device/kernel/errors'; -import { runCmd, runCmdBackground } from '../utils/exec.ts'; -import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; -import { - buildCoreDeviceConsoleLaunchArgs, - checkCoreDeviceConsoleCaptureSupport, - type CoreDeviceConsoleCaptureSupport, -} from '../platforms/apple/core/physical-device-console.ts'; -import { - clearPidFile, - writePidFile, - type AppLogResult, - type AppLogState, -} from './app-log-process.ts'; -import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts'; - -export const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED = { - message: 'iOS physical-device app console capture is not supported by the installed devicectl.', - hint: 'This devicectl does not expose process launch --console. Markers can still be written to app.log, but app output is not being captured. Use an iOS simulator for agent-device app logs or inspect physical-device logs in Console.app/Xcode until this Xcode toolchain exposes scriptable console capture.', -} as const; -const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED = { - message: 'Could not verify iOS physical-device app console capture support.', - hint: 'Retry logs clear --restart. If the probe keeps failing, run logs doctor and inspect the request diagnostics for the devicectl help command.', -} as const; -export const IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE = formatIosDeviceConsoleCaptureNote( - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, -); -export const IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED_NOTE = formatIosDeviceConsoleCaptureNote( - IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED, -); - -export type IosDeviceConsoleCaptureSupport = CoreDeviceConsoleCaptureSupport; - -export function buildAppleLogPredicate( - appBundleId: string, - executableName?: string | undefined, -): string { - const escapedBundleId = escapeAppleLogPredicateString(appBundleId); - const clauses = [ - `subsystem == "${escapedBundleId}"`, - // App frameworks/extensions often log through subsystem names prefixed by the app bundle id. - `subsystem CONTAINS "${escapedBundleId}"`, - `processImagePath ENDSWITH[c] "/${escapedBundleId}"`, - `senderImagePath ENDSWITH[c] "/${escapedBundleId}"`, - ]; - if (executableName) { - const escapedExecutable = escapeAppleLogPredicateString(executableName); - clauses.push( - `process == "${escapedExecutable}"`, - `processImagePath ENDSWITH[c] "/${escapedExecutable}"`, - `senderImagePath ENDSWITH[c] "/${escapedExecutable}"`, - `processImagePath CONTAINS[c] "/${escapedExecutable}.app/"`, - `senderImagePath CONTAINS[c] "/${escapedExecutable}.app/"`, - ); - } - return clauses.join(' OR '); -} - -export function buildIosSimulatorLogStreamArgs(params: { - deviceId: string; - appBundleId: string; - executableName?: string | undefined; - simulatorSetPath?: string; -}): string[] { - const { deviceId, appBundleId, executableName, simulatorSetPath } = params; - return buildSimctlArgs( - [ - 'spawn', - deviceId, - 'log', - 'stream', - '--style', - 'compact', - '--level', - 'info', - '--predicate', - buildAppleLogPredicate(appBundleId, executableName), - ], - { simulatorSetPath }, - ); -} - -export function buildIosDeviceConsoleLaunchArgs(deviceId: string, appBundleId: string): string[] { - return buildCoreDeviceConsoleLaunchArgs(deviceId, appBundleId); -} - -export async function checkIosDeviceConsoleCaptureSupport(): Promise { - return await checkCoreDeviceConsoleCaptureSupport(); -} - -function formatIosDeviceConsoleCaptureNote(message: { message: string; hint: string }): string { - return `${message.message} ${message.hint}`; -} - -export async function readRecentIosSimulatorLogShowForBundle(params: { - deviceId: string; - appBundleId: string; - executableName?: string | undefined; - startedAt?: number; - simulatorSetPath?: string; -}): Promise<{ text: string; recoveredLineCount: number } | null> { - const { deviceId, appBundleId, executableName, startedAt, simulatorSetPath } = params; - const args = buildSimctlArgs( - [ - 'spawn', - deviceId, - 'log', - 'show', - '--style', - 'compact', - '--info', - '--predicate', - buildAppleLogPredicate(appBundleId, executableName), - ], - { simulatorSetPath }, - ); - if (typeof startedAt === 'number' && Number.isFinite(startedAt) && startedAt > 0) { - args.push('--start', `@${Math.floor(startedAt / 1000)}`); - } else { - args.push('--last', '5m'); - } - const result = await runXcrun(args, { allowFailure: true, timeoutMs: 4_000 }); - if (result.exitCode !== 0 || result.stdout.trim().length === 0) { - return null; - } - const lines = result.stdout - .split('\n') - .map((line) => line.trimEnd()) - .filter((line) => { - const trimmed = line.trim(); - return ( - trimmed.length > 0 && !trimmed.startsWith('Timestamp Ty Process[PID:TID]') - ); - }); - if (lines.length === 0) { - return null; - } - return { - text: `${lines.join('\n')}\n`, - recoveredLineCount: lines.length, - }; -} - -export async function startIosSimulatorAppLog( - deviceId: string, - appBundleId: string, - stream: fs.WriteStream, - redactionPatterns: RegExp[], - simulatorSetPath?: string, - pidPath?: string, -): Promise { - const executableName = await resolveIosSimulatorExecutableName({ - deviceId, - appBundleId, - simulatorSetPath, - }); - return startAppleAppLogStream({ - backend: 'ios-simulator', - cmd: 'xcrun', - args: buildIosSimulatorLogStreamArgs({ - deviceId, - appBundleId, - executableName, - simulatorSetPath, - }), - stream, - redactionPatterns, - pidPath, - }); -} - -function escapeAppleLogPredicateString(value: string): string { - return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); -} - -async function resolveIosSimulatorExecutableName(params: { - deviceId: string; - appBundleId: string; - simulatorSetPath?: string; -}): Promise { - const { deviceId, appBundleId, simulatorSetPath } = params; - const container = await runXcrun( - buildSimctlArgs(['get_app_container', deviceId, appBundleId, 'app'], { simulatorSetPath }), - { allowFailure: true, timeoutMs: 4_000 }, - ); - if (container.exitCode !== 0) return undefined; - const appPath = container.stdout.trim(); - if (!appPath) return undefined; - const plistPath = path.join(appPath, 'Info.plist'); - const executable = await runCmd( - 'plutil', - ['-extract', 'CFBundleExecutable', 'raw', '-o', '-', plistPath], - { allowFailure: true, timeoutMs: 4_000 }, - ); - if (executable.exitCode !== 0) return undefined; - return executable.stdout.trim() || undefined; -} - -export async function startMacOsAppLog( - appBundleId: string, - stream: fs.WriteStream, - redactionPatterns: RegExp[], - pidPath?: string, -): Promise { - return startAppleAppLogStream({ - backend: 'macos', - cmd: 'log', - args: ['stream', '--style', 'compact', '--predicate', buildAppleLogPredicate(appBundleId)], - stream, - redactionPatterns, - pidPath, - }); -} - -export async function startIosDeviceAppLog( - deviceId: string, - appBundleId: string, - stream: fs.WriteStream, - redactionPatterns: RegExp[], - pidPath?: string, -): Promise { - const support = await checkIosDeviceConsoleCaptureSupport(); - if (!support.supported) { - stream.end(); - throw buildIosDeviceConsoleCaptureError(support); - } - return startAppleAppLogStream({ - backend: 'ios-device', - cmd: 'xcrun', - args: buildIosDeviceConsoleLaunchArgs(deviceId, appBundleId), - stream, - redactionPatterns, - pidPath, - stopSignals: ['SIGKILL'], - }); -} - -function buildIosDeviceConsoleCaptureError( - support: Extract, -): AppError { - if (support.reason === 'probe-failed') { - return new AppError('COMMAND_FAILED', IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED.message, { - backend: 'ios-device', - hint: IOS_DEVICE_CONSOLE_CAPTURE_PROBE_FAILED.hint, - stderr: support.stderr, - }); - } - return new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.message, { - backend: 'ios-device', - hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.hint, - stderr: support.stderr, - }); -} - -function startAppleAppLogStream(params: { - backend: AppLogResult['backend']; - cmd: string; - args: string[]; - stream: fs.WriteStream; - redactionPatterns: RegExp[]; - pidPath?: string; - stopSignals?: NodeJS.Signals[]; -}): AppLogResult { - let state: AppLogState = 'active'; - const background = runCmdBackground(params.cmd, params.args, { - allowFailure: true, - captureOutput: false, - }); - void background.wait.catch(() => {}); - const child = background.child; - const writer = createLineWriter(params.stream, { redactionPatterns: params.redactionPatterns }); - if (typeof child.pid === 'number') { - writePidFile(params.pidPath, child.pid); - } - const wait = attachChildToStream(child, params.stream, { - endStreamOnClose: true, - writer, - }).then( - (result) => { - state = result.exitCode === 0 ? 'ended' : 'failed'; - clearPidFile(params.pidPath); - return result; - }, - (error: unknown) => { - state = 'failed'; - clearPidFile(params.pidPath); - throw error; - }, - ); - return { - backend: params.backend, - getState: () => state, - startedAt: Date.now(), - wait, - stop: async () => { - for (const signal of params.stopSignals ?? ['SIGINT', 'SIGKILL']) { - child.kill(signal); - await waitForChildExit(wait); - } - clearPidFile(params.pidPath); - }, - }; -} diff --git a/src/daemon/app-log-network-recovery.ts b/src/daemon/app-log-network-recovery.ts new file mode 100644 index 0000000000..6f30b1be47 --- /dev/null +++ b/src/daemon/app-log-network-recovery.ts @@ -0,0 +1,242 @@ +import path from 'node:path'; +import type { LogBackend } from '@agent-device/contracts/observability'; +import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { + readRecentAndroidLogcatForPackage, + readTrackedAndroidLogcatPid, + resolveAndroidPid, +} from './network-log-android-recovery.ts'; +import { readRecentIosSimulatorLogShowForBundle } from './network-log-ios-simulator-recovery.ts'; +import { + mergeNetworkDumps, + readRecentNetworkTraffic, + readRecentNetworkTrafficFromText, + type NetworkDump, + type NetworkIncludeMode, +} from './network-log.ts'; + +const APP_LOG_PID_FILENAME = 'app-log.pid'; +type NetworkAppLogState = 'active' | 'recovering' | 'ended' | 'failed'; + +export type SessionNetworkCapture = { + backend: LogBackend; + dump: NetworkDump; + notes: string[]; +}; + +type SessionNetworkCaptureParams = { + device: DeviceInfo; + appBundleId?: string; + appLogState?: NetworkAppLogState; + appLogStartedAt?: number; + appLogPath: string; + maxEntries: number; + include: NetworkIncludeMode; + maxPayloadChars: number; + maxScanLines: number; +}; + +type AndroidNetworkRecoveryContext = { + reason: 'inactive' | 'stale-active'; + trackedPid?: string; +}; + +type IosSimulatorNetworkRecovery = { + dump: NetworkDump; + recoveredLineCount: number; +}; + +type NetworkRecoveryResult = { + dump: NetworkDump; + note?: string; +}; + +function resolveLegacyNetworkLogBackend(device: DeviceInfo): LogBackend { + if (isIosFamily(device)) return device.kind === 'simulator' ? 'ios-simulator' : 'ios-device'; + if (isMacOs(device)) return 'macos'; + if (device.platform === 'harmonyos') return 'harmonyos'; + return 'android'; +} + +export async function readSessionNetworkCapture( + params: SessionNetworkCaptureParams, +): Promise { + const backend = resolveLegacyNetworkLogBackend(params.device); + let dump = readRecentNetworkTraffic(params.appLogPath, { + backend, + maxEntries: params.maxEntries, + include: params.include, + maxPayloadChars: params.maxPayloadChars, + maxScanLines: params.maxScanLines, + }); + const notes: string[] = []; + + const android = await recoverAndroidNetworkCapture(params, dump); + dump = android.dump; + appendNetworkNote(notes, android.note); + const iosSimulator = await recoverIosSimulatorNetworkCapture(params, dump); + dump = iosSimulator.dump; + appendNetworkNote(notes, iosSimulator.note); + appendNetworkNote(notes, buildNetworkLifecycleNote(params, notes.length)); + appendNetworkNote( + notes, + dump.entries.length === 0 ? buildNoHttpEntriesNote(params.device) : null, + ); + return { backend, dump, notes }; +} + +async function recoverAndroidNetworkCapture( + params: SessionNetworkCaptureParams, + dump: NetworkDump, +): Promise { + const context = await resolveAndroidNetworkRecoveryContext(params); + if (!context || !params.appBundleId) return { dump }; + const recovered = await readRecentAndroidLogcatForPackage(params.device.id, params.appBundleId); + if (!recovered) return { dump }; + const recoveredDump = readRecentNetworkTrafficFromText(recovered.text, { + path: `${params.appLogPath} (adb logcat recovery)`, + backend: 'android', + maxEntries: params.maxEntries, + include: params.include, + maxPayloadChars: params.maxPayloadChars, + maxScanLines: params.maxScanLines, + }); + if (recoveredDump.entries.length === 0) return { dump }; + return { + dump: mergeNetworkDumps(recoveredDump, dump, params.maxEntries), + note: buildAndroidRecoveryNote(context, recovered.recoveredPids), + }; +} + +async function recoverIosSimulatorNetworkCapture( + params: SessionNetworkCaptureParams, + dump: NetworkDump, +): Promise { + if (!canRecoverIosSimulatorCapture(params) || dump.entries.length > 0) return { dump }; + const recovered = await readRecentIosSimulatorNetworkCapture({ + deviceId: params.device.id, + appBundleId: params.appBundleId as string, + startedAt: params.appLogStartedAt, + simulatorSetPath: params.device.simulatorSetPath, + appLogPath: params.appLogPath, + maxEntries: params.maxEntries, + include: params.include, + maxPayloadChars: params.maxPayloadChars, + maxScanLines: params.maxScanLines, + }); + if (!recovered) return { dump }; + if (recovered.dump.entries.length === 0) { + return { dump, note: buildEmptyIosSimulatorRecoveryNote(recovered.recoveredLineCount) }; + } + return { + dump: mergeNetworkDumps(recovered.dump, dump, params.maxEntries), + note: buildIosSimulatorRecoveryNote(recovered), + }; +} + +function canRecoverIosSimulatorCapture(params: SessionNetworkCaptureParams): boolean { + return ( + isIosFamily(params.device) && params.device.kind === 'simulator' && Boolean(params.appBundleId) + ); +} + +function buildIosSimulatorRecoveryNote(recovered: IosSimulatorNetworkRecovery): string { + const entryCount = recovered.dump.entries.length; + return `Recovered ${entryCount} iOS simulator HTTP entr${ + entryCount === 1 ? 'y' : 'ies' + } from simctl log show (${recovered.recoveredLineCount} app log lines scanned).`; +} + +function buildEmptyIosSimulatorRecoveryNote(recoveredLineCount: number): string | undefined { + if (recoveredLineCount === 0) return undefined; + return `Recovered ${recoveredLineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`; +} + +function buildNetworkLifecycleNote( + params: SessionNetworkCaptureParams, + recoveryNoteCount: number, +): string | undefined { + if (params.appLogState === undefined) { + return 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.'; + } + if (params.appLogState === 'active' || recoveryNoteCount > 0) return undefined; + if (isIosFamily(params.device) && params.device.kind === 'simulator') { + return 'Session app log stream is inactive. The iOS simulator recovery path scanned recent simctl log history, but a fresh logs clear --restart window is still the most reliable repro loop.'; + } + return 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.'; +} + +function appendNetworkNote(notes: string[], note: string | null | undefined): void { + if (note) notes.push(note); +} + +async function resolveAndroidNetworkRecoveryContext(params: { + device: DeviceInfo; + appBundleId?: string; + appLogPath: string; + appLogState?: NetworkAppLogState; +}): Promise { + const { device, appBundleId, appLogPath, appLogState } = params; + if (device.platform !== 'android' || !appBundleId) return null; + if (appLogState !== undefined && appLogState !== 'active') return { reason: 'inactive' }; + if (appLogState !== 'active') return null; + + const trackedPid = readTrackedAndroidLogcatPid( + path.join(path.dirname(appLogPath), APP_LOG_PID_FILENAME), + ); + if (!trackedPid) return null; + const currentPid = await resolveAndroidPid(device.id, appBundleId); + if (!currentPid || currentPid === trackedPid) return null; + return { reason: 'stale-active', trackedPid }; +} + +function buildAndroidRecoveryNote( + context: AndroidNetworkRecoveryContext, + recoveredPids: string[], +): string { + if (context.reason === 'stale-active') { + return `Session app log stream was still bound to prior Android PID ${context.trackedPid}. Recovered recent Android HTTP entries from adb logcat for PID set ${recoveredPids.join(', ')}.`; + } + return `Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${recoveredPids.join(', ')}.`; +} + +async function readRecentIosSimulatorNetworkCapture(params: { + deviceId: string; + appBundleId: string; + startedAt?: number; + simulatorSetPath?: string; + appLogPath: string; + maxEntries: number; + include: NetworkIncludeMode; + maxPayloadChars: number; + maxScanLines: number; +}): Promise { + const recovered = await readRecentIosSimulatorLogShowForBundle({ + deviceId: params.deviceId, + appBundleId: params.appBundleId, + startedAt: params.startedAt, + simulatorSetPath: params.simulatorSetPath, + }); + if (!recovered) return null; + return { + dump: readRecentNetworkTrafficFromText(recovered.text, { + path: `${params.appLogPath} (simctl log show recovery)`, + backend: 'ios-simulator', + maxEntries: params.maxEntries, + include: params.include, + maxPayloadChars: params.maxPayloadChars, + maxScanLines: params.maxScanLines, + }), + recoveredLineCount: recovered.recoveredLineCount, + }; +} + +function buildNoHttpEntriesNote(device: DeviceInfo): string { + if (isIosFamily(device) && device.kind === 'simulator') { + return 'No HTTP(s) entries were found in recent iOS simulator app logs. If the app only emits non-HTTP diagnostics, inspect logs path or add app-side URLSession/network logging for per-request timing and payload details.'; + } + if (isIosFamily(device)) { + return 'No HTTP(s) entries were found in recent iOS device app logs. iOS network dump only sees what the app emits into Unified Logging for this process.'; + } + return 'No HTTP(s) entries were found in recent session app logs.'; +} diff --git a/src/daemon/app-log-process.ts b/src/daemon/app-log-process.ts deleted file mode 100644 index f51a7b1ca0..0000000000 --- a/src/daemon/app-log-process.ts +++ /dev/null @@ -1,121 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { readProcessCommand, readProcessStartTime } from '../utils/host-process.ts'; -import type { LogBackend } from '@agent-device/contracts/observability'; -import type { ExecResult } from '../utils/exec.ts'; - -export const APP_LOG_PID_FILENAME = 'app-log.pid'; - -export type AppLogState = 'active' | 'recovering' | 'ended' | 'failed'; - -export type AppLogFailure = { - backend: LogBackend; - code: string; - message: string; - hint?: string; -}; - -export type AppLogResult = { - backend: LogBackend; - getState: () => AppLogState; - startedAt: number; - stop: () => Promise; - wait: Promise; -}; - -type StoredAppLogProcessMeta = { - pid: number; - startTime?: string; - command?: string; -}; - -function parsePidFile(raw: string): StoredAppLogProcessMeta | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - if (/^\d+$/.test(trimmed)) { - return { pid: Number.parseInt(trimmed, 10) }; - } - try { - const parsed = JSON.parse(trimmed) as StoredAppLogProcessMeta; - if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null; - return parsed; - } catch { - return null; - } -} - -function isManagedAppLogCommand(command: string): boolean { - const normalized = command.toLowerCase().replaceAll('\\', '/'); - return ( - normalized.includes('log stream') || - normalized.includes('logcat') || - normalized.includes('hilog') || - normalized.includes('devicectl device process launch') - ); -} - -function shouldTerminateStoredProcess(meta: StoredAppLogProcessMeta): boolean { - const currentStartTime = readProcessStartTime(meta.pid); - if (!currentStartTime) return false; - if (meta.startTime && currentStartTime !== meta.startTime) return false; - const currentCommand = readProcessCommand(meta.pid); - if (!currentCommand || !isManagedAppLogCommand(currentCommand)) return false; - if (meta.command && currentCommand !== meta.command) return false; - return true; -} - -export function readStoredAppLogProcessMeta( - pidPath: string | undefined, -): StoredAppLogProcessMeta | null { - if (!pidPath || !fs.existsSync(pidPath)) return null; - try { - return parsePidFile(fs.readFileSync(pidPath, 'utf8')); - } catch { - return null; - } -} - -export function writePidFile(pidPath: string | undefined, pid: number): void { - if (!pidPath) return; - const dir = path.dirname(pidPath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const metadata: StoredAppLogProcessMeta = { - pid, - startTime: readProcessStartTime(pid) ?? undefined, - command: readProcessCommand(pid) ?? undefined, - }; - fs.writeFileSync(pidPath, `${JSON.stringify(metadata)}\n`); -} - -export function clearPidFile(pidPath: string | undefined): void { - if (!pidPath || !fs.existsSync(pidPath)) return; - try { - fs.unlinkSync(pidPath); - } catch { - // best-effort cleanup - } -} - -export function cleanupStaleAppLogProcesses(sessionsDir: string): void { - if (!fs.existsSync(sessionsDir)) return; - const entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const pidPath = path.join(sessionsDir, entry.name, APP_LOG_PID_FILENAME); - if (!fs.existsSync(pidPath)) continue; - try { - const meta = parsePidFile(fs.readFileSync(pidPath, 'utf8')); - if (meta && shouldTerminateStoredProcess(meta)) { - try { - process.kill(meta.pid, 'SIGTERM'); - } catch { - // process already gone - } - } - } catch { - // ignore malformed pid files - } finally { - clearPidFile(pidPath); - } - } -} diff --git a/src/daemon/app-log-request-scope.ts b/src/daemon/app-log-request-scope.ts deleted file mode 100644 index bbef5cbcd5..0000000000 --- a/src/daemon/app-log-request-scope.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Isolates request-scope provider composition behind its dynamic import. -// The shared app-log implementation remains eager for observability and teardown. -export { withAppLogProvider } from './app-log.ts'; diff --git a/src/daemon/app-log-resource-fence.ts b/src/daemon/app-log-resource-fence.ts new file mode 100644 index 0000000000..72efc34107 --- /dev/null +++ b/src/daemon/app-log-resource-fence.ts @@ -0,0 +1,92 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { + DurableResourceEnvelope, + DurableResourceLifecycleState, + ResourceOwnershipFence, +} from '@agent-device/contracts/platform'; +import { readAppLogResourceRecord, writeAppLogResourceRecord } from './app-log-resource-store.ts'; + +const resourceFenceTails = new Map>(); + +export type AppLogResourceFenceLease = Readonly<{ + envelope: DurableResourceEnvelope<'app-log'>; + transition( + lifecycle: DurableResourceLifecycleState, + update?: Readonly<{ + descriptor?: DurableResourceEnvelope<'app-log'>['descriptor']; + metadata?: DurableResourceEnvelope<'app-log'>['metadata']; + }>, + ): DurableResourceEnvelope<'app-log'>; +}>; + +/** + * Serializes one app-log resource from persisted fence validation through the + * native side effect and its persisted transition. Production invokes this + * only while the process owns the daemon lock; the per-record queue supplies + * the narrower request/startup mutual exclusion inside that owner. + */ +export async function withAppLogResourceFence(params: { + resourcePath: string; + expected: ResourceOwnershipFence; + run(lease: AppLogResourceFenceLease): Promise; +}): Promise { + return await serializeResource(params.resourcePath, async () => { + const record = readAppLogResourceRecord(params.resourcePath); + if (record.status !== 'decoded') { + throw resourceFenceError( + record.status === 'missing' + ? 'App-log resource record is missing' + : `App-log resource record is unreattachable: ${record.message}`, + ); + } + if (!sameFence(record.envelope.fence, params.expected)) { + throw resourceFenceError('App-log resource ownership fence was lost'); + } + + let current = record.envelope; + return await params.run({ + get envelope() { + return current; + }, + transition: (lifecycle, update = {}) => { + current = Object.freeze({ + ...current, + lifecycle, + ...(update.descriptor === undefined ? {} : { descriptor: update.descriptor }), + ...(update.metadata === undefined ? {} : { metadata: update.metadata }), + }); + writeAppLogResourceRecord(params.resourcePath, current); + return current; + }, + }); + }); +} + +function sameFence(left: ResourceOwnershipFence, right: ResourceOwnershipFence): boolean { + return left.token === right.token && left.generation === right.generation; +} + +async function serializeResource(resourcePath: string, task: () => Promise): Promise { + const previous = resourceFenceTails.get(resourcePath) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.catch(() => {}).then(() => current); + resourceFenceTails.set(resourcePath, tail); + await previous.catch(() => {}); + try { + return await task(); + } finally { + release(); + if (resourceFenceTails.get(resourcePath) === tail) resourceFenceTails.delete(resourcePath); + } +} + +function resourceFenceError(message: string): AppError { + return new AppError('COMMAND_FAILED', message, { + reason: 'ownership-fence-lost', + retriable: false, + hint: 'Use the current app-log resource owner or retain the recovery record for manual recovery.', + }); +} diff --git a/src/daemon/app-log-resource-recovery.ts b/src/daemon/app-log-resource-recovery.ts new file mode 100644 index 0000000000..f978863271 --- /dev/null +++ b/src/daemon/app-log-resource-recovery.ts @@ -0,0 +1,408 @@ +import path from 'node:path'; +import { + isConfirmedCleanup, + narrowDeviceBinding, + runtimeUse, + type AppLogRuntimeOperations, + type AppLogCompletion, + type AppLogLiveHandle, + type CleanupOutcome, + type DeviceBinding, + type DeviceRuntimeGateway, + type DurableResourceEnvelope, + type PlatformRequestScope, + type ReattachOutcome, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { emitDiagnostic } from '../utils/diagnostics.ts'; +import { withAppLogResourceFence } from './app-log-resource-fence.ts'; +import { + listAppLogResourcePaths, + readAppLogResourceRecord, + resolveAppLogResourcePath, +} from './app-log-resource-store.ts'; +import { safeSessionName } from './session-paths.ts'; + +const appLogRecoveryUse = runtimeUse()({ + required: ['appLogReattach', 'appLogCleanup'], +}); + +const DEFAULT_APP_LOG_RECOVERY_DEADLINE_MS = 5_000; + +export type AppLogRecoverySummary = Readonly<{ + scanned: number; + recovered: number; + retained: number; +}>; + +export type AppLogRecoveryDiagnostic = Readonly<{ + phase: string; + resourcePath: string; + data: Readonly>; +}>; + +type AppLogRecoveryParams = { + sessionsDir: string; + gateway: DeviceRuntimeGateway; + scope: PlatformRequestScope; + perRecordDeadlineMs?: number; + onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; +}; + +type AppLogRecoveryPathOutcome = 'ignored' | 'recovered' | 'retained'; + +/** + * Recovers only persisted app-log resources and never creates SessionState. + * The caller must own the daemon lock for the entire invocation. + */ +export async function recoverAppLogResourcesAfterDaemonLock( + params: AppLogRecoveryParams, +): Promise { + const paths = listAppLogResourcePaths(params.sessionsDir); + const outcomes: AppLogRecoveryPathOutcome[] = []; + for (const resourcePath of paths) { + outcomes.push(await recoverAppLogResourcePath(params, resourcePath)); + } + return { + scanned: paths.length, + recovered: outcomes.filter((outcome) => outcome === 'recovered').length, + retained: outcomes.filter((outcome) => outcome === 'retained').length, + }; +} + +async function recoverAppLogResourcePath( + params: AppLogRecoveryParams, + resourcePath: string, +): Promise { + const record = readAppLogResourceRecord(resourcePath); + if (record.status === 'unreattachable') { + emitRecoveryDiagnostic( + 'app_log_recovery_record_unreattachable', + resourcePath, + { reason: record.reason, message: record.message, version: record.version }, + params.onDiagnostic, + ); + return 'retained'; + } + if (record.status === 'missing') return 'ignored'; + const canonicalResourcePath = canonicalAppLogResourcePath( + params.sessionsDir, + record.envelope.sessionId, + ); + if (path.resolve(resourcePath) !== path.resolve(canonicalResourcePath)) { + emitRecoveryDiagnostic( + 'app_log_recovery_session_path_mismatch', + resourcePath, + { envelopeSessionId: record.envelope.sessionId, canonicalResourcePath }, + params.onDiagnostic, + ); + return 'retained'; + } + if (record.envelope.lifecycle === 'completed') return 'ignored'; + return await recoverDecodedAppLogResource(params, resourcePath, record.envelope); +} + +async function recoverDecodedAppLogResource( + params: AppLogRecoveryParams, + resourcePath: string, + envelope: DurableResourceEnvelope<'app-log'>, +): Promise { + try { + const recovered = await recoverOneBeforeDeadline({ + resourcePath, + envelope, + gateway: params.gateway, + scope: params.scope, + deadlineMs: params.perRecordDeadlineMs ?? DEFAULT_APP_LOG_RECOVERY_DEADLINE_MS, + onDiagnostic: params.onDiagnostic, + }); + return recovered ? 'recovered' : 'retained'; + } catch (error) { + emitRecoveryFailure(resourcePath, error, params.onDiagnostic); + return 'retained'; + } +} + +function canonicalAppLogResourcePath(sessionsDir: string, sessionId: string): string { + return resolveAppLogResourcePath(path.join(sessionsDir, safeSessionName(sessionId))); +} + +function emitRecoveryFailure( + resourcePath: string, + error: unknown, + onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void, +): void { + emitRecoveryDiagnostic( + error instanceof AppLogRecoveryDeadlineError + ? 'app_log_recovery_timed_out' + : 'app_log_recovery_failed', + resourcePath, + { + error: error instanceof Error ? error.message : String(error), + ...(error instanceof AppLogRecoveryDeadlineError ? { deadlineMs: error.deadlineMs } : {}), + }, + onDiagnostic, + ); +} + +class AppLogRecoveryDeadlineError extends Error { + readonly deadlineMs: number; + + constructor(deadlineMs: number) { + super(`App-log recovery exceeded its ${deadlineMs}ms deadline`); + this.name = 'AppLogRecoveryDeadlineError'; + this.deadlineMs = deadlineMs; + } +} + +async function recoverOneBeforeDeadline(params: { + resourcePath: string; + envelope: DurableResourceEnvelope<'app-log'>; + gateway: DeviceRuntimeGateway; + scope: PlatformRequestScope; + deadlineMs: number; + onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; +}): Promise { + const deadlineController = new AbortController(); + const scope: PlatformRequestScope = { + ...params.scope, + signal: AbortSignal.any([params.scope.signal, deadlineController.signal]), + }; + let rejectDeadline: (error: AppLogRecoveryDeadlineError) => void = () => {}; + const deadline = new Promise((_resolve, reject) => { + rejectDeadline = reject; + }); + const timer = setTimeout(() => { + const error = new AppLogRecoveryDeadlineError(params.deadlineMs); + deadlineController.abort(error); + rejectDeadline(error); + }, params.deadlineMs); + timer.unref?.(); + + const acquisition = acquireRecoveryAuthority({ ...params, scope }); + let acquired: AppLogRecoveryAuthority; + try { + acquired = await Promise.race([acquisition, deadline]); + } finally { + clearTimeout(timer); + // A runtime that ignores cancellation is quarantined here. Acquisition checks + // the aborted request scope before returning any authority to clean or persist. + void acquisition.catch(() => {}); + } + + // Once exact-owner authority is acquired, settle its bounded cleanup before the + // daemon lock may be released. The deadline intentionally no longer races this phase. + return settleRecoveryAuthority({ ...params, ...acquired }); +} + +type AppLogRecoveryAuthority = Readonly<{ + binding: DeviceBinding; + reattached: ReattachOutcome; +}>; + +async function acquireRecoveryAuthority(params: { + resourcePath: string; + envelope: DurableResourceEnvelope<'app-log'>; + gateway: DeviceRuntimeGateway; + scope: PlatformRequestScope; + onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; +}): Promise { + let binding: DeviceBinding | undefined; + let reattached: ReattachOutcome | undefined; + try { + binding = await params.gateway.bind({ + device: deviceFromEnvelope(params.envelope), + intent: { + kind: 'exact-owner', + owner: params.envelope.owner, + fence: params.envelope.fence, + }, + scope: params.scope, + }); + params.scope.signal.throwIfAborted(); + const runtime = narrowDeviceBinding(binding, appLogRecoveryUse); + reattached = await runtime.operations.appLogReattach({ envelope: params.envelope }); + params.scope.signal.throwIfAborted(); + return { binding, reattached }; + } catch (error) { + await disposeAbortedRecoveryAuthority(params, error, binding, reattached); + throw error; + } +} + +async function disposeAbortedRecoveryAuthority( + params: Pick[0], 'resourcePath' | 'onDiagnostic'>, + primaryError: unknown, + binding: DeviceBinding | undefined, + reattached: ReattachOutcome | undefined, +): Promise { + if (reattached?.status === 'active') { + await disposeRecoveryValue( + reattached.handle, + 'app_log_recovery_late_handle_cleanup_failed', + params, + primaryError, + ); + } + if (binding) { + await disposeRecoveryValue( + binding, + 'app_log_recovery_late_binding_cleanup_failed', + params, + primaryError, + ); + } +} + +async function disposeRecoveryValue( + value: AsyncDisposable, + phase: string, + params: Pick[0], 'resourcePath' | 'onDiagnostic'>, + primaryError: unknown, +): Promise { + try { + await value[Symbol.asyncDispose](); + } catch (cleanupError) { + emitRecoveryDiagnostic( + phase, + params.resourcePath, + { + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + primaryError: primaryError instanceof Error ? primaryError.message : String(primaryError), + }, + params.onDiagnostic, + ); + } +} + +async function settleRecoveryAuthority(params: { + resourcePath: string; + envelope: DurableResourceEnvelope<'app-log'>; + binding: DeviceBinding; + reattached: ReattachOutcome; + onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; +}): Promise { + try { + const runtime = narrowDeviceBinding(params.binding, appLogRecoveryUse); + const { reattached } = params; + switch (reattached.status) { + case 'active': { + const cleanup = await withAppLogResourceFence({ + resourcePath: params.resourcePath, + expected: params.envelope.fence, + run: async (lease) => { + lease.transition('open', { + metadata: { ...(lease.envelope.metadata ?? {}), phase: 'completing' }, + }); + const outcome = await reattached.handle.forceCleanup(); + transitionCleanupOutcome(lease, outcome); + return outcome; + }, + }); + return isConfirmedCleanup(cleanup); + } + case 'completed': + await transitionRecoveredTerminal(params, { + backend: reattached.result.backend, + outputPath: reattached.result.outputPath, + completedAt: reattached.result.completedAt, + }); + return true; + case 'missing': + await transitionRecoveredTerminal(params, { recoveryStatus: 'already-missing' }); + return true; + case 'unreattachable': { + if ( + reattached.reason === 'descriptor-invalid' || + reattached.reason === 'descriptor-version-unsupported' || + reattached.reason === 'ownership-fence-lost' + ) { + emitRecoveryDiagnostic( + 'app_log_recovery_retained', + params.resourcePath, + { reason: reattached.reason, message: reattached.message }, + params.onDiagnostic, + ); + return false; + } + const cleanup = await runtime.operations.appLogCleanup({ envelope: params.envelope }); + await withAppLogResourceFence({ + resourcePath: params.resourcePath, + expected: params.envelope.fence, + run: async (lease) => transitionCleanupOutcome(lease, cleanup), + }); + return isConfirmedCleanup(cleanup); + } + } + } finally { + await params.binding[Symbol.asyncDispose](); + } +} + +async function transitionRecoveredTerminal( + params: { + resourcePath: string; + envelope: DurableResourceEnvelope<'app-log'>; + }, + metadata: Record, +): Promise { + await withAppLogResourceFence({ + resourcePath: params.resourcePath, + expected: params.envelope.fence, + run: async (lease) => { + lease.transition('completed', { + metadata: { ...(lease.envelope.metadata ?? {}), phase: 'completed', ...metadata }, + }); + }, + }); +} + +function transitionCleanupOutcome( + lease: Parameters[0]['run']>[0], + outcome: CleanupOutcome, +): void { + lease.transition(isConfirmedCleanup(outcome) ? 'completed' : 'open', { + metadata: { + ...(lease.envelope.metadata ?? {}), + phase: isConfirmedCleanup(outcome) ? 'completed' : 'cleanup-pending', + cleanupStatus: outcome.status, + ...(outcome.status === 'cleanup-pending' + ? { + cleanupPendingReason: outcome.reason, + ...(outcome.message ? { cleanupPendingMessage: outcome.message } : {}), + } + : {}), + }, + }); +} + +function deviceFromEnvelope(envelope: DurableResourceEnvelope<'app-log'>): DeviceInfo { + return { + platform: envelope.device.family, + id: envelope.device.id, + name: envelope.device.id, + kind: envelope.device.kind, + ...(envelope.device.target === undefined ? {} : { target: envelope.device.target }), + ...(envelope.device.appleOs === undefined ? {} : { appleOs: envelope.device.appleOs }), + ...(envelope.device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: envelope.device.iosPhysicalDeviceBackend }), + }; +} + +function emitRecoveryDiagnostic( + phase: string, + resourcePath: string, + data: Record, + onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void, +): void { + if (onDiagnostic) { + onDiagnostic({ phase, resourcePath, data }); + return; + } + emitDiagnostic({ + level: 'warn', + phase, + data: { resourcePath, ...data }, + }); +} diff --git a/src/daemon/app-log-resource-store.ts b/src/daemon/app-log-resource-store.ts new file mode 100644 index 0000000000..4f1a0d1d01 --- /dev/null +++ b/src/daemon/app-log-resource-store.ts @@ -0,0 +1,158 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + type DurableEnvelopeDecodeOutcome, + type DurableResourceEnvelope, +} from '@agent-device/contracts/platform'; +import { decodeDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { openVerifiedFileForRead } from '../utils/verified-file.ts'; + +const APP_LOG_RESOURCE_FILENAME = 'app-log.resource.json'; + +export type AppLogResourceRecordRead = + | Readonly<{ status: 'missing' }> + | Readonly<{ status: 'decoded'; envelope: DurableResourceEnvelope<'app-log'> }> + | Readonly<{ + status: 'unreattachable'; + reason: 'descriptor-invalid' | 'descriptor-version-unsupported'; + message: string; + version?: number; + }>; + +export function resolveAppLogResourcePath(sessionDir: string): string { + return path.join(sessionDir, APP_LOG_RESOURCE_FILENAME); +} + +export function readAppLogResourceRecord(resourcePath: string): AppLogResourceRecordRead { + let fd: number | undefined; + let value: unknown; + try { + fd = openVerifiedFileForRead(resourcePath); + if (fd === undefined) return { status: 'missing' }; + value = JSON.parse(fs.readFileSync(fd, 'utf8')) as unknown; + } catch (error) { + if (isMissingFile(error)) return { status: 'missing' }; + return invalidResourceRecord( + error instanceof Error ? error.message : 'App-log resource record is invalid', + ); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + + const decoded = decodeDurableResourceEnvelope(value); + return narrowAppLogEnvelope(decoded); +} + +export function writeAppLogResourceRecord( + resourcePath: string, + envelope: DurableResourceEnvelope<'app-log'>, +): void { + const dir = path.dirname(resourcePath); + fs.mkdirSync(dir, { recursive: true }); + const temporaryPath = path.join( + dir, + `.${path.basename(resourcePath)}.${process.pid}.${crypto.randomUUID()}.tmp`, + ); + let fd: number | undefined; + try { + assertSafeResourceDestination(resourcePath); + fd = fs.openSync(temporaryPath, 'wx', 0o600); + fs.writeFileSync(fd, `${JSON.stringify(envelope)}\n`, 'utf8'); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + assertSafeResourceDestination(resourcePath); + fs.renameSync(temporaryPath, resourcePath); + syncDirectoryBestEffort(dir); + } finally { + if (fd !== undefined) fs.closeSync(fd); + try { + fs.rmSync(temporaryPath, { force: true }); + } catch {} + } +} + +export function listAppLogResourcePaths(sessionsDir: string): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => resolveAppLogResourcePath(path.join(sessionsDir, entry.name))) + .filter(pathEntryExistsWithoutFollowing) + .sort(); +} + +function assertSafeResourceDestination(resourcePath: string): void { + let destination: fs.Stats; + try { + destination = fs.lstatSync(resourcePath); + } catch (error) { + if (isMissingFile(error)) return; + throw error; + } + if (destination.isSymbolicLink()) { + throw new Error('Refusing to replace an app-log resource symbolic link'); + } + if (!destination.isFile()) { + throw new Error('Refusing to replace an app-log resource that is not a regular file'); + } +} + +function pathEntryExistsWithoutFollowing(resourcePath: string): boolean { + try { + fs.lstatSync(resourcePath); + return true; + } catch (error) { + return !isMissingFile(error); + } +} + +function invalidResourceRecord(message: string): AppLogResourceRecordRead { + return { + status: 'unreattachable', + reason: 'descriptor-invalid', + message, + }; +} + +function narrowAppLogEnvelope(decoded: DurableEnvelopeDecodeOutcome): AppLogResourceRecordRead { + if (decoded.status !== 'decoded') return decoded; + if (decoded.envelope.resourceKind !== 'app-log') { + return { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: `Expected app-log resource record, received ${decoded.envelope.resourceKind}`, + }; + } + return { + status: 'decoded', + envelope: decoded.envelope as DurableResourceEnvelope<'app-log'>, + }; +} + +function syncDirectoryBestEffort(dir: string): void { + let fd: number | undefined; + try { + fd = fs.openSync(dir, 'r'); + fs.fsyncSync(fd); + } catch { + // Atomic rename is the correctness boundary. Directory fsync support differs + // by host filesystem, so durability hardening remains best effort here. + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} + +function isMissingFile(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ); +} diff --git a/src/daemon/app-log-session-resource.ts b/src/daemon/app-log-session-resource.ts new file mode 100644 index 0000000000..334204f52e --- /dev/null +++ b/src/daemon/app-log-session-resource.ts @@ -0,0 +1,487 @@ +import type { LogBackend } from '@agent-device/contracts/observability'; +import { + createDurableResourceEnvelope, + decodeDurableResourceEnvelope, +} from '@agent-device/capture-kit'; +import { + isConfirmedCleanup, + runtimeOwnerKey, + type AppLogCompletion, + type AppLogLiveHandle, + type CleanupOutcome, + type DurableResourceEnvelope, + type PendingTransferGuard, + type ResourceOwnershipFence, + type RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { deviceIdentity, sameDeviceIdentity, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { emitDiagnostic } from '../utils/diagnostics.ts'; +import type { AppLogAdmissionLedger } from './app-log-admission-ledger.ts'; +import type { SessionStore } from './session-store.ts'; +import type { SessionState } from './types.ts'; +import { + withAppLogResourceFence, + type AppLogResourceFenceLease, +} from './app-log-resource-fence.ts'; +import { readAppLogResourceRecord, writeAppLogResourceRecord } from './app-log-resource-store.ts'; + +export type AppLogSessionSnapshot = Readonly<{ + active: boolean; + state: 'active' | 'recovering' | 'ended' | 'failed' | 'inactive'; + backend?: LogBackend; + startedAt?: number; + failureCode?: string; + failureMessage?: string; + hint?: string; +}>; + +export function inspectSessionAppLog(session: SessionState): AppLogSessionSnapshot { + if (session.appLog) { + const snapshot = session.appLog.handle.inspect(); + return { + active: snapshot.state === 'active' || snapshot.state === 'recovering', + ...snapshot, + }; + } + if (session.appLogFailure) { + return { + active: false, + state: 'failed', + backend: session.appLogFailure.backend, + failureCode: session.appLogFailure.code, + failureMessage: session.appLogFailure.message, + hint: session.appLogFailure.hint, + }; + } + return { active: false, state: 'inactive' }; +} + +type AdoptStartedSessionAppLogParams = { + admissionLedger: AppLogAdmissionLedger; + session: SessionState; + sessionName: string; + sessionStore: SessionStore; + resourcePath: string; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: ResourceOwnershipFence; + pendingHandle: PendingTransferGuard; + envelope: DurableResourceEnvelope<'app-log'>; + throwIfCanceled(): void; +}; + +type AppLogAdoptionState = + | { kind: 'pending' } + | { kind: 'persisted' } + | { kind: 'transferred'; handle: AppLogLiveHandle }; + +export async function adoptStartedSessionAppLog( + params: AdoptStartedSessionAppLogParams, +): Promise { + let state: AppLogAdoptionState = { kind: 'pending' }; + try { + const envelope = withAppLogPhase(validateStartedEnvelope(params), 'active'); + writeAppLogResourceRecord(params.resourcePath, envelope); + state = { kind: 'persisted' }; + params.throwIfCanceled(); + const handle = params.pendingHandle.transfer(); + state = { kind: 'transferred', handle }; + params.sessionStore.set(params.sessionName, { + ...params.session, + appLog: { handle, envelope }, + appLogFailure: undefined, + }); + } catch (error) { + await recoverFailedAppLogAdoption(params, state, error); + throw error; + } +} + +async function recoverFailedAppLogAdoption( + params: AdoptStartedSessionAppLogParams, + state: AppLogAdoptionState, + primaryError: unknown, +): Promise { + const persisted = state.kind === 'pending' ? persistIncoherentRuntimeEnvelope(params) : true; + let cleanupError = await disposeFailedAppLogAdoption(params.pendingHandle, state); + const transition = confirmFailedAdoptionTransition(params, persisted, cleanupError); + cleanupError = transition.cleanupError; + updateUndurableCleanupBlock( + params.admissionLedger, + params.device, + persisted, + cleanupError, + transition.confirmed, + ); + emitFailedAdoptionCleanupDiagnostic(params.sessionName, primaryError, cleanupError); +} + +async function disposeFailedAppLogAdoption( + pendingHandle: PendingTransferGuard, + state: AppLogAdoptionState, +): Promise { + try { + if (state.kind === 'transferred') await state.handle[Symbol.asyncDispose](); + else await pendingHandle[Symbol.asyncDispose](); + return undefined; + } catch (error) { + return error; + } +} + +function confirmFailedAdoptionTransition( + params: Pick, + persisted: boolean, + cleanupError: unknown | undefined, +): { confirmed: boolean; cleanupError: unknown | undefined } { + if (!persisted) return { confirmed: false, cleanupError }; + try { + return { + confirmed: markCleanupAfterFailedAdoption( + params.resourcePath, + params.fence, + cleanupError === undefined, + ), + cleanupError, + }; + } catch (transitionError) { + emitDiagnostic({ + level: 'error', + phase: 'app_log_pending_adoption_transition_failed', + data: { + session: params.sessionName, + transitionError: + transitionError instanceof Error ? transitionError.message : String(transitionError), + }, + }); + return { confirmed: false, cleanupError: cleanupError ?? transitionError }; + } +} + +function updateUndurableCleanupBlock( + ledger: AppLogAdmissionLedger, + device: DeviceInfo, + persisted: boolean, + cleanupError: unknown | undefined, + transitionConfirmed: boolean, +): void { + if ((!persisted && cleanupError === undefined) || transitionConfirmed) { + ledger.clearUndurableCleanup(device); + return; + } + ledger.blockUndurableCleanup( + device, + cleanupError instanceof Error + ? cleanupError.message + : 'The durable cleanup transition could not be confirmed', + ); +} + +function emitFailedAdoptionCleanupDiagnostic( + sessionName: string, + primaryError: unknown, + cleanupError: unknown | undefined, +): void { + if (cleanupError === undefined) return; + emitDiagnostic({ + level: 'error', + phase: 'app_log_pending_adoption_cleanup_failed', + data: { + session: sessionName, + primaryError: primaryError instanceof Error ? primaryError.message : String(primaryError), + cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }, + }); +} + +function persistIncoherentRuntimeEnvelope(params: { + sessionName: string; + resourcePath: string; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: ResourceOwnershipFence; + envelope: DurableResourceEnvelope<'app-log'>; +}): boolean { + try { + const envelope = createExpectedRecoveryEnvelope(params, params.envelope.descriptor); + writeAppLogResourceRecord(params.resourcePath, envelope); + return true; + } catch (descriptorError) { + try { + const envelope = createExpectedRecoveryEnvelope(params, { + version: 0, + body: { reason: 'runtime-contract-invalid' }, + }); + writeAppLogResourceRecord(params.resourcePath, envelope); + return true; + } catch (persistenceError) { + emitDiagnostic({ + level: 'error', + phase: 'app_log_runtime_contract_tombstone_failed', + data: { + session: params.sessionName, + descriptorError: + descriptorError instanceof Error ? descriptorError.message : String(descriptorError), + persistenceError: + persistenceError instanceof Error ? persistenceError.message : String(persistenceError), + }, + }); + return false; + } + } +} + +function createExpectedRecoveryEnvelope( + params: { + sessionName: string; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: ResourceOwnershipFence; + }, + descriptor: DurableResourceEnvelope<'app-log'>['descriptor'], +): DurableResourceEnvelope<'app-log'> { + return createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: params.sessionName, + device: deviceIdentity(params.device), + owner: params.owner, + fence: params.fence, + lifecycle: 'open', + descriptor, + metadata: { phase: 'runtime-contract-invalid', runtimeContractInvalid: true }, + }); +} + +export function recordSessionAppLogFailure(params: { + session: SessionState; + sessionName: string; + sessionStore: SessionStore; + error: unknown; + backend?: LogBackend; +}): ReturnType { + const normalized = normalizeError(params.error); + params.sessionStore.set(params.sessionName, { + ...params.session, + appLog: undefined, + appLogFailure: { + backend: params.backend, + code: normalized.code, + message: normalized.message, + hint: normalized.hint, + }, + }); + return normalized; +} + +export function clearSessionAppLogFailure(params: { + session: SessionState; + sessionName: string; + sessionStore: SessionStore; +}): void { + params.sessionStore.set(params.sessionName, { + ...params.session, + appLogFailure: undefined, + }); +} + +export async function finishSessionAppLog(params: { + session: SessionState; + sessionName: string; + sessionStore: SessionStore; + resourcePath: string; +}): Promise { + const resource = params.session.appLog; + if (!resource) { + throw new AppError('INVALID_ARGS', 'no app log stream active'); + } + const outcome = await withAppLogResourceFence({ + resourcePath: params.resourcePath, + expected: resource.envelope.fence, + run: async (lease) => { + markAppLogResourceCompleting(lease); + const result = await resource.handle.finish(); + if (result.status === 'completed') { + lease.transition('completed', { + metadata: { + ...(lease.envelope.metadata ?? {}), + backend: result.result.backend, + outputPath: result.result.outputPath, + completedAt: result.result.completedAt, + phase: 'completed', + }, + }); + } else { + lease.transition('open', { + metadata: { + ...(lease.envelope.metadata ?? {}), + phase: 'cleanup-pending', + cleanupPendingReason: result.reason, + ...(result.message ? { cleanupPendingMessage: result.message } : {}), + }, + }); + } + return result; + }, + }); + if (outcome.status === 'cleanup-pending') throw cleanupPendingError(outcome); + params.sessionStore.set(params.sessionName, { + ...params.session, + appLog: undefined, + appLogFailure: undefined, + }); + return outcome.result; +} + +/** Generic close/teardown cleanup; it never re-selects a platform implementation. */ +export async function forceCleanupSessionAppLog(params: { + session: SessionState; + sessionName?: string; + sessionStore?: SessionStore; + resourcePath: string; +}): Promise { + const resource = params.session.appLog; + if (!resource) return; + const outcome = await withAppLogResourceFence({ + resourcePath: params.resourcePath, + expected: resource.envelope.fence, + run: async (lease) => { + markAppLogResourceCompleting(lease); + const result = await resource.handle.forceCleanup(); + lease.transition(isConfirmedCleanup(result) ? 'completed' : 'open', { + metadata: { + ...(lease.envelope.metadata ?? {}), + phase: isConfirmedCleanup(result) ? 'completed' : 'cleanup-pending', + cleanupStatus: result.status, + ...(result.status === 'cleanup-pending' + ? { + cleanupPendingReason: result.reason, + ...(result.message ? { cleanupPendingMessage: result.message } : {}), + } + : {}), + }, + }); + return result; + }, + }); + if (!isConfirmedCleanup(outcome)) throw cleanupPendingError(outcome); + if (params.sessionStore && params.sessionName) { + params.sessionStore.set(params.sessionName, { + ...params.session, + appLog: undefined, + appLogFailure: undefined, + }); + } +} + +function markAppLogResourceCompleting(lease: AppLogResourceFenceLease): void { + lease.transition('open', { + metadata: { ...(lease.envelope.metadata ?? {}), phase: 'completing' }, + }); +} + +function validateStartedEnvelope(params: { + sessionName: string; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: ResourceOwnershipFence; + envelope: DurableResourceEnvelope<'app-log'>; +}): DurableResourceEnvelope<'app-log'> { + const decoded = decodeDurableResourceEnvelope(params.envelope); + if (decoded.status !== 'decoded' || decoded.envelope.resourceKind !== 'app-log') { + throw invalidStartedEnvelope(); + } + const { envelope } = decoded; + if (!matchesStartedEnvelopeAuthority(envelope, params)) throw invalidStartedEnvelope(); + return createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: envelope.sessionId, + device: envelope.device, + owner: envelope.owner, + fence: envelope.fence, + lifecycle: envelope.lifecycle, + descriptor: envelope.descriptor, + ...(envelope.metadata === undefined ? {} : { metadata: envelope.metadata }), + }); +} + +function matchesStartedEnvelopeAuthority( + envelope: DurableResourceEnvelope, + expected: Pick, +): boolean { + return ( + envelope.sessionId === expected.sessionName && + sameDeviceIdentity(envelope.device, deviceIdentity(expected.device)) && + runtimeOwnerKey(envelope.owner) === runtimeOwnerKey(expected.owner) && + envelope.fence.token === expected.fence.token && + envelope.fence.generation === expected.fence.generation && + isStartedLifecycle(envelope.lifecycle) + ); +} + +function isStartedLifecycle(lifecycle: DurableResourceEnvelope['lifecycle']): boolean { + return lifecycle === 'open'; +} + +function invalidStartedEnvelope(): AppError { + return new AppError('COMMAND_FAILED', 'App-log runtime returned an incoherent durable envelope', { + reason: 'runtime-contract-invalid', + hint: 'Retain the runtime diagnostics and report the selected device and owner.', + }); +} + +function markCleanupAfterFailedAdoption( + resourcePath: string, + expected: ResourceOwnershipFence, + confirmed: boolean, +): boolean { + const record = readAppLogResourceRecord(resourcePath); + if ( + record.status !== 'decoded' || + record.envelope.fence.token !== expected.token || + record.envelope.fence.generation !== expected.generation + ) { + return false; + } + writeAppLogResourceRecord(resourcePath, { + ...record.envelope, + lifecycle: confirmed ? 'completed' : 'open', + metadata: { + ...(record.envelope.metadata ?? {}), + phase: confirmed ? 'completed' : 'cleanup-pending', + cleanupStatus: confirmed ? 'cleaned' : 'cleanup-pending', + }, + }); + return true; +} + +function withAppLogPhase( + envelope: DurableResourceEnvelope<'app-log'>, + phase: string, +): DurableResourceEnvelope<'app-log'> { + return Object.freeze({ + ...envelope, + lifecycle: 'open', + metadata: { ...(envelope.metadata ?? {}), phase }, + }); +} + +function cleanupPendingError( + outcome: + | Extract + | { + status: 'cleanup-pending'; + reason: string; + message?: string; + }, +): AppError { + return new AppError( + 'COMMAND_FAILED', + outcome.message ?? 'App-log cleanup could not be confirmed', + { + reason: outcome.reason, + retriable: outcome.reason !== 'ownership-fence-lost', + hint: 'Keep app-log.resource.json and retry cleanup through its exact runtime owner.', + }, + ); +} diff --git a/src/daemon/app-log-start-preflight.ts b/src/daemon/app-log-start-preflight.ts new file mode 100644 index 0000000000..f56cefdb71 --- /dev/null +++ b/src/daemon/app-log-start-preflight.ts @@ -0,0 +1,61 @@ +import crypto from 'node:crypto'; +import path from 'node:path'; +import type { ResourceOwnershipFence } from '@agent-device/contracts/platform'; +import { deviceIdentity, deviceIdentityKey, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { AppLogAdmissionLedger } from './app-log-admission-ledger.ts'; +import { listAppLogResourcePaths, readAppLogResourceRecord } from './app-log-resource-store.ts'; + +export function createNextAppLogFence(params: { + ledger: AppLogAdmissionLedger; + resourcePath: string; + device: DeviceInfo; +}): ResourceOwnershipFence { + const { ledger, resourcePath, device } = params; + ledger.assertStartAllowed(device); + const selectedDeviceKey = deviceIdentityKey(deviceIdentity(device)); + assertNoConflictingManifest(resourcePath, selectedDeviceKey); + + const record = readAppLogResourceRecord(resourcePath); + return Object.freeze({ + token: crypto.randomUUID(), + generation: record.status === 'decoded' ? record.envelope.fence.generation + 1 : 1, + }); +} + +function assertNoConflictingManifest(resourcePath: string, selectedDeviceKey: string): void { + const sessionsDir = path.dirname(path.dirname(resourcePath)); + for (const existingPath of listAppLogResourcePaths(sessionsDir)) { + const existing = readAppLogResourceRecord(existingPath); + if (existing.status === 'unreattachable') throw unreattachableManifest(existing); + if ( + existing.status === 'decoded' && + existing.envelope.lifecycle !== 'completed' && + (existingPath === resourcePath || + deviceIdentityKey(existing.envelope.device) === selectedDeviceKey) + ) { + throw new AppError( + 'COMMAND_FAILED', + 'An app-log resource for this device has not reached a confirmed terminal state', + { + reason: 'cleanup-unconfirmed', + hint: 'Retry exact-owner cleanup using the existing app-log.resource.json before starting a replacement.', + }, + ); + } + } +} + +function unreattachableManifest( + record: Extract, { status: 'unreattachable' }>, +): AppError { + return new AppError( + 'COMMAND_FAILED', + `An app-log recovery record is unreattachable: ${record.message}`, + { + reason: record.reason, + retriable: false, + hint: 'Retain the corrupt or future-version app-log.resource.json for manual recovery; no replacement capture is safe.', + }, + ); +} diff --git a/src/daemon/app-log-stream.ts b/src/daemon/app-log-stream.ts deleted file mode 100644 index 6ab3b45851..0000000000 --- a/src/daemon/app-log-stream.ts +++ /dev/null @@ -1,95 +0,0 @@ -import fs from 'node:fs'; -import type { Readable } from 'node:stream'; -import type { ExecResult } from '../utils/exec.ts'; - -export async function waitForChildExit( - wait: Promise, - timeoutMs = 2_000, -): Promise { - await Promise.race([ - wait.then(() => undefined).catch(() => undefined), - new Promise((resolve) => setTimeout(resolve, timeoutMs)), - ]); -} - -function redactChunk(chunk: string, patterns: RegExp[]): string { - if (patterns.length === 0) return chunk; - let output = chunk; - for (const pattern of patterns) { - output = output.replace(pattern, '[REDACTED]'); - } - return output; -} - -type LineWriter = { onChunk: (chunk: string) => void; flush: () => void }; - -export function createLineWriter( - stream: fs.WriteStream, - options: { redactionPatterns: RegExp[]; includeTokens?: string[] }, -): LineWriter { - const includeTokens = options.includeTokens?.filter((token) => token.length > 0) ?? []; - let pending = ''; - - const writeLine = (line: string): void => { - if (includeTokens.length > 0) { - const shouldInclude = includeTokens.some((token) => line.includes(token)); - if (!shouldInclude) return; - } - stream.write(redactChunk(line, options.redactionPatterns)); - }; - - return { - onChunk: (chunk: string) => { - const combined = `${pending}${chunk}`; - const lines = combined.split('\n'); - pending = lines.pop() ?? ''; - for (const line of lines) { - writeLine(`${line}\n`); - } - }, - flush: () => { - if (!pending) return; - writeLine(pending); - pending = ''; - }, - }; -} - -type StreamableChildProcess = { - killed: boolean; - kill(signal?: NodeJS.Signals | number): boolean; - stdout: Readable | null; - stderr: Readable | null; - on(event: 'error', listener: (error: Error) => void): unknown; - on(event: 'close', listener: (code: number | null) => void): unknown; -}; - -export function attachChildToStream( - child: StreamableChildProcess, - stream: fs.WriteStream, - options: { - endStreamOnClose: boolean; - writer: LineWriter; - }, -): Promise { - const stdout = child.stdout; - const stderr = child.stderr; - if (!stdout || !stderr) { - return Promise.resolve({ stdout: '', stderr: 'missing stdio pipes', exitCode: 1 }); - } - stdout.setEncoding('utf8'); - stderr.setEncoding('utf8'); - stdout.on('data', options.writer.onChunk); - stderr.on('data', options.writer.onChunk); - stream.on('error', () => { - if (!child.killed) child.kill('SIGKILL'); - }); - child.on('error', () => stream.destroy()); - return new Promise((resolve) => { - child.on('close', (code) => { - options.writer.flush(); - if (options.endStreamOnClose) stream.end(); - resolve({ stdout: '', stderr: '', exitCode: code ?? 1 }); - }); - }); -} diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index 2ad2ce73c9..0487256cf7 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -1,432 +1,40 @@ import fs from 'node:fs'; import path from 'node:path'; -import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; -import { tryGetPlugin } from '../core/platform-plugin-registry.ts'; -import { registerBuiltinPlatformPlugins } from '../core/interactors/register-builtins.ts'; -import { createScopedProvider } from '../utils/scoped-provider.ts'; +import { ensureAppLogPath } from '../utils/app-log-files.ts'; import { - assertAndroidPackageArgSafe, - readTrackedAndroidLogcatPid, - readRecentAndroidLogcatForPackage, - resolveAndroidPid, - startAndroidAppLog, -} from './app-log-android.ts'; -import { - readRecentIosSimulatorLogShowForBundle, - startIosDeviceAppLog, - startIosSimulatorAppLog, - startMacOsAppLog, -} from './app-log-ios.ts'; -import { APP_LOG_PID_FILENAME, type AppLogResult, type AppLogState } from './app-log-process.ts'; -import { waitForChildExit } from './app-log-stream.ts'; -import { startHarmonyAppLog } from './app-log-harmonyos.ts'; -import { - mergeNetworkDumps, - readRecentNetworkTraffic, - readRecentNetworkTrafficFromText, - type NetworkDump, - type NetworkIncludeMode, -} from './network-log.ts'; -import type { LogBackend } from '@agent-device/contracts/observability'; - -// Populate the PlatformPlugin registry once at module load (idempotent; registers -// only lazy closures, so no leaf code is imported and CLI cold-start is unaffected -// — mirrors the same call in `core/capabilities.ts`). `resolveLogBackend` reads the -// per-platform app-log facet from this registry, so it must be populated first. -registerBuiltinPlatformPlugins(); - -export type { AppLogResult } from './app-log-process.ts'; -export type { AppLogState } from './app-log-process.ts'; -export type { AppLogFailure } from './app-log-process.ts'; -export { runAppLogDoctor } from './app-log-doctor.ts'; - -export type SessionNetworkCapture = { - backend: LogBackend; - dump: NetworkDump; - notes: string[]; -}; - -type AndroidNetworkRecoveryContext = { - reason: 'inactive' | 'stale-active'; - trackedPid?: string; -}; - -type IosSimulatorNetworkRecovery = { - dump: NetworkDump; - recoveredLineCount: number; -}; - -export type AppLogStartRequest = { - device: DeviceInfo; - appBundleId: string; - outPath: string; - pidPath?: string; -}; - -type SessionNetworkCaptureParams = { - device: DeviceInfo; - appBundleId?: string; - appLogState?: AppLogState; - appLogStartedAt?: number; - appLogPath: string; - maxEntries: number; - include: NetworkIncludeMode; - maxPayloadChars: number; - maxScanLines: number; -}; - -export type AppLogProvider = { - start(request: AppLogStartRequest): Promise; -}; - -const DEFAULT_MAX_APP_LOG_BYTES = 5 * 1024 * 1024; -const DEFAULT_MAX_ROTATED_FILES = 1; - -const localAppLogProvider: AppLogProvider = { - start: async (request) => await startLocalAppLog(request), -}; - -const appLogProviderScope = createScopedProvider(localAppLogProvider, createLocalAppLogProvider); - -function createLocalAppLogProvider(provider: Partial = {}): AppLogProvider { - return { - ...localAppLogProvider, - ...provider, - }; -} - -function resolveAppLogProvider(provider?: AppLogProvider): AppLogProvider { - return appLogProviderScope.resolve(provider); -} - -export async function withAppLogProvider( - provider: AppLogProvider | undefined, - fn: () => Promise, -): Promise { - return await appLogProviderScope.run(provider, fn); -} - -function parsePositiveIntEnv(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw) return fallback; - const parsed = Number.parseInt(raw, 10); - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; -} - -function getAppLogConfig(): { maxBytes: number; maxRotatedFiles: number } { - return { - maxBytes: parsePositiveIntEnv('AGENT_DEVICE_APP_LOG_MAX_BYTES', DEFAULT_MAX_APP_LOG_BYTES), - maxRotatedFiles: parsePositiveIntEnv( - 'AGENT_DEVICE_APP_LOG_MAX_FILES', - DEFAULT_MAX_ROTATED_FILES, - ), - }; -} - -function getAppLogRedactionPatterns(): RegExp[] { - const raw = process.env.AGENT_DEVICE_APP_LOG_REDACT_PATTERNS; - if (!raw) return []; - const patterns = raw - .split(',') - .map((part) => part.trim()) - .filter((part) => part.length > 0); - const result: RegExp[] = []; - for (const pattern of patterns) { - try { - result.push(new RegExp(pattern, 'gi')); - } catch { - // Skip invalid user pattern. - } - } - return result; -} - -export function rotateAppLogIfNeeded( - outPath: string, - config: { maxBytes: number; maxRotatedFiles: number }, -): void { - if (!fs.existsSync(outPath)) return; - const stats = fs.statSync(outPath); - if (stats.size < config.maxBytes) return; - - for (let index = config.maxRotatedFiles; index >= 1; index -= 1) { - const from = index === 1 ? outPath : `${outPath}.${index - 1}`; - const to = `${outPath}.${index}`; - if (!fs.existsSync(from)) continue; - if (fs.existsSync(to)) fs.unlinkSync(to); - fs.renameSync(from, to); - } -} - -function ensureLogPath(outPath: string): void { - const dir = path.dirname(outPath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - rotateAppLogIfNeeded(outPath, getAppLogConfig()); -} + openVerifiedFileForAppend, + openVerifiedFileForRead, + openVerifiedFileForTruncate, +} from '../utils/verified-file.ts'; export function getAppLogPathMetadata(outPath: string): { exists: boolean; sizeBytes: number; modifiedAt?: string; } { - if (!fs.existsSync(outPath)) { - return { exists: false, sizeBytes: 0 }; + const descriptor = openVerifiedFileForRead(outPath); + if (descriptor === undefined) return { exists: false, sizeBytes: 0 }; + try { + const stats = fs.fstatSync(descriptor); + return { + exists: true, + sizeBytes: stats.size, + modifiedAt: stats.mtime.toISOString(), + }; + } finally { + fs.closeSync(descriptor); } - const stats = fs.statSync(outPath); - return { - exists: true, - sizeBytes: stats.size, - modifiedAt: stats.mtime.toISOString(), - }; -} - -export function resolveLogBackend(device: DeviceInfo): LogBackend { - // Routes the platform branch through the PlatformPlugin app-log facet (issue - // #974). Apple/Android carry a `resolveBackend`; linux/web (and any unregistered - // platform) fall through to the historical `'android'` default. The daemon - // app-log routing parity test pins this against the former hand branch. - return tryGetPlugin(device.platform)?.appLog?.resolveBackend(device) ?? 'android'; -} - -export async function readSessionNetworkCapture( - params: SessionNetworkCaptureParams, -): Promise { - const { - device, - appBundleId, - appLogState, - appLogStartedAt, - appLogPath, - maxEntries, - include, - maxPayloadChars, - maxScanLines, - } = params; - const backend = resolveLogBackend(device); - let dump = readRecentNetworkTraffic(appLogPath, { - backend, - maxEntries, - include, - maxPayloadChars, - maxScanLines, - }); - const notes: string[] = []; - - const androidRecovery = await resolveAndroidNetworkRecoveryContext({ - device, - appBundleId, - appLogPath, - appLogState, - }); - if (androidRecovery) { - const recovered = await readRecentAndroidLogcatForPackage(device.id, appBundleId as string); - if (recovered) { - const recoveredDump = readRecentNetworkTrafficFromText(recovered.text, { - path: `${appLogPath} (adb logcat recovery)`, - backend: 'android', - maxEntries, - include, - maxPayloadChars, - maxScanLines, - }); - if (recoveredDump.entries.length > 0) { - dump = mergeNetworkDumps(recoveredDump, dump, maxEntries); - notes.push(buildAndroidRecoveryNote(androidRecovery, recovered.recoveredPids)); - } - } - } - - const canRecoverIosSimulatorLogShow = - isIosFamily(device) && device.kind === 'simulator' && Boolean(appBundleId); - if (canRecoverIosSimulatorLogShow && dump.entries.length === 0) { - const recovered = await readRecentIosSimulatorNetworkCapture({ - deviceId: device.id, - appBundleId: appBundleId as string, - startedAt: appLogStartedAt, - simulatorSetPath: device.simulatorSetPath, - appLogPath, - maxEntries, - include, - maxPayloadChars, - maxScanLines, - }); - if (recovered) { - if (recovered.dump.entries.length > 0) { - dump = mergeNetworkDumps(recovered.dump, dump, maxEntries); - notes.push( - `Recovered ${recovered.dump.entries.length} iOS simulator HTTP entr${ - recovered.dump.entries.length === 1 ? 'y' : 'ies' - } from simctl log show (${recovered.recoveredLineCount} app log lines scanned).`, - ); - } else if (recovered.recoveredLineCount > 0) { - notes.push( - `Recovered ${recovered.recoveredLineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, - ); - } - } - } - - if (appLogState === undefined) { - notes.push( - 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', - ); - } else if (appLogState !== 'active' && notes.length === 0) { - if (isIosFamily(device) && device.kind === 'simulator') { - notes.push( - 'Session app log stream is inactive. The iOS simulator recovery path scanned recent simctl log history, but a fresh logs clear --restart window is still the most reliable repro loop.', - ); - } else { - notes.push( - 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', - ); - } - } - - if (dump.entries.length === 0) { - notes.push(buildNoHttpEntriesNote(device)); - } - - return { backend, dump, notes }; -} - -async function resolveAndroidNetworkRecoveryContext(params: { - device: DeviceInfo; - appBundleId?: string; - appLogPath: string; - appLogState?: AppLogState; -}): Promise { - const { device, appBundleId, appLogPath, appLogState } = params; - if (device.platform !== 'android' || !appBundleId) { - return null; - } - if (appLogState !== undefined && appLogState !== 'active') { - return { reason: 'inactive' }; - } - if (appLogState !== 'active') { - return null; - } - - const trackedPid = readTrackedAndroidLogcatPid( - path.join(path.dirname(appLogPath), APP_LOG_PID_FILENAME), - ); - if (!trackedPid) { - return null; - } - const currentPid = await resolveAndroidPid(device.id, appBundleId); - if (!currentPid || currentPid === trackedPid) { - return null; - } - return { reason: 'stale-active', trackedPid }; -} - -function buildAndroidRecoveryNote( - context: AndroidNetworkRecoveryContext, - recoveredPids: string[], -): string { - if (context.reason === 'stale-active') { - return `Session app log stream was still bound to prior Android PID ${context.trackedPid}. Recovered recent Android HTTP entries from adb logcat for PID set ${recoveredPids.join(', ')}.`; - } - return `Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${recoveredPids.join(', ')}.`; -} - -export async function startAppLog( - device: DeviceInfo, - appBundleId: string, - outPath: string, - pidPath?: string, -): Promise { - return await resolveAppLogProvider().start({ device, appBundleId, outPath, pidPath }); -} - -async function startLocalAppLog({ - device, - appBundleId, - outPath, - pidPath, -}: AppLogStartRequest): Promise { - ensureLogPath(outPath); - const stream = fs.createWriteStream(outPath, { flags: 'a' }); - const redactionPatterns = getAppLogRedactionPatterns(); - if (isIosFamily(device)) { - if (device.kind === 'device') { - return await startIosDeviceAppLog(device.id, appBundleId, stream, redactionPatterns, pidPath); - } - return await startIosSimulatorAppLog( - device.id, - appBundleId, - stream, - redactionPatterns, - device.simulatorSetPath, - pidPath, - ); - } - if (device.platform === 'android') { - assertAndroidPackageArgSafe(appBundleId); - return await startAndroidAppLog(device.id, appBundleId, stream, redactionPatterns, pidPath); - } - if (device.platform === 'harmonyos') { - return await startHarmonyAppLog(device.id, appBundleId, stream, redactionPatterns, pidPath); - } - if (isMacOs(device)) { - return await startMacOsAppLog(appBundleId, stream, redactionPatterns, pidPath); - } - stream.end(); - throw new AppError('UNSUPPORTED_PLATFORM', `unsupported platform: ${device.platform}`); -} - -async function readRecentIosSimulatorNetworkCapture(params: { - deviceId: string; - appBundleId: string; - startedAt?: number; - simulatorSetPath?: string; - appLogPath: string; - maxEntries: number; - include: NetworkIncludeMode; - maxPayloadChars: number; - maxScanLines: number; -}): Promise { - const recovered = await readRecentIosSimulatorLogShowForBundle({ - deviceId: params.deviceId, - appBundleId: params.appBundleId, - startedAt: params.startedAt, - simulatorSetPath: params.simulatorSetPath, - }); - if (!recovered) { - return null; - } - return { - dump: readRecentNetworkTrafficFromText(recovered.text, { - path: `${params.appLogPath} (simctl log show recovery)`, - backend: 'ios-simulator', - maxEntries: params.maxEntries, - include: params.include, - maxPayloadChars: params.maxPayloadChars, - maxScanLines: params.maxScanLines, - }), - recoveredLineCount: recovered.recoveredLineCount, - }; -} - -function buildNoHttpEntriesNote(device: DeviceInfo): string { - if (isIosFamily(device) && device.kind === 'simulator') { - return 'No HTTP(s) entries were found in recent iOS simulator app logs. If the app only emits non-HTTP diagnostics, inspect logs path or add app-side URLSession/network logging for per-request timing and payload details.'; - } - if (isIosFamily(device)) { - return 'No HTTP(s) entries were found in recent iOS device app logs. iOS network dump only sees what the app emits into Unified Logging for this process.'; - } - return 'No HTTP(s) entries were found in recent session app logs.'; -} - -export async function stopAppLog(appLog: AppLogResult): Promise { - await appLog.stop(); - await waitForChildExit(appLog.wait); } export function appendAppLogMarker(outPath: string, marker: string): void { - ensureLogPath(outPath); + ensureAppLogPath(outPath); const line = `[agent-device][mark][${new Date().toISOString()}] ${marker.trim() || 'marker'}\n`; - fs.appendFileSync(outPath, line, 'utf8'); + const descriptor = openVerifiedFileForAppend(outPath); + try { + fs.writeFileSync(descriptor, line, 'utf8'); + } finally { + fs.closeSync(descriptor); + } } export function clearAppLogFiles(outPath: string): { @@ -437,10 +45,11 @@ export function clearAppLogFiles(outPath: string): { const dir = path.dirname(outPath); const base = path.basename(outPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - if (fs.existsSync(outPath)) { - fs.truncateSync(outPath, 0); - } else { - fs.writeFileSync(outPath, '', 'utf8'); + const descriptor = openVerifiedFileForTruncate(outPath); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); } let removedRotatedFiles = 0; for (const entry of fs.readdirSync(dir)) { diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts index 21175bb9e4..a54c84fe3a 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -5,6 +5,15 @@ import { PUBLIC_COMMANDS } from '../../../command-catalog.ts'; import { makeAndroidSession, makeSessionStore } from '../../../__tests__/test-utils/index.ts'; import { withTestDeviceInventoryProvider as withTargetDeviceResolutionScope } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localRuntimeOwner, + narrowDeviceBinding, + providerRuntimeOwner, + type AppLogRuntimeOperations, + type DeviceBinding, + type RuntimeProviderMode, +} from '@agent-device/contracts/platform'; +import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; import { handleSessionCommands } from '../session.ts'; function assertAndroidCapabilityHonesty(availableCommands: unknown): void { @@ -16,6 +25,7 @@ test('capabilities reports supported commands for the selected session device', const sessionName = 'android-capabilities'; const sessionStore = makeSessionStore('agent-device-capabilities-'); sessionStore.set(sessionName, makeAndroidSession(sessionName)); + const runtime = createAdmissionRuntime({ available: true, providerMode: 'local' }); const response = await handleSessionCommands({ req: { @@ -28,6 +38,7 @@ test('capabilities reports supported commands for the selected session device', sessionName, logPath: path.join(os.tmpdir(), 'daemon.log'), sessionStore, + bindDevice: runtime.bindDevice, invoke: async () => ({ ok: true, data: {} }), }); @@ -48,12 +59,56 @@ test('capabilities reports supported commands for the selected session device', 'fill', 'network', 'perf', + PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.gesture, ]), ); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.capabilities); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.devices); assertAndroidCapabilityHonesty(response.data?.availableCommands); + expect(runtime.uses).toEqual([{ required: [], preferred: ['appLogInspect'] }]); +}); + +test('capabilities excludes logs from an unavailable provider-mode XCTest runtime fact', async () => { + const sessionName = 'provider-xctest-capabilities'; + const sessionStore = makeSessionStore('agent-device-capabilities-provider-xctest-'); + sessionStore.set(sessionName, { + name: sessionName, + device: { + platform: 'apple', + appleOs: 'ios', + id: 'provider-ios-device', + name: 'Provider iPhone', + kind: 'device', + iosPhysicalDeviceBackend: 'xctest', + }, + createdAt: Date.now(), + actions: [], + }); + const runtime = createAdmissionRuntime({ + available: false, + providerMode: 'provider-runtime', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: PUBLIC_COMMANDS.capabilities, + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + bindDevice: runtime.bindDevice, + invoke: async () => ({ ok: true, data: {} }), + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) return; + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.logs); + expect(runtime.uses).toEqual([{ required: [], preferred: ['appLogInspect'] }]); }); test('capabilities accepts a stopped Android AVD placeholder for explicit platform discovery', async () => { @@ -97,3 +152,50 @@ test('capabilities accepts a stopped Android AVD placeholder for explicit platfo expect.arrayContaining(['open', 'screenshot', 'snapshot', 'press', 'fill']), ); }); + +function createAdmissionRuntime(options: { + available: boolean; + providerMode: RuntimeProviderMode; +}) { + const uses: Array<{ required: readonly string[]; preferred: readonly string[] }> = []; + const bindDevice: BindDeviceRuntime = async (device, use) => { + uses.push({ required: [...use.required], preferred: [...use.preferred] }); + const unavailable = { + available: false as const, + reason: + options.providerMode === 'provider-runtime' + ? ('unsupported-provider-mode' as const) + : ('owner-capability-missing' as const), + }; + const binding: DeviceBinding = { + device, + owner: + options.providerMode === 'provider-runtime' + ? providerRuntimeOwner('test', 'capabilities') + : localRuntimeOwner(device.platform), + facts: { + device: { + family: device.platform, + ...(device.appleOs === undefined ? {} : { appleOs: device.appleOs }), + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + ...(device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), + providerMode: options.providerMode, + }, + operations: { + appLogInspect: options.available ? { available: true } : unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + }, + }, + operations: options.available ? { appLogInspect: async () => ({ backend: 'android' }) } : {}, + [Symbol.asyncDispose]: async () => {}, + }; + return narrowDeviceBinding(binding, use); + }; + return { bindDevice, uses }; +} diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index 20ada7e21a..51939c4f7e 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -429,7 +429,7 @@ test('daemon session teardown stops active Apple xctrace perf capture', async () }, } as unknown as SessionState; - await teardownSessionResources(session, sessionName); + await teardownSessionResources({ appLog: 'already-settled', session, sessionName }); expect(mockCleanupAppleXctracePerfCapture).toHaveBeenCalledWith(activeCapture); expect(session.applePerf?.active).toBeUndefined(); @@ -501,7 +501,7 @@ test('daemon session teardown finalizes an active iOS simulator recording', asyn const session = makeIosSimulatorRecordingSession(sessionName); const kill = recordingKillMock(session); - await teardownSessionResources(session, sessionName); + await teardownSessionResources({ appLog: 'already-settled', session, sessionName }); expect(kill).toHaveBeenCalledWith('SIGINT'); expect(session.recording).toBeUndefined(); @@ -512,9 +512,9 @@ test('daemon session teardown surfaces a recording finalization failure', async const session = makeIosSimulatorRecordingSession(sessionName, { recorderExitCode: 1 }); const kill = recordingKillMock(session); - await expect(teardownSessionResources(session, sessionName)).rejects.toThrow( - /recording: .*failed to stop recording/, - ); + await expect( + teardownSessionResources({ appLog: 'already-settled', session, sessionName }), + ).rejects.toThrow(/recording: .*failed to stop recording/); expect(kill).toHaveBeenCalledWith('SIGINT'); expect(session.recording).toBeUndefined(); @@ -750,7 +750,7 @@ test('daemon session teardown stops active Android native perf capture', async ( }, } as unknown as SessionState; - await teardownSessionResources(session, sessionName); + await teardownSessionResources({ appLog: 'already-settled', session, sessionName }); expect(mockCleanupAndroidNativePerfSession).toHaveBeenCalledWith(session.device, activeCapture); expect(session.nativePerf?.android).toBeUndefined(); @@ -769,7 +769,7 @@ test('daemon session teardown stops Android snapshot helper session', async () = appBundleId: 'com.example.app', } as SessionState; - await teardownSessionResources(session, sessionName); + await teardownSessionResources({ appLog: 'already-settled', session, sessionName }); expect(mockStopAndroidSnapshotHelperSessionForDevice).toHaveBeenCalledWith(session.device); }); @@ -956,7 +956,9 @@ test('daemon session teardown attempts every resource after an earlier cleanup r new AppError('COMMAND_FAILED', 'perfetto stop failed'), ); - await expect(teardownSessionResources(session, sessionName)).rejects.toMatchObject({ + await expect( + teardownSessionResources({ appLog: 'already-settled', session, sessionName }), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: expect.objectContaining({ reason: 'session_cleanup_incomplete', diff --git a/src/daemon/handlers/__tests__/session-logs.test.ts b/src/daemon/handlers/__tests__/session-logs.test.ts index e267f7135a..95510f3a8a 100644 --- a/src/daemon/handlers/__tests__/session-logs.test.ts +++ b/src/daemon/handlers/__tests__/session-logs.test.ts @@ -1,425 +1,417 @@ -import { test, expect } from 'vitest'; -import * as os from 'node:os'; -import * as path from 'node:path'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type AppLogRuntimeOperations, + type CleanupOutcome, + type DeviceBinding, +} from '@agent-device/contracts/platform'; +import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createTestAppLogLiveHandle } from '../../../__tests__/test-utils/app-log-live-handle.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, -} from '../../app-log-ios.ts'; + createAppLogAdmissionLedger, + type AppLogAdmissionLedger, +} from '../../app-log-admission-ledger.ts'; +import { handleSessionCommands } from '../session.ts'; +import { + resolveAppLogResourcePath, + readAppLogResourceRecord, +} from '../../app-log-resource-store.ts'; +import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; +import type { SessionStore } from '../../session-store.ts'; import { - mockStartAppLog, - mockRunAppLogDoctor, - makeSessionStore, makeSession, + makeSessionStore, + makeTestAppLogResource, noopInvoke, } from './session-test-harness.ts'; -import { handleSessionCommands } from '../session.ts'; + +const DEVICE: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, +}; + +type RuntimeHarness = ReturnType; + +let runtime: RuntimeHarness; +let admissionLedger: AppLogAdmissionLedger; + +beforeEach(() => { + runtime = createRuntimeHarness(); + admissionLedger = createAppLogAdmissionLedger(); +}); test('logs requires an active session', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'logs', - positionals: ['path'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); + const response = await runLogs(makeSessionStore(), 'missing', ['path']); expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - } + if (response?.ok === false) expect(response.error.code).toBe('SESSION_NOT_FOUND'); + expect(runtime.bind).not.toHaveBeenCalled(); }); -test('logs rejects invalid action', async () => { - const sessionStore = makeSessionStore(); - sessionStore.set( - 'default', - makeSession('default', { - platform: 'apple', - id: 'sim-1', - name: 'iPhone', - kind: 'simulator', - booted: true, - }), - ); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'logs', - positionals: ['invalid'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); +test('logs rejects an invalid plan before binding a runtime', async () => { + const { sessionStore, sessionName } = openSession(); + const response = await runLogs(sessionStore, sessionName, ['invalid'], {}, runtime.bindDevice); expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/path, start, stop, doctor, mark, or clear/); - } + if (response?.ok === false) expect(response.error.code).toBe('INVALID_ARGS'); + expect(runtime.boundUses()).toEqual([{ required: [], preferred: ['appLogInspect'] }]); }); -test('logs start requires app session (appBundleId)', async () => { - const sessionStore = makeSessionStore(); - sessionStore.set( - 'default', - makeSession('default', { - platform: 'apple', - id: 'sim-1', - name: 'iPhone', - kind: 'simulator', - booted: true, - }), - ); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'logs', - positionals: ['start'], - flags: {}, +test('logs preserves whole-command unsupported precedence before parsing the action', async () => { + runtime = createRuntimeHarness({ inspectAvailable: false }); + const { sessionStore, sessionName } = openSession(); + const response = await runLogs(sessionStore, sessionName, ['invalid'], {}, runtime.bindDevice); + expect(response).toMatchObject({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'logs is not supported on this device', + hint: 'Use a runtime with app-log support.', }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/app session|open first/i); - } + expect(runtime.boundUses()).toEqual([{ required: [], preferred: ['appLogInspect'] }]); }); -test('logs stop requires active app log stream', async () => { - const sessionStore = makeSessionStore(); - sessionStore.set( - 'default', - makeSession('default', { - platform: 'apple', - id: 'sim-1', - name: 'iPhone', - kind: 'simulator', - booted: true, - }), - ); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'logs', - positionals: ['stop'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, +test('logs path binds only inspect and preserves public status projection', async () => { + const { sessionStore, sessionName } = openSession(); + const response = await runLogs(sessionStore, sessionName, ['path'], {}, runtime.bindDevice); + expect(response).toMatchObject({ + ok: true, + data: { active: false, state: 'inactive', backend: 'ios-simulator' }, }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/no app log stream/i); - } + expect(runtime.boundUses()).toEqual([ + { required: [], preferred: ['appLogInspect'] }, + { required: ['appLogInspect'], preferred: [] }, + ]); + expect(runtime.inspect).toHaveBeenCalledOnce(); }); -test('logs clear requires stream to be stopped first', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; +test('logs doctor binds only doctor and merges live-state notes', async () => { + const { sessionStore, sessionName } = openSession(); + const session = sessionStore.get(sessionName)!; sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, + ...session, + appLog: makeTestAppLogResource(session, { + backend: 'ios-simulator', + state: 'ended', }), - appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: '/tmp/app.log', - startedAt: Date.now(), - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }); + const response = await runLogs(sessionStore, sessionName, ['doctor'], {}, runtime.bindDevice); + expect(response).toMatchObject({ + ok: true, + data: { + backend: 'ios-simulator', + state: 'ended', + checks: { simulatorLogStream: true }, }, }); + expect(runtime.boundUses()).toEqual([ + { required: [], preferred: ['appLogInspect'] }, + { required: ['appLogInspect', 'appLogDoctor'], preferred: [] }, + ]); +}); - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: ['clear'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), +test.each([ + { action: ['mark', 'checkpoint'], expected: { marked: true } }, + { action: ['clear'], expected: { cleared: true } }, +] as const)( + 'logs $action gates support and binds its exact inspect use', + async ({ action, expected }) => { + const { sessionStore, sessionName } = openSession(); + const response = await runLogs(sessionStore, sessionName, [...action], {}, runtime.bindDevice); + expect(response).toMatchObject({ ok: true, data: expected }); + expect(runtime.boundUses()).toEqual([ + { required: [], preferred: ['appLogInspect'] }, + { required: ['appLogInspect'], preferred: [] }, + ]); + }, +); + +test('logs stop uses the adopted handle after the required support bind', async () => { + const { sessionStore, sessionName } = openSession(); + await expectStarted(await runLogs(sessionStore, sessionName, ['start'], {}, runtime.bindDevice)); + runtime.resetUses(); + const response = await runLogs(sessionStore, sessionName, ['stop'], {}, runtime.bindDevice); + expect(response).toMatchObject({ ok: true, data: { stopped: true } }); + expect(runtime.boundUses()).toEqual([ + { required: [], preferred: ['appLogInspect'] }, + { required: ['appLogInspect'], preferred: [] }, + ]); + expect(runtime.finish).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)?.appLog).toBeUndefined(); + expect(readRecord(sessionStore, sessionName)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed' }, + }); +}); + +test('logs start persists the durable envelope before adopting the live handle', async () => { + const { sessionStore, sessionName } = openSession(); + await expectStarted(await runLogs(sessionStore, sessionName, ['start'], {}, runtime.bindDevice)); + expect(runtime.start.mock.calls[0]?.[0].appBundleId).toBe('com.example.app'); + expect(sessionStore.get(sessionName)?.appLog).toBeDefined(); + expect(readRecord(sessionStore, sessionName)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open', sessionId: sessionName, metadata: { phase: 'active' } }, + }); + expect(runtime.boundUses()).toEqual([ + { required: [], preferred: ['appLogInspect'] }, + { required: ['appLogInspect', 'appLogStart'], preferred: [] }, + ]); +}); + +test.each([ + { action: ['start'], message: /logs start requires an app session/ }, + { + action: ['clear'], + flags: { restart: true }, + message: /logs clear --restart requires an app session/, + }, +] as const)( + '$action proves the app session before binding start operations', + async ({ action, flags = {}, message }) => { + const { sessionStore, sessionName } = openSession(); + const session = sessionStore.get(sessionName)!; + sessionStore.set(sessionName, { ...session, appBundleId: undefined }); + + const response = await runLogs( + sessionStore, + sessionName, + [...action], + flags, + runtime.bindDevice, + ); + + expect(response).toMatchObject({ ok: false, error: { code: 'INVALID_ARGS' } }); + if (response?.ok === false) expect(response.error.message).toMatch(message); + expect(runtime.boundUses()).toEqual([{ required: [], preferred: ['appLogInspect'] }]); + expect(runtime.start).not.toHaveBeenCalled(); + }, +); + +test('logs clear --restart finishes generation one before adopting generation two', async () => { + const { sessionStore, sessionName } = openSession(); + await expectStarted(await runLogs(sessionStore, sessionName, ['start'], {}, runtime.bindDevice)); + const response = await runLogs( sessionStore, - invoke: noopInvoke, + sessionName, + ['clear'], + { restart: true }, + runtime.bindDevice, + ); + expect(response).toMatchObject({ ok: true, data: { cleared: true, restarted: true } }); + expect(readRecord(sessionStore, sessionName)).toMatchObject({ + status: 'decoded', + envelope: { + lifecycle: 'open', + fence: { generation: 2 }, + metadata: { phase: 'active' }, + }, }); +}); - expect(response).toBeTruthy(); +test('cancellation after native start persists recovery truth and cleans the pending handle', async () => { + const { sessionStore, sessionName } = openSession(); + const response = await runLogs( + sessionStore, + sessionName, + ['start'], + {}, + runtime.bindDevice, + () => { + throw new AppError('CANCELED', 'request canceled'); + }, + ); expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/logs stop/i); - } + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)?.appLog).toBeUndefined(); + expect(readRecord(sessionStore, sessionName)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed' }, + }); }); -test('logs --restart is only supported with logs clear', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.example.app', +test('rejected pending cleanup retains cleanup-pending record and blocks replacement', async () => { + runtime.forceCleanup.mockResolvedValue({ + status: 'cleanup-pending', + reason: 'cleanup-unconfirmed', }); - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: ['path'], - flags: { restart: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), + const { sessionStore, sessionName } = openSession(); + const canceled = await runLogs( sessionStore, - invoke: noopInvoke, + sessionName, + ['start'], + {}, + runtime.bindDevice, + () => { + throw new AppError('CANCELED', 'request canceled'); + }, + ); + expect(canceled?.ok).toBe(false); + expect(readRecord(sessionStore, sessionName)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open', metadata: { phase: 'cleanup-pending' } }, }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/only supported with logs clear/i); + runtime.bind.mockClear(); + const replacement = await runLogs(sessionStore, sessionName, ['start'], {}, runtime.bindDevice); + expect(replacement?.ok).toBe(false); + if (replacement?.ok === false) { + expect(replacement.error.details?.reason).toBe('cleanup-unconfirmed'); } + expect(runtime.start).not.toHaveBeenCalledTimes(2); }); -test('logs clear --restart requires app session bundle id', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, - }), - ); - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: ['clear'], - flags: { restart: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, +test('post-transfer SessionStore failure disposes the transferred handle and preserves primary error', async () => { + const { sessionStore, sessionName } = openSession(); + const primary = new Error('store adoption failed'); + vi.spyOn(sessionStore, 'set').mockImplementationOnce(() => { + throw primary; }); - expect(response).toBeTruthy(); + const response = await runLogs(sessionStore, sessionName, ['start'], {}, runtime.bindDevice); expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/app session|open /i); - } + if (response?.ok === false) expect(response.error.message).toBe(primary.message); + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); }); -function makeIosDeviceLogSession(): { - sessionStore: ReturnType; - sessionName: string; -} { +function openSession() { const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-console-logs'; + const sessionName = 'logs-session'; sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'ios', - id: '00008150-0000AAAA', - name: 'iPhone', - kind: 'device', - }), + ...makeSession(sessionName, DEVICE), appBundleId: 'com.example.app', }); return { sessionStore, sessionName }; } -function mockIosDeviceLogBackend(): void { - mockStartAppLog.mockResolvedValue({ - backend: 'ios-device', - startedAt: 1_712_040_000_000, - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }); - mockRunAppLogDoctor.mockResolvedValue({ - checks: { devicectlAvailable: true, devicectlConsoleCapture: true }, - notes: [], - }); -} - -function mockUnsupportedIosDeviceLogBackend(): void { - mockStartAppLog.mockRejectedValue( - new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.message, { - backend: 'ios-device', - hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.hint, - }), - ); - mockRunAppLogDoctor.mockResolvedValue({ - checks: { devicectlAvailable: true, devicectlConsoleCapture: false }, - notes: [IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE], - }); -} - -async function runLogsCommandForSession( - sessionStore: ReturnType, +async function runLogs( + sessionStore: SessionStore, sessionName: string, - action: 'clear' | 'path' | 'doctor', + positionals: string[], flags: Record = {}, + bindDevice?: BindDeviceRuntime, + throwIfCanceled?: () => void, ) { return await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: [action], - flags, - }, + req: { token: 't', session: sessionName, command: 'logs', positionals, flags }, sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), + logPath: '/tmp/daemon.log', sessionStore, invoke: noopInvoke, + bindDevice, + appLogAdmissionLedger: admissionLedger, + throwIfCanceled, }); } -function expectActiveIosDeviceLogsPath( - response: Awaited>, -) { - expect(response?.ok).toBe(true); - if (!response || !response.ok) return; - expect(response.data?.active).toBe(true); - expect(response.data?.state).toBe('active'); - expect(response.data?.backend).toBe('ios-device'); - expect(response.data?.failureCode).toBeUndefined(); - expect(response.data?.failureMessage).toBeUndefined(); - expect(response.data?.startedAt).toBe('2024-04-02T06:40:00.000Z'); +async function expectStarted(response: Awaited>) { + expect(response).toMatchObject({ ok: true, data: { started: true } }); } -function expectEndedIosDeviceLogsPath(response: Awaited>) { - expect(response?.ok).toBe(true); - if (!response || !response.ok) return; - expect(response.data?.active).toBe(false); - expect(response.data?.state).toBe('ended'); - expect(response.data?.backend).toBe('ios-device'); - expect(response.data?.notes).toContain( - 'The app log stream process ended. Run logs clear --restart before the next capture window.', +function readRecord(sessionStore: SessionStore, sessionName: string) { + return readAppLogResourceRecord( + resolveAppLogResourcePath(sessionStore.resolveSessionDir(sessionName)), ); } -function expectActiveIosDeviceLogsDoctor( - response: Awaited>, -) { - expect(response?.ok).toBe(true); - if (!response || !response.ok) return; - expect(response.data?.active).toBe(true); - expect(response.data?.state).toBe('active'); - expect(response.data?.backend).toBe('ios-device'); - expect(response.data?.checks).toEqual({ - devicectlAvailable: true, - devicectlConsoleCapture: true, - }); - expect(response.data?.notes).toEqual([]); -} - -function expectUnsupportedIosDeviceLogsDoctor( - response: Awaited>, -) { - expect(response?.ok).toBe(true); - if (!response || !response.ok) return; - expect(response.data?.active).toBe(false); - expect(response.data?.state).toBe('failed'); - expect(response.data?.backend).toBe('ios-device'); - expect(response.data?.failureCode).toBe('UNSUPPORTED_OPERATION'); - expect(response.data?.notes).toEqual([IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE]); -} - -test('logs clear --restart starts active iOS physical-device console capture', async () => { - const { sessionStore, sessionName } = makeIosDeviceLogSession(); - mockIosDeviceLogBackend(); - - const restartResponse = await runLogsCommandForSession(sessionStore, sessionName, 'clear', { - restart: true, - }); - expect(restartResponse?.ok).toBe(true); - if (restartResponse && restartResponse.ok) { - expect(restartResponse.data?.restarted).toBe(true); - } - expect(mockStartAppLog).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: '00008150-0000AAAA' }), - 'com.example.app', - expect.stringContaining('app.log'), - expect.stringContaining('app-log.pid'), - ); - - expectActiveIosDeviceLogsPath(await runLogsCommandForSession(sessionStore, sessionName, 'path')); - expectActiveIosDeviceLogsDoctor( - await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), - ); -}); - -test('logs path reports cleanly ended iOS physical-device console capture as inactive', async () => { - const { sessionStore, sessionName } = makeIosDeviceLogSession(); - const session = sessionStore.get(sessionName); - if (!session) throw new Error('Expected test session'); - sessionStore.set(sessionName, { - ...session, - appLog: { - platform: 'apple', - backend: 'ios-device', - outPath: '/tmp/app.log', - startedAt: 1_712_040_000_000, - getState: () => 'ended', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), +function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { + const owner = localRuntimeOwner('apple'); + const inspect = vi.fn(async () => ({ backend: 'ios-simulator' as const })); + const doctor = vi.fn(async () => ({ + backend: 'ios-simulator' as const, + checks: { simulatorLogStream: true }, + notes: [] as string[], + })); + const finish = vi.fn(async () => ({ + status: 'completed' as const, + result: { + backend: 'ios-simulator' as const, + outputPath: '/tmp/app.log', + completedAt: Date.now(), }, + })); + const forceCleanup = vi.fn<() => Promise>(async () => ({ + status: 'cleaned', + })); + const start = vi.fn(async (input) => { + const handle = createTestAppLogLiveHandle({ + inspect: () => ({ + backend: 'ios-simulator', + state: 'active', + startedAt: 1_712_040_000_000, + }), + finish, + forceCleanup, + }); + const envelope = createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: input.sessionId, + device: { id: DEVICE.id, family: DEVICE.platform, appleOs: 'ios', kind: DEVICE.kind }, + owner, + fence: input.fence, + lifecycle: 'open', + descriptor: { version: 1, body: { outputPath: input.outputPath } }, + }); + return createAppLogStartResult(handle, envelope); }); - - expectEndedIosDeviceLogsPath(await runLogsCommandForSession(sessionStore, sessionName, 'path')); -}); - -test('logs doctor deduplicates unsupported iOS physical-device console capture notes', async () => { - const { sessionStore, sessionName } = makeIosDeviceLogSession(); - mockUnsupportedIosDeviceLogBackend(); - - const restartResponse = await runLogsCommandForSession(sessionStore, sessionName, 'clear', { - restart: true, - }); - expect(restartResponse?.ok).toBe(false); - expectUnsupportedIosDeviceLogsDoctor( - await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), + const operations: AppLogRuntimeOperations = { + appLogInspect: inspect, + appLogDoctor: doctor, + appLogStart: start, + appLogReattach: async () => ({ status: 'missing' }), + appLogCleanup: async () => ({ status: 'cleaned' }), + }; + const uses: Array<{ required: readonly string[]; preferred: readonly string[] }> = []; + const bind = vi.fn( + async (device: DeviceInfo): Promise> => ({ + device, + owner, + facts: { + device: { + family: device.platform, + appleOs: 'ios', + kind: device.kind, + providerMode: 'local', + }, + operations: { + appLogInspect: + options.inspectAvailable === false + ? { + available: false, + reason: 'owner-capability-missing', + hint: 'Use a runtime with app-log support.', + } + : { available: true }, + appLogDoctor: { available: true }, + appLogStart: { available: true }, + appLogReattach: { available: true }, + appLogCleanup: { available: true }, + }, + }, + operations, + [Symbol.asyncDispose]: async () => {}, + }), ); -}); + const bindDevice: BindDeviceRuntime = async (device, use) => { + uses.push(use); + return narrowDeviceBinding(await bind(device), use); + }; + return { + bind, + bindDevice, + boundUses: () => + uses.map((use) => ({ required: [...use.required], preferred: [...use.preferred] })), + resetUses: () => { + uses.length = 0; + }, + inspect, + doctor, + start, + finish, + forceCleanup, + }; +} diff --git a/src/daemon/handlers/__tests__/session-network.test.ts b/src/daemon/handlers/__tests__/session-network.test.ts index 4320df564d..62f602dd1a 100644 --- a/src/daemon/handlers/__tests__/session-network.test.ts +++ b/src/daemon/handlers/__tests__/session-network.test.ts @@ -2,9 +2,55 @@ import { test, expect } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { mockRunCmd, makeSessionStore, makeSession, noopInvoke } from './session-test-harness.ts'; +import { + mockRunCmd, + makeSessionStore, + makeSession, + makeTestAppLogResource, + noopInvoke, +} from './session-test-harness.ts'; import { handleSessionCommands } from '../session.ts'; +function androidAppLog( + sessionName: string, + state: 'active' | 'failed', + outputPath = '/tmp/app.log', +) { + return makeTestAppLogResource( + { + name: sessionName, + device: { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + }, + }, + { backend: 'android', state, outputPath }, + ); +} + +function iosSimulatorAppLog(sessionName: string, outputPath: string) { + return makeTestAppLogResource( + { + name: sessionName, + device: { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + }, + }, + { + backend: 'ios-simulator', + state: 'active', + outputPath, + startedAt: 1_712_040_000_000, + }, + ); +} + test('network requires an active session', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ @@ -39,15 +85,7 @@ test('network dump adds a targeted note when the session app log stream is inact booted: true, }), appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: '/tmp/app.log', - startedAt: Date.now(), - getState: () => 'failed', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, + appLog: androidAppLog(sessionName, 'failed'), }); const response = await handleSessionCommands({ @@ -86,15 +124,7 @@ test('network dump recovers Android entries from adb logcat when the session str booted: true, }), appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: '/tmp/app.log', - startedAt: Date.now(), - getState: () => 'failed', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, + appLog: androidAppLog(sessionName, 'failed'), }); mockRunCmd.mockImplementation(async (_cmd, args) => { @@ -162,15 +192,7 @@ test('network dump merges Android recovery entries ahead of stale session log tr booted: true, }), appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: appLogPath, - startedAt: Date.now(), - getState: () => 'failed', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, + appLog: androidAppLog(sessionName, 'failed', appLogPath), }); mockRunCmd.mockImplementation(async (_cmd, args) => { @@ -224,15 +246,7 @@ test('network dump recovers Android entries from previous package pid in bounded booted: true, }), appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: '/tmp/app.log', - startedAt: Date.now(), - getState: () => 'failed', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, + appLog: androidAppLog(sessionName, 'failed'), }); mockRunCmd.mockImplementation(async (_cmd, args) => { @@ -306,15 +320,7 @@ test('network dump recovers Android entries when an active stream is still bound booted: true, }), appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: appLogPath, - startedAt: Date.now(), - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, + appLog: androidAppLog(sessionName, 'active', appLogPath), }); mockRunCmd.mockImplementation(async (_cmd, args) => { @@ -380,15 +386,7 @@ test('network dump recovers iOS simulator entries from simctl log show when the booted: true, }), appBundleId: 'com.agentdevice.tester', - appLog: { - platform: 'apple', - backend: 'ios-simulator', - outPath: appLogPath, - startedAt: 1_712_040_000_000, - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, + appLog: iosSimulatorAppLog(sessionName, appLogPath), }); mockRunCmd.mockImplementation(async (_cmd, args) => { @@ -457,15 +455,7 @@ test('network dump explains when iOS simulator recovery found app logs but no HT booted: true, }), appBundleId: 'com.agentdevice.tester', - appLog: { - platform: 'apple', - backend: 'ios-simulator', - outPath: appLogPath, - startedAt: 1_712_040_000_000, - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, + appLog: iosSimulatorAppLog(sessionName, appLogPath), }); mockRunCmd.mockImplementation(async (_cmd, args) => { diff --git a/src/daemon/handlers/__tests__/session-test-harness.ts b/src/daemon/handlers/__tests__/session-test-harness.ts index d0b55a76f0..7c79dffbca 100644 --- a/src/daemon/handlers/__tests__/session-test-harness.ts +++ b/src/daemon/handlers/__tests__/session-test-harness.ts @@ -1,6 +1,10 @@ import { isMacOs } from '@agent-device/kernel/device'; import { expect, vi, beforeEach } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { localRuntimeOwner, type AppLogLiveState } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createTestAppLogLiveHandle } from '../../../__tests__/test-utils/app-log-live-handle.ts'; +import type { LogBackend } from '@agent-device/contracts/observability'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -75,15 +79,6 @@ vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => { resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), }; }); -vi.mock('../../app-log.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - runAppLogDoctor: vi.fn(async () => ({ checks: {}, notes: [] })), - startAppLog: vi.fn(), - stopAppLog: vi.fn(async () => {}), - }; -}); vi.mock('../session-deploy.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -118,7 +113,6 @@ import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId, } from '../../../platforms/apple/core/apps.ts'; -import { runAppLogDoctor, startAppLog, stopAppLog } from '../../app-log.ts'; import { defaultInstallOps, defaultReinstallOps } from '../session-deploy.ts'; export const mockDispatch = vi.mocked(dispatchCommand); @@ -145,9 +139,6 @@ export const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked( resolveIosSimulatorDeepLinkBundleId, ); export const mockEnsureAndroidEmulatorBooted = vi.mocked(ensureAndroidEmulatorBooted); -export const mockStartAppLog = vi.mocked(startAppLog); -const mockStopAppLog = vi.mocked(stopAppLog); -export const mockRunAppLogDoctor = vi.mocked(runAppLogDoctor); const mockDefaultInstallOpsIos = vi.mocked(defaultInstallOps.ios); const mockDefaultInstallOpsAndroid = vi.mocked(defaultInstallOps.android); const mockDefaultReinstallOpsIos = vi.mocked(defaultReinstallOps.ios); @@ -203,11 +194,6 @@ beforeEach(() => { mockResolveIosSimulatorDeepLinkBundleId.mockReset(); mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue(undefined); mockEnsureAndroidEmulatorBooted.mockReset(); - mockStartAppLog.mockReset(); - mockStopAppLog.mockReset(); - mockStopAppLog.mockResolvedValue(undefined); - mockRunAppLogDoctor.mockReset(); - mockRunAppLogDoctor.mockResolvedValue({ checks: {}, notes: [] }); mockDefaultInstallOpsIos.mockReset(); mockDefaultInstallOpsAndroid.mockReset(); mockDefaultReinstallOpsIos.mockReset(); @@ -228,6 +214,49 @@ export function makeSession(name: string, device: SessionState['device']): Sessi }; } +export function makeTestAppLogResource( + session: Pick, + options: { + backend: LogBackend; + state?: AppLogLiveState; + startedAt?: number; + outputPath?: string; + }, +): NonNullable { + const outputPath = options.outputPath ?? '/tmp/app.log'; + const handle = createTestAppLogLiveHandle({ + inspect: () => ({ + backend: options.backend, + state: options.state ?? 'active', + startedAt: options.startedAt ?? Date.now(), + }), + finish: async () => ({ + status: 'completed', + result: { backend: options.backend, outputPath, completedAt: Date.now() }, + }), + forceCleanup: async () => ({ status: 'cleaned' }), + }); + const envelope = createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: session.name, + device: { + id: session.device.id, + family: session.device.platform, + kind: session.device.kind, + ...(session.device.appleOs === undefined ? {} : { appleOs: session.device.appleOs }), + ...(session.device.target === undefined ? {} : { target: session.device.target }), + ...(session.device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: session.device.iosPhysicalDeviceBackend }), + }, + owner: localRuntimeOwner(session.device.platform), + fence: { token: 'test-fence', generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: {} }, + }); + return { handle, envelope }; +} + export const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {}, diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 8fe47a71c2..798194503a 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -131,7 +131,7 @@ async function stopBestEffortSessionResources( ): Promise { // Recording overlay finalization needs the Apple runner. await attemptCleanup('recording', () => stopSessionRecordingForTeardown(session)); - await attemptCleanup('app_log', () => stopSessionAppLog(session)); + await attemptCleanup('app_log', () => stopSessionAppLog({ session, sessionStore })); await attemptCleanup('audio_probe', async () => { await stopSessionAudioProbe(session, 'session-close'); }); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 40e43f8ac9..1eaad86a9f 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -24,11 +24,14 @@ import { getRequestSignal } from '../../request/cancel.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; import { errorResponse, requireCommandSupported } from './response.ts'; import { resolveImplicitSessionScope, sessionMatchesScope } from '../session-routing.ts'; +import { appLogAdmissionUse } from '@agent-device/contracts/platform'; +import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; export async function handleSessionInventoryCommands(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; + bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, sessionStore } = params; switch (req.command) { @@ -37,7 +40,12 @@ export async function handleSessionInventoryCommands(params: { case 'devices': return await devicesInventoryResponse(req); case 'capabilities': - return await capabilitiesInventoryResponse({ req, sessionName, sessionStore }); + return await capabilitiesInventoryResponse({ + req, + sessionName, + sessionStore, + bindDevice: params.bindDevice, + }); case 'apps': return await handleAppsInventory({ req, sessionName, sessionStore }); default: @@ -157,6 +165,7 @@ async function capabilitiesInventoryResponse(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; + bindDevice?: BindDeviceRuntime; }): Promise { const resolution = await resolveInventoryCommandDevice({ ...params, @@ -165,12 +174,15 @@ async function capabilitiesInventoryResponse(params: { }); if ('response' in resolution) return resolution.response; const { device } = resolution; + const logsAvailable = params.bindDevice + ? (await params.bindDevice(device, appLogAdmissionUse)).facts.appLogInspect.available + : false; return { ok: true, data: { device: publicDeviceInfo(device), availableCommands: listCapabilityCommands().filter((command) => - isCommandSupportedOnDevice(command, device), + command === 'logs' ? logsAvailable : isCommandSupportedOnDevice(command, device), ), }, }; diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index ed69dd4ade..9ee23da89e 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -3,35 +3,41 @@ import { isPerfArea, isPerfKind, isPerfMemoryKind, - LOG_ACTION_VALUES as LOG_ACTIONS, PERF_ACTION_ERROR_MESSAGE, PERF_AREA_ERROR_MESSAGE, PERF_KIND_ERROR_MESSAGE, PERF_MEMORY_KIND_ERROR_MESSAGE, type LogBackend, - type LogAction as LogsAction, type PerfAction, type PerfArea, type PerfKind, } from '@agent-device/contracts/observability'; +import { + appLogAdmissionUse, + resolveLogsRuntimePlan, + type AppLogFailure, + type AppLogRuntimeOperations, + type LogsRuntimePlan, + type RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; import { uniqueStrings } from '@agent-device/kernel/collections'; import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '@agent-device/kernel/contracts'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; import { resolveWebProvider } from '../../platforms/web/provider.ts'; +import { appendAppLogMarker, clearAppLogFiles, getAppLogPathMetadata } from '../app-log.ts'; +import type { AppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; +import { readSessionNetworkCapture } from '../app-log-network-recovery.ts'; +import { resolveAppLogResourcePath } from '../app-log-resource-store.ts'; import { - appendAppLogMarker, - clearAppLogFiles, - getAppLogPathMetadata, - readSessionNetworkCapture, - resolveLogBackend, - runAppLogDoctor, - startAppLog, - stopAppLog, - type AppLogFailure, - type AppLogResult, - type AppLogState, -} from '../app-log.ts'; + adoptStartedSessionAppLog, + clearSessionAppLogFailure, + finishSessionAppLog, + inspectSessionAppLog, + recordSessionAppLogFailure, +} from '../app-log-session-resource.ts'; +import { createNextAppLogFence } from '../app-log-start-preflight.ts'; +import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData, SessionState } from '../types.ts'; import { errorResponse, requireCommandSupported, type DaemonFailureResponse } from './response.ts'; @@ -44,7 +50,6 @@ import { buildPerfResponseData, } from './session-perf.ts'; -const LOG_ACTIONS_MESSAGE = `logs requires ${LOG_ACTIONS.slice(0, -1).join(', ')}, or ${LOG_ACTIONS.at(-1)}`; const NETWORK_ACTIONS = ['dump', 'log'] as const; const NETWORK_ACTIONS_MESSAGE = `network requires ${NETWORK_ACTIONS.join(' or ')}`; const NETWORK_INCLUDE_MESSAGE = `network include mode must be one of: ${NETWORK_INCLUDE_MODES.join(', ')}`; @@ -54,14 +59,21 @@ type ObservabilityParams = { sessionName: string; sessionStore: SessionStore; androidAdbExecutor?: AndroidAdbExecutor; + bindDevice?: BindDeviceRuntime; + appLogAdmissionLedger?: AppLogAdmissionLedger; + throwIfCanceled?: () => void; }; -type LogsHandlerParams = ObservabilityParams & { +type LogsHandlerParams = Omit & { session: SessionState; - restart: boolean; + bindDevice: BindDeviceRuntime; + appLogAdmissionLedger: AppLogAdmissionLedger; }; +type ExecutableLogsRuntimePlan = + | Extract + | (Extract & { appBundleId: string }); type SessionLogStatus = { active: boolean; - state: AppLogState | 'inactive'; + state: 'active' | 'recovering' | 'ended' | 'failed' | 'inactive'; backend: LogBackend; startedAt?: number; failureCode?: string; @@ -70,62 +82,18 @@ type SessionLogStatus = { notes?: string[]; }; -const LOG_ACTION_HANDLERS: Record< - LogsAction, - (params: LogsHandlerParams) => Promise | DaemonResponse -> = { - path: ({ session, sessionName, sessionStore }) => - handleLogsPath(session, sessionName, sessionStore), - doctor: ({ session, sessionName, sessionStore }) => - handleLogsDoctor(session, sessionName, sessionStore), - mark: ({ req, sessionName, sessionStore }) => handleLogsMark(req, sessionName, sessionStore), - clear: ({ session, sessionName, sessionStore, restart }) => - handleLogsClear(session, sessionName, sessionStore, restart), - start: ({ session, sessionName, sessionStore }) => - handleLogsStart(session, sessionName, sessionStore), - stop: ({ session, sessionName, sessionStore }) => - handleLogsStop(session, sessionName, sessionStore), -}; - -function resolveSessionLogStatus(session: SessionState): SessionLogStatus { - if (session.appLog) { - const state = session.appLog.getState(); - const active = state === 'active' || state === 'recovering'; - return { - active, - state, - backend: session.appLog.backend, - startedAt: session.appLog.startedAt, - notes: buildAppLogStateNotes(state), - }; - } - if (session.appLogFailure) { - return { - active: false, - state: 'failed', - backend: session.appLogFailure.backend, - failureCode: session.appLogFailure.code, - failureMessage: session.appLogFailure.message, - hint: session.appLogFailure.hint, - notes: [buildAppLogFailureNote(session.appLogFailure)], - }; - } - return { - active: false, - state: 'inactive', - backend: resolveLogBackend(session.device), - }; -} - -function buildAppLogFailure( - normalized: ReturnType, - backend: LogBackend, -): AppLogFailure { +function resolveSessionLogStatus( + session: SessionState, + fallbackBackend: LogBackend, +): SessionLogStatus { + const snapshot = inspectSessionAppLog(session); return { - backend, - code: normalized.code, - message: normalized.message, - hint: normalized.hint, + ...snapshot, + backend: snapshot.backend ?? fallbackBackend, + notes: + snapshot.state === 'failed' && session.appLogFailure + ? [buildAppLogFailureNote(session.appLogFailure)] + : buildAppLogStateNotes(snapshot.state), }; } @@ -133,7 +101,7 @@ function buildAppLogFailureNote(failure: AppLogFailure): string { return failure.hint ? `${failure.message} ${failure.hint}` : failure.message; } -function buildAppLogStateNotes(state: AppLogState): string[] | undefined { +function buildAppLogStateNotes(state: SessionLogStatus['state']): string[] | undefined { if (state === 'failed') { return [ 'The app log stream process exited with an error. Run logs doctor for backend diagnostics.', @@ -148,39 +116,12 @@ function buildAppLogStateNotes(state: AppLogState): string[] | undefined { } function mergeLogDoctorNotes( - doctorNotes: string[], + doctorNotes: readonly string[], status: Pick, ): string[] { return uniqueStrings([...doctorNotes, ...(status.notes ?? [])]); } -function buildSessionAppLog( - session: SessionState, - outPath: string, - appLog: AppLogResult, -): NonNullable { - return { - ...appLog, - platform: session.device.platform, - outPath, - }; -} - -function storeAppLogStartFailure( - sessionStore: SessionStore, - sessionName: string, - session: SessionState, - error: unknown, -): DaemonFailureResponse { - const normalized = normalizeError(error); - sessionStore.set(sessionName, { - ...session, - appLog: undefined, - appLogFailure: buildAppLogFailure(normalized, resolveLogBackend(session.device)), - }); - return { ok: false, error: normalized }; -} - export async function handleSessionObservabilityCommands( params: ObservabilityParams, ): Promise { @@ -423,40 +364,97 @@ async function handleLogsCommand(params: ObservabilityParams): Promise { + switch (plan.kind) { + case 'path': { + const runtime = await params.bindDevice(params.session.device, plan.use); + const inspection = await runtime.operations.appLogInspect(); + return handleLogsPath(params, inspection.backend); + } + case 'doctor': { + const runtime = await params.bindDevice(params.session.device, plan.use); + const doctor = await runtime.operations.appLogDoctor({ + appBundleId: params.session.appBundleId, + }); + return handleLogsDoctor(params, doctor); + } + case 'start': + case 'clear-restart': + return await executeLogsStartCapablePlan(params, plan); + case 'stop': + await params.bindDevice(params.session.device, plan.use); + return await handleLogsStop(params); + case 'mark': + await params.bindDevice(params.session.device, plan.use); + return handleLogsMark(plan.marker, params.sessionName, params.sessionStore); + case 'clear': + await params.bindDevice(params.session.device, plan.use); + return handleLogsClear(params); + } +} + +async function executeLogsStartCapablePlan( + params: LogsHandlerParams, + plan: Extract, +): Promise { + const runtime = await params.bindDevice(params.session.device, plan.use); + return plan.kind === 'start' + ? await handleLogsStart(params, plan.appBundleId, runtime.owner, runtime.operations.appLogStart) + : await handleLogsClearRestart( + params, + plan.appBundleId, + runtime.owner, + runtime.operations.appLogStart, + ); +} + +function requireLogsPlanSession( + plan: LogsRuntimePlan, session: SessionState, - sessionName: string, - sessionStore: SessionStore, -): DaemonResponse { +): ExecutableLogsRuntimePlan | DaemonFailureResponse { + if (!plan.requiresAppSession) return plan; + if (session.appBundleId) return Object.freeze({ ...plan, appBundleId: session.appBundleId }); + return errorResponse( + 'INVALID_ARGS', + `logs ${plan.kind === 'start' ? 'start' : 'clear --restart'} requires an app session; run open first`, + ); +} + +function handleLogsPath(params: LogsHandlerParams, backend: LogBackend): DaemonResponse { + const { session, sessionName, sessionStore } = params; const logPath = sessionStore.resolveAppLogPath(sessionName); const metadata = getAppLogPathMetadata(logPath); - const status = resolveSessionLogStatus(session); + const status = resolveSessionLogStatus(session, backend); return { ok: true, data: { @@ -478,13 +476,16 @@ function handleLogsPath( } async function handleLogsDoctor( - session: SessionState, - sessionName: string, - sessionStore: SessionStore, + params: LogsHandlerParams, + doctor: Readonly<{ + backend: LogBackend; + checks: Readonly>; + notes: readonly string[]; + }>, ): Promise { + const { session, sessionName, sessionStore } = params; const logPath = sessionStore.resolveAppLogPath(sessionName); - const doctor = await runAppLogDoctor(session.device, session.appBundleId); - const status = resolveSessionLogStatus(session); + const status = resolveSessionLogStatus(session, doctor.backend); return { ok: true, data: { @@ -502,107 +503,143 @@ async function handleLogsDoctor( } function handleLogsMark( - req: DaemonRequest, + marker: string, sessionName: string, sessionStore: SessionStore, ): DaemonResponse { - const marker = req.positionals?.slice(1).join(' ') ?? ''; const logPath = sessionStore.resolveAppLogPath(sessionName); appendAppLogMarker(logPath, marker); return { ok: true, data: { path: logPath, marked: true } }; } -async function handleLogsClear( - session: SessionState, - sessionName: string, - sessionStore: SessionStore, - restart: boolean, -): Promise { - if (session.appLog && !restart) { +function handleLogsClear(params: LogsHandlerParams): DaemonResponse { + const { session, sessionName, sessionStore } = params; + if (session.appLog) { return errorResponse( 'INVALID_ARGS', 'logs clear requires logs to be stopped first; run logs stop', ); } const logPath = sessionStore.resolveAppLogPath(sessionName); - if (!restart) { - const cleared = clearAppLogFiles(logPath); - sessionStore.set(sessionName, { ...session, appLogFailure: undefined }); - return { ok: true, data: cleared }; - } - const appBundleId = session.appBundleId; - if (!appBundleId) { - return errorResponse( - 'INVALID_ARGS', - 'logs clear --restart requires an app session; run open first', - ); - } + const cleared = clearAppLogFiles(logPath); + clearSessionAppLogFailure({ session, sessionName, sessionStore }); + return { ok: true, data: cleared }; +} +async function handleLogsClearRestart( + params: LogsHandlerParams, + appBundleId: string, + owner: RuntimeOwnerRef, + start: AppLogRuntimeOperations['appLogStart'], +): Promise { + const { session, sessionName, sessionStore } = params; if (session.appLog) { - await stopAppLog(session.appLog); - } - const cleared = clearAppLogFiles(logPath); - const appLogPidPath = sessionStore.resolveAppLogPidPath(sessionName); - try { - const appLogStream = await startAppLog(session.device, appBundleId, logPath, appLogPidPath); - sessionStore.set(sessionName, { - ...session, - appLog: buildSessionAppLog(session, logPath, appLogStream), - appLogFailure: undefined, + await finishSessionAppLog({ + session, + sessionName, + sessionStore, + resourcePath: resolveAppLogResourcePath(sessionStore.resolveSessionDir(sessionName)), }); - return { ok: true, data: { ...cleared, restarted: true } }; - } catch (err) { - return storeAppLogStartFailure(sessionStore, sessionName, session, err); } + const logPath = sessionStore.resolveAppLogPath(sessionName); + const cleared = clearAppLogFiles(logPath); + const started = await startSessionAppLog(params, appBundleId, start, owner); + return started.ok ? { ok: true, data: { ...cleared, restarted: true } } : started; } async function handleLogsStart( - session: SessionState, - sessionName: string, - sessionStore: SessionStore, + params: LogsHandlerParams, + appBundleId: string, + owner: RuntimeOwnerRef, + start: AppLogRuntimeOperations['appLogStart'], ): Promise { + const { session } = params; if (session.appLog) { return errorResponse('INVALID_ARGS', 'app log already streaming; run logs stop first'); } - if (!session.appBundleId) { - return errorResponse( - 'INVALID_ARGS', - 'logs start requires an app session; run open first', - ); + return await startSessionAppLog(params, appBundleId, start, owner); +} + +async function handleLogsStop(params: LogsHandlerParams): Promise { + const { session, sessionName, sessionStore } = params; + if (!session.appLog) { + return errorResponse('INVALID_ARGS', 'no app log stream active'); } + const outPath = sessionStore.resolveAppLogPath(sessionName); + await finishSessionAppLog({ + session, + sessionName, + sessionStore, + resourcePath: resolveAppLogResourcePath(sessionStore.resolveSessionDir(sessionName)), + }); + return { ok: true, data: { path: outPath, stopped: true } }; +} - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - const appLogPidPath = sessionStore.resolveAppLogPidPath(sessionName); +async function startSessionAppLog( + params: LogsHandlerParams, + appBundleId: string, + start: AppLogRuntimeOperations['appLogStart'], + owner: RuntimeOwnerRef, +): Promise { + const { session, sessionName, sessionStore } = params; + const outputPath = sessionStore.resolveAppLogPath(sessionName); + const resourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir(sessionName)); try { - const appLogStream = await startAppLog( - session.device, - session.appBundleId, - appLogPath, - appLogPidPath, - ); - sessionStore.set(sessionName, { - ...session, - appLog: buildSessionAppLog(session, appLogPath, appLogStream), - appLogFailure: undefined, + const fence = createNextAppLogFence({ + ledger: params.appLogAdmissionLedger, + resourcePath, + device: session.device, }); - return { ok: true, data: { path: appLogPath, started: true } }; - } catch (err) { - return storeAppLogStartFailure(sessionStore, sessionName, session, err); + const result = await start({ + sessionId: sessionName, + appBundleId, + outputPath, + pidPath: sessionStore.resolveAppLogPidPath(sessionName), + fence, + }); + await adoptStartedSessionAppLog({ + admissionLedger: params.appLogAdmissionLedger, + session, + sessionName, + sessionStore, + resourcePath, + device: session.device, + owner, + fence, + pendingHandle: result.pendingHandle, + envelope: result.envelope, + throwIfCanceled: params.throwIfCanceled ?? (() => {}), + }); + return { ok: true, data: { path: outputPath, started: true } }; + } catch (error) { + const normalized = recordSessionAppLogFailure({ + session, + sessionName, + sessionStore, + error, + }); + return { ok: false, error: normalized }; } } -async function handleLogsStop( - session: SessionState, - sessionName: string, - sessionStore: SessionStore, -): Promise { - if (!session.appLog) { - return errorResponse('INVALID_ARGS', 'no app log stream active'); +function requireLogsHandlerParams( + params: ObservabilityParams & { session: SessionState }, +): LogsHandlerParams { + if (!params.bindDevice) { + throw new AppError('COMMAND_FAILED', 'Device runtime gateway is not configured', { + reason: 'runtime-gateway-missing', + }); } - const outPath = session.appLog.outPath; - await stopAppLog(session.appLog); - sessionStore.set(sessionName, { ...session, appLog: undefined, appLogFailure: undefined }); - return { ok: true, data: { path: outPath, stopped: true } }; + if (!params.appLogAdmissionLedger) { + throw new AppError('COMMAND_FAILED', 'App-log admission ledger is not configured', { + reason: 'runtime-gateway-missing', + }); + } + return { + ...params, + bindDevice: params.bindDevice, + appLogAdmissionLedger: params.appLogAdmissionLedger, + }; } // --------------------------------------------------------------------------- @@ -618,11 +655,12 @@ async function handleNetworkCommand(params: ObservabilityParams): Promise void; }; type SessionCommandHandler = (params: SessionCommandParams) => Promise; @@ -284,7 +289,8 @@ const handleSessionInventoryCommandGroup: SessionCommandHandler = async ({ req, sessionName, sessionStore, -}) => await handleSessionInventoryCommands({ req, sessionName, sessionStore }); + bindDevice, +}) => await handleSessionInventoryCommands({ req, sessionName, sessionStore, bindDevice }); const handleSessionStateCommandGroup: SessionCommandHandler = async ({ req, @@ -298,8 +304,19 @@ const handleSessionObservabilityCommandGroup: SessionCommandHandler = async ({ sessionName, sessionStore, androidAdbExecutor, + bindDevice, + appLogAdmissionLedger, + throwIfCanceled, }) => - await handleSessionObservabilityCommands({ req, sessionName, sessionStore, androidAdbExecutor }); + await handleSessionObservabilityCommands({ + req, + sessionName, + sessionStore, + androidAdbExecutor, + bindDevice, + appLogAdmissionLedger, + throwIfCanceled, + }); const handleSessionReplayCommandGroup: SessionCommandHandler = async ({ req, @@ -489,6 +506,9 @@ export async function handleSessionCommands(params: { invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; androidAdbExecutor?: AndroidAdbExecutor; + bindDevice?: BindDeviceRuntime; + appLogAdmissionLedger?: AppLogAdmissionLedger; + throwIfCanceled?: () => void; }): Promise { const { req, @@ -500,6 +520,9 @@ export async function handleSessionCommands(params: { invoke, invokeReplayAction, androidAdbExecutor, + bindDevice, + appLogAdmissionLedger, + throwIfCanceled, } = params; const handler = @@ -516,6 +539,9 @@ export async function handleSessionCommands(params: { invoke, invokeReplayAction, androidAdbExecutor, + bindDevice, + appLogAdmissionLedger, + throwIfCanceled, }); } diff --git a/src/daemon/network-log-android-recovery.ts b/src/daemon/network-log-android-recovery.ts new file mode 100644 index 0000000000..c41fe47ac8 --- /dev/null +++ b/src/daemon/network-log-android-recovery.ts @@ -0,0 +1,148 @@ +import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; +import { androidDeviceForSerial } from '../platforms/android/adb.ts'; +import { resolveAndroidAdbExecutor } from '../platforms/android/adb-executor.ts'; +import { captureAndroidLogcatWithAdb } from '../platforms/android/logcat.ts'; + +type StoredNetworkLogProcessMeta = Readonly<{ + pid: number; + command?: string; +}>; + +export async function resolveAndroidPid( + deviceId: string, + appBundleId: string, +): Promise { + const pidResult = await resolveAndroidAdbExecutor(androidDeviceForSerial(deviceId))( + ['shell', 'pidof', appBundleId], + { allowFailure: true }, + ); + const pid = pidResult.stdout.trim().split(/\s+/)[0]; + return pid && /^\d+$/.test(pid) ? pid : null; +} + +export function readTrackedAndroidLogcatPid(pidPath: string | undefined): string | null { + const command = readStoredNetworkLogProcessMeta(pidPath)?.command; + if (!command) return null; + const match = /(?:^|\s)--pid\s+(\d+)(?:\s|$)/.exec(command); + return match?.[1] ?? null; +} + +function readStoredNetworkLogProcessMeta( + pidPath: string | undefined, +): StoredNetworkLogProcessMeta | null { + if (!pidPath) return null; + const raw = readStoredNetworkLogProcessText(pidPath); + if (raw === null || raw.length === 0 || /^\d+$/.test(raw)) return null; + return decodeStoredNetworkLogProcessMeta(raw); +} + +function readStoredNetworkLogProcessText(pidPath: string): string | null { + try { + return fs.readFileSync(pidPath, 'utf8').trim(); + } catch { + return null; + } +} + +function decodeStoredNetworkLogProcessMeta(raw: string): StoredNetworkLogProcessMeta | null { + try { + const parsed = JSON.parse(raw) as unknown; + if (!isUnknownRecord(parsed)) return null; + const record = parsed as Record; + const pid = positiveInteger(record.pid); + if (pid === null) return null; + return { + pid, + ...(typeof record.command === 'string' ? { command: record.command } : {}), + }; + } catch { + return null; + } +} + +function isUnknownRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +function positiveInteger(value: unknown): number | null { + const number = Number(value); + return Number.isInteger(value) && number > 0 ? number : null; +} + +export async function readRecentAndroidLogcatForPackage( + deviceId: string, + appBundleId: string, +): Promise<{ pid: string | null; text: string; recoveredPids: string[] } | null> { + assertAndroidNetworkPackageSafe(appBundleId); + const pid = await resolveAndroidPid(deviceId, appBundleId); + const adb = resolveAndroidAdbExecutor(androidDeviceForSerial(deviceId)); + const text = await captureAndroidLogcatWithAdb(adb, { lines: 4000, timeoutMs: 3_000 }).catch( + () => '', + ); + if (text.trim().length === 0) return null; + const recoveredPids = collectAndroidPackagePids(text, appBundleId, pid); + if (recoveredPids.length === 0) return null; + const filteredText = filterAndroidLogcatToPids(text, appBundleId, recoveredPids); + if (filteredText.trim().length === 0) return null; + return { pid, text: filteredText, recoveredPids }; +} + +function assertAndroidNetworkPackageSafe(appBundleId: string): void { + if (!/^[a-zA-Z0-9._:-]+$/.test(appBundleId)) { + throw new AppError('INVALID_ARGS', `Invalid Android package name for logs: ${appBundleId}`); + } +} + +function collectAndroidPackagePids( + content: string, + appBundleId: string, + currentPid: string | null, +): string[] { + const pids = new Set(); + if (currentPid) pids.add(currentPid); + for (const line of content.split('\n')) { + if (!line.includes(appBundleId)) continue; + for (const candidate of extractAndroidPidsFromPackageLine(line, appBundleId)) { + pids.add(candidate); + } + } + return [...pids]; +} + +function extractAndroidPidsFromPackageLine(line: string, appBundleId: string): string[] { + const escapedPackage = escapeRegExp(appBundleId); + const patterns = [ + new RegExp(`\\bStart proc\\s+(\\d+):${escapedPackage}(?:\\b|/)`, 'i'), + new RegExp(`\\b(\\d+):${escapedPackage}(?:\\b|/)`, 'i'), + new RegExp(`${escapedPackage}.*?\\bpid\\s*[=:]?\\s*(\\d+)\\b`, 'i'), + new RegExp(`\\bpid\\s*[=:]?\\s*(\\d+)\\b.*${escapedPackage}`, 'i'), + ]; + const results: string[] = []; + for (const pattern of patterns) { + const pid = pattern.exec(line)?.[1]; + if (pid && /^\d+$/.test(pid)) results.push(pid); + } + return results; +} + +function filterAndroidLogcatToPids(content: string, appBundleId: string, pids: string[]): string { + const pidSet = new Set(pids); + return content + .split('\n') + .filter((line) => { + if (!line.trim()) return false; + if (line.includes(appBundleId)) return true; + const linePid = parseAndroidThreadtimePid(line); + return linePid ? pidSet.has(linePid) : false; + }) + .join('\n'); +} + +function parseAndroidThreadtimePid(line: string): string | null { + return /\(\s*(\d+)\)\s*:/.exec(line)?.[1] ?? null; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/daemon/network-log-ios-simulator-recovery.ts b/src/daemon/network-log-ios-simulator-recovery.ts new file mode 100644 index 0000000000..10e39f0177 --- /dev/null +++ b/src/daemon/network-log-ios-simulator-recovery.ts @@ -0,0 +1,76 @@ +import { buildSimctlArgs } from '../platforms/apple/core/simctl.ts'; +import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; + +export async function readRecentIosSimulatorLogShowForBundle(params: { + deviceId: string; + appBundleId: string; + executableName?: string; + startedAt?: number; + simulatorSetPath?: string; +}): Promise<{ text: string; recoveredLineCount: number } | null> { + const args = buildSimctlArgs( + [ + 'spawn', + params.deviceId, + 'log', + 'show', + '--style', + 'compact', + '--info', + '--predicate', + buildNetworkLogPredicate(params.appBundleId, params.executableName), + ], + { simulatorSetPath: params.simulatorSetPath }, + ); + if ( + typeof params.startedAt === 'number' && + Number.isFinite(params.startedAt) && + params.startedAt > 0 + ) { + args.push('--start', `@${Math.floor(params.startedAt / 1000)}`); + } else { + args.push('--last', '5m'); + } + const result = await runXcrun(args, { allowFailure: true, timeoutMs: 4_000 }); + if (result.exitCode !== 0 || result.stdout.trim().length === 0) return null; + const lines = result.stdout + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => { + const trimmed = line.trim(); + return ( + trimmed.length > 0 && !trimmed.startsWith('Timestamp Ty Process[PID:TID]') + ); + }); + return lines.length === 0 + ? null + : { text: `${lines.join('\n')}\n`, recoveredLineCount: lines.length }; +} + +function buildNetworkLogPredicate(appBundleId: string, executableName?: string): string { + const escapedBundleId = escapePredicateString(appBundleId); + const clauses = [ + `subsystem == "${escapedBundleId}"`, + `subsystem CONTAINS "${escapedBundleId}"`, + ...imagePathPredicateClauses(escapedBundleId, false), + ]; + if (executableName) { + const escapedExecutable = escapePredicateString(executableName); + clauses.push( + `process == "${escapedExecutable}"`, + ...imagePathPredicateClauses(escapedExecutable, true), + ); + } + return clauses.join(' OR '); +} + +function imagePathPredicateClauses(value: string, includeAppContainer: boolean): string[] { + return ['processImagePath', 'senderImagePath'].flatMap((field) => [ + `${field} ENDSWITH[c] "/${value}"`, + ...(includeAppContainer ? [`${field} CONTAINS[c] "/${value}.app/"`] : []), + ]); +} + +function escapePredicateString(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); +} diff --git a/src/daemon/platform-request-scope.ts b/src/daemon/platform-request-scope.ts index 524168c992..09d9e17aeb 100644 --- a/src/daemon/platform-request-scope.ts +++ b/src/daemon/platform-request-scope.ts @@ -26,3 +26,16 @@ export function createPlatformRequestScope(req: DaemonRequest): PlatformRequestS }), }); } + +/** Process-owned scope for post-lock durable-resource recovery before request admission. */ +export function createDaemonRecoveryPlatformScope(): PlatformRequestScope { + return Object.freeze({ + signal: processLifetimeSignal, + diagnostics: Object.freeze({ + emit: (event: PlatformDiagnosticEvent) => emitDiagnostic(event), + }), + progress: Object.freeze({ + report: () => {}, + }), + }); +} diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 066552637e..4d90744aa5 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -6,7 +6,7 @@ import { updateDiagnosticsScope, } from '../utils/diagnostics.ts'; import { applyCommandDefaults } from '../cli-schema/command-schema.ts'; -import { normalizeError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; import type { DaemonCommandContext } from './context.ts'; import { contextFromFlags as contextFromFlagsWithLog } from './context.ts'; import { assertSessionSelectorMatches } from './session-selector.ts'; @@ -40,12 +40,22 @@ import { } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { teardownSessionResources } from './session-teardown.ts'; +import type { + AppLogRuntimeOperations, + DeviceRuntimeGateway, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { createRequestRuntimeBindings, type BindDeviceRuntime } from './request-runtime-binding.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. const leaseRegistryExecutionLocks = new WeakMap>>(); +const requestScopeFinalizers = new WeakMap< + RequestExecutionScope, + (response: DaemonResponse) => DaemonResponse +>(); -export type RequestExecutionScope = { +export type RequestExecutionScope = AsyncDisposable & { req: DaemonRequest; command: string; sessionName: string; @@ -55,6 +65,7 @@ export type RequestExecutionScope = { runAdmitted(task: () => Promise): Promise; runLocked(task: () => Promise): Promise; retainDeviceExecutionLock(deviceId: string): Promise; + bindDevice: BindDeviceRuntime; throwIfCanceled(): void; }; @@ -64,7 +75,8 @@ export type LockedRequestScope = { logPath: string; existingSession: SessionState | undefined; retainDeviceExecutionLock(deviceId: string): Promise; - finalize(response: DaemonResponse): DaemonResponse; + bindDevice: BindDeviceRuntime; + throwIfCanceled(): void; contextFromFlags( flags: CommandFlags | undefined, appBundleId?: string, @@ -85,6 +97,8 @@ export async function createRequestExecutionScope(params: { req: DaemonRequest; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; + deviceRuntimeGateway?: DeviceRuntimeGateway; + platformRequestScope?: PlatformRequestScope; }): Promise { const { sessionStore, leaseRegistry } = params; let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); @@ -135,6 +149,13 @@ export async function createRequestExecutionScope(params: { locks: executionLocks, initialKeys: executionLockKeys, }); + const runtimeBindings = + params.deviceRuntimeGateway && params.platformRequestScope + ? createRequestRuntimeBindings({ + gateway: params.deviceRuntimeGateway, + scope: params.platformRequestScope, + }) + : undefined; const scope: RequestExecutionScope = { req: scopedReq, @@ -145,6 +166,15 @@ export async function createRequestExecutionScope(params: { startedAtMs, retainDeviceExecutionLock: async (deviceId) => await requestExecutionLocks.retainDevice(deviceId), + bindDevice: + runtimeBindings?.bindDevice ?? + (async () => { + throw new AppError( + 'COMMAND_FAILED', + 'Device runtime gateway is not configured for this request scope', + { reason: 'runtime-gateway-missing' }, + ); + }), throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), runAdmitted: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); @@ -152,7 +182,14 @@ export async function createRequestExecutionScope(params: { sessionName, sessionStore, leaseRegistry, - teardownSession: teardownSessionResources, + teardownSession: async (session, expiredSessionName) => + await teardownSessionResources({ + appLog: 'run', + session, + sessionName: expiredSessionName, + stateDir: sessionStore.resolveDaemonStateDir(), + sessionStore, + }), }); scopedReq = admitRequestLeaseForLockedScope({ req: scopedReq, @@ -167,7 +204,21 @@ export async function createRequestExecutionScope(params: { throwIfRequestCanceled(scopedReq.meta?.requestId); return await requestExecutionLocks.run(async () => await scope.runAdmitted(task)); }, + [Symbol.asyncDispose]: async () => await runtimeBindings?.[Symbol.asyncDispose](), }; + requestScopeFinalizers.set(scope, (response) => { + if (shouldRecordRequestEvents) { + sessionStore.recordEvent( + sessionName, + buildRequestFinishedEvent({ + req: scopedReq, + response, + durationMs: Math.max(0, Date.now() - startedAtMs), + }), + ); + } + return response; + }); return scope; } catch (error) { if (shouldRecordRequestEvents) { @@ -241,6 +292,7 @@ export function prepareLockedRequestScope(params: { } return finalized; }; + requestScopeFinalizers.set(scope, finalize); if ( existingSession?.recording?.invalidatedReason && @@ -248,13 +300,13 @@ export function prepareLockedRequestScope(params: { ) { return { type: 'response', - response: finalize({ + response: { ok: false, error: { code: 'COMMAND_FAILED', message: existingSession.recording.invalidatedReason, }, - }), + }, }; } @@ -281,7 +333,8 @@ export function prepareLockedRequestScope(params: { logPath, existingSession, retainDeviceExecutionLock: scope.retainDeviceExecutionLock, - finalize, + bindDevice: scope.bindDevice, + throwIfCanceled: scope.throwIfCanceled, contextFromFlags, handlerContextFromFlags: (flags, appBundleId, traceLogPath) => ({ @@ -293,6 +346,16 @@ export function prepareLockedRequestScope(params: { }; } +/** Final response/event construction runs only after request bindings dispose. */ +export function finalizeRequestExecutionScope( + scope: RequestExecutionScope, + response: DaemonResponse, +): DaemonResponse { + const finalize = requestScopeFinalizers.get(scope); + requestScopeFinalizers.delete(scope); + return finalize ? finalize(response) : response; +} + function contextFromRequestFlags( logPath: string, flags: CommandFlags | undefined, diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 23d4f977ab..0d061a8aaf 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -9,6 +9,8 @@ import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; import type { LeaseRegistry } from './lease-registry.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './types.ts'; +import type { BindDeviceRuntime } from './request-runtime-binding.ts'; +import type { AppLogAdmissionLedger } from './app-log-admission-ledger.ts'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -23,6 +25,9 @@ type RequestHandlerChainParams = { invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; androidAdbExecutor?: AndroidAdbExecutor; + bindDevice: BindDeviceRuntime; + appLogAdmissionLedger?: AppLogAdmissionLedger; + throwIfCanceled(): void; contextFromFlags: ( flags: CommandFlags | undefined, appBundleId?: string, @@ -117,6 +122,9 @@ async function runSessionHandler( invoke: params.invoke, invokeReplayAction: params.invokeReplayAction, androidAdbExecutor: params.androidAdbExecutor, + bindDevice: params.bindDevice, + appLogAdmissionLedger: params.appLogAdmissionLedger, + throwIfCanceled: params.throwIfCanceled, }), ); } diff --git a/src/daemon/request-platform-providers.ts b/src/daemon/request-platform-providers.ts index c5aad27b80..0fc462a7f3 100644 --- a/src/daemon/request-platform-providers.ts +++ b/src/daemon/request-platform-providers.ts @@ -12,7 +12,6 @@ import type { LinuxToolProvider } from '../platforms/linux/tool-provider.ts'; import type { VegaToolProvider } from '../platforms/vega/tool-provider.ts'; import { withWebProvider, type WebProvider } from '../platforms/web/provider.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { AppLogProvider } from './app-log.ts'; import { hasExplicitDeviceSelector } from './device-selector-intent.ts'; import { withRecordingProvider, type RecordingProvider } from './recording-provider.ts'; import type { DaemonRequest, SessionState } from './types.ts'; @@ -43,8 +42,6 @@ export type VegaToolProviderResolver = PlatformProviderResolver; -export type AppLogProviderResolver = PlatformProviderResolver; - export type RecordingProviderResolver = PlatformProviderResolver; export type PlatformProviderResolvers = { @@ -54,7 +51,6 @@ export type PlatformProviderResolvers = { linuxToolProvider?: LinuxToolProviderResolver; vegaToolProvider?: VegaToolProviderResolver; webProvider?: WebProviderResolver; - appLogProvider?: AppLogProviderResolver; recordingProvider?: RecordingProviderResolver; }; @@ -123,9 +119,6 @@ type ResolvedRequestPlatformProviders = { web?: { provider?: WebProvider; }; - appLog?: { - provider?: AppLogProvider; - }; recording?: { provider?: RecordingProvider; }; @@ -259,19 +252,6 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ appendRequestProviderWrapper(wrappers, scopedProviders.web, withWebProvider); }, }, - { - resolverKey: 'appLogProvider', - resolve(providers, context) { - const appLogProvider = providers.appLogProvider; - if (!appLogProvider) return {}; - return { appLog: { provider: appLogProvider(context) } }; - }, - async appendWrapper(scopedProviders, wrappers) { - if (!scopedProviders.appLog?.provider) return; - const { withAppLogProvider } = await import('./app-log-request-scope.ts'); - appendRequestProviderWrapper(wrappers, scopedProviders.appLog, withAppLogProvider); - }, - }, { resolverKey: 'recordingProvider', resolve(providers, context) { diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 4c50287dfb..6d4af35174 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -1,7 +1,11 @@ import { withResolveTargetDeviceCacheScope } from '../core/dispatch-resolve.ts'; import { withDeviceInventoryContext } from '../core/device-inventory-context.ts'; import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; -import type { ComposedDeviceInventoryGateways } from '@agent-device/contracts/platform'; +import type { + AppLogRuntimeOperations, + ComposedDeviceInventoryGateways, + DeviceRuntimeGateway, +} from '@agent-device/contracts/platform'; import { AppError, normalizeError, @@ -20,7 +24,6 @@ import { type AndroidAdbProviderResolver, type AppleRunnerProviderResolver, type AppleToolProviderResolver, - type AppLogProviderResolver, type LinuxToolProviderResolver, type RequestPlatformProviderScope, type RecordingProviderResolver, @@ -43,17 +46,21 @@ import { } from './request-handler-chain.ts'; import { createRequestExecutionScope, + finalizeRequestExecutionScope, type LockedRequestScope, prepareLockedRequestScope, type RequestExecutionScope, } from './request-execution-scope.ts'; -import { buildRequestFinishedEvent, shouldRecordEventForRequest } from './session-event-log.ts'; import { unsupportedSaveScriptFlagResponse } from './request-save-script-policy.ts'; import { canRunReplayScopedAction } from './daemon-command-registry.ts'; import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts'; import { openWebSessionNames } from './web-session-names.ts'; import { inferFillText } from './action-utils.ts'; import { createPlatformRequestScope } from './platform-request-scope.ts'; +import { + createAppLogAdmissionLedger, + type AppLogAdmissionLedger, +} from './app-log-admission-ledger.ts'; // --------------------------------------------------------------------------- // Request handler API @@ -71,9 +78,10 @@ export type RequestRouterDeps = { linuxToolProvider?: LinuxToolProviderResolver; vegaToolProvider?: VegaToolProviderResolver; webProvider?: WebProviderResolver; - appLogProvider?: AppLogProviderResolver; recordingProvider?: RecordingProviderResolver; deviceInventoryGateways: ComposedDeviceInventoryGateways; + deviceRuntimeGateway: DeviceRuntimeGateway; + appLogAdmissionLedger?: AppLogAdmissionLedger; providerRuntimeIds?: readonly string[]; providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; @@ -98,9 +106,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { linuxToolProvider, vegaToolProvider, webProvider, - appLogProvider, recordingProvider, deviceInventoryGateways, + deviceRuntimeGateway, + appLogAdmissionLedger = createAppLogAdmissionLedger(), providerRuntimeIds, providerRuntimeRequiredIds, leaseLifecycleProvider, @@ -157,11 +166,13 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { if (unsupportedSaveScript) return unsupportedSaveScript; let scope: RequestExecutionScope | undefined; + let response: DaemonResponse; + const platformRequestScope = createPlatformRequestScope(req); try { - return await withDeviceInventoryContext( + response = await withDeviceInventoryContext( { ...deviceInventoryGateways, - requestScope: createPlatformRequestScope(req), + requestScope: platformRequestScope, }, async () => await withResolveTargetDeviceCacheScope(async () => { @@ -169,15 +180,16 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { req, sessionStore, leaseRegistry, + deviceRuntimeGateway, + platformRequestScope, }); return await executeRequestScope(scope); }), ); } catch (error) { - const response = finalizeThrownRequestError(error); - recordThrownRequestEvent(sessionStore, scope, response); - return response; + response = finalizeThrownRequestError(error); } + return await finalizeRequestBindingCleanup(scope, response); } async function executeRequestScope( @@ -221,7 +233,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { (shouldUseDefaultWebProvider(lockedScope) ? createDefaultWebProvider(stateDir, sessionStore) : undefined), - appLogProvider, recordingProvider, }, }, @@ -253,9 +264,12 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { ? createReplayScopedActionInvoker(lockedScope, providerScope) : undefined, androidAdbExecutor: providerScope.androidAdbExecutor, + bindDevice: lockedScope.bindDevice, + appLogAdmissionLedger, + throwIfCanceled: lockedScope.throwIfCanceled, contextFromFlags: lockedScope.handlerContextFromFlags, }); - if (handlerResponse) return lockedScope.finalize(handlerResponse); + if (handlerResponse) return handlerResponse; return await dispatchGenericForLockedScope({ lockedScope, @@ -276,26 +290,29 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { registerParameterizedFillDiagnosticValue(req); let childScope: RequestExecutionScope | undefined; + let response: DaemonResponse; try { const scopedReq = bindReplayDeviceExecutionLock(req, parentScope); childScope = await createRequestExecutionScope({ req: scopedReq, sessionStore, leaseRegistry, + deviceRuntimeGateway, + platformRequestScope: createPlatformRequestScope(scopedReq), }); // The outer replay keeps its stable session lock plus the device lock // from the first device binding through response projection and ref // finalization. A same-session replay action reuses that admitted scope // instead of reacquiring the non-reentrant locks. Nested changes remain // visible to capture lineage through snapshot/frame/runtime/store state. - return childScope.sessionName === parentScope.sessionName - ? await executeRequestScope(childScope, providerScope) - : await executeRequestScope(childScope); + response = + childScope.sessionName === parentScope.sessionName + ? await executeRequestScope(childScope, providerScope) + : await executeRequestScope(childScope); } catch (error) { - const response = finalizeThrownRequestError(error); - recordThrownRequestEvent(sessionStore, childScope, response); - return response; + response = finalizeThrownRequestError(error); } + return await finalizeRequestBindingCleanup(childScope, response); }; } @@ -384,7 +401,7 @@ async function dispatchGenericForLockedScope(params: { const { lockedScope, logPath, sessionStore } = params; const session = sessionStore.get(lockedScope.sessionName); if (!session) { - return lockedScope.finalize(noActiveSessionError()); + return noActiveSessionError(); } const { dispatchGenericCommand } = await loadGenericRequestHandlerModule(); @@ -396,7 +413,7 @@ async function dispatchGenericForLockedScope(params: { sessionStore, contextFromFlags: lockedScope.contextFromFlags, }); - return lockedScope.finalize(dispatchResponse); + return dispatchResponse; } function bindReplayDeviceExecutionLock( @@ -441,20 +458,30 @@ function finalizeThrownRequestError(error: unknown): DaemonResponse { return { ok: false, error: normalizedError }; } -function recordThrownRequestEvent( - sessionStore: SessionStore, +async function finalizeRequestBindingCleanup( 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), - }), - ); +): Promise { + if (!scope) return response; + let finalResponse = response; + try { + await scope[Symbol.asyncDispose](); + } catch (cleanupError) { + if (response.ok) { + finalResponse = finalizeThrownRequestError(cleanupError); + } else { + emitDiagnostic({ + level: 'error', + phase: 'request_binding_cleanup_failed', + data: { + primaryCode: response.error.code, + cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }, + }); + flushDiagnosticsToSessionFile({ force: true }); + } + } + return finalizeRequestExecutionScope(scope, finalResponse); } /** diff --git a/src/daemon/request-runtime-binding.ts b/src/daemon/request-runtime-binding.ts new file mode 100644 index 0000000000..8502190fa5 --- /dev/null +++ b/src/daemon/request-runtime-binding.ts @@ -0,0 +1,62 @@ +import { deviceIdentity, deviceIdentityKey, type DeviceInfo } from '@agent-device/kernel/device'; +import { + AsyncCleanupStack, + narrowDeviceBinding, + type AppLogRuntimeOperations, + type BoundDeviceRuntime, + type DeviceBinding, + type DeviceRuntimeGateway, + type PlatformRequestScope, + type RuntimeOperationKey, + type RuntimeUse, +} from '@agent-device/contracts/platform'; + +export type BindDeviceRuntime = < + const Required extends readonly RuntimeOperationKey[], + const Preferred extends readonly Exclude< + RuntimeOperationKey, + Required[number] + >[], +>( + device: DeviceInfo, + use: RuntimeUse, +) => Promise>>; + +export type RequestRuntimeBindings = AsyncDisposable & + Readonly<{ + bindDevice: BindDeviceRuntime; + }>; + +/** Private broad-binding cache; handlers receive only the selected projection. */ +export function createRequestRuntimeBindings(params: { + gateway: DeviceRuntimeGateway; + scope: PlatformRequestScope; +}): RequestRuntimeBindings { + const cleanups = new AsyncCleanupStack(); + const bindings = new Map>>(); + + const bindDevice: BindDeviceRuntime = async (device, use) => { + const key = deviceIdentityKey(deviceIdentity(device)); + let bindingPromise = bindings.get(key); + if (!bindingPromise) { + bindingPromise = params.gateway + .bind({ + device, + intent: { kind: 'ordinary' }, + scope: params.scope, + }) + .then((binding) => cleanups.use(binding)); + bindings.set(key, bindingPromise); + void bindingPromise.catch(() => { + if (bindings.get(key) === bindingPromise) bindings.delete(key); + }); + } + const binding = await bindingPromise; + return narrowDeviceBinding(binding, use); + }; + + return { + bindDevice, + [Symbol.asyncDispose]: async () => await cleanups[Symbol.asyncDispose](), + }; +} diff --git a/src/daemon/server/daemon-runtime-device-claims.test.ts b/src/daemon/server/daemon-runtime-device-claims.test.ts index 6886663515..890e752333 100644 --- a/src/daemon/server/daemon-runtime-device-claims.test.ts +++ b/src/daemon/server/daemon-runtime-device-claims.test.ts @@ -3,7 +3,14 @@ import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../session-teardown.ts', () => ({ teardownSessionResources: vi.fn() })); +vi.mock('../session-teardown.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stopSessionAppLog: vi.fn(async () => {}), + teardownSessionResources: vi.fn(), + }; +}); import { SessionStore } from '../session-store.ts'; import { teardownSessionResources } from '../session-teardown.ts'; diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index a2191db5dd..9704435580 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -1,21 +1,23 @@ import crypto from 'node:crypto'; import { asAppError, AppError } from '@agent-device/kernel/errors'; import { SessionStore } from '../session-store.ts'; -import { cleanupStaleAppLogProcesses } from '../app-log-process.ts'; import { resolveDaemonPaths, resolveDaemonServerMode } from '../config.ts'; import { createDaemonHttpServer } from './http-server.ts'; import { trackDownloadableArtifact } from '../artifact-tracking.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../provider-device-runtime.ts'; -import { createPlatformDeviceInventoryGateways } from '../../platform-runtime.ts'; import { - createDefaultProviderDeviceRuntimes, + createPlatformAppLogRuntimeGateway, + createPlatformDeviceInventoryGateways, +} from '../../platform-runtime.ts'; +import { + createDefaultProviderRuntimeComposition, DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, } from '../../provider-device-runtimes.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; import { clearDaemonShutdownReport, writeDaemonShutdownReport } from '../daemon-shutdown-report.ts'; import { createRequestHandler } from '../request-router.ts'; -import { teardownSessionResources } from '../session-teardown.ts'; +import { stopSessionAppLog, teardownSessionResources } from '../session-teardown.ts'; import { IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS } from '../handlers/record-trace-ios-simulator.ts'; import { closeDaemonServers } from './server-shutdown.ts'; import type { DaemonInvokeFn, SessionState } from '../types.ts'; @@ -55,6 +57,12 @@ import { listAndroidAdbSerialsQuick, restoreOrphanedAndroidTestImeOnDaemonStartup, } from '../../platforms/android/ime-lifecycle.ts'; +import { + recoverAppLogResourcesAfterDaemonLock, + type AppLogRecoveryDiagnostic, +} from '../app-log-resource-recovery.ts'; +import { createDaemonRecoveryPlatformScope } from '../platform-request-scope.ts'; +import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000; const DAEMON_SESSION_LEASE_RELEASE_TIMEOUT_MS = 1_000; @@ -98,7 +106,27 @@ export async function teardownDaemonSessionForShutdown(params: { }): Promise { const { session, sessionStore, stateDir, stderr, beforeDelete, afterSuccessfulTeardown } = params; const timeoutMs = resolveDaemonSessionTeardownTimeoutMs(session); - const teardown = teardownSessionResources(session, session.name, stateDir).then( + // The ownership-fenced app-log side effect must settle while this process + // still owns the daemon lock. It is intentionally outside the generic + // teardown race so lock release and runtime shutdown cannot overtake it. + const appLogTeardownSucceeded = await stopSessionAppLog({ session, sessionStore }).then( + () => true, + (error) => { + stderr.write( + `Daemon app-log teardown error (${session.name}): ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + return false; + }, + ); + const sessionAfterAppLog = sessionStore.get(session.name) ?? session; + const teardown = teardownSessionResources({ + appLog: 'already-settled', + session: sessionAfterAppLog, + sessionName: session.name, + stateDir, + }).then( () => true, (error) => { stderr.write( @@ -109,13 +137,14 @@ export async function teardownDaemonSessionForShutdown(params: { return false; }, ); - const teardownSucceeded = await Promise.race([ + const genericTeardownSucceeded = await Promise.race([ teardown, sleep(timeoutMs).then(() => { stderr.write(`Daemon session teardown timed out (${session.name}).\n`); return false; }), ]); + const teardownSucceeded = appLogTeardownSucceeded && genericTeardownSucceeded; // ADR 0012 decision 6, R7 + commit semantics (C2/C5a): commit the healed // `.ad` iff the repair transaction completed, else leave a bounded // `REPAIR_SESSION_EXPIRED` tombstone for the reaped-before-finalize case. @@ -140,6 +169,26 @@ export type DaemonRuntimeController = { token: string; }; +export async function flushDaemonStartupDiagnostics( + logPath: string, + diagnostics: readonly AppLogRecoveryDiagnostic[], +): Promise { + if (diagnostics.length === 0) return; + await withDiagnosticsScope( + { command: 'daemon-startup', session: 'daemon', logPath, debug: false }, + async () => { + for (const diagnostic of diagnostics) { + emitDiagnostic({ + level: 'warn', + phase: diagnostic.phase, + data: { resourcePath: diagnostic.resourcePath, ...diagnostic.data }, + }); + } + flushDiagnosticsToSessionFile({ force: true }); + }, + ); +} + export async function startDaemonRuntime( options: DaemonRuntimeOptions = {}, ): Promise { @@ -153,14 +202,23 @@ export async function startDaemonRuntime( const retainArtifacts = isEnvTruthy(env.AGENT_DEVICE_RETAIN_ARTIFACTS); setRunnerLeaseOwnerStateDir(baseDir); - cleanupStaleAppLogProcesses(sessionsDir); - const sessionStore = new SessionStore(sessionsDir); + const appLogAdmissionLedger = createAppLogAdmissionLedger(); const version = readVersion(); const token = crypto.randomBytes(24).toString('hex'); const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; const daemonCodeSignature = resolveDaemonCodeSignature(); - const providerDeviceRuntimes = await createDefaultProviderDeviceRuntimes(env); + const providerComposition = await createDefaultProviderRuntimeComposition(env); + const providerDeviceRuntimes = [...providerComposition.runtimes]; + const deviceRuntimeGateway = createPlatformAppLogRuntimeGateway({ + providerRuntimes: providerDeviceRuntimes, + providerModules: providerComposition.appLogModules, + sessionsDir, + resolveSessionArtifacts: (sessionId) => ({ + outputPath: sessionStore.resolveAppLogPath(sessionId), + pidPath: sessionStore.resolveAppLogPidPath(sessionId), + }), + }); const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders( providerDeviceRuntimes, { providerRuntimeRequiredIds: DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS }, @@ -196,6 +254,8 @@ export async function startDaemonRuntime( leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, cloudArtifactProvider, deviceInventoryGateways, + deviceRuntimeGateway, + appLogAdmissionLedger, appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, @@ -333,7 +393,35 @@ export async function startDaemonRuntime( let servers: DaemonServer[] = []; let socketPort: number | undefined; let httpPort: number | undefined; + const startupAppLogDiagnostics: AppLogRecoveryDiagnostic[] = []; try { + const { recoverLegacyAppLogMarkersAfterDaemonLock } = + await import('../../platform-runtime-app-log-host.ts'); + const legacyMarkerRecovery = await recoverLegacyAppLogMarkersAfterDaemonLock(sessionsDir); + appLogAdmissionLedger.retainLegacyMarkers(legacyMarkerRecovery.retained); + for (const markerPath of legacyMarkerRecovery.recovered) { + startupAppLogDiagnostics.push({ + phase: 'app_log_legacy_marker_recovered', + resourcePath: markerPath, + data: {}, + }); + } + for (const retained of legacyMarkerRecovery.retained) { + startupAppLogDiagnostics.push({ + phase: 'app_log_legacy_marker_retained', + resourcePath: retained.markerPath, + data: { + reason: retained.reason, + ...(retained.message === undefined ? {} : { message: retained.message }), + }, + }); + } + await recoverAppLogResourcesAfterDaemonLock({ + sessionsDir, + gateway: deviceRuntimeGateway, + scope: createDaemonRecoveryPlatformScope(), + onDiagnostic: (diagnostic) => startupAppLogDiagnostics.push(diagnostic), + }); await cleanupWebBrowserOrphansForDaemonStartup({ stateDir: baseDir, sessionStore }); // Fire-and-forget: gated on a state-dir marker so it only touches adb when a prior run here // actually activated the test IME (never on hosts that don't use it, e.g. the macOS runner). @@ -346,6 +434,7 @@ export async function startDaemonRuntime( socketPort = opened.socketPort; httpPort = opened.httpPort; publishDaemonInfo(socketPort, httpPort); + await flushDaemonStartupDiagnostics(logPath, startupAppLogDiagnostics); // After publication: publishDaemonInfo truncates daemon.log, so anything // written before it is lost — including the prune's own diagnostic. await pruneDeviceClaimsForDaemonStartup(logPath); @@ -409,6 +498,7 @@ export async function startDaemonRuntime( }, }); expiredProviderLeaseReleaser.shutdown(); + await deviceRuntimeGateway.shutdown(); await Promise.allSettled( providerDeviceRuntimes.map(async (runtime) => await runtime.shutdown()), ); diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index f6044cc2b6..b704d8b514 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -2,7 +2,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '../utils/diagnostics.ts'; import { isMacOs, isApplePlatform } from '@agent-device/kernel/device'; import { runMacOsAlertAction } from '../platforms/apple/os/macos/helper.ts'; -import { stopAppLog } from './app-log.ts'; import { stopIosRunnerSession } from '../platforms/apple/core/runner/runner-client.ts'; import { cleanupAppleXctracePerfCapture } from '../platforms/apple/core/perf-xctrace.ts'; import { cleanupAndroidNativePerfSession } from '../platforms/android/perf.ts'; @@ -12,6 +11,9 @@ import { cleanupRetainedMaterializedPathsForSession } from './materialized-path- import { stopSessionAudioProbe } from './audio-probe.ts'; import { stopSessionRecordingForTeardown } from './handlers/record-trace-recording.ts'; import type { SessionState } from './types.ts'; +import type { SessionStore } from './session-store.ts'; +import { forceCleanupSessionAppLog } from './app-log-session-resource.ts'; +import { resolveAppLogResourcePath } from './app-log-resource-store.ts'; export { stopSessionAudioProbe } from './audio-probe.ts'; @@ -39,9 +41,18 @@ export async function stopAppleRunnerForClose(session: SessionState): Promise { +export async function stopSessionAppLog(params: { + session: SessionState; + sessionStore: SessionStore; +}): Promise { + const { session, sessionStore } = params; if (!session.appLog) return; - await stopAppLog(session.appLog); + await forceCleanupSessionAppLog({ + session, + sessionName: session.name, + sessionStore, + resourcePath: resolveAppLogResourcePath(sessionStore.resolveSessionDir(session.name)), + }); } export async function stopSessionApplePerfCapture(session: SessionState): Promise { @@ -142,11 +153,25 @@ export function reportSessionCleanupFailures(params: { ); } +type SessionResourceTeardownRequest = { + session: SessionState; + sessionName: string; + stateDir?: string; +} & ({ appLog: 'run'; sessionStore: SessionStore } | { appLog: 'already-settled' }); + export async function teardownSessionResources( - session: SessionState, - sessionName: string, - stateDir?: string, + request: SessionResourceTeardownRequest, ): Promise { + const { session, sessionName, stateDir } = request; + const appLogSteps: SessionCleanupStep[] = + request.appLog === 'run' + ? [ + { + step: 'app_log', + run: () => stopSessionAppLog({ session, sessionStore: request.sessionStore }), + }, + ] + : []; const steps: SessionCleanupStep[] = [ // Finalize any still-active recording BEFORE the Apple runner is stopped // below: the runner supplies gesture-telemetry for overlay finalization, and @@ -154,7 +179,7 @@ export async function teardownSessionResources( // (and its 0-byte, slot-holding mp4) when a session is torn down — including // on daemon shutdown — without an explicit `record stop`. { step: 'recording', run: () => stopSessionRecordingForTeardown(session) }, - { step: 'app_log', run: () => stopSessionAppLog(session) }, + ...appLogSteps, { step: 'audio_probe', run: async () => { diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 179c17518b..198edca2e2 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -5,7 +5,6 @@ import type { PreresolvedInteractionTarget, ScrollDirection, } from '@agent-device/contracts/interaction'; -import type { LogBackend } from '@agent-device/contracts/observability'; import type { RecordingExportQuality, RecordingScope } from '@agent-device/contracts/recording'; import type { SessionAction, SessionSurface } from '@agent-device/contracts/session'; import type { @@ -18,14 +17,19 @@ import type { SessionRuntimeHints as PublicSessionRuntimeHints, DaemonRequest as WireRequest, } from '@agent-device/kernel/contracts'; -import type { DeviceInfo, Platform, PlatformSelector } from '@agent-device/kernel/device'; +import type { DeviceInfo, PlatformSelector } from '@agent-device/kernel/device'; import type { Rect, SnapshotState, SnapshotCaptureBackend } from '@agent-device/kernel/snapshot'; import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; // Type-only import; erased at runtime. ref-frame.ts imports SessionState from // here, so this back-edge must stay type-only to avoid a runtime cycle. import type { SnapshotDiagnosticsState } from '@agent-device/contracts/capture'; import type { DeviceLease } from '@agent-device/contracts/device'; -import type { AudioProbeSource } from '@agent-device/contracts/platform'; +import type { + AudioProbeSource, + AppLogFailure, + AppLogLiveHandle, + DurableResourceEnvelope, +} from '@agent-device/contracts/platform'; import type { AndroidNativePerfSession } from '../platforms/android/perf.ts'; import type { SessionScriptPublicationState } from './session-script-publication-state.ts'; import type { @@ -36,7 +40,6 @@ import type { ReplayTargetGuardDenotation, TargetAnnotationV1, } from '@agent-device/contracts/replay'; -import type { AppLogFailure, AppLogState } from './app-log-process.ts'; import type { RefFrameScope, RefFrameState } from './ref-frame.ts'; export type DaemonInstallSource = PublicDaemonInstallSource; export type SessionRuntimeHints = PublicSessionRuntimeHints; @@ -520,15 +523,14 @@ export type SessionState = { | (SessionRecordingBase & { platform: 'web'; }); - /** Session-scoped app log stream; logs written to outPath for agent to grep */ + /** + * Neutral session-owned app-log resource. Durable coordinates are persisted + * independently; the in-memory handle is never serialized or reconstructed + * by SessionStore. + */ appLog?: { - platform: Platform; - backend: LogBackend; - outPath: string; - startedAt: number; - getState: () => AppLogState; - stop: () => Promise; - wait: Promise; + handle: AppLogLiveHandle; + envelope: DurableResourceEnvelope<'app-log'>; }; appLogFailure?: AppLogFailure; }; diff --git a/src/platform-runtime-app-log-android-transport.ts b/src/platform-runtime-app-log-android-transport.ts new file mode 100644 index 0000000000..47c6881a0e --- /dev/null +++ b/src/platform-runtime-app-log-android-transport.ts @@ -0,0 +1,77 @@ +import type { + AppLogProcessCommand, + AppLogProcessTransport, + HostCommandResult, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AndroidAdbProcess } from './platforms/android/adb-executor.ts'; +import { + createManagedAppLogProcesses, + type ManagedAppLogCommand, +} from './platform-runtime-app-log-process.ts'; + +export async function resolveAndroidAppLogProcessTransport( + sessionsDir: string, + device: DeviceInfo, + local: AppLogProcessTransport, +): Promise { + const { resolveScopedAndroidAdbBackgroundTransport } = + await import('./platforms/android/adb-executor.ts'); + const transport = resolveScopedAndroidAdbBackgroundTransport(device); + if (transport.mode === 'local') return local; + if (!transport.spawn) return Object.freeze({ mode: 'transport-composed' }); + const spawn = transport.spawn; + const processes = createManagedAppLogProcesses(sessionsDir, { + launch: async (command, signal) => { + signal?.throwIfAborted(); + const adb = providerAdbCommand(command, device.id); + const child = spawn([...adb.args], { + allowFailure: adb.options?.allowFailure, + cwd: adb.options?.cwd, + env: adb.options?.env ? { ...process.env, ...adb.options.env } : undefined, + timeoutMs: adb.options?.timeoutMs, + captureOutput: false, + signal, + }); + return providerBackgroundCommand(child); + }, + }); + return Object.freeze({ mode: 'transport-composed', start: processes.start }); +} + +function providerAdbCommand( + command: AppLogProcessCommand, + deviceId: string, +): Extract { + if (command.kind !== 'android-adb' || command.serial !== deviceId) { + throw new Error('Android app-log provider transport received a non-device-scoped adb command'); + } + return command; +} + +function providerBackgroundCommand(child: AndroidAdbProcess): ManagedAppLogCommand { + return Object.freeze({ child, wait: waitForProviderProcess(child) }); +} + +function waitForProviderProcess(child: AndroidAdbProcess): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (result: HostCommandResult | Error) => { + if (settled) return; + settled = true; + if (result instanceof Error) reject(result); + else resolve(result); + }; + child.on('error', (error) => settle(error)); + child.once('close', (code, signal) => + settle({ + stdout: '', + stderr: '', + exitCode: code ?? (signal ? 1 : 0), + }), + ); + if (child.exitCode !== null && child.exitCode !== undefined) { + queueMicrotask(() => settle({ stdout: '', stderr: '', exitCode: child.exitCode ?? 0 })); + } + }); +} diff --git a/src/platform-runtime-app-log-host.test.ts b/src/platform-runtime-app-log-host.test.ts new file mode 100644 index 0000000000..d65e1da714 --- /dev/null +++ b/src/platform-runtime-app-log-host.test.ts @@ -0,0 +1,31 @@ +import { expect, test, vi } from 'vitest'; + +const capabilities = vi.hoisted(() => ({ + appleTools: { + isXcrunAvailable: async () => true, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + toolchains: { prepare: async () => undefined }, +})); + +vi.mock('./platform-runtime-apple-tool-host.ts', () => ({ + createAppleToolHost: () => capabilities.appleTools, +})); +vi.mock('./platform-runtime-toolchain-host.ts', () => ({ + createHostToolchainPreparer: () => capabilities.toolchains, +})); + +import { createAppLogRuntimeHost } from './platform-runtime-app-log-host.ts'; + +test('app-log host composes the shared lazy Apple-tool and toolchain capabilities', () => { + const host = createAppLogRuntimeHost({ + sessionsDir: '/tmp/sessions', + resolveSessionArtifacts: () => ({ + outputPath: '/tmp/sessions/one/app.log', + pidPath: '/tmp/sessions/one/app-log.pid', + }), + }); + + expect(host.appleTools).toBe(capabilities.appleTools); + expect(host.toolchains).toBe(capabilities.toolchains); +}); diff --git a/src/platform-runtime-app-log-host.ts b/src/platform-runtime-app-log-host.ts new file mode 100644 index 0000000000..094b109c21 --- /dev/null +++ b/src/platform-runtime-app-log-host.ts @@ -0,0 +1,64 @@ +import type { + AppLogRuntimeHost, + AppLogSessionArtifacts, + HostCommandRequest, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { createAppleToolHost } from './platform-runtime-apple-tool-host.ts'; +import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; +import { runCmd, whichCmd } from './utils/exec.ts'; +import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; +import { createManagedAppLogProcesses } from './platform-runtime-app-log-process.ts'; + +export function createAppLogRuntimeHost(options: { + sessionsDir: string; + resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; +}): AppLogRuntimeHost { + const processes = createManagedAppLogProcesses(options.sessionsDir); + const localProcessTransport = Object.freeze({ mode: 'local' as const, start: processes.start }); + return Object.freeze({ + commands: Object.freeze({ + which: async (executable: string) => ((await whichCmd(executable)) ? executable : undefined), + run: async (request: HostCommandRequest, signal?: AbortSignal) => { + const result = await runCmd(request.executable, [...request.args], { + allowFailure: request.allowFailure, + cwd: request.cwd, + env: request.env ? { ...process.env, ...request.env } : undefined, + signal, + timeoutMs: request.timeoutMs, + }); + return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }; + }, + }), + appleTools: createAppleToolHost(), + toolchains: createHostToolchainPreparer(), + artifacts: Object.freeze({ resolveSession: options.resolveSessionArtifacts }), + outputs: Object.freeze({ + openAppend: async (pathname: string) => await openAppLogOutput(options.sessionsDir, pathname), + readTail: async (pathname: string, maxBytes: number) => + await readAppLogOutputTail(options.sessionsDir, pathname, maxBytes), + }), + processes, + processTransports: Object.freeze({ + resolve: async (device: DeviceInfo) => { + if (device.platform !== 'android') return localProcessTransport; + const { resolveAndroidAppLogProcessTransport } = + await import('./platform-runtime-app-log-android-transport.ts'); + return resolveAndroidAppLogProcessTransport( + options.sessionsDir, + device, + localProcessTransport, + ); + }, + }), + clock: Object.freeze({ + now: () => Date.now(), + sleep: async (milliseconds: number, signal?: AbortSignal) => { + await sleep(milliseconds, undefined, signal ? { signal } : undefined); + }, + }), + }); +} + +export { recoverLegacyAppLogMarkersAfterDaemonLock } from './platform-runtime-app-log-process.ts'; diff --git a/src/platform-runtime-app-log-output.test.ts b/src/platform-runtime-app-log-output.test.ts new file mode 100644 index 0000000000..a4e37cb4ef --- /dev/null +++ b/src/platform-runtime-app-log-output.test.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, test } from 'vitest'; +import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +test('reads a bounded trusted app.log suffix and rejects paths outside sessions', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-output-')); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const sessionDir = path.join(sessionsDir, 'one'); + fs.mkdirSync(sessionDir, { recursive: true }); + const outputPath = path.join(sessionDir, 'app.log'); + fs.writeFileSync(outputPath, 'before\nshared\n'); + + expect(await readAppLogOutputTail(sessionsDir, outputPath, 7)).toBe('shared\n'); + await expect( + readAppLogOutputTail(sessionsDir, path.join(root, 'outside', 'app.log'), 10), + ).rejects.toThrow('outside the daemon-owned sessions directory'); + await expect(openAppLogOutput(sessionsDir, path.join(sessionDir, 'other.log'))).rejects.toThrow( + 'outside the daemon-owned sessions directory', + ); +}); + +test('rejects a symlinked session directory that escapes the sessions root', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-symlink-')); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(sessionsDir); + fs.mkdirSync(outside); + fs.symlinkSync(outside, path.join(sessionsDir, 'linked')); + await expect( + openAppLogOutput(sessionsDir, path.join(sessionsDir, 'linked', 'app.log')), + ).rejects.toThrow('resolves outside'); +}); + +test('rejects a final app.log symlink before append and preserves the outside file', async () => { + const fixture = finalSymlinkFixture('append'); + await expect(openAppLogOutput(fixture.sessionsDir, fixture.outputPath)).rejects.toThrow( + 'symbolic link', + ); + expect(fs.readFileSync(fixture.outsidePath, 'utf8')).toBe('outside-append'); +}); + +test('rejects a final app.log symlink before tail read and preserves the outside file', async () => { + const fixture = finalSymlinkFixture('tail'); + await expect(readAppLogOutputTail(fixture.sessionsDir, fixture.outputPath, 100)).rejects.toThrow( + 'regular file', + ); + expect(fs.readFileSync(fixture.outsidePath, 'utf8')).toBe('outside-tail'); +}); + +test('aligns a bounded UTF-8 suffix to the first complete line', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-tail-')); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const sessionDir = path.join(sessionsDir, 'one'); + fs.mkdirSync(sessionDir, { recursive: true }); + const outputPath = path.join(sessionDir, 'app.log'); + fs.writeFileSync(outputPath, 'old\n🙂partial\nshared\n'); + + expect(await readAppLogOutputTail(sessionsDir, outputPath, 17)).toBe('shared\n'); +}); + +test('rejects an asynchronous stream-open failure without an unhandled error', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-open-')); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const outputPath = path.join(sessionsDir, 'one', 'app.log'); + fs.mkdirSync(outputPath, { recursive: true }); + + await expect(openAppLogOutput(sessionsDir, outputPath)).rejects.toThrow('regular file'); +}); + +function finalSymlinkFixture(label: string) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-app-log-${label}-`)); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const sessionDir = path.join(sessionsDir, 'one'); + const outsidePath = path.join(root, 'outside.log'); + const outputPath = path.join(sessionDir, 'app.log'); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync(outsidePath, `outside-${label}`); + fs.symlinkSync(outsidePath, outputPath); + return { sessionsDir, outputPath, outsidePath }; +} diff --git a/src/platform-runtime-app-log-output.ts b/src/platform-runtime-app-log-output.ts new file mode 100644 index 0000000000..dbab93324b --- /dev/null +++ b/src/platform-runtime-app-log-output.ts @@ -0,0 +1,130 @@ +import type { AppLogOutputSink } from '@agent-device/contracts/platform'; +import fs from 'node:fs'; +import { ensureAppLogPath } from './utils/app-log-files.ts'; +import { requireManagedSessionArtifactPath } from './utils/managed-session-artifact-path.ts'; +import { openVerifiedFileForAppend, openVerifiedFileForRead } from './utils/verified-file.ts'; + +export async function openAppLogOutput( + sessionsDir: string, + pathname: string, +): Promise { + pathname = requireManagedOutputPath(sessionsDir, pathname); + ensureAppLogPath(pathname); + const descriptor = openVerifiedFileForAppend(pathname); + let stream: fs.WriteStream; + try { + stream = fs.createWriteStream(pathname, { fd: descriptor, autoClose: true }); + } catch (error) { + fs.closeSync(descriptor); + throw error; + } + let streamError: Error | undefined; + stream.on('error', (error) => { + streamError ??= error; + }); + const patterns = redactionPatterns(); + let pending = ''; + let writes = Promise.resolve(); + let disposal: Promise | undefined; + const enqueue = (work: () => Promise) => { + const next = writes.then(work); + writes = next.catch(() => undefined); + return next; + }; + const writeText = async (text: string) => { + const combined = `${pending}${text}`; + const lines = combined.split('\n'); + pending = lines.pop() ?? ''; + for (const line of lines) await writeChunk(stream, redact(`${line}\n`, patterns)); + }; + return { + write: async (chunk) => await enqueue(async () => await writeText(decodeAppLogChunk(chunk))), + [Symbol.asyncDispose]: async () => { + disposal ??= enqueue(async () => { + if (pending) { + await writeChunk(stream, redact(pending, patterns)); + pending = ''; + } + await closeStream(stream); + if (streamError) throw streamError; + }); + await disposal; + }, + }; +} + +export async function readAppLogOutputTail( + sessionsDir: string, + pathname: string, + maxBytes: number, +): Promise { + pathname = requireManagedOutputPath(sessionsDir, pathname); + if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 1024 * 1024) { + throw new TypeError('App-log tail size must be between 1 byte and 1 MiB'); + } + const handle = openVerifiedFileForRead(pathname); + if (handle === undefined) return ''; + const size = fs.fstatSync(handle).size; + const length = Math.min(size, maxBytes); + const start = size - length; + const prefixLength = start > 0 ? 1 : 0; + const buffer = Buffer.alloc(length + prefixLength); + try { + fs.readSync(handle, buffer, 0, buffer.length, start - prefixLength); + } finally { + fs.closeSync(handle); + } + const decoded = buffer.subarray(prefixLength).toString('utf8'); + if (prefixLength === 0 || buffer[0] === 0x0a) return decoded; + const firstCompleteLine = decoded.indexOf('\n'); + return firstCompleteLine < 0 ? '' : decoded.slice(firstCompleteLine + 1); +} + +function requireManagedOutputPath(sessionsDir: string, pathname: string): string { + return requireManagedSessionArtifactPath({ + sessionsDir, + pathname, + basename: 'app.log', + label: 'App-log output', + }); +} + +function redactionPatterns(): RegExp[] { + return (process.env.AGENT_DEVICE_APP_LOG_REDACT_PATTERNS ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .flatMap((pattern) => { + try { + return [new RegExp(pattern, 'gi')]; + } catch { + return []; + } + }); +} + +function redact(chunk: string, patterns: readonly RegExp[]): string { + return patterns.reduce((output, pattern) => output.replace(pattern, '[REDACTED]'), chunk); +} + +function decodeAppLogChunk(chunk: string | Uint8Array): string { + return typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); +} + +async function writeChunk(stream: fs.WriteStream, chunk: string): Promise { + if (!chunk) return; + await new Promise((resolve, reject) => { + stream.write(chunk, (error) => (error ? reject(error) : resolve())); + }); +} + +async function closeStream(stream: fs.WriteStream): Promise { + if (stream.closed) return; + await new Promise((resolve, reject) => { + stream.once('error', reject); + stream.end(() => { + stream.off('error', reject); + resolve(); + }); + }); +} diff --git a/src/platform-runtime-app-log-process.test.ts b/src/platform-runtime-app-log-process.test.ts new file mode 100644 index 0000000000..e3754d8335 --- /dev/null +++ b/src/platform-runtime-app-log-process.test.ts @@ -0,0 +1,338 @@ +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const exec = vi.hoisted(() => ({ run: vi.fn() })); +const processIdentity = vi.hoisted(() => ({ + alive: true, + command: 'adb -s emulator-5554 logcat --pid 123' as string | null, + startTime: 'Sun Aug 9 22:00:00 2026' as string | null, +})); + +vi.mock('./utils/exec.ts', () => ({ runCmdBackground: exec.run })); +vi.mock('./utils/host-process.ts', () => ({ + isProcessAlive: () => processIdentity.alive, + readProcessCommand: () => processIdentity.command, + readProcessStartTime: () => processIdentity.startTime, + waitForProcessExit: async () => true, +})); + +import { + createManagedAppLogProcesses, + recoverLegacyAppLogMarkersAfterDaemonLock, +} from './platform-runtime-app-log-process.ts'; + +const roots: string[] = []; + +afterEach(() => { + exec.run.mockReset(); + processIdentity.alive = true; + processIdentity.command = 'adb -s emulator-5554 logcat --pid 123'; + processIdentity.startTime = 'Sun Aug 9 22:00:00 2026'; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('managed app-log process host', () => { + test('publishes a complete marker atomically and preserves invalid evidence', async () => { + const fixture = processFixture(); + const processes = createManagedAppLogProcesses(fixture.sessionsDir); + const running = await processes.start(fixture.request); + expect(await processes.readMarker(fixture.markerPath)).toEqual({ + status: 'decoded', + marker: { + pid: 99, + startTime: processIdentity.startTime, + command: processIdentity.command, + }, + }); + expect(fs.readdirSync(path.dirname(fixture.markerPath))).toEqual(['app-log.pid']); + + fs.writeFileSync(fixture.markerPath, '{torn'); + expect(await processes.readMarker(fixture.markerPath)).toMatchObject({ status: 'invalid' }); + expect(fs.existsSync(fixture.markerPath)).toBe(true); + expect(running.marker).toBeDefined(); + }); + + test('authorizes cleanup by exact process identity instead of command vocabulary', async () => { + processIdentity.command = '/opt/acme/bin/device-output --stream session-1'; + const fixture = processFixture({ settled: true }); + const processes = createManagedAppLogProcesses(fixture.sessionsDir); + + const running = await processes.start(fixture.request); + + expect(running.marker).toEqual({ + pid: 99, + startTime: processIdentity.startTime, + command: processIdentity.command, + }); + await expect(processes.inspect(running.marker!)).resolves.toBe('owned-alive'); + }); + + test('rolls back start when process identity is incomplete', async () => { + processIdentity.startTime = null; + const fixture = processFixture({ settled: true }); + await expect( + createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request), + ).rejects.toThrow('complete ownership marker'); + expect(fixture.child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(fs.existsSync(fixture.markerPath)).toBe(false); + }); + + test('rolls back a provider child that exposes no durable process identity', async () => { + const fixture = processFixture({ settled: true }); + const child = Object.assign(new EventEmitter(), { + exitCode: undefined, + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true), + }); + await expect( + createManagedAppLogProcesses(fixture.sessionsDir, { + launch: async () => ({ + child, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }), + }).start(fixture.request), + ).rejects.toThrow('complete ownership marker'); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(fs.existsSync(fixture.markerPath)).toBe(false); + }); + + test('forwards setup cancellation to a custom process launcher', async () => { + const fixture = processFixture({ settled: true }); + const controller = new AbortController(); + const reason = new Error('provider launch cancelled'); + const launch = vi.fn(async (_command, signal) => { + expect(signal).toBe(controller.signal); + throw reason; + }); + + await expect( + createManagedAppLogProcesses(fixture.sessionsDir, { launch }).start( + fixture.request, + controller.signal, + ), + ).rejects.toBe(reason); + expect(launch).toHaveBeenCalledOnce(); + }); + + test('refuses pre-existing marker evidence before spawning a replacement', async () => { + const fixture = processFixture({ settled: true }); + fs.writeFileSync(fixture.markerPath, '{retained'); + await expect( + createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request), + ).rejects.toThrow('must be recovered before start'); + expect(exec.run).not.toHaveBeenCalled(); + expect(fs.readFileSync(fixture.markerPath, 'utf8')).toBe('{retained'); + }); + + test('treats PID command reuse as ownership lost and never terminates it', async () => { + const fixture = processFixture(); + const processes = createManagedAppLogProcesses(fixture.sessionsDir); + const running = await processes.start(fixture.request); + processIdentity.command = 'unrelated process'; + expect(await processes.inspect(running.marker!)).toBe('ownership-lost'); + expect(await processes.terminate(running.marker!)).toBe('ownership-lost'); + expect(fixture.child.kill).not.toHaveBeenCalled(); + expect(fs.existsSync(fixture.markerPath)).toBe(true); + }); + + test('handles output rejection immediately and fails the managed wait', async () => { + const fixture = processFixture({ rejectOutput: true }); + const running = await createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request); + fixture.child.stdout.write('line\n'); + await vi.waitFor(() => expect(fixture.child.kill).toHaveBeenCalledWith('SIGKILL')); + fixture.resolveWait(); + await expect(running.wait).rejects.toThrow('sink failed'); + }); + + test('terminates the owned child exactly once across terminate and async disposal', async () => { + const fixture = processFixture(); + const running = await createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request); + const terminating = running.terminate(); + fixture.resolveWait(); + await terminating; + await running[Symbol.asyncDispose](); + expect(fixture.child.kill).toHaveBeenCalledTimes(1); + expect(fixture.child.kill).toHaveBeenCalledWith('SIGINT'); + }); + + test('retains a marker that is corrupted before the managed child exits', async () => { + const fixture = processFixture(); + const running = await createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request); + fs.writeFileSync(fixture.markerPath, '{corrupt'); + fixture.resolveWait(); + await running.wait; + expect(fs.readFileSync(fixture.markerPath, 'utf8')).toBe('{corrupt'); + }); + + test('rejects marker paths outside the sessions root', async () => { + const fixture = processFixture({ settled: true }); + await expect( + createManagedAppLogProcesses(fixture.sessionsDir).start({ + ...fixture.request, + markerPath: path.join(path.dirname(fixture.sessionsDir), 'app-log.pid'), + }), + ).rejects.toThrow('outside the daemon-owned sessions directory'); + }); + + test('rejects a symlinked marker directory that escapes the sessions root', async () => { + const fixture = processFixture({ settled: true }); + const outside = path.join(path.dirname(fixture.sessionsDir), 'outside'); + const linked = path.join(fixture.sessionsDir, 'linked'); + fs.mkdirSync(outside); + fs.symlinkSync(outside, linked); + await expect( + createManagedAppLogProcesses(fixture.sessionsDir).start({ + ...fixture.request, + markerPath: path.join(linked, 'app-log.pid'), + }), + ).rejects.toThrow('resolves outside'); + expect(exec.run).not.toHaveBeenCalled(); + }); + + test('retains a final app-log.pid symlink as invalid evidence without following it', async () => { + const fixture = processFixture({ settled: true }); + const outsidePath = path.join(path.dirname(fixture.sessionsDir), 'outside-marker.json'); + const outsideMarker = { + pid: 99, + startTime: processIdentity.startTime, + command: processIdentity.command, + }; + fs.writeFileSync(outsidePath, `${JSON.stringify(outsideMarker)}\n`); + fs.symlinkSync(outsidePath, fixture.markerPath); + const processes = createManagedAppLogProcesses(fixture.sessionsDir); + + await expect(processes.readMarker(fixture.markerPath)).resolves.toMatchObject({ + status: 'invalid', + }); + const recovery = await recoverLegacyAppLogMarkersAfterDaemonLock(fixture.sessionsDir); + expect(recovery.retained).toEqual([ + expect.objectContaining({ markerPath: fixture.markerPath, reason: 'invalid' }), + ]); + await expect(processes.start(fixture.request)).rejects.toThrow( + 'must be recovered before start', + ); + expect(exec.run).not.toHaveBeenCalled(); + expect(fs.readFileSync(outsidePath, 'utf8')).toBe(`${JSON.stringify(outsideMarker)}\n`); + expect(fs.lstatSync(fixture.markerPath).isSymbolicLink()).toBe(true); + }); + + test('recovers only complete owned legacy markers and retains untrusted evidence', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-legacy-')); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const recoveredPath = legacyMarker(sessionsDir, 'recovered', { + pid: 99, + startTime: processIdentity.startTime, + command: processIdentity.command, + }); + const invalidPath = legacyMarker(sessionsDir, 'invalid', 99); + const corruptPath = legacyMarker(sessionsDir, 'corrupt', { pid: 99 }); + const skippedPath = legacyMarker(sessionsDir, 'manifest', { + pid: 99, + startTime: processIdentity.startTime, + command: processIdentity.command, + }); + fs.writeFileSync(path.join(path.dirname(skippedPath), 'app-log.resource.json'), '{}'); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + processIdentity.alive = false; + return true; + }); + const result = await recoverLegacyAppLogMarkersAfterDaemonLock(sessionsDir); + kill.mockRestore(); + + expect(result.recovered).toEqual([recoveredPath]); + expect(result.retained).toEqual( + expect.arrayContaining([ + expect.objectContaining({ markerPath: invalidPath, reason: 'invalid' }), + expect.objectContaining({ markerPath: corruptPath, reason: 'invalid' }), + ]), + ); + expect(fs.existsSync(recoveredPath)).toBe(false); + expect(fs.existsSync(invalidPath)).toBe(true); + expect(fs.existsSync(corruptPath)).toBe(true); + expect(fs.existsSync(skippedPath)).toBe(true); + }); + + test('scopes ownership-lost legacy markers to the device encoded by their command', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-legacy-identity-')); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const markerPath = legacyMarker(sessionsDir, 'android', { + pid: 99, + startTime: processIdentity.startTime, + command: 'adb -s emulator-5554 logcat -v time', + }); + processIdentity.command = 'unrelated process'; + + const result = await recoverLegacyAppLogMarkersAfterDaemonLock(sessionsDir); + + expect(result.retained).toEqual([ + { + markerPath, + reason: 'ownership-lost', + device: { + id: 'emulator-5554', + family: 'android', + kind: 'emulator', + target: 'mobile', + }, + }, + ]); + expect(fs.existsSync(markerPath)).toBe(true); + }); +}); + +function legacyMarker(sessionsDir: string, sessionId: string, marker: unknown): string { + const sessionDir = path.join(sessionsDir, sessionId); + fs.mkdirSync(sessionDir, { recursive: true }); + const markerPath = path.join(sessionDir, 'app-log.pid'); + fs.writeFileSync(markerPath, `${JSON.stringify(marker)}\n`); + return markerPath; +} + +function processFixture(options: { settled?: boolean; rejectOutput?: boolean } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-process-')); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const sessionDir = path.join(sessionsDir, 'one'); + fs.mkdirSync(sessionDir, { recursive: true }); + const markerPath = path.join(sessionDir, 'app-log.pid'); + const child = Object.assign(new EventEmitter(), { + pid: 99, + exitCode: null as number | null, + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true), + }); + let resolveWait = () => {}; + const wait = options.settled + ? Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + : new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { + resolveWait = () => resolve({ stdout: '', stderr: '', exitCode: 0 }); + }); + exec.run.mockReturnValue({ child, wait }); + return { + sessionsDir, + markerPath, + child, + resolveWait, + request: { + command: { + kind: 'host', + request: { executable: 'adb', args: ['logcat', '--pid', '123'], allowFailure: true }, + }, + output: { + write: async () => { + if (options.rejectOutput) throw new Error('sink failed'); + }, + [Symbol.asyncDispose]: async () => {}, + }, + markerPath, + } as const, + }; +} diff --git a/src/platform-runtime-app-log-process.ts b/src/platform-runtime-app-log-process.ts new file mode 100644 index 0000000000..e91202ee5a --- /dev/null +++ b/src/platform-runtime-app-log-process.ts @@ -0,0 +1,424 @@ +import type { + AppLogBackgroundProcess, + AppLogBackgroundProcessRequest, + AppLogProcessMarker, + AppLogProcessMarkerReadOutcome, + AppLogProcessOwnership, + AppLogProcessCommand, + HostCommandRequest, + HostCommandResult, +} from '@agent-device/contracts/platform'; +import { decodeAppLogProcessMarker } from '@agent-device/capture-kit'; +import type { DeviceIdentity } from '@agent-device/kernel/device'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runCmdBackground } from './utils/exec.ts'; +import { + isProcessAlive, + readProcessCommand, + readProcessStartTime, + waitForProcessExit, +} from './utils/host-process.ts'; +import { requireManagedSessionArtifactPath } from './utils/managed-session-artifact-path.ts'; +import { openVerifiedFileForRead } from './utils/verified-file.ts'; + +const APP_LOG_PID_FILENAME = 'app-log.pid'; + +type ManagedAppLogCommandChild = Readonly<{ + pid?: number; + exitCode?: number | null; + stdout?: NodeJS.ReadableStream | null; + stderr?: NodeJS.ReadableStream | null; + kill(signal?: NodeJS.Signals | number): boolean; +}>; + +export type ManagedAppLogCommand = Readonly<{ + child: ManagedAppLogCommandChild; + wait: Promise; +}>; + +export type ManagedAppLogCommandLauncher = ( + command: AppLogProcessCommand, + signal?: AbortSignal, +) => ManagedAppLogCommand | Promise; + +export function createManagedAppLogProcesses( + sessionsDir: string, + options: Readonly<{ launch?: ManagedAppLogCommandLauncher }> = {}, +) { + const root = path.resolve(sessionsDir); + const launch = options.launch ?? launchLocalAppLogCommand; + return Object.freeze({ + start: async (request: AppLogBackgroundProcessRequest, signal?: AbortSignal) => + await startAppLogProcess(root, request, launch, signal), + readMarker: async (markerPath: string) => readMarker(root, markerPath), + clearMarker: async (markerPath: string) => clearMarker(root, markerPath), + inspect: async (marker: AppLogProcessMarker) => inspectOwnedProcess(marker), + terminate: async (marker: AppLogProcessMarker) => await terminateOwnedProcess(marker), + }); +} + +export type LegacyAppLogMarkerRecovery = Readonly<{ + recovered: readonly string[]; + retained: readonly Readonly<{ + markerPath: string; + reason: 'invalid' | 'ownership-lost'; + message?: string; + device?: DeviceIdentity; + }>[]; +}>; + +/** Recovers marker-only sessions after the daemon lock is held. */ +export async function recoverLegacyAppLogMarkersAfterDaemonLock( + sessionsDir: string, +): Promise { + const recovered: string[] = []; + const retained: Array<{ + markerPath: string; + reason: 'invalid' | 'ownership-lost'; + message?: string; + device?: DeviceIdentity; + }> = []; + if (!fs.existsSync(sessionsDir)) return { recovered, retained }; + const root = path.resolve(sessionsDir); + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const sessionDir = path.join(root, entry.name); + if (fs.existsSync(path.join(sessionDir, 'app-log.resource.json'))) continue; + const markerPath = path.join(sessionDir, APP_LOG_PID_FILENAME); + const read = readMarker(root, markerPath); + if (read.status === 'missing') continue; + if (read.status === 'invalid') { + retained.push({ markerPath, reason: 'invalid', message: read.message }); + continue; + } + const outcome = await terminateOwnedProcess(read.marker); + if (outcome === 'ownership-lost') { + const device = legacyMarkerDeviceIdentity(read.marker.command); + retained.push({ markerPath, reason: 'ownership-lost', ...(device ? { device } : {}) }); + continue; + } + clearMarker(root, markerPath); + recovered.push(markerPath); + } + return Object.freeze({ recovered: Object.freeze(recovered), retained: Object.freeze(retained) }); +} + +function legacyMarkerDeviceIdentity(command: string): DeviceIdentity | undefined { + const android = /(?:^|\s)(?:\S*\/)?adb\s+-s\s+([^\s]+)/.exec(command)?.[1]; + if (android) { + return Object.freeze({ + id: android, + family: 'android', + kind: android.startsWith('emulator-') ? 'emulator' : 'device', + target: 'mobile', + }); + } + const simulator = /(?:^|\s)xcrun\s+(?:--set\s+\S+\s+)?simctl\s+spawn\s+([^\s]+)/.exec( + command, + )?.[1]; + if (simulator) { + return Object.freeze({ + id: simulator, + family: 'apple', + appleOs: 'ios', + kind: 'simulator', + target: 'mobile', + }); + } + const device = /(?:^|\s)xcrun\s+devicectl\b[^\n]*?\s--device\s+([^\s]+)/.exec(command)?.[1]; + if (!device) return undefined; + return Object.freeze({ + id: device, + family: 'apple', + appleOs: 'ios', + kind: 'device', + target: 'mobile', + iosPhysicalDeviceBackend: 'coredevice', + }); +} + +async function startAppLogProcess( + root: string, + request: AppLogBackgroundProcessRequest, + launch: ManagedAppLogCommandLauncher, + signal?: AbortSignal, +): Promise { + assertMarkerAvailable(root, request.markerPath); + signal?.throwIfAborted(); + const background = await launch(request.command, signal); + const child = background.child; + const waitForWrites = forwardProcessOutput(child, request.output); + const marker = await publishProcessMarkerOrRollback(root, request.markerPath, background); + const wait = background.wait + .then(async (result) => { + await waitForWrites(); + return result; + }) + .finally(() => { + if (request.markerPath && marker) clearPublishedMarker(root, request.markerPath, marker); + }); + const terminate = createManagedProcessTermination(child, wait); + return { marker, wait, terminate, [Symbol.asyncDispose]: terminate }; +} + +function launchLocalAppLogCommand( + command: AppLogProcessCommand, + signal?: AbortSignal, +): ManagedAppLogCommand { + const request = localHostCommand(command); + return runCmdBackground(request.executable, [...request.args], { + allowFailure: request.allowFailure, + cwd: request.cwd, + env: request.env ? { ...process.env, ...request.env } : undefined, + timeoutMs: request.timeoutMs, + captureOutput: false, + signal, + }); +} + +function localHostCommand(command: AppLogProcessCommand): HostCommandRequest { + if (command.kind === 'host') return command.request; + return { + executable: 'adb', + args: ['-s', command.serial, ...command.args], + ...command.options, + }; +} + +function assertMarkerAvailable(root: string, markerPath?: string): void { + if (!markerPath) return; + if (readMarker(root, markerPath).status === 'missing') return; + throw new Error('Existing app-log process marker evidence must be recovered before start'); +} + +function forwardProcessOutput( + child: ManagedAppLogCommandChild, + output: AppLogBackgroundProcessRequest['output'], +): () => Promise { + let writes = Promise.resolve(); + let firstWriteError: unknown; + const forward = (chunk: string | Buffer) => { + writes = writes + .then(async () => await output.write(chunk)) + .catch((error: unknown) => { + firstWriteError ??= error; + if (typeof child.exitCode !== 'number') child.kill('SIGKILL'); + }); + }; + child.stdout?.on('data', forward); + child.stderr?.on('data', forward); + return async () => { + await writes; + if (firstWriteError !== undefined) throw firstWriteError; + }; +} + +async function publishProcessMarkerOrRollback( + root: string, + markerPath: string | undefined, + background: ManagedAppLogCommand, +): Promise { + const marker = background.child.pid + ? await resolveProcessMarker(background.child.pid) + : undefined; + try { + if (!marker) { + throw new Error('Managed app-log process did not expose a complete ownership marker'); + } + if (markerPath) writeMarker(root, markerPath, marker); + return marker; + } catch (error) { + background.child.kill('SIGKILL'); + await background.wait.catch(() => undefined); + throw error; + } +} + +function createManagedProcessTermination( + child: ManagedAppLogCommandChild, + wait: AppLogBackgroundProcess['wait'], +): () => Promise { + let termination: Promise | undefined; + return async () => + await (termination ??= (async () => { + if (typeof child.exitCode === 'number') return; + child.kill('SIGINT'); + if (!(await settlesWithin(wait, 2_000))) child.kill('SIGKILL'); + await wait.catch(() => undefined); + })()); +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + return await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), timeoutMs); + const settled = () => { + clearTimeout(timeout); + resolve(true); + }; + void promise.then(settled, settled); + }); +} + +function clearPublishedMarker( + root: string, + markerPath: string, + published: AppLogProcessMarker, +): void { + const current = readMarker(root, markerPath); + if ( + current.status === 'decoded' && + current.marker.pid === published.pid && + current.marker.startTime === published.startTime && + current.marker.command === published.command + ) { + clearMarker(root, markerPath); + } +} + +function processMarker(pid: number): AppLogProcessMarker | undefined { + const startTime = readProcessStartTime(pid); + const command = readProcessCommand(pid); + if (!startTime || !command) return undefined; + return Object.freeze({ + pid, + startTime, + command, + }); +} + +async function resolveProcessMarker(pid: number): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + const marker = processMarker(pid); + if (marker) return marker; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return undefined; +} + +function readMarker(root: string, markerPath: string): AppLogProcessMarkerReadOutcome { + const resolved = requireManagedMarkerPath(root, markerPath); + let handle: number; + try { + const opened = openVerifiedFileForRead(resolved); + if (opened === undefined) return { status: 'missing' }; + handle = opened; + } catch (error) { + return { + status: 'invalid', + message: error instanceof Error ? error.message : 'App-log process marker is invalid', + }; + } + try { + const value = JSON.parse(fs.readFileSync(handle, 'utf8')) as unknown; + return decodeAppLogProcessMarker(value); + } catch (error) { + return { + status: 'invalid', + message: error instanceof Error ? error.message : 'App-log process marker is invalid', + }; + } finally { + fs.closeSync(handle); + } +} + +function writeMarker(root: string, markerPath: string, marker: AppLogProcessMarker): void { + const resolved = requireManagedMarkerPath(root, markerPath); + if (readMarker(root, resolved).status !== 'missing') { + throw new Error( + 'Existing app-log process marker evidence must be recovered before replacement', + ); + } + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + const temporary = `${resolved}.${process.pid}.${Date.now()}.tmp`; + let handle: number | undefined; + let linked = false; + let published = false; + try { + handle = fs.openSync(temporary, 'wx', 0o600); + fs.writeFileSync(handle, `${JSON.stringify(marker)}\n`); + fs.fsyncSync(handle); + fs.closeSync(handle); + handle = undefined; + fs.linkSync(temporary, resolved); + linked = true; + fs.unlinkSync(temporary); + const directory = fs.openSync(path.dirname(resolved), 'r'); + try { + fs.fsyncSync(directory); + } finally { + fs.closeSync(directory); + } + published = true; + } finally { + if (handle !== undefined) { + try { + fs.closeSync(handle); + } catch { + // Preserve the publication failure. + } + } + if (!published) { + for (const unresolvedPath of linked ? [resolved, temporary] : [temporary]) { + try { + fs.unlinkSync(unresolvedPath); + } catch { + // Keep the original publication failure. + } + } + } + } +} + +function clearMarker(root: string, markerPath: string): void { + const resolved = requireManagedMarkerPath(root, markerPath); + try { + fs.unlinkSync(resolved); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} + +function requireManagedMarkerPath(root: string, markerPath: string): string { + return requireManagedSessionArtifactPath({ + sessionsDir: root, + pathname: markerPath, + basename: APP_LOG_PID_FILENAME, + label: 'App-log process marker', + }); +} + +function inspectOwnedProcess(marker: AppLogProcessMarker): AppLogProcessOwnership { + if (!isProcessAlive(marker.pid)) return 'missing'; + return markerMatchesLiveProcess(marker) ? 'owned-alive' : 'ownership-lost'; +} + +async function terminateOwnedProcess( + marker: AppLogProcessMarker, +): Promise<'terminated' | 'already-missing' | 'ownership-lost'> { + if (!isProcessAlive(marker.pid)) return 'already-missing'; + if (!markerMatchesLiveProcess(marker)) return 'ownership-lost'; + try { + process.kill(marker.pid, 'SIGTERM'); + } catch { + return 'already-missing'; + } + if (!(await waitForProcessExit(marker.pid, 2_000))) { + if (!markerMatchesLiveProcess(marker)) return 'ownership-lost'; + try { + process.kill(marker.pid, 'SIGKILL'); + } catch { + return 'already-missing'; + } + await waitForProcessExit(marker.pid, 2_000); + } + return isProcessAlive(marker.pid) ? 'ownership-lost' : 'terminated'; +} + +function markerMatchesLiveProcess(marker: AppLogProcessMarker): boolean { + if (!marker.startTime || !marker.command) return false; + return ( + readProcessStartTime(marker.pid) === marker.startTime && + readProcessCommand(marker.pid) === marker.command + ); +} diff --git a/src/platform-runtime-app-log.test.ts b/src/platform-runtime-app-log.test.ts new file mode 100644 index 0000000000..fe31b7f10f --- /dev/null +++ b/src/platform-runtime-app-log.test.ts @@ -0,0 +1,269 @@ +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import type { + AppLogRuntimeHost, + AppLogRuntimeOperations, + DeviceBinding, + DeviceRuntimeOwner, + PlatformRequestScope, + RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { providerRuntimeOwner } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { describe, expect, test, vi } from 'vitest'; +import { + createComposedAppLogRuntimeGateway, + type AppLogRuntimeProviderRegistration, +} from './platform-runtime-app-log.ts'; + +const device: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'limrun:ios:lease-a', + name: 'Provider iOS', + kind: 'simulator', + target: 'mobile', + booted: true, +}; +const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + +describe('composed app-log runtime gateway', () => { + test('selects an exact provider owner without ordinary ownsDevice arbitration', async () => { + const ownsDevice = vi.fn(() => false); + const ref = providerRuntimeOwner('limrun', 'stable'); + const runtime = providerRuntime({ ref, ownsDevice }); + const runtimeGateway = gateway([runtime]); + + const binding = await runtimeGateway.bind({ + device, + intent: { kind: 'exact-owner', owner: ref, fence: { token: 'fence', generation: 1 } }, + scope, + }); + expect(binding.owner).toEqual(ref); + expect(ownsDevice).not.toHaveBeenCalled(); + }); + + test('rejects ambiguous ordinary provider ownership', async () => { + const first = providerRuntime({ + provider: 'first', + ref: providerRuntimeOwner('first', 'stable'), + ownsDevice: () => true, + }); + const second = providerRuntime({ + provider: 'second', + ref: providerRuntimeOwner('second', 'stable'), + ownsDevice: () => true, + }); + await expect( + gateway([first, second]).bind({ device, intent: { kind: 'ordinary' }, scope }), + ).rejects.toMatchObject({ details: { reason: 'runtime-contract-invalid' } }); + }); + + test('rejects duplicate stable provider owner refs', async () => { + const ref = providerRuntimeOwner('limrun', 'stable'); + expect(() => gateway([providerRuntime({ ref }), providerRuntime({ ref })])).toThrow( + 'Duplicate app-log runtime owner', + ); + }); + + test('loads only the exact provider instance selected by stable owner metadata', async () => { + const target = providerRuntimeOwner('limrun', 'target'); + const unrelatedLoad = vi.fn(async () => { + throw new Error('must stay lazy'); + }); + const runtimeGateway = gateway([ + providerRuntime({ ref: providerRuntimeOwner('limrun', 'other'), load: unrelatedLoad }), + providerRuntime({ ref: target, ownsDevice: () => false }), + ]); + await expect( + runtimeGateway.bind({ + device, + intent: { kind: 'exact-owner', owner: target, fence: { token: 'fence', generation: 1 } }, + scope, + }), + ).resolves.toMatchObject({ owner: target }); + expect(unrelatedLoad).not.toHaveBeenCalled(); + }); + + test('does not fall back to a local runtime for a provider without an app-log module', async () => { + const hostLoad = vi.fn(async () => ({}) as AppLogRuntimeHost); + const localLoad = vi.fn(async () => + runtimeOwner({ ref: { kind: 'local-family', family: 'apple' } }), + ); + const runtimeGateway = createComposedAppLogRuntimeGateway({ + modules: new Map([['apple', { family: 'apple', loadRuntime: localLoad }]]), + loadHost: hostLoad, + providerRuntimes: [ + { + provider: 'webdriver', + leaseLifecycle: {}, + deviceInventoryProvider: async () => null, + ownsDevice: () => true, + getInteractor: () => undefined, + shutdown: async () => {}, + }, + ], + }); + const binding = await runtimeGateway.bind({ device, intent: { kind: 'ordinary' }, scope }); + expect(binding.facts.operations.appLogInspect).toMatchObject({ + available: false, + reason: 'unsupported-provider-mode', + }); + expect(hostLoad).not.toHaveBeenCalled(); + expect(localLoad).not.toHaveBeenCalled(); + }); + + test('rejects a swapped local module before loading host mechanics', async () => { + const hostLoad = vi.fn(async () => ({}) as AppLogRuntimeHost); + const runtimeGateway = createComposedAppLogRuntimeGateway({ + modules: new Map([ + [ + 'apple', + { + family: 'android', + loadRuntime: async () => + runtimeOwner({ ref: { kind: 'local-family', family: 'android' } }), + }, + ], + ]), + loadHost: hostLoad, + }); + await expect( + runtimeGateway.bind({ device, intent: { kind: 'ordinary' }, scope }), + ).rejects.toMatchObject({ details: { reason: 'runtime-contract-invalid' } }); + expect(hostLoad).not.toHaveBeenCalled(); + }); + + test('accepts transport-composed facts from the selected local family owner', async () => { + const ref = { kind: 'local-family', family: 'apple' } as const; + const runtimeGateway = createComposedAppLogRuntimeGateway({ + modules: new Map([ + [ + 'apple', + { + family: 'apple', + loadRuntime: async () => runtimeOwner({ ref, providerMode: 'transport-composed' }), + }, + ], + ]), + loadHost: async () => ({}) as AppLogRuntimeHost, + }); + + await expect( + runtimeGateway.bind({ device, intent: { kind: 'ordinary' }, scope }), + ).resolves.toMatchObject({ + owner: ref, + facts: { device: { providerMode: 'transport-composed' } }, + }); + }); + + test.each(['owner', 'device', 'facts'] as const)( + 'disposes a provider binding with mismatched %s identity', + async (mismatch) => { + const disposed = vi.fn(async () => {}); + const ref = providerRuntimeOwner('limrun', 'stable'); + const runtime = providerRuntime({ ref, mismatch, disposed }); + await expect( + gateway([runtime]).bind({ + device, + intent: { kind: 'exact-owner', owner: ref, fence: { token: 'fence', generation: 1 } }, + scope, + }), + ).rejects.toMatchObject({ details: { reason: 'runtime-contract-invalid' } }); + expect(disposed).toHaveBeenCalledOnce(); + }, + ); +}); + +function gateway(registrations: readonly AppLogRuntimeProviderRegistration[]) { + return createComposedAppLogRuntimeGateway({ + modules: new Map(), + loadHost: async () => ({}) as AppLogRuntimeHost, + providerRuntimes: registrations.map(({ runtime }) => runtime), + providerModules: registrations, + }); +} + +function providerRuntime(options: { + ref: RuntimeOwnerRef; + provider?: string; + ownsDevice?: (device: DeviceInfo) => boolean; + mismatch?: 'owner' | 'device' | 'facts'; + disposed?: () => Promise; + load?: () => Promise>; +}): AppLogRuntimeProviderRegistration { + const owner = runtimeOwner(options); + const runtime: ProviderDeviceRuntime = { + provider: options.provider ?? 'limrun', + leaseLifecycle: {}, + deviceInventoryProvider: async () => null, + ownsDevice: options.ownsDevice ?? (() => true), + getInteractor: () => undefined, + shutdown: async () => {}, + }; + return { + runtime, + module: { + owner: options.ref as Extract, + loadRuntime: options.load ?? (async () => owner), + }, + }; +} + +function runtimeOwner(options: { + ref: RuntimeOwnerRef; + mismatch?: 'owner' | 'device' | 'facts'; + providerMode?: 'local' | 'transport-composed' | 'provider-runtime'; + disposed?: () => Promise; +}): DeviceRuntimeOwner { + return { + owner: options.ref, + ownsDevice: () => true, + bind: async () => binding(options), + shutdown: async () => {}, + }; +} + +function binding(options: { + ref: RuntimeOwnerRef; + mismatch?: 'owner' | 'device' | 'facts'; + providerMode?: 'local' | 'transport-composed' | 'provider-runtime'; + disposed?: () => Promise; +}): DeviceBinding { + const bindingDevice = options.mismatch === 'device' ? { ...device, id: 'wrong' } : device; + const bindingOwner = + options.mismatch === 'owner' ? providerRuntimeOwner('limrun', 'wrong') : options.ref; + return { + device: bindingDevice, + owner: bindingOwner, + facts: { + device: { + family: bindingDevice.platform, + appleOs: bindingDevice.appleOs, + kind: bindingDevice.kind, + target: bindingDevice.target, + providerMode: + options.mismatch === 'facts' + ? 'transport-composed' + : (options.providerMode ?? 'provider-runtime'), + }, + operations: unavailableFacts(), + }, + operations: {}, + [Symbol.asyncDispose]: options.disposed ?? (async () => {}), + }; +} + +function unavailableFacts() { + const unavailable = { available: false, reason: 'unsupported-provider-mode' } as const; + return { + appLogInspect: unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + }; +} diff --git a/src/platform-runtime-app-log.ts b/src/platform-runtime-app-log.ts new file mode 100644 index 0000000000..19efd342a5 --- /dev/null +++ b/src/platform-runtime-app-log.ts @@ -0,0 +1,264 @@ +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import type { + AppLogRuntimeHost, + AppLogRuntimeOperations, + AppLogRuntimePlatformModule, + AppLogRuntimeProviderModule, + DeviceBinding, + DeviceBindingRequest, + DeviceRuntimeGateway, + DeviceRuntimeOwner, + RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { createUnavailableAppLogBinding } from '@agent-device/capture-kit'; +import { + providerRuntimeOwner, + runtimeOwnerKey, + sameRuntimeOwner, +} from '@agent-device/contracts/platform'; +import { + deviceIdentity, + deviceShape, + sameDeviceIdentity, + sameDeviceShape, + type DeviceInfo, + type Platform, +} from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +export type AppLogRuntimeProviderRegistration = Readonly<{ + runtime: ProviderDeviceRuntime; + module: AppLogRuntimeProviderModule; +}>; + +export function createComposedAppLogRuntimeGateway(options: { + modules: ReadonlyMap; + loadHost: () => Promise; + providerRuntimes?: readonly ProviderDeviceRuntime[]; + providerModules?: readonly AppLogRuntimeProviderRegistration[]; +}): DeviceRuntimeGateway { + const providersByOwner = new Map(); + const modulesByRuntime = new Map(); + for (const registration of options.providerModules ?? []) { + const { runtime, module } = registration; + const key = runtimeOwnerKey(module.owner); + if (module.owner.provider !== runtime.provider) { + throw runtimeContractError(`Provider app-log runtime metadata is invalid: ${key}`); + } + if (providersByOwner.has(key)) { + throw runtimeContractError(`Duplicate app-log runtime owner: ${key}`); + } + if (modulesByRuntime.has(runtime)) { + throw runtimeContractError(`Duplicate app-log module registration for ${runtime.provider}`); + } + providersByOwner.set(key, registration); + modulesByRuntime.set(runtime, module); + } + const localLoads = new Map>>(); + const providerLoads = new Map< + AppLogRuntimeProviderModule, + Promise> + >(); + const loadedOwners = new Map>(); + let hostLoad: Promise | undefined; + const loadHost = async () => { + hostLoad ??= options.loadHost(); + try { + return await hostLoad; + } catch (error) { + hostLoad = undefined; + throw error; + } + }; + const registerOwner = (owner: DeviceRuntimeOwner) => { + const key = runtimeOwnerKey(owner.owner); + const existing = loadedOwners.get(key); + if (existing && existing !== owner) { + throw runtimeContractError(`Duplicate app-log runtime owner: ${key}`); + } + loadedOwners.set(key, owner); + return owner; + }; + const loadLocal = async (family: Platform) => { + const existing = localLoads.get(family); + if (existing) return await existing; + const module = options.modules.get(family); + if (!module) throw runtimeContractError(`Missing app-log module for ${family}`); + if (module.family !== family) { + throw runtimeContractError( + `App-log module registered for ${family} declares ${module.family}`, + ); + } + const pending = loadHost().then(async (host) => { + const owner = await module.loadRuntime(host); + if (owner.owner.kind !== 'local-family' || owner.owner.family !== family) { + throw runtimeContractError(`App-log module for ${family} returned a different local owner`); + } + return registerOwner(owner); + }); + localLoads.set(family, pending); + try { + return await pending; + } catch (error) { + if (localLoads.get(family) === pending) localLoads.delete(family); + throw error; + } + }; + const loadProvider = async (module: AppLogRuntimeProviderModule) => { + const existing = providerLoads.get(module); + if (existing) return await existing; + const pending = loadHost().then(async (host) => { + const owner = await module.loadRuntime(host); + if (!sameRuntimeOwner(owner.owner, module.owner)) { + throw runtimeContractError( + 'Provider app-log runtime returned a different advertised owner', + ); + } + return registerOwner(owner); + }); + providerLoads.set(module, pending); + try { + return await pending; + } catch (error) { + if (providerLoads.get(module) === pending) providerLoads.delete(module); + throw error; + } + }; + + return Object.freeze({ + bind: async (request) => { + if (request.intent.kind === 'exact-owner') { + const selected = await selectExactOwner( + request.intent.owner, + providersByOwner, + loadProvider, + loadLocal, + ); + return await bindAndValidate(selected, request); + } + const matchingProviders = (options.providerRuntimes ?? []).filter((runtime) => + runtime.ownsDevice(request.device), + ); + if (matchingProviders.length > 1) { + throw runtimeContractError('Multiple provider runtimes claim the selected device'); + } + const provider = matchingProviders[0]; + if (provider) { + const module = modulesByRuntime.get(provider); + if (!module) { + return unavailableProviderBinding(provider, request.device); + } + return await bindAndValidate(await loadProvider(module), request); + } + return await bindAndValidate(await loadLocal(request.device.platform), request); + }, + shutdown: async () => { + await Promise.allSettled( + [...loadedOwners.values()].map(async (owner) => await owner.shutdown()), + ); + loadedOwners.clear(); + localLoads.clear(); + providerLoads.clear(); + }, + }); +} + +async function bindAndValidate( + owner: DeviceRuntimeOwner, + request: DeviceBindingRequest, +): Promise> { + const binding = await owner.bind(request); + const failure = bindingContractFailure(owner, binding, request); + if (!failure) return binding; + let cleanupError: string | undefined; + try { + await binding[Symbol.asyncDispose](); + } catch (error) { + cleanupError = error instanceof Error ? error.message : String(error); + } + throw runtimeContractError(failure, cleanupError); +} + +function bindingContractFailure( + selected: DeviceRuntimeOwner, + binding: DeviceBinding, + request: DeviceBindingRequest, +): string | undefined { + if (!sameRuntimeOwner(binding.owner, selected.owner)) { + return 'App-log binding returned a different owner than the selected runtime'; + } + if (!matchesRequestedOwner(binding.owner, request)) { + return 'Exact app-log binding returned a different persisted owner'; + } + if (!sameDeviceIdentity(deviceIdentity(binding.device), deviceIdentity(request.device))) { + return 'App-log binding returned a different device identity'; + } + if (!factsMatchBindingIdentity(binding)) { + return 'App-log binding facts do not match its device and owner'; + } + return undefined; +} + +function matchesRequestedOwner(owner: RuntimeOwnerRef, request: DeviceBindingRequest): boolean { + return request.intent.kind !== 'exact-owner' || sameRuntimeOwner(owner, request.intent.owner); +} + +function factsMatchBindingIdentity(binding: DeviceBinding): boolean { + const { device, owner, facts } = binding; + return ( + sameDeviceShape(facts.device, deviceShape(device)) && + providerModeMatchesOwner(facts.device.providerMode, owner) + ); +} + +function providerModeMatchesOwner( + mode: DeviceBinding['facts']['device']['providerMode'], + owner: RuntimeOwnerRef, +): boolean { + return owner.kind === 'local-family' + ? mode === 'local' || mode === 'transport-composed' + : mode === 'provider-runtime'; +} + +async function selectExactOwner( + ref: RuntimeOwnerRef, + providersByOwner: ReadonlyMap, + loadProvider: ( + module: AppLogRuntimeProviderModule, + ) => Promise>, + loadLocal: (family: Platform) => Promise>, +): Promise> { + if (ref.kind === 'local-family') { + const owner = await loadLocal(ref.family); + if (sameRuntimeOwner(owner.owner, ref)) return owner; + throw ownerUnavailable(ref); + } + const registration = providersByOwner.get(runtimeOwnerKey(ref)); + if (registration) return await loadProvider(registration.module); + throw ownerUnavailable(ref); +} + +function unavailableProviderBinding( + runtime: ProviderDeviceRuntime, + device: DeviceInfo, +): DeviceBinding { + const owner = providerRuntimeOwner(runtime.provider, 'default'); + return createUnavailableAppLogBinding(device, owner, { + available: false, + reason: 'unsupported-provider-mode', + }); +} + +function ownerUnavailable(owner: RuntimeOwnerRef): AppError { + return new AppError('UNSUPPORTED_OPERATION', 'The exact app-log runtime owner is unavailable.', { + reason: 'owner-unavailable', + owner: runtimeOwnerKey(owner), + }); +} + +function runtimeContractError(message: string, cleanupError?: string): AppError { + return new AppError('COMMAND_FAILED', message, { + reason: 'runtime-contract-invalid', + ...(cleanupError ? { cleanupError } : {}), + }); +} diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts index 32f4d8f5c8..ea9403f887 100644 --- a/src/platform-runtime.ts +++ b/src/platform-runtime.ts @@ -1,14 +1,43 @@ -import type { ProviderDeviceInventorySource } from '@agent-device/contracts/device'; +import type { + ProviderDeviceInventorySource, + ProviderDeviceRuntime, +} from '@agent-device/contracts/device'; import { createPlatformModuleRegistry, + type AppLogRuntimeOperations, + type AppLogRuntimePlatformModule, + type AppLogSessionArtifacts, type ComposedDeviceInventoryGateways, + type DeviceRuntimeGateway, } from '@agent-device/contracts/platform'; -import { inventoryModule as appleInventoryModule } from '@agent-device/platform-apple'; -import { createAndroidInventoryModule } from '@agent-device/platform-android'; -import { createHarmonyInventoryModule } from '@agent-device/platform-harmonyos'; -import { inventoryModule as vegaInventoryModule } from '@agent-device/platform-vega'; -import { inventoryModule as linuxInventoryModule } from '@agent-device/platform-linux'; -import { inventoryModule as webInventoryModule } from '@agent-device/platform-web'; +import { + inventoryModule as appleInventoryModule, + runtimeModule as appleRuntimeModule, +} from '@agent-device/platform-apple'; +import { + createAndroidInventoryModule, + runtimeModule as androidRuntimeModule, +} from '@agent-device/platform-android'; +import { + createHarmonyInventoryModule, + runtimeModule as harmonyosRuntimeModule, +} from '@agent-device/platform-harmonyos'; +import { + inventoryModule as vegaInventoryModule, + runtimeModule as vegaRuntimeModule, +} from '@agent-device/platform-vega'; +import { + inventoryModule as linuxInventoryModule, + runtimeModule as linuxRuntimeModule, +} from '@agent-device/platform-linux'; +import { + inventoryModule as webInventoryModule, + runtimeModule as webRuntimeModule, +} from '@agent-device/platform-web'; +import { + createComposedAppLogRuntimeGateway, + type AppLogRuntimeProviderRegistration, +} from './platform-runtime-app-log.ts'; import { createComposedDeviceInventoryGateways } from './platform-runtime-device-inventory.ts'; const androidInventoryModule = createAndroidInventoryModule({ @@ -43,6 +72,42 @@ export function createPlatformDeviceInventoryGateways( }); } +const appLogRuntimeModules: ReadonlyMap = new Map< + Platform, + AppLogRuntimePlatformModule +>([ + ['apple', appleRuntimeModule], + ['android', androidRuntimeModule], + ['harmonyos', harmonyosRuntimeModule], + ['vega', vegaRuntimeModule], + ['linux', linuxRuntimeModule], + ['web', webRuntimeModule], +]); + +type Platform = AppLogRuntimePlatformModule['family']; + +export function createPlatformAppLogRuntimeGateway( + options: Readonly<{ + providerRuntimes?: readonly ProviderDeviceRuntime[]; + providerModules?: readonly AppLogRuntimeProviderRegistration[]; + resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; + sessionsDir: string; + }>, +): DeviceRuntimeGateway { + return createComposedAppLogRuntimeGateway({ + modules: appLogRuntimeModules, + loadHost: async () => { + const { createAppLogRuntimeHost } = await import('./platform-runtime-app-log-host.ts'); + return createAppLogRuntimeHost({ + sessionsDir: options.sessionsDir, + resolveSessionArtifacts: options.resolveSessionArtifacts, + }); + }, + providerRuntimes: options.providerRuntimes, + providerModules: options.providerModules, + }); +} + function configuredValues(...values: Array): string[] { return values.flatMap((value) => { const configured = value?.trim(); diff --git a/src/platforms/android/__tests__/adb-executor.test.ts b/src/platforms/android/__tests__/adb-executor.test.ts index 696fca9621..2b5b2cbda7 100644 --- a/src/platforms/android/__tests__/adb-executor.test.ts +++ b/src/platforms/android/__tests__/adb-executor.test.ts @@ -28,6 +28,7 @@ import { pullAndroidAdbFile, resolveAndroidAdbExecutor, resolveAndroidAdbProvider, + resolveScopedAndroidAdbBackgroundTransport, withAndroidAdbProvider, type AndroidAdbProvider, } from '../adb-executor.ts'; @@ -122,6 +123,46 @@ test('scoped provider only resolves for the matching device serial', async () => ); }); +test('scoped background transport resolves only an explicitly supplied matching spawner', async () => { + const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + } as const; + const spawn = vi.fn>(); + + await withAndroidAdbProvider( + { + exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + spawn, + }, + { serial: device.id }, + async () => { + assert.deepEqual(resolveScopedAndroidAdbBackgroundTransport(device), { + mode: 'transport-composed', + spawn, + }); + assert.deepEqual( + resolveScopedAndroidAdbBackgroundTransport({ ...device, id: 'other-device' }), + { + mode: 'local', + }, + ); + }, + ); + + await withAndroidAdbProvider( + async () => ({ stdout: '', stderr: '', exitCode: 0 }), + { serial: device.id }, + async () => + assert.deepEqual(resolveScopedAndroidAdbBackgroundTransport(device), { + mode: 'transport-composed', + }), + ); +}); + test('createLocalAndroidAdbProvider exposes exec, spawn, and reverse over local adb', async () => { mockRunCmd.mockClear(); mockRunCmdBackground.mockClear(); diff --git a/src/platforms/android/__tests__/ime-lifecycle.test.ts b/src/platforms/android/__tests__/ime-lifecycle.test.ts index 2d13e9c3af..dc32fa3311 100644 --- a/src/platforms/android/__tests__/ime-lifecycle.test.ts +++ b/src/platforms/android/__tests__/ime-lifecycle.test.ts @@ -265,7 +265,13 @@ test('session teardown fails when a real IME restore reports set-failed', async await activateAndroidTestIme(ANDROID_EMULATOR, { stateDir }); state.blockImeSetTo(LATIN_IME); await assert.rejects( - async () => await teardownSessionResources(session, session.name, stateDir), + async () => + await teardownSessionResources({ + appLog: 'already-settled', + session, + sessionName: session.name, + stateDir, + }), /android_ime: Android test IME could not be restored/, ); }); diff --git a/src/platforms/android/adb-executor.ts b/src/platforms/android/adb-executor.ts index 244f788a29..faeb2025be 100644 --- a/src/platforms/android/adb-executor.ts +++ b/src/platforms/android/adb-executor.ts @@ -1,10 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { Readable, Writable } from 'node:stream'; -import { - ANDROID_ADB_TIMEOUT_FAILURE, - classifyAndroidAdbFailure, - type AndroidAdbFailureClassification, -} from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Rect } from '@agent-device/kernel/snapshot'; import type { AndroidSnapshotHelperArtifact } from './snapshot-helper-types.ts'; @@ -23,6 +18,112 @@ import { } from '../../utils/exec.ts'; import { AppError } from '@agent-device/kernel/errors'; +export type AdbFailureClassification = Readonly<{ + reason: + | 'timeout' + | 'device_offline' + | 'device_unauthorized' + | 'device_not_found' + | 'multiple_devices' + | 'no_devices' + | 'connection_dropped' + | 'server_version_mismatch' + | 'install_insufficient_storage' + | 'install_update_incompatible' + | 'install_version_downgrade' + | 'install_failed'; + hint: string; + retriable?: boolean; +}>; + +type AdbFailureMatcher = AdbFailureClassification & + Readonly<{ pattern: RegExp; matchStdout?: boolean }>; + +const ADB_FAILURE_MATCHERS: readonly AdbFailureMatcher[] = [ + { + reason: 'device_unauthorized', + pattern: /device unauthorized|device still authorizing/, + hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.', + }, + { + reason: 'device_offline', + pattern: /device offline/, + hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.', + retriable: true, + }, + { + reason: 'multiple_devices', + pattern: /more than one (?:device\/emulator|device and emulator)/, + hint: 'Multiple Android devices are connected — pass --serial (see adb devices) to select one.', + }, + { + reason: 'no_devices', + pattern: /no devices\/emulators found|no devices found/, + hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.', + }, + { + reason: 'device_not_found', + pattern: /device (?:'[^']*' )?not found/, + hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.', + retriable: true, + }, + { + reason: 'server_version_mismatch', + pattern: /adb server version \(\d+\) doesn't match this client/, + hint: 'Multiple adb installs conflict — adb restarts its server automatically, so retry; align PATH to a single adb to stop recurrences.', + retriable: true, + }, + { + reason: 'connection_dropped', + pattern: /transport error|connection reset|broken pipe|protocol fault/, + hint: 'The adb connection dropped — retry; if it persists, run adb kill-server and reconnect the device.', + retriable: true, + }, + { + reason: 'install_insufficient_storage', + pattern: /install_failed_insufficient_storage/, + hint: 'The device is out of storage — free up space or uninstall unused apps, then retry the install.', + matchStdout: true, + }, + { + reason: 'install_update_incompatible', + pattern: /install_failed_update_incompatible/, + hint: 'The installed app has an incompatible signature — uninstall the existing app first, then retry the install.', + matchStdout: true, + }, + { + reason: 'install_version_downgrade', + pattern: /install_failed_version_downgrade/, + hint: 'The APK is older than the installed app — uninstall the app first (or install with downgrade allowed), then retry.', + matchStdout: true, + }, + { + reason: 'install_failed', + pattern: /install_failed_\w+|install_parse_failed_\w+/, + hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.', + matchStdout: true, + }, +]; + +const ANDROID_ADB_TIMEOUT_FAILURE: AdbFailureClassification = Object.freeze({ + reason: 'timeout', + hint: 'adb timed out — the adb server may be wedged. Run adb kill-server && adb start-server, check adb devices, then retry.', +}); + +export function classifyAdbFailure( + stderr: string, + stdout = '', +): AdbFailureClassification | undefined { + const stderrText = stderr.toLowerCase(); + const stdoutText = stdout.toLowerCase(); + for (const { pattern, matchStdout, ...classification } of ADB_FAILURE_MATCHERS) { + if (pattern.test(stderrText) || (matchStdout && pattern.test(stdoutText))) { + return classification; + } + } + return undefined; +} + export type AndroidAdbExecutorOptions = Pick< ExecOptions, 'allowFailure' | 'timeoutMs' | 'binaryStdout' | 'stdin' | 'signal' @@ -181,15 +282,6 @@ type AndroidAdbProviderScope = { const androidAdbProviderScope = new AsyncLocalStorage(); -export type AdbFailureClassification = AndroidAdbFailureClassification; - -/** - * Maps well-known adb failure output to an actionable hint (and a `retriable` - * flag for clearly transient families). Matches stderr; install verdicts also - * match stdout. Returns undefined for unrecognized output. - */ -export const classifyAdbFailure = classifyAndroidAdbFailure; - /** * Enriches a failed adb command error in place with the classified hint, * `retriable` flag, and machine-readable `adbFailure` family, so every adb call @@ -373,6 +465,26 @@ export function resolveAndroidAdbProvider( : createLocalAndroidAdbProvider(device); } +/** + * Returns only the request-scoped provider background transport for this device. + * Unlike {@link resolveAndroidAdbProvider}, this never falls back to host adb: callers + * use absence to keep provider-backed long-lived processes fail-closed. + */ +export type ScopedAndroidAdbBackgroundTransport = + | Readonly<{ mode: 'local' }> + | Readonly<{ mode: 'transport-composed'; spawn?: AndroidAdbSpawner }>; + +export function resolveScopedAndroidAdbBackgroundTransport( + device: DeviceInfo, +): ScopedAndroidAdbBackgroundTransport { + const scoped = androidAdbProviderScope.getStore(); + if (scoped?.serial !== device.id) return { mode: 'local' }; + return { + mode: 'transport-composed', + ...(scoped.provider.spawn ? { spawn: scoped.provider.spawn } : {}), + }; +} + export function resolveAndroidTextInjector(device: DeviceInfo): AndroidTextInjector | undefined { const scoped = androidAdbProviderScope.getStore(); return scoped?.serial === device.id ? scoped.provider.text : undefined; diff --git a/src/platforms/android/emulator-lifecycle.ts b/src/platforms/android/emulator-lifecycle.ts index 735ae4ee5e..bc28580d44 100644 --- a/src/platforms/android/emulator-lifecycle.ts +++ b/src/platforms/android/emulator-lifecycle.ts @@ -1,8 +1,4 @@ -import { - isAndroidEmulatorSerial, - normalizeAndroidDeviceName, - type DeviceInventoryRequest, -} from '@agent-device/contracts/device'; +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError, asAppError } from '@agent-device/kernel/errors'; import type { ExecResult } from '../../utils/exec.ts'; @@ -16,6 +12,7 @@ const ANDROID_BOOT_POLL_MS = 1_000; const ANDROID_BOOT_PROP_TIMEOUT_MS = 10_000; const ANDROID_EMULATOR_BOOT_POLL_MS = 1_000; const ANDROID_EMULATOR_BOOT_TIMEOUT_MS = 120_000; +const ANDROID_EMULATOR_SERIAL_PREFIX = 'emulator-'; export type AndroidEmulatorLifecycleDependencies = Readonly<{ discoverLocal: (request: DeviceInventoryRequest) => Promise; @@ -115,6 +112,14 @@ function isRunningEmulator(device: DeviceInfo): boolean { return isAndroidEmulatorSerial(device.id); } +function isAndroidEmulatorSerial(serial: string): boolean { + return serial.startsWith(ANDROID_EMULATOR_SERIAL_PREFIX); +} + +function normalizeAndroidDeviceName(value: string): string { + return value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); +} + async function waitForEmulatorDiscovery(params: { dependencies: AndroidEmulatorLifecycleDependencies; avdName: string; diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index fe354aef10..2f7a86e383 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -86,7 +86,6 @@ const APPLE_SUPPORTS_BY_DEFAULT: Record boolean> [PUBLIC_COMMANDS.install]: supportsAppInstallation, [PUBLIC_COMMANDS.reinstall]: supportsAppInstallation, [PUBLIC_COMMANDS.installFromSource]: supportsAppInstallation, - [PUBLIC_COMMANDS.logs]: supportsCoreDevicePhysicalOperation, [PUBLIC_COMMANDS.perf]: supportsCoreDevicePhysicalOperation, [PUBLIC_COMMANDS.record]: supportsCoreDevicePhysicalOperation, [PUBLIC_COMMANDS.push]: supportsAppAndDeviceLifecycle, @@ -113,7 +112,6 @@ const APPLE_UNSUPPORTED_HINT_BY_DEFAULT: Record< [PUBLIC_COMMANDS.install]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.reinstall]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.installFromSource]: coreDeviceOnlyPhysicalOperationHint, - [PUBLIC_COMMANDS.logs]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.perf]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.record]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.viewport]: (device) => @@ -153,12 +151,6 @@ export const applePlugin = { supportsByDefault: APPLE_SUPPORTS_BY_DEFAULT, unsupportedHintByDefault: APPLE_UNSUPPORTED_HINT_BY_DEFAULT, }, - // Wraps the Apple arm of `resolveLogBackend` verbatim: macOS -> 'macos'; - // an iOS `device` -> 'ios-device'; every other iOS kind -> 'ios-simulator'. - appLog: { - resolveBackend: (device: DeviceInfo) => - isMacOs(device) ? 'macos' : device.kind === 'device' ? 'ios-device' : 'ios-simulator', - }, // Wraps the Apple arm of `supportsPlatformPerfMetrics`: every Apple device // (ios/macos, any kind/target) reports perf-metrics support. `metricsSamplerTag` // wraps the else-arm of the former `buildPerfResponseData` sampling branch: every @@ -166,7 +158,7 @@ export const applePlugin = { perf: { supportsMetrics: () => true, metricsSamplerTag: () => 'apple' }, // Wraps the Apple arm of `resolveRecordingBackendForDevice` verbatim: macOS -> // 'macos'; an iOS `device` -> 'ios-device'; every other iOS kind (simulator, incl. - // tvOS/iPadOS/visionOS) -> 'ios-simulator'. Mirrors the appLog resolveBackend shape. + // tvOS/iPadOS/visionOS) -> 'ios-simulator'. recording: { resolveBackendTag: (device: DeviceInfo) => isMacOs(device) ? 'macos' : device.kind === 'device' ? 'ios-device' : 'ios-simulator', diff --git a/src/platforms/harmonyos/hdc.ts b/src/platforms/harmonyos/hdc.ts index 4b24ede5b0..4afd5c451a 100644 --- a/src/platforms/harmonyos/hdc.ts +++ b/src/platforms/harmonyos/hdc.ts @@ -1,8 +1,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { runCmd, whichCmd, type ExecOptions, type ExecResult } from '../../utils/exec.ts'; +import { runCmd, type ExecOptions, type ExecResult } from '../../utils/exec.ts'; export type HarmonyHdcOptions = Pick< ExecOptions, @@ -23,14 +22,6 @@ export async function runHarmonyHdc( }); } -export async function ensureHdcAvailable(): Promise { - await ensureHarmonyToolchainPathConfigured(); - if (await whichCmd('hdc')) return; - throw new AppError('TOOL_MISSING', 'hdc not found in PATH', { - hint: 'Install HarmonyOS Command Line Tools, then add its sdk/default/openharmony/toolchains directory to PATH.', - }); -} - /** * DevEco's command-line tools do not amend PATH for non-interactive processes. * Honor the documented roots so the daemon sees the same HDC binary as a shell. @@ -60,17 +51,3 @@ export async function ensureHarmonyToolchainPathConfigured( const currentEntries = (env.PATH ?? '').split(path.delimiter).filter(Boolean); env.PATH = [...new Set([...executableRoots, ...currentEntries])].join(path.delimiter); } - -export function harmonyDeviceForTarget( - target: string, - options: { name: string; emulator: boolean }, -): DeviceInfo { - return { - platform: 'harmonyos', - id: target, - name: options.name, - kind: options.emulator ? 'emulator' : 'device', - target: 'mobile', - booted: true, - }; -} diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 82f66dffb5..31f5ae37e9 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,6 +1,7 @@ import type { DefaultCloudWebDriverProviderRuntimeEnv } from '@agent-device/provider-webdriver'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { LIMRUN_PROVIDER } from '@agent-device/provider-limrun'; +import type { AppLogRuntimeProviderRegistration } from './platform-runtime-app-log.ts'; import { providerWebDriver } from './provider-webdriver.ts'; export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & @@ -11,19 +12,34 @@ export const DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS = [ 'limrun' satisfies typeof LIMRUN_PROVIDER, ] as const; -export async function createDefaultProviderDeviceRuntimes( +export type DefaultProviderRuntimeComposition = Readonly<{ + runtimes: readonly ProviderDeviceRuntime[]; + appLogModules: readonly AppLogRuntimeProviderRegistration[]; +}>; + +export async function createDefaultProviderRuntimeComposition( env: DefaultProviderDeviceRuntimeEnv = process.env, -): Promise { +): Promise { const runtimes = providerWebDriver.createDefaultRuntimes(env); const apiKey = env.LIMRUN_API_KEY?.trim(); - if (!apiKey) return runtimes; + if (!apiKey) return Object.freeze({ runtimes, appLogModules: [] }); - const { LimrunRuntime } = await import('./provider-limrun-runtime.ts'); - return [ - ...runtimes, - new LimrunRuntime({ + const [limrunRuntime, dependencies] = await Promise.all([ + import('@agent-device/provider-limrun'), + import('./sdk/limrun-runtime-dependencies.ts'), + ]); + const registration = limrunRuntime.createLimrunRuntime( + { apiKey, region: env.LIMRUN_REGION?.trim() || undefined, - }), - ]; + }, + dependencies.createLimrunRuntimeDependencies(), + { includeAppLogModule: true }, + ); + return Object.freeze({ + runtimes: Object.freeze([...runtimes, registration.runtime]), + appLogModules: Object.freeze([ + { runtime: registration.runtime, module: registration.appLogModule }, + ]), + }); } diff --git a/src/utils/__tests__/app-log-files.test.ts b/src/utils/__tests__/app-log-files.test.ts new file mode 100644 index 0000000000..26910b7b36 --- /dev/null +++ b/src/utils/__tests__/app-log-files.test.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { ensureAppLogPath, rotateAppLogIfNeeded } from '../app-log-files.ts'; + +test('rotateAppLogIfNeeded rotates files and discards the oldest generation', () => { + const root = mkdtempForTestSync('agent-device-app-log-rotate-'); + const outPath = path.join(root, 'app.log'); + fs.writeFileSync(outPath, 'a'.repeat(20)); + fs.writeFileSync(`${outPath}.1`, 'old1'); + fs.writeFileSync(`${outPath}.2`, 'old2'); + + rotateAppLogIfNeeded(outPath, { maxBytes: 10, maxRotatedFiles: 2 }); + + expect(fs.existsSync(outPath)).toBe(false); + expect(fs.readFileSync(`${outPath}.1`, 'utf8')).toHaveLength(20); + expect(fs.readFileSync(`${outPath}.2`, 'utf8')).toBe('old1'); +}); + +test('ensureAppLogPath applies configured rotation and creates the parent directory', () => { + const root = mkdtempForTestSync('agent-device-app-log-ensure-'); + const outPath = path.join(root, 'session', 'app.log'); + ensureAppLogPath(outPath, {}); + expect(fs.existsSync(path.dirname(outPath))).toBe(true); + + fs.writeFileSync(outPath, 'content'); + ensureAppLogPath(outPath, { + AGENT_DEVICE_APP_LOG_MAX_BYTES: '1', + AGENT_DEVICE_APP_LOG_MAX_FILES: '1', + }); + expect(fs.readFileSync(`${outPath}.1`, 'utf8')).toBe('content'); +}); + +test('rotation rejects a final app.log symlink without touching its target', () => { + const root = mkdtempForTestSync('agent-device-app-log-rotate-symlink-'); + const outPath = path.join(root, 'app.log'); + const outsidePath = path.join(root, 'outside.log'); + fs.writeFileSync(outsidePath, 'outside'); + fs.symlinkSync(outsidePath, outPath); + + expect(() => rotateAppLogIfNeeded(outPath, { maxBytes: 1, maxRotatedFiles: 1 })).toThrow( + 'symbolic link', + ); + expect(fs.readFileSync(outsidePath, 'utf8')).toBe('outside'); + expect(fs.lstatSync(outPath).isSymbolicLink()).toBe(true); +}); diff --git a/src/utils/__tests__/verified-file.test.ts b/src/utils/__tests__/verified-file.test.ts new file mode 100644 index 0000000000..cb765cfd9f --- /dev/null +++ b/src/utils/__tests__/verified-file.test.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, test } from 'vitest'; +import { + openVerifiedFileForAppend, + openVerifiedFileForRead, + openVerifiedFileForTruncate, +} from '../verified-file.ts'; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +test('creates, appends, reads, and truncates only through verified descriptors', () => { + const pathname = fixturePath('regular'); + const append = openVerifiedFileForAppend(pathname); + fs.writeSync(append, 'first'); + fs.closeSync(append); + + const read = openVerifiedFileForRead(pathname); + expect(read).toBeTypeOf('number'); + expect(fs.readFileSync(read!, 'utf8')).toBe('first'); + fs.closeSync(read!); + + const truncate = openVerifiedFileForTruncate(pathname); + fs.writeSync(truncate, 'second'); + fs.closeSync(truncate); + expect(fs.readFileSync(pathname, 'utf8')).toBe('second'); +}); + +test.each(['read', 'append', 'truncate'] as const)( + 'rejects a final symlink before %s and preserves its target', + (operation) => { + const pathname = fixturePath(operation); + const outside = `${pathname}.outside`; + fs.writeFileSync(outside, 'outside'); + fs.symlinkSync(outside, pathname); + + expect(() => { + const descriptor = + operation === 'read' + ? openVerifiedFileForRead(pathname) + : operation === 'append' + ? openVerifiedFileForAppend(pathname) + : openVerifiedFileForTruncate(pathname); + if (descriptor !== undefined) fs.closeSync(descriptor); + }).toThrow('regular file'); + expect(fs.readFileSync(outside, 'utf8')).toBe('outside'); + }, +); + +test('returns absent for a missing read without creating the file', () => { + const pathname = fixturePath('missing'); + expect(openVerifiedFileForRead(pathname)).toBeUndefined(); + expect(fs.existsSync(pathname)).toBe(false); +}); + +function fixturePath(label: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-verified-${label}-`)); + roots.push(root); + return path.join(root, 'artifact'); +} diff --git a/src/utils/app-log-files.ts b/src/utils/app-log-files.ts new file mode 100644 index 0000000000..b06f9d1bc6 --- /dev/null +++ b/src/utils/app-log-files.ts @@ -0,0 +1,55 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_MAX_APP_LOG_BYTES = 5 * 1024 * 1024; +const DEFAULT_MAX_ROTATED_FILES = 1; + +export type AppLogRotationConfig = Readonly<{ + maxBytes: number; + maxRotatedFiles: number; +}>; + +/** Shared lower-level file policy for daemon markers and runtime output sinks. */ +export function ensureAppLogPath(outPath: string, env: NodeJS.ProcessEnv = process.env): void { + const directory = path.dirname(outPath); + if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true }); + assertAppLogFileIsNotSymbolicLink(outPath); + rotateAppLogIfNeeded(outPath, { + maxBytes: positiveIntEnv(env.AGENT_DEVICE_APP_LOG_MAX_BYTES, DEFAULT_MAX_APP_LOG_BYTES), + maxRotatedFiles: positiveIntEnv(env.AGENT_DEVICE_APP_LOG_MAX_FILES, DEFAULT_MAX_ROTATED_FILES), + }); +} + +export function rotateAppLogIfNeeded(outPath: string, config: AppLogRotationConfig): void { + const current = lstatAppLogFile(outPath); + if (!current || current.size < config.maxBytes) return; + for (let index = config.maxRotatedFiles; index >= 1; index -= 1) { + const from = index === 1 ? outPath : `${outPath}.${index - 1}`; + const to = `${outPath}.${index}`; + if (!lstatAppLogFile(from)) continue; + if (lstatAppLogFile(to)) fs.unlinkSync(to); + fs.renameSync(from, to); + } +} + +function assertAppLogFileIsNotSymbolicLink(outPath: string): void { + void lstatAppLogFile(outPath); +} + +function lstatAppLogFile(outPath: string): fs.Stats | undefined { + try { + const stats = fs.lstatSync(outPath); + if (stats.isSymbolicLink()) { + throw new Error(`App-log file must not be a symbolic link: ${outPath}`); + } + return stats; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +function positiveIntEnv(raw: string | undefined, fallback: number): number { + const parsed = Number.parseInt(raw ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/src/utils/managed-session-artifact-path.test.ts b/src/utils/managed-session-artifact-path.test.ts new file mode 100644 index 0000000000..e3f4e71ef8 --- /dev/null +++ b/src/utils/managed-session-artifact-path.test.ts @@ -0,0 +1,87 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, test } from 'vitest'; +import { requireManagedSessionArtifactPath } from './managed-session-artifact-path.ts'; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +test('accepts the named artifact below the sessions root', () => { + const root = temporaryRoot(); + const sessionsDir = path.join(root, 'sessions'); + const pathname = path.join(sessionsDir, 'one', 'app.log'); + fs.mkdirSync(path.dirname(pathname), { recursive: true }); + + expect( + requireManagedSessionArtifactPath({ + sessionsDir, + pathname, + basename: 'app.log', + label: 'App-log output', + }), + ).toBe(path.join(fs.realpathSync.native(path.dirname(pathname)), 'app.log')); +}); + +test('rejects a symlinked parent that escapes the sessions root', () => { + const root = temporaryRoot(); + const sessionsDir = path.join(root, 'sessions'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(sessionsDir); + fs.mkdirSync(outside); + fs.symlinkSync(outside, path.join(sessionsDir, 'linked')); + + expect(() => + requireManagedSessionArtifactPath({ + sessionsDir, + pathname: path.join(sessionsDir, 'linked', 'app-log.pid'), + basename: 'app-log.pid', + label: 'App-log process marker', + }), + ).toThrow('App-log process marker resolves outside the daemon-owned sessions directory'); +}); + +test('returns the verified real parent rather than a symlink alias', () => { + const root = temporaryRoot(); + const sessionsDir = path.join(root, 'sessions'); + const realSession = path.join(sessionsDir, 'real'); + const linkedSession = path.join(sessionsDir, 'linked'); + fs.mkdirSync(realSession, { recursive: true }); + fs.symlinkSync(realSession, linkedSession); + + expect( + requireManagedSessionArtifactPath({ + sessionsDir, + pathname: path.join(linkedSession, 'app-log.pid'), + basename: 'app-log.pid', + label: 'App-log process marker', + }), + ).toBe(path.join(fs.realpathSync.native(realSession), 'app-log.pid')); +}); + +test('accepts different lexical aliases for the same verified sessions root', () => { + const root = temporaryRoot(); + const realSessionsDir = path.join(root, 'real-sessions'); + const linkedSessionsDir = path.join(root, 'linked-sessions'); + const realSession = path.join(realSessionsDir, 'one'); + fs.mkdirSync(realSession, { recursive: true }); + fs.symlinkSync(realSessionsDir, linkedSessionsDir); + + expect( + requireManagedSessionArtifactPath({ + sessionsDir: linkedSessionsDir, + pathname: path.join(realSession, 'app-log.pid'), + basename: 'app-log.pid', + label: 'App-log process marker', + }), + ).toBe(path.join(fs.realpathSync.native(realSession), 'app-log.pid')); +}); + +function temporaryRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-artifact-')); + roots.push(root); + return root; +} diff --git a/src/utils/managed-session-artifact-path.ts b/src/utils/managed-session-artifact-path.ts new file mode 100644 index 0000000000..d58b1daed3 --- /dev/null +++ b/src/utils/managed-session-artifact-path.ts @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +type ManagedSessionArtifactPath = Readonly<{ + sessionsDir: string; + pathname: string; + basename: string; + label: string; +}>; + +export function requireManagedSessionArtifactPath(input: ManagedSessionArtifactPath): string { + const root = path.resolve(input.sessionsDir); + const resolved = path.resolve(input.pathname); + if (path.basename(resolved) !== input.basename) { + throw new Error(`${input.label} is outside the daemon-owned sessions directory`); + } + if (fs.existsSync(root) && fs.existsSync(path.dirname(resolved))) { + const realRoot = fs.realpathSync.native(root); + const realParent = fs.realpathSync.native(path.dirname(resolved)); + if (realParent !== realRoot && !realParent.startsWith(`${realRoot}${path.sep}`)) { + throw new Error(`${input.label} resolves outside the daemon-owned sessions directory`); + } + return path.join(realParent, input.basename); + } + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error(`${input.label} is outside the daemon-owned sessions directory`); + } + return resolved; +} diff --git a/src/utils/verified-file.ts b/src/utils/verified-file.ts new file mode 100644 index 0000000000..60e6ac38e0 --- /dev/null +++ b/src/utils/verified-file.ts @@ -0,0 +1,125 @@ +import fs from 'node:fs'; + +/** Opens a regular final-path file for verified reads, or returns absent. */ +export function openVerifiedFileForRead(pathname: string): number | undefined { + return openVerifiedFile(pathname, fs.constants.O_RDONLY, false); +} + +/** Opens or creates a verified regular final-path file with atomic append semantics. */ +export function openVerifiedFileForAppend(pathname: string): number { + return openVerifiedFile(pathname, fs.constants.O_WRONLY | fs.constants.O_APPEND, true)!; +} + +/** Opens or creates a verified regular final-path file, then truncates only through that fd. */ +export function openVerifiedFileForTruncate(pathname: string): number { + const descriptor = openVerifiedFile(pathname, fs.constants.O_RDWR, true)!; + try { + fs.ftruncateSync(descriptor, 0); + return descriptor; + } catch (error) { + fs.closeSync(descriptor); + throw error; + } +} + +function openVerifiedFile( + pathname: string, + accessFlags: number, + create: boolean, +): number | undefined { + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = attemptVerifiedOpen(pathname, accessFlags, create); + if (result.status === 'retry') continue; + return result.status === 'missing' ? undefined : result.descriptor; + } + throw new Error(`Final file could not be opened without an identity race: ${pathname}`); +} + +type VerifiedOpenAttempt = + | Readonly<{ status: 'opened'; descriptor: number }> + | Readonly<{ status: 'missing' }> + | Readonly<{ status: 'retry' }>; + +function attemptVerifiedOpen( + pathname: string, + accessFlags: number, + create: boolean, +): VerifiedOpenAttempt { + const before = lstatRegularFile(pathname); + if (!before && !create) return { status: 'missing' }; + const opened = openCandidate(pathname, accessFlags, before === undefined, create); + if (typeof opened !== 'number') return opened; + try { + assertOpenedIdentity(pathname, opened, before); + return { status: 'opened', descriptor: opened }; + } catch (error) { + fs.closeSync(opened); + throw error; + } +} + +function openCandidate( + pathname: string, + accessFlags: number, + creating: boolean, + create: boolean, +): number | Readonly<{ status: 'missing' | 'retry' }> { + try { + return fs.openSync( + pathname, + accessFlags | + optionalNoFollowFlag() | + (creating ? fs.constants.O_CREAT | fs.constants.O_EXCL : 0), + 0o600, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const recovery = recoverableOpenFailure(code, creating, create); + if (recovery) return recovery; + throw error; + } +} + +function recoverableOpenFailure( + code: string | undefined, + creating: boolean, + create: boolean, +): Readonly<{ status: 'missing' | 'retry' }> | undefined { + if (code === 'EEXIST') return creating ? { status: 'retry' } : undefined; + if (code !== 'ENOENT') return undefined; + if (creating) return undefined; + return { status: create ? 'retry' : 'missing' }; +} + +function assertOpenedIdentity(pathname: string, descriptor: number, before?: fs.Stats): void { + const opened = fs.fstatSync(descriptor); + const after = lstatRegularFile(pathname); + if (!opened.isFile()) throw identityChangedError(pathname); + if (!after) throw identityChangedError(pathname); + if (!sameFile(opened, after)) throw identityChangedError(pathname); + if (before && !sameFile(before, opened)) throw identityChangedError(pathname); +} + +function identityChangedError(pathname: string): Error { + return new Error(`Final file identity changed while it was opened: ${pathname}`); +} + +function lstatRegularFile(pathname: string): fs.Stats | undefined { + try { + const stats = fs.lstatSync(pathname); + if (!stats.isFile()) throw new Error(`Final path must be a regular file: ${pathname}`); + return stats; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +function sameFile(left: fs.Stats, right: fs.Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function optionalNoFollowFlag(): number { + const noFollow = fs.constants.O_NOFOLLOW; + return Number.isInteger(noFollow) && noFollow > 0 ? noFollow : 0; +} diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index b7257a2b28..4af87c3d91 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -42,7 +42,7 @@ test( assertAndroidProviderContract(world); }); }, - 15_000, + 25_000, ); test('Provider-backed Android reads keep chrome provenance internal across public node payloads', async () => { diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index e1a87b68da..c7c8e77bf1 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -13,7 +13,7 @@ import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, androidSnapshotHelperOutput, } from '../../../src/__tests__/test-utils/index.ts'; -import { runCmd } from '../../../src/utils/exec.ts'; +import { runCmd, runCmdBackground } from '../../../src/utils/exec.ts'; import { validPng } from './assertions.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; import { @@ -73,6 +73,7 @@ export async function createAndroidSettingsWorld(options?: { ); const apkPath = path.join(tempRoot, 'Demo.apk'); const aabPath = path.join(tempRoot, 'Demo.aab'); + const logcatProcessPath = createScriptedLogcatExecutable(tempRoot); const previousAppEventTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = 'demo://agent-device/event?name={event}&payload={payload}&platform={platform}'; @@ -115,33 +116,9 @@ export async function createAndroidSettingsWorld(options?: { bundleInstallCalls.push({ bundlePath, mode: bundleOptions.mode }); }, spawn: (args) => { - const child = makeMockAdbProcess(); + if (!args.includes('logcat')) return makeMockAdbProcess(args); + const child = makeScriptedLogcatProcess(logcatProcessPath, args); spawnedLogcat.push(child); - queueMicrotask(() => { - if (args.includes('logcat')) { - child.stdout?.push(`I/AgentDevice(4242): ${args.join(' ')}\n`); - child.stdout?.push( - [ - '04-01 10:00:15.000 D/Network(4242):', - JSON.stringify({ - method: 'POST', - url: 'https://api.example.com/v1/login', - status: 401, - headers: { 'x-id': 'abc' }, - requestBody: { email: 'test@example.com' }, - responseBody: { error: 'bad_credentials' }, - }), - '\n', - ].join(' '), - ); - return; - } - child.stdout?.push(`I/AgentDevice(4242): ${args.join(' ')}\n`); - child.stdout?.push(null); - child.stderr?.push(null); - child.emit('exit', 0, null); - child.emit('close', 0, null); - }); return child; }, }; @@ -153,6 +130,7 @@ export async function createAndroidSettingsWorld(options?: { }; } const daemon = await createProviderScenarioHarness({ + platformAppLogRuntime: true, androidAdbProvider: () => adbProvider, deviceInventoryProvider: async (request) => { inventoryRequests.push({ ...request }); @@ -186,6 +164,9 @@ export async function createAndroidSettingsWorld(options?: { closed = true; restoreEnv('AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE', previousAppEventTemplate); hostAdbGuard.restore(); + for (const child of spawnedLogcat) { + if (!child.killed && typeof child.exitCode !== 'number') child.kill('SIGKILL'); + } fs.rmSync(tempRoot, { recursive: true, force: true }); await daemon.close(); }, @@ -414,7 +395,7 @@ function androidMetricsAdbResult(key: string): AndroidAdbResult | undefined { return { stdout: [ 'Uptime: 10000', - 'Stats since: 9000000000', + 'Stats since: 5000000000', 'Total frames rendered: 4', 'Janky frames: 1 (25.00%)', ].join('\n'), @@ -568,7 +549,16 @@ function escapeXml(value: string): string { .replaceAll('>', '>'); } -function makeMockAdbProcess(): EventEmitter & AndroidAdbProcess { +function makeScriptedLogcatProcess(executable: string, args: string[]): AndroidAdbProcess { + const background = runCmdBackground(executable, args, { + allowFailure: true, + captureOutput: false, + }); + void background.wait.catch(() => undefined); + return background.child; +} + +function makeMockAdbProcess(args: string[]): EventEmitter & AndroidAdbProcess { const child = new EventEmitter() as EventEmitter & AndroidAdbProcess; child.stdin = null; child.stdout = new PassThrough(); @@ -582,9 +572,42 @@ function makeMockAdbProcess(): EventEmitter & AndroidAdbProcess { queueMicrotask(() => child.emit('close', 0, null)); return true; }; + queueMicrotask(() => { + child.stdout?.push(`I/AgentDevice(4242): ${args.join(' ')}\n`); + child.stdout?.push(null); + child.stderr?.push(null); + child.emit('exit', 0, null); + child.emit('close', 0, null); + }); return child; } +function createScriptedLogcatExecutable(tempRoot: string): string { + const executable = path.join(tempRoot, 'provider-logcat'); + const networkEntry = JSON.stringify({ + method: 'POST', + url: 'https://api.example.com/v1/login', + status: 401, + headers: { 'x-id': 'abc' }, + requestBody: { email: 'test@example.com' }, + responseBody: { error: 'bad_credentials' }, + }); + fs.writeFileSync( + executable, + [ + '#!/bin/sh', + 'printf "I/AgentDevice(4242): provider logcat\\n"', + `printf '%s\\n' '04-01 10:00:15.000 D/Network(4242): ${networkEntry}'`, + 'trap \'test -n "$child" && kill "$child" 2>/dev/null; exit 0\' INT TERM', + 'while :; do sleep 10 & child=$!; wait "$child"; done', + '', + ].join('\n'), + 'utf8', + ); + fs.chmodSync(executable, 0o755); + return executable; +} + export async function waitForFileContent(filePath: string, expected: string): Promise { const deadline = Date.now() + 1_000; while (Date.now() < deadline) { diff --git a/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts b/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts new file mode 100644 index 0000000000..44480bd633 --- /dev/null +++ b/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; +import { createProviderScenarioHarness } from './harness.ts'; +import { createRecordingAppleToolProvider } from './providers.ts'; + +test('provider-inventory Apple logs doctor stays on the scoped Apple tool provider', async () => { + const appleTool = createRecordingAppleToolProvider({ + simctl: async (args) => { + assert.deepEqual(args, ['help']); + return { stdout: 'simctl help', stderr: '', exitCode: 0 }; + }, + }); + const daemon = await createProviderScenarioHarness({ + platformAppLogRuntime: true, + appleToolProvider: () => appleTool.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + }); + daemon.setSession('default', { + name: 'default', + device: PROVIDER_SCENARIO_IOS_SIMULATOR, + appBundleId: 'com.example.app', + createdAt: Date.now(), + actions: [], + }); + + try { + const selection = { + platform: 'ios' as const, + udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + }; + const result = await daemon.client().observability.logs({ + action: 'doctor', + ...selection, + }); + assert.equal((result.checks as { simctlAvailable?: boolean }).simctlAvailable, true); + assert.ok( + appleTool.calls.some((call) => call[0] === 'simctl' && call[1] === 'help'), + JSON.stringify(appleTool.calls), + ); + } finally { + await daemon.close(); + } +}); diff --git a/test/integration/provider-scenarios/fixtures.ts b/test/integration/provider-scenarios/fixtures.ts index 21215af79f..f65b058644 100644 --- a/test/integration/provider-scenarios/fixtures.ts +++ b/test/integration/provider-scenarios/fixtures.ts @@ -14,6 +14,7 @@ export const PROVIDER_SCENARIO_ANDROID: DeviceInfo = { export const PROVIDER_SCENARIO_IOS_SIMULATOR: DeviceInfo = { platform: 'apple', + appleOs: 'ios', id: 'sim-1', name: 'iPhone 15', kind: 'simulator', @@ -23,6 +24,7 @@ export const PROVIDER_SCENARIO_IOS_SIMULATOR: DeviceInfo = { export const PROVIDER_SCENARIO_IOS_DEVICE: DeviceInfo = { platform: 'apple', + appleOs: 'ios', id: 'ios-device-1', name: 'QA iPhone', kind: 'device', @@ -32,6 +34,7 @@ export const PROVIDER_SCENARIO_IOS_DEVICE: DeviceInfo = { export const PROVIDER_SCENARIO_IOS_REINSTALL_DEVICE: DeviceInfo = { platform: 'apple', + appleOs: 'ios', id: 'device-1', name: 'iPhone Device', kind: 'device', @@ -41,6 +44,7 @@ export const PROVIDER_SCENARIO_IOS_REINSTALL_DEVICE: DeviceInfo = { export const PROVIDER_SCENARIO_TVOS: DeviceInfo = { platform: 'apple', + appleOs: 'tvos', id: 'tv-sim-1', name: 'Apple TV', kind: 'simulator', diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index ed9f20d3f6..0872c7b4af 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -23,6 +23,8 @@ import { createTestDeviceInventoryGateways, createTestDeviceInventoryGatewaysFromProvider, } from '../../../src/__tests__/test-utils/device-inventory-gateways.ts'; +import { createPlatformAppLogRuntimeGateway } from '../../../src/platform-runtime.ts'; +import { unavailableDeviceRuntimeGateway } from '../../../src/daemon/__tests__/test-device-runtime-gateway.ts'; const PROVIDER_SCENARIO_TOKEN = 'provider-scenario-token'; const PROVIDER_SCENARIO_TEMP_REMOVE_OPTIONS = { @@ -61,13 +63,30 @@ export async function createProviderScenarioHarness( ( | { deviceInventoryProvider: DeviceInventoryProvider; deviceInventorySource?: never } | { deviceInventorySource: ProviderDeviceInventorySource; deviceInventoryProvider?: never } - ), + ) & { platformAppLogRuntime?: boolean }, ): Promise { const sessionDir = fs.mkdtempSync( path.join(os.tmpdir(), 'agent-device-provider-scenario-session-'), ); const sessionStore = new SessionStore(sessionDir); - const { deviceInventoryProvider, deviceInventorySource, ...routerDeps } = deps; + const { + deviceInventoryProvider, + deviceInventorySource, + deviceRuntimeGateway: configuredDeviceRuntimeGateway, + platformAppLogRuntime, + ...routerDeps + } = deps; + const deviceRuntimeGateway = + configuredDeviceRuntimeGateway ?? + (platformAppLogRuntime + ? createPlatformAppLogRuntimeGateway({ + sessionsDir: sessionDir, + resolveSessionArtifacts: (sessionId) => ({ + outputPath: sessionStore.resolveAppLogPath(sessionId), + pidPath: sessionStore.resolveAppLogPidPath(sessionId), + }), + }) + : unavailableDeviceRuntimeGateway); const requestHandler = createRequestHandler({ logPath: path.join(os.tmpdir(), 'agent-device-provider-scenario-daemon.log'), token: PROVIDER_SCENARIO_TOKEN, @@ -76,6 +95,7 @@ export async function createProviderScenarioHarness( deviceInventoryGateways: deviceInventorySource ? createTestDeviceInventoryGateways({ provider: deviceInventorySource }) : createTestDeviceInventoryGatewaysFromProvider(deviceInventoryProvider), + deviceRuntimeGateway, trackDownloadableArtifact, ...routerDeps, }); @@ -107,6 +127,7 @@ export async function createProviderScenarioHarness( session: (name = 'default') => sessionStore.get(name), setSession: (name, session) => sessionStore.set(name, session), close: async () => { + await deviceRuntimeGateway.shutdown(); removeProviderScenarioTempDir(sessionDir); }, }; diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index e1f2e1e16b..7268c553b6 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -2,7 +2,13 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import type { AppLogProvider } from '../../../src/daemon/app-log.ts'; +import { + providerRuntimeOwner, + type AppLogRuntimeOperations, + type DeviceRuntimeGateway, +} from '@agent-device/contracts/platform'; +import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createTestAppLogLiveHandle } from '../../../src/__tests__/test-utils/app-log-live-handle.ts'; import { assertFlatToolCall } from './assertions.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; import { createProviderScenarioHarness } from './harness.ts'; @@ -86,24 +92,14 @@ test('Provider-backed integration iOS Settings permission and alert flow uses pr }); let appLogStopCount = 0; const appLogStarts: Array<{ appBundleId: string; outPath: string }> = []; - const appLogProvider: AppLogProvider = { - start: async ({ appBundleId, outPath }) => { - appLogStarts.push({ appBundleId, outPath }); - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - fs.appendFileSync(outPath, 'Settings log stream started\n', 'utf8'); - return { - backend: 'ios-simulator', - startedAt: Date.now(), - getState: () => 'active', - stop: async () => { - appLogStopCount += 1; - }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }; + const deviceRuntimeGateway = createRecordingAppLogRuntimeGateway({ + starts: appLogStarts, + stopped: () => { + appLogStopCount += 1; }, - }; + }); const daemon = await createProviderScenarioHarness({ - appLogProvider: () => appLogProvider, + deviceRuntimeGateway, appleRunnerProvider: () => appleRunnerProvider, appleToolProvider: () => appleTool.provider, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], @@ -196,3 +192,107 @@ test('Provider-backed integration iOS Settings permission and alert flow uses pr await daemon.close(); } }); + +function createRecordingAppLogRuntimeGateway(params: { + starts: Array<{ appBundleId: string; outPath: string }>; + stopped(): void; +}): DeviceRuntimeGateway { + const owner = providerRuntimeOwner('provider-scenario', 'ios-settings'); + return { + bind: async ({ device }) => { + if (device.platform !== 'apple' || !device.appleOs) { + throw new TypeError('The iOS provider scenario requires an explicit Apple leaf'); + } + const appleOs = device.appleOs; + return { + device, + owner, + facts: { + device: { + family: 'apple', + appleOs, + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + providerMode: 'provider-runtime', + }, + operations: { + appLogInspect: { available: true }, + appLogDoctor: { available: true }, + appLogStart: { available: true }, + appLogReattach: { available: true }, + appLogCleanup: { available: true }, + }, + }, + operations: { + appLogInspect: async () => ({ backend: 'ios-simulator' }), + appLogDoctor: async () => ({ + backend: 'ios-simulator', + checks: { simctlAvailable: true }, + notes: [], + }), + appLogStart: async (input) => { + params.starts.push({ appBundleId: input.appBundleId, outPath: input.outputPath }); + fs.mkdirSync(path.dirname(input.outputPath), { recursive: true }); + fs.appendFileSync(input.outputPath, 'Settings log stream started\n', 'utf8'); + let completion: + | Promise<{ + status: 'completed'; + result: { + backend: 'ios-simulator'; + outputPath: string; + completedAt: number; + }; + }> + | undefined; + const finish = async () => + (completion ??= (async () => { + params.stopped(); + return { + status: 'completed' as const, + result: { + backend: 'ios-simulator' as const, + outputPath: input.outputPath, + completedAt: Date.now(), + }, + }; + })()); + const handle = createTestAppLogLiveHandle({ + inspect: () => ({ + backend: 'ios-simulator', + state: 'active', + startedAt: Date.now(), + }), + finish, + forceCleanup: async () => { + await finish(); + return { status: 'cleaned' }; + }, + }); + return createAppLogStartResult( + handle, + createDurableResourceEnvelope({ + resourceKind: 'app-log', + sessionId: input.sessionId, + device: { + id: device.id, + family: 'apple', + appleOs, + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + }, + owner, + fence: input.fence, + lifecycle: 'open', + descriptor: { version: 1, body: { transport: 'provider-scenario' } }, + }), + ); + }, + appLogReattach: async () => ({ status: 'missing' }), + appLogCleanup: async () => ({ status: 'already-missing' }), + }, + [Symbol.asyncDispose]: async () => {}, + }; + }, + shutdown: async () => {}, + }; +} diff --git a/test/integration/provider-scenarios/macos-world.ts b/test/integration/provider-scenarios/macos-world.ts index 4a6a86fac2..7930cad1cb 100644 --- a/test/integration/provider-scenarios/macos-world.ts +++ b/test/integration/provider-scenarios/macos-world.ts @@ -47,6 +47,7 @@ export async function createMacOsDesktopWorld( }, }); const daemon = await createProviderScenarioHarness({ + platformAppLogRuntime: true, appleRunnerProvider: options.appleRunnerProvider ? () => options.appleRunnerProvider : undefined,