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
9 changes: 9 additions & 0 deletions docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,15 @@ contract handle beside its descriptor and metadata; the field has one R7 transit
descriptor and neutral metadata enter the authoritative persisted recovery record. Concrete platform
classes, provider clients, child handles, timers, transports, and wait promises enter neither store.

The daemon shares the lifecycle mechanics of those facet-specific resources through one
`DurableCaptureResource` coordinator. It owns bounded manifest I/O, per-resource fence serialization,
start/persist/adopt compensation, terminal transitions, and deadline-bounded exact-owner recovery.
The coordinator receives a facet's resource kind and manifest store, neutral session slot, completion
metadata projection, failure wording, and exact-owner recovery adapter; it does not select platforms,
interpret command flags, or form a generic runtime facet. App-log and screen recording retain distinct handles,
descriptors, facts, native finalization semantics, and admission policy. Reusable codec/live-handle
mechanics below the daemon remain in `@agent-device/capture-kit`, preserving the package direction.

A daemon-owned, process-lifetime admission ledger may retain bounded cleanup uncertainty that has no
honest durable representation, but it is not a second live-resource store: it contains no handle or
descriptor, never supersedes the persisted manifest, and is keyed by canonical device identity when
Expand Down
19 changes: 19 additions & 0 deletions src/daemon/__tests__/app-log-resource-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ test.each([
expect(fs.readFileSync(resourcePath, 'utf8')).toBe(body);
});

test('malformed app-log records preserve the underlying decoder message', () => {
const resourcePath = resolveAppLogResourcePath(
path.join(mkdtempForTestSync('app-log-malformed-message-'), 'session'),
);
fs.mkdirSync(path.dirname(resourcePath), { recursive: true });
fs.writeFileSync(resourcePath, '{');
let expectedMessage = '';
try {
JSON.parse('{');
} catch (error) {
expectedMessage = error instanceof Error ? error.message : '';
}

expect(readAppLogResourceRecord(resourcePath)).toMatchObject({
status: 'unreattachable',
message: expectedMessage,
});
});

test('app-log resource listing is deterministic and ignores unrelated artifacts', () => {
const sessionsDir = mkdtempForTestSync('app-log-record-list-');
for (const sessionName of ['zeta', 'alpha']) {
Expand Down
54 changes: 54 additions & 0 deletions src/daemon/__tests__/durable-capture-admission-ledger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { createDurableCaptureAdmissionLedger } from '../durable-capture-admission-ledger.ts';

const device: DeviceInfo = {
platform: 'android',
id: 'ledger-device',
name: 'Pixel',
kind: 'emulator',
};
const otherDevice: DeviceInfo = {
platform: 'android',
id: 'other-device',
name: 'Other Pixel',
kind: 'emulator',
};

test('blocks only the affected device in this process-lifetime ledger', () => {
const ledger = createDurableCaptureAdmissionLedger({ displayName: 'Screen recording' });
ledger.blockUndurableCleanup(device, 'cleanup could not be confirmed');
expect(() => ledger.assertStartAllowed(device)).toThrow(/process-local/);
expect(() => ledger.assertStartAllowed(otherDevice)).not.toThrow();
expect(() =>
createDurableCaptureAdmissionLedger({ displayName: 'Screen recording' }).assertStartAllowed(
device,
),
).not.toThrow();
});

test('expires bounded cleanup uncertainty and reports it once', () => {
let now = 1_000;
const expired: string[] = [];
const ledger = createDurableCaptureAdmissionLedger({
displayName: 'Screen recording',
now: () => now,
undurableCleanupTtlMs: 500,
onUndurableCleanupExpired: ({ reason }) => expired.push(reason),
});
ledger.blockUndurableCleanup(device, 'cleanup could not be confirmed');
expect(() => ledger.assertStartAllowed(device)).toThrow(/process-local/);
now += 501;
expect(() => ledger.assertStartAllowed(device)).not.toThrow();
expect(expired).toEqual(['cleanup could not be confirmed']);
expect(() => ledger.assertStartAllowed(device)).not.toThrow();
});

test('clearing a device releases only its recorded uncertainty', () => {
const ledger = createDurableCaptureAdmissionLedger({ displayName: 'Screen recording' });
ledger.blockUndurableCleanup(device, 'first');
ledger.blockUndurableCleanup(otherDevice, 'second');
ledger.clearUndurableCleanup(device);
expect(() => ledger.assertStartAllowed(device)).not.toThrow();
expect(() => ledger.assertStartAllowed(otherDevice)).toThrow(/process-local/);
});
73 changes: 73 additions & 0 deletions src/daemon/__tests__/durable-capture-recovery-authority.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { expect, test, vi } from 'vitest';
import { createDurableResourceEnvelope } from '@agent-device/capture-kit';
import { localRuntimeOwner, type AppLogLiveHandle } from '@agent-device/contracts/platform';
import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts';
import {
acquireDurableCaptureRecoveryAuthorityBeforeDeadline,
DurableCaptureRecoveryDeadlineError,
} from '../durable-capture-recovery-authority.ts';

const envelope = createDurableResourceEnvelope({
resourceKind: 'app-log',
sessionId: 'session',
device: { id: 'emulator-5554', family: 'android', kind: 'emulator' },
owner: localRuntimeOwner('android'),
fence: { token: 'fence', generation: 1 },
lifecycle: 'open',
descriptor: { version: 1, body: {} },
});

test('deadline abort disposes authority that becomes active after the caller has timed out', async () => {
vi.useFakeTimers();
try {
let resolveReattach!: (outcome: { status: 'active'; handle: AppLogLiveHandle }) => void;
const forceCleanup = vi.fn(async () => ({ status: 'cleaned' }) as const);
const disposeControl = vi.fn(async () => {});
const cleanupFailures = vi.fn();
const acquisition = acquireDurableCaptureRecoveryAuthorityBeforeDeadline({
displayName: 'app-log',
envelope,
scope: {
signal: new AbortController().signal,
diagnostics: { emit: () => {} },
progress: { report: () => {} },
},
deadlineMs: 25,
acquireControl: async () => ({
reattach: async () =>
await new Promise<{ status: 'active'; handle: AppLogLiveHandle }>((resolve) => {
resolveReattach = resolve;
}),
cleanup: async () => ({ status: 'already-missing' }),
[Symbol.asyncDispose]: disposeControl,
}),
onLateCleanupFailure: cleanupFailures,
});

const timedOut = expect(acquisition).rejects.toBeInstanceOf(
DurableCaptureRecoveryDeadlineError,
);
await vi.advanceTimersByTimeAsync(25);
await timedOut;

resolveReattach({
status: 'active',
handle: createTestAppLogLiveHandle({
inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }),
finish: async () => ({
status: 'completed',
alreadyCompleted: true,
result: { backend: 'android', outputPath: '/tmp/app.log', completedAt: 1 },
}),
forceCleanup,
}),
});
await vi.advanceTimersByTimeAsync(0);

expect(forceCleanup).toHaveBeenCalledOnce();
expect(disposeControl).toHaveBeenCalledOnce();
expect(cleanupFailures).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
57 changes: 57 additions & 0 deletions src/daemon/__tests__/durable-capture-resource-adoption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect, test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
makeDurableCaptureContext,
makeDurableCaptureStartResult,
testCaptureResource,
testCaptureStore,
} from './durable-capture-resource.fixtures.ts';

test('canceled adoption cleans the pending handle before terminalizing its manifest', async () => {
const context = makeDurableCaptureContext();
const start = makeDurableCaptureStartResult(context);
const cancellation = new AppError('CANCELED', 'request canceled');

await expect(
testCaptureResource.adoptStarted({
...context,
...start,
throwIfCanceled: () => {
throw cancellation;
},
}),
).rejects.toBe(cancellation);
expect(start.forceCleanup).toHaveBeenCalledOnce();
expect(testCaptureStore.read(context.resourcePath)).toMatchObject({
status: 'decoded',
envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } },
});
});

test('a failed terminal transition preserves the primary error and blocks replacement', async () => {
const context = makeDurableCaptureContext();
const start = makeDurableCaptureStartResult(context, {
cleanup: { status: 'cleanup-pending', reason: 'cleanup-unconfirmed' },
});
const primary = new AppError('CANCELED', 'request canceled');
const resourceDir = path.dirname(context.resourcePath);

try {
await expect(
testCaptureResource.adoptStarted({
...context,
...start,
throwIfCanceled: () => {
fs.chmodSync(resourceDir, 0o500);
throw primary;
},
}),
).rejects.toBe(primary);
} finally {
fs.chmodSync(resourceDir, 0o700);
}

expect(() => context.admissionLedger.assertStartAllowed(context.device)).toThrow(/process-local/);
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ 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';
import { withDurableCaptureResourceFence } from '../durable-capture-resource-fence.ts';
import { createDurableCaptureResourceStore } from '../durable-capture-resource-store.ts';

test('ownership fence rejects a stale token before the native side effect', async () => {
const store = createDurableCaptureResourceStore({
resourceKind: 'screen-recording',
fileName: 'screen-recording.resource.json',
displayName: 'Screen recording',
});

test('rejects a stale fence before its side effect', async () => {
const resourcePath = makeRecord();
const sideEffect = vi.fn(async () => {});
await expect(
withAppLogResourceFence({
withDurableCaptureResourceFence({
store,
resourcePath,
expected: { token: 'stale', generation: 1 },
run: sideEffect,
Expand All @@ -23,29 +26,31 @@ test('ownership fence rejects a stale token before the native side effect', asyn
expect(sideEffect).not.toHaveBeenCalled();
});

test('ownership fence serializes validation, side effect, and persisted transition', async () => {
test('serializes validation, native work, and transition for one resource path', async () => {
const resourcePath = makeRecord();
const order: string[] = [];
let releaseFirst!: () => void;
let markFirstStarted!: () => void;
let release!: () => void;
let started!: () => void;
const firstStarted = new Promise<void>((resolve) => {
markFirstStarted = resolve;
started = resolve;
});
const firstReleased = new Promise<void>((resolve) => {
releaseFirst = resolve;
release = resolve;
});
const first = withAppLogResourceFence({
const first = withDurableCaptureResourceFence({
store,
resourcePath,
expected: { token: 'current', generation: 1 },
run: async (lease) => {
order.push('first-start');
markFirstStarted();
started();
await firstReleased;
lease.transition('open', { metadata: { phase: 'completing' } });
order.push('first-end');
},
});
const second = withAppLogResourceFence({
const second = withDurableCaptureResourceFence({
store,
resourcePath,
expected: { token: 'current', generation: 1 },
run: async () => {
Expand All @@ -54,23 +59,23 @@ test('ownership fence serializes validation, side effect, and persisted transiti
});
await firstStarted;
expect(order).toEqual(['first-start']);
releaseFirst();
release();
await Promise.all([first, second]);
expect(order).toEqual(['first-start', 'first-end', 'second']);
expect(readAppLogResourceRecord(resourcePath)).toMatchObject({
expect(store.read(resourcePath)).toMatchObject({
status: 'decoded',
envelope: { lifecycle: 'open', metadata: { phase: 'completing' } },
envelope: { metadata: { phase: 'completing' } },
});
});

function makeRecord(): string {
const resourcePath = resolveAppLogResourcePath(
path.join(mkdtempForTestSync('app-log-fence-'), 'session'),
const resourcePath = store.resolvePath(
path.join(mkdtempForTestSync('capture-fence-'), 'session'),
);
writeAppLogResourceRecord(
store.write(
resourcePath,
createDurableResourceEnvelope({
resourceKind: 'app-log',
resourceKind: 'screen-recording',
sessionId: 'session',
device: { id: 'emulator-5554', family: 'android', kind: 'emulator' },
owner: localRuntimeOwner('android'),
Expand Down
51 changes: 51 additions & 0 deletions src/daemon/__tests__/durable-capture-resource-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import path from 'node:path';
import { expect, test, vi } from 'vitest';
import { createDurableResourceEnvelope } from '@agent-device/capture-kit';
import { localRuntimeOwner } from '@agent-device/contracts/platform';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { appLogDurableResource } from '../app-log-session-resource.ts';

test('generic recovery terminalizes missing native authority through one exact control', async () => {
const sessionStore = makeSessionStore('durable-capture-recovery-');
const sessionsDir = path.dirname(sessionStore.resolveSessionDir('session'));
const resourcePath = appLogDurableResource.store.resolvePath(
sessionStore.resolveSessionDir('session'),
);
appLogDurableResource.store.write(
resourcePath,
createDurableResourceEnvelope({
resourceKind: 'app-log',
sessionId: 'session',
device: { id: 'emulator-5554', family: 'android', kind: 'emulator' },
owner: localRuntimeOwner('android'),
fence: { token: 'fence', generation: 1 },
lifecycle: 'open',
descriptor: { version: 1, body: { pid: 123 } },
}),
);
const dispose = vi.fn(async () => {});

await expect(
appLogDurableResource.recoverAll({
sessionsDir,
scope: {
signal: new AbortController().signal,
diagnostics: { emit: () => {} },
progress: { report: () => {} },
},
acquireControl: async () => ({
reattach: async () => ({ status: 'missing' }),
cleanup: async () => ({ status: 'already-missing' }),
[Symbol.asyncDispose]: dispose,
}),
}),
).resolves.toEqual({ scanned: 1, recovered: 1, retained: 0 });
expect(dispose).toHaveBeenCalledOnce();
expect(appLogDurableResource.store.read(resourcePath)).toMatchObject({
status: 'decoded',
envelope: {
lifecycle: 'completed',
metadata: { phase: 'completed', recoveryStatus: 'already-missing' },
},
});
});
Loading
Loading