Skip to content

Commit 558ef2b

Browse files
committed
refactor: share capture binding ownership with transition controls
1 parent 6122806 commit 558ef2b

5 files changed

Lines changed: 115 additions & 77 deletions

File tree

‎packages/capture-kit/src/durable-capture/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ export type {
2323
} from './recovery.ts';
2424
export type { DurableCaptureRecoveryControl } from './recovery-authority.ts';
2525
export type { DurableCaptureResourceRecord, DurableCaptureResourceStore } from './store.ts';
26+
27+
export { createDurableCaptureSessionBinding } from './session-binding.ts';
Lines changed: 10 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createDurableCaptureSessionBinding } from './session-binding.ts';
12
import { AppError } from '@agent-device/kernel/errors';
23
import type { DurableCaptureSessionBinding, DurableCaptureSessionResource } from './definition.ts';
34

@@ -42,46 +43,18 @@ export function makeCaptureSessionBinding<K extends string, H extends AsyncDispo
4243
}>,
4344
): DurableCaptureSessionBinding<K, H> {
4445
const ref = store.lookup(address);
45-
let retained = slot.read(ref.session);
46-
const requireSession = (): S => {
47-
const session = store.resolveCurrent(ref);
48-
if (session === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired');
49-
return session;
50-
};
51-
const assertAdoptable = (): void => {
52-
if (slot.read(requireSession())) throw new AppError('COMMAND_FAILED', 'Test resource changed');
53-
};
54-
return Object.freeze({
46+
return createDurableCaptureSessionBinding({
5547
address,
5648
sessionDir: store.resolveSessionDir(address),
57-
read: () => {
49+
initialSession: ref.session,
50+
resolveCurrent: () => store.resolveCurrent(ref),
51+
requireCurrent: () => {
5852
const session = store.resolveCurrent(ref);
59-
if (session !== undefined) retained = slot.read(session);
60-
return retained;
61-
},
62-
assertAdoptable,
63-
canPersist: () => {
64-
const session = store.resolveCurrent(ref);
65-
return session !== undefined && slot.read(session) === undefined;
66-
},
67-
adopt: (resource) => {
68-
assertAdoptable();
69-
store.update(ref, (current) => slot.replace(current, resource));
70-
retained = resource;
71-
},
72-
clear: (expected) => {
73-
const current = store.resolveCurrent(ref);
74-
if (current === undefined) return 'retired';
75-
const active = slot.read(current);
76-
if (
77-
active?.handle !== expected.handle ||
78-
active.envelope.fence.token !== expected.envelope.fence.token ||
79-
active.envelope.fence.generation !== expected.envelope.fence.generation
80-
)
81-
return 'resource-changed';
82-
store.update(ref, (session) => slot.replace(session, undefined));
83-
retained = undefined;
84-
return 'cleared';
53+
if (session === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired');
54+
return session;
8555
},
56+
assertAdmissionOpen: () => {},
57+
read: slot.read,
58+
write: (resource) => store.update(ref, (current) => slot.replace(current, resource)),
8659
});
8760
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { expect, test, vi } from 'vitest';
2+
import { adoptStartedDurableCapture } from './adoption.ts';
3+
import {
4+
makeDurableCaptureContext,
5+
makeDurableCaptureStartResult,
6+
testCaptureDefinition,
7+
} from './durable-capture.fixtures.ts';
8+
import { createDurableCaptureSessionBinding } from './session-binding.ts';
9+
10+
test('closed admission refuses adoption before writing or retaining the resource', async () => {
11+
const context = makeDurableCaptureContext();
12+
const start = makeDurableCaptureStartResult(context);
13+
await adoptStartedDurableCapture(
14+
testCaptureDefinition,
15+
{ ...context, ...start, throwIfCanceled: () => {} },
16+
context.resourcePath,
17+
);
18+
const resource = context.binding.read()!;
19+
const session = { capture: undefined };
20+
const write = vi.fn();
21+
const refusal = new Error('admission closed');
22+
const binding = createDurableCaptureSessionBinding({
23+
address: context.binding.address,
24+
sessionDir: context.binding.sessionDir,
25+
initialSession: session,
26+
resolveCurrent: () => session,
27+
requireCurrent: () => session,
28+
assertAdmissionOpen: () => {
29+
throw refusal;
30+
},
31+
read: (current) => current.capture,
32+
write,
33+
});
34+
expect(() => binding.adopt(resource)).toThrow(refusal);
35+
expect(write).not.toHaveBeenCalled();
36+
expect(binding.read()).toBeUndefined();
37+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { AppError } from '@agent-device/kernel/errors';
2+
import type { DurableCaptureSessionBinding, DurableCaptureSessionResource } from './definition.ts';
3+
4+
export function createDurableCaptureSessionBinding<S, K extends string, H extends AsyncDisposable>(
5+
port: Readonly<{
6+
address: string;
7+
sessionDir: string;
8+
initialSession: S;
9+
resolveCurrent(): S | undefined;
10+
requireCurrent(): S;
11+
assertAdmissionOpen(): void;
12+
read(session: S): DurableCaptureSessionResource<K, H> | undefined;
13+
write(resource: DurableCaptureSessionResource<K, H> | undefined): void;
14+
}>,
15+
): DurableCaptureSessionBinding<K, H> {
16+
let retained = port.read(port.resolveCurrent() ?? port.initialSession);
17+
const assertAdoptable = (): void => {
18+
port.assertAdmissionOpen();
19+
if (port.read(port.requireCurrent())) {
20+
throw new AppError('COMMAND_FAILED', 'Session capture resource has changed', {
21+
reason: 'session_resource_changed',
22+
session: port.address,
23+
});
24+
}
25+
};
26+
return Object.freeze({
27+
address: port.address,
28+
sessionDir: port.sessionDir,
29+
read: () => {
30+
const current = port.resolveCurrent();
31+
if (current) retained = port.read(current);
32+
return retained;
33+
},
34+
assertAdoptable,
35+
canPersist: () => {
36+
const current = port.resolveCurrent();
37+
return current !== undefined && port.read(current) === undefined;
38+
},
39+
adopt: (resource) => {
40+
assertAdoptable();
41+
port.write(resource);
42+
retained = resource;
43+
},
44+
clear: (expected) => {
45+
const current = port.resolveCurrent();
46+
if (!current) return 'retired';
47+
const active = port.read(current);
48+
if (
49+
active?.handle !== expected.handle ||
50+
active.envelope.fence.token !== expected.envelope.fence.token ||
51+
active.envelope.fence.generation !== expected.envelope.fence.generation
52+
)
53+
return 'resource-changed';
54+
port.write(undefined);
55+
retained = undefined;
56+
return 'cleared';
57+
},
58+
});
59+
}

‎src/daemon/session-capture-binding.ts‎

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createDurableCaptureSessionBinding } from '@agent-device/capture-kit/durable-capture';
12
import type {
23
DurableCaptureSessionBinding,
34
DurableCaptureSessionResource,
@@ -14,48 +15,14 @@ export function bindSessionCapture<K extends string, H extends AsyncDisposable>(
1415
write(resource: DurableCaptureSessionResource<K, H> | undefined): void;
1516
}>,
1617
): DurableCaptureSessionBinding<K, H> {
17-
let retained = slot.read(sessionStore.resolveCurrent(ref) ?? ref.session);
18-
const assertAdoptable = (): void => {
19-
sessionStore.assertAdmissionOpen(ref.address);
20-
if (slot.read(sessionStore.requireCurrent(ref))) {
21-
throw new AppError('COMMAND_FAILED', 'Session capture resource has changed', {
22-
reason: 'session_resource_changed',
23-
session: ref.address,
24-
});
25-
}
26-
};
27-
return Object.freeze({
18+
return createDurableCaptureSessionBinding({
2819
address: ref.address,
2920
sessionDir: sessionStore.resolveSessionDir(ref.address),
30-
read: () => {
31-
const current = sessionStore.resolveCurrent(ref);
32-
if (current) retained = slot.read(current);
33-
return retained;
34-
},
35-
assertAdoptable,
36-
canPersist: () => {
37-
const current = sessionStore.resolveCurrent(ref);
38-
return current !== undefined && slot.read(current) === undefined;
39-
},
40-
adopt: (resource) => {
41-
assertAdoptable();
42-
slot.write(resource);
43-
retained = resource;
44-
},
45-
clear: (expected) => {
46-
const current = sessionStore.resolveCurrent(ref);
47-
if (!current) return 'retired';
48-
const active = slot.read(current);
49-
if (
50-
active?.handle !== expected.handle ||
51-
active.envelope.fence.token !== expected.envelope.fence.token ||
52-
active.envelope.fence.generation !== expected.envelope.fence.generation
53-
)
54-
return 'resource-changed';
55-
slot.write(undefined);
56-
retained = undefined;
57-
return 'cleared';
58-
},
21+
initialSession: ref.session,
22+
resolveCurrent: () => sessionStore.resolveCurrent(ref),
23+
requireCurrent: () => sessionStore.requireCurrent(ref),
24+
assertAdmissionOpen: () => sessionStore.assertAdmissionOpen(ref.address),
25+
...slot,
5926
});
6027
}
6128

0 commit comments

Comments
 (0)