Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
import path from 'node:path';
import { safeSessionName } from '@agent-device/host-kit/session-paths';
import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts';
import { makeCaptureFixtureStore } from '../../durable-capture/session-binding.fixtures.ts';

export type CaptureAdmissionSessionStore<S> = Readonly<{
set(name: string, session: S): void;
resolveSessionDir(name: string): string;
get(name: string): S | undefined;
sessionsDir: string;
}>;
export type CaptureAdmissionSessionStore<S> = ReturnType<
typeof makeCaptureAdmissionSessionStore<S>
>;

/**
* The whole of the daemon `SessionStore` these admission modules ever address — `set`,
* `resolveSessionDir`, and the read-back a test asserts on — over a fresh temp directory, so a
* test of this family needs no session record, store class, or daemon import.
*/
export function makeCaptureAdmissionSessionStore<S>(
prefix: string,
): CaptureAdmissionSessionStore<S> {
export function makeCaptureAdmissionSessionStore<S>(prefix: string) {
const sessionsDir = mkdtempForTestSync(prefix);
const sessions = new Map<string, S>();
return {
set: (name, session) => void sessions.set(name, session),
get: (name) => sessions.get(name),
resolveSessionDir: (name) => path.join(sessionsDir, safeSessionName(name)),
return Object.freeze({
...makeCaptureFixtureStore<S>((name) => path.join(sessionsDir, safeSessionName(name))),
sessionsDir,
};
});
}
22 changes: 22 additions & 0 deletions packages/capture-kit/src/durable-capture/adoption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,25 @@ test('a failed terminal transition preserves the primary error and reports it un
reason: expect.stringMatching(/./),
});
});

test('adoption refuses a retired lifetime even when its address has a vacant successor', async () => {
const context = makeDurableCaptureContext();
const start = makeDurableCaptureStartResult(context);
context.sessionStore.retire(context.sessionStore.lookup(context.sessionName));
const successor = { name: 'successor' };
context.sessionStore.set(context.sessionName, successor);
await expect(
adoptStartedDurableCapture(
testCaptureDefinition,
{
...context,
...start,
throwIfCanceled: () => {},
},
context.resourcePath,
),
).rejects.toMatchObject({ code: 'COMMAND_FAILED' });
expect(start.forceCleanup).toHaveBeenCalledOnce();
expect(context.sessionStore.get(context.sessionName)).toBe(successor);
expect(testCaptureStore.read(context.resourcePath).status).toBe('missing');
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { makeCaptureSessionBinding } from './session-binding.fixtures.ts';
import { makeCaptureSessionBinding, makeCaptureFixtureStore } from './session-binding.fixtures.ts';
import path from 'node:path';
import { vi, type Mock } from 'vitest';
import {
Expand Down Expand Up @@ -78,15 +78,10 @@ export function makeDurableCaptureContext(
) {
const sessionsDir = mkdtempForTestSync('durable-capture-resource-');
const sessionName = 'session';
const sessions = new Map<string, TestCaptureSession>();
const session: TestCaptureSession = { name: sessionName };
sessions.set(sessionName, session);
const resolveSessionDir = (name: string): string => path.join(sessionsDir, name);
const sessionStore = {
get: (name: string) => sessions.get(name),
set: (name: string, next: TestCaptureSession) => void sessions.set(name, next),
resolveSessionDir,
};
const sessionStore = makeCaptureFixtureStore<TestCaptureSession>(resolveSessionDir);
sessionStore.set(sessionName, session);
const reportUndurableCleanup: Mock<
(device: DeviceInfo, outcome: DurableCaptureCleanupOutcome) => void
> = vi.fn();
Expand All @@ -96,7 +91,7 @@ export function makeDurableCaptureContext(
read: (session) => session.capture,
replace: (session, capture) => ({ ...session, capture }),
}),
sessions,
sessions: sessionStore,
sessionsDir,
resolveSessionDir,
session,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,51 @@
import { AppError } from '@agent-device/kernel/errors';
import type { DurableCaptureSessionBinding, DurableCaptureSessionResource } from './definition.ts';

type FixtureSessionRef<S> = Readonly<{ address: string; session: S; lifetime: object }>;

export function makeCaptureFixtureStore<S>(resolveSessionDir: (address: string) => string) {
const entries = new Map<string, { current: S }>();
const resolveCurrent = (ref: FixtureSessionRef<S>): S | undefined => {
const entry = entries.get(ref.address);
return entry === ref.lifetime ? entry.current : undefined;
};
return Object.freeze({
resolveSessionDir,
get: (address: string): S | undefined => entries.get(address)?.current,
set: (address: string, session: S): void => {
const entry = entries.get(address);
if (entry) entry.current = session;
else entries.set(address, { current: session });
},
lookup: (address: string): FixtureSessionRef<S> => {
const entry = entries.get(address);
if (!entry) throw new AppError('COMMAND_FAILED', 'Test session retired');
return Object.freeze({ address, session: entry.current, lifetime: entry });
},
resolveCurrent,
update: (ref: FixtureSessionRef<S>, rebuild: (current: S) => S): void => {
const current = resolveCurrent(ref);
if (current === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired');
entries.get(ref.address)!.current = rebuild(current);
},
retire: (ref: FixtureSessionRef<S>): boolean =>
resolveCurrent(ref) !== undefined && entries.delete(ref.address),
});
}

export function makeCaptureSessionBinding<K extends string, H extends AsyncDisposable, S>(
store: Readonly<{
get(name: string): S | undefined;
set(name: string, session: S): void;
resolveSessionDir(name: string): string;
}>,
store: ReturnType<typeof makeCaptureFixtureStore<S>>,
address: string,
slot: Readonly<{
read(session: S): DurableCaptureSessionResource<K, H> | undefined;
replace(session: S, resource: DurableCaptureSessionResource<K, H> | undefined): S;
}>,
): DurableCaptureSessionBinding<K, H> {
const ref = store.lookup(address);
let retained = slot.read(ref.session);
const requireSession = (): S => {
const session = store.get(address);
if (!session) throw new AppError('COMMAND_FAILED', 'Test session retired');
const session = store.resolveCurrent(ref);
if (session === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired');
return session;
};
const assertAdoptable = (): void => {
Expand All @@ -25,23 +55,32 @@ export function makeCaptureSessionBinding<K extends string, H extends AsyncDispo
address,
sessionDir: store.resolveSessionDir(address),
read: () => {
const session = store.get(address);
return session === undefined ? undefined : slot.read(session);
const session = store.resolveCurrent(ref);
if (session !== undefined) retained = slot.read(session);
return retained;
},
assertAdoptable,
canPersist: () => {
const session = store.get(address);
const session = store.resolveCurrent(ref);
return session !== undefined && slot.read(session) === undefined;
},
adopt: (resource) => {
assertAdoptable();
store.set(address, slot.replace(requireSession(), resource));
store.update(ref, (current) => slot.replace(current, resource));
retained = resource;
},
clear: (expected) => {
const current = store.get(address);
if (!current) return 'retired';
if (slot.read(current)?.handle !== expected.handle) return 'resource-changed';
store.set(address, slot.replace(current, undefined));
const current = store.resolveCurrent(ref);
if (current === undefined) return 'retired';
const active = slot.read(current);
if (
active?.handle !== expected.handle ||
active.envelope.fence.token !== expected.envelope.fence.token ||
active.envelope.fence.generation !== expected.envelope.fence.generation
)
return 'resource-changed';
store.update(ref, (session) => slot.replace(session, undefined));
retained = undefined;
return 'cleared';
},
});
Expand Down
64 changes: 64 additions & 0 deletions packages/capture-kit/src/durable-capture/transitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,67 @@ test('a disposal finish disposes a preserving kind’s material too', async () =
envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } },
});
});

test.each(['rebuild', 'retire', 'token', 'generation'] as const)(
'a held finish after %s clears only its matching lifetime, handle and fence',
async (change) => {
const context = makeDurableCaptureContext();
const start = makeDurableCaptureStartResult(context);
let enter!: () => void;
let release!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const resumed = new Promise<void>((resolve) => {
release = resolve;
});
start.finish.mockImplementationOnce(async () => {
enter();
await resumed;
return { status: 'completed', result: { outputPath: '/tmp/capture', completedAt: 2 } };
});
await adoptStartedDurableCapture(
testCaptureDefinition,
{
...context,
...start,
throwIfCanceled: () => {},
},
context.resourcePath,
);
const finishing = finishLiveDurableCapture(
testCaptureDefinition,
{
binding: context.binding,
intent: 'capture',
},
context.resourcePath,
);
await entered;
const ref = context.sessionStore.lookup(context.sessionName);
const active = context.sessionStore.get(context.sessionName)!.capture!;
if (change === 'retire') {
context.sessionStore.retire(ref);
context.sessionStore.set(context.sessionName, { name: 'successor', capture: active });
} else {
const fence = {
...active.envelope.fence,
...(change === 'token' ? { token: 'replacement' } : {}),
...(change === 'generation' ? { generation: active.envelope.fence.generation + 1 } : {}),
};
context.sessionStore.update(ref, (current) => ({
...current,
name: 'updated',
capture: { ...active, envelope: { ...active.envelope, fence } },
Comment thread
thymikee marked this conversation as resolved.
}));
}
const before = context.sessionStore.get(context.sessionName)!;
release();
await finishing;
const current = context.sessionStore.get(context.sessionName)!;
expect(start.finish).toHaveBeenCalledOnce();
expect(current.name).toBe(change === 'retire' ? 'successor' : 'updated');
if (change === 'rebuild') expect(current.capture).toBeUndefined();
else expect(current).toBe(before);
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,6 @@ test('a pid outside the native range is unknown without a liveness probe', () =>
'unknown',
);
assert.equal(mockIsProcessAlive.mock.calls.length, 0);
assert.equal(mockIsProcessZombie.mock.calls.length, 0);
assert.equal(mockReadProcessStartTime.mock.calls.length, 0);
});
3 changes: 1 addition & 2 deletions packages/host-kit/src/session-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import { expandSessionPath } from './session-paths.ts';

test('expandSessionPath resolves tilde, relative-with-cwd, and absolute paths', () => {
const homePath = expandSessionPath('~/flows/replay.ad');
assert.equal(homePath.startsWith(os.homedir()), true);
assert.equal(homePath.endsWith(path.join('flows', 'replay.ad')), true);
assert.equal(homePath, path.join(os.homedir(), 'flows', 'replay.ad'));

const relativePath = expandSessionPath('workflows/replay.ad', '/tmp/agent-device-cwd');
assert.equal(relativePath, path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad'));
Expand Down
14 changes: 4 additions & 10 deletions scripts/layering/session-resource-ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,10 @@ test('session resources are constructed only by their durable domain owners', ()
`sessionStore.update(ref, { appLog: log, appLogFailure: undefined });`,
],
[
'src/daemon/audio-probe-session-binding.ts',
`sessionStore.update(ref, { audioProbe: audio });`,
],
[
'src/daemon/perf-capture-session-binding.ts',
`sessionStore.update(ref, { perfCapture: perf });`,
],
[
'src/daemon/screen-recording-session-binding.ts',
`sessionStore.update(ref, { screenRecording: recording });`,
'src/daemon/session-capture-binding.ts',
`sessionStore.update(ref, { audioProbe: audio });
sessionStore.update(ref, { perfCapture: perf });
sessionStore.update(ref, { screenRecording: recording });`,
],
[
'packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts',
Expand Down
11 changes: 4 additions & 7 deletions scripts/layering/session-resource-ownership.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Catches: a session resource field (appLog, appLogFailure, audioProbe, perfCapture) written
// Catches: a session resource field (appLog, appLogFailure, audioProbe, perfCapture, screenRecording) written
// from outside its declared owner module — R7's session-state-ownership shape applied to the
// narrower set of per-resource fields these session-scoped runtimes carry, where the same
// aliasing hazard (get()/set() hand back and re-put the live reference) applies.
Expand Down Expand Up @@ -27,15 +27,12 @@ const SCANNED_ROOTS = ['src/daemon/', 'packages/capture-kit/src/capture-admissio
const RESOURCE_OWNERS: Readonly<Record<string, ReadonlySet<string>>> = {
appLog: new Set(['src/daemon/app-log-session-resource.ts', 'src/daemon/session-state.ts']),
appLogFailure: new Set(['src/daemon/app-log-session-resource.ts', 'src/daemon/session-state.ts']),
audioProbe: new Set(['src/daemon/audio-probe-session-binding.ts', 'src/daemon/session-state.ts']),
audioProbe: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']),
screenRecording: new Set([
'src/daemon/screen-recording-session-binding.ts',
'src/daemon/session-state.ts',
]),
perfCapture: new Set([
'src/daemon/perf-capture-session-binding.ts',
'src/daemon/session-capture-binding.ts',
Comment thread
thymikee marked this conversation as resolved.
'src/daemon/session-state.ts',
]),
perfCapture: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']),
};

/** Durable session-resource records have one whole-record construction owner per domain. */
Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/test-utils/registered-daemon-fixture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { test } from 'vitest';
import { resolveDaemonPaths } from '../../daemon-resolution.ts';
import { stopDaemonProcess } from '../../daemon-process.ts';
import { mkdtempForTestSync } from './tmp-dir.ts';
import {
spawnRegisteredDaemonFixture,
waitForRegisteredDaemonFixture,
finishRegisteredDaemonFixture,
} from './registered-daemon-fixture.ts';

test('a joined fixture exit refuses its remaining registration metadata', async () => {
const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-fixture-exited-publication-'));
const child = spawnRegisteredDaemonFixture(
paths,
{
httpPort: 4210,
token: 'fixture',
version: 'test',
codeOrigin: 'checkout',
codeSignature: 'fixture',
},
undefined,
);
try {
const observed = await waitForRegisteredDaemonFixture(paths, child);
assert.equal(
(
await stopDaemonProcess(
{ pid: child.pid, startTime: observed.processStartTime ?? null },
{ mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_000 },
)
).status,
'exited',
);
await child.exited;
assert.equal(fs.existsSync(paths.infoPath), true);
await assert.rejects(waitForRegisteredDaemonFixture(paths, child), /exited before publication/);
} finally {
await finishRegisteredDaemonFixture(paths.baseDir);
}
});
1 change: 1 addition & 0 deletions src/__tests__/test-utils/registered-daemon-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export async function waitForRegisteredDaemonFixture(
void child.exited.then((result) => {
exit = result;
});
await Promise.resolve();
for (let attempt = 0; attempt < 400; attempt += 1) {
if (exit)
throw new Error(`Registered child exited before publication: ${JSON.stringify(exit)}`);
Expand Down
Loading
Loading