Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions fallow-baselines/health.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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:*",
Expand Down
17 changes: 17 additions & 0 deletions packages/capture-kit/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
69 changes: 69 additions & 0 deletions packages/capture-kit/src/app-log-live-handle.ts
Original file line number Diff line number Diff line change
@@ -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<FinishOutcome<AppLogCompletion>>;
forceCleanup(): Promise<CleanupOutcome>;
}>;

/** Internal idempotent adapter underlying the narrower public handle factories. */
export function createAppLogLiveHandle(
implementation: AppLogLiveHandleImplementation,
): AppLogLiveHandle {
let finish: Promise<FinishOutcome<AppLogCompletion>> | undefined;
let cleanup: Promise<CleanupOutcome> | undefined;
let disposal: Promise<void> | undefined;
const forceCleanup = (): Promise<CleanupOutcome> => (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<FinishOutcome<AppLogCompletion>>;
}>,
): 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.',
},
);
}
43 changes: 43 additions & 0 deletions packages/capture-kit/src/app-log-pid-monitor.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
181 changes: 181 additions & 0 deletions packages/capture-kit/src/app-log-pid-monitor.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
startProcess(pid: string): Promise<AppLogBackgroundProcess>;
sleep(milliseconds: number, signal?: AbortSignal): Promise<void>;
}>;

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<void> {
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<void> {
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<void> {
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: 'exited' }> | 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<void> {
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<boolean> {
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<void> {
if (input.stopped() && !wait.settled()) await process.terminate();
}

async function assertProcessWaitSucceeded(
outcome: ProcessWaitObservation['outcome'],
): Promise<void> {
const settled = await outcome;
if (settled.status === 'failed') throw settled.error;
}

async function pauseBeforeProcessRestart(input: PidScopedProcessMonitor): Promise<void> {
if (input.stopped()) return;
input.setState('recovering');
await input.sleep(500);
}

async function startAndAdoptSubsequentProcess(
input: PidScopedProcessMonitor,
): Promise<SubsequentProcessStart> {
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<void> {
try {
await process.terminate();
await process.wait;
} finally {
await disposeAdoptedProcess(input, process);
}
}

async function disposeAdoptedProcess(
input: PidScopedProcessMonitor,
process: AppLogBackgroundProcess,
): Promise<void> {
try {
await process[Symbol.asyncDispose]();
} finally {
input.setActive(undefined);
}
}
Loading
Loading