Skip to content

Commit bcb6c55

Browse files
authored
refactor(capture-kit): move durable-capture resource mechanics out of the daemon (#2320)
* refactor(daemon): give durable capture a session-store port and a cleanup report The durable-capture mechanics reached two daemon-owned authorities directly: the concrete `SessionStore` class plus `SessionState`, and the admission ledger, which `recoverFailedAdoption` called to block or clear a replacement start. Both are daemon policy, so neither can travel with the mechanics. Replace them with a two-member `DurableCaptureSessionStore<S>` port and a session type parameter, and let the mechanics report what they observed — `DurableCaptureCleanupOutcome` — while `createDurableCaptureResource` keeps the clear/block decision and the reason text. Recovery takes the session directory resolver from its caller instead of importing `safeSessionName`. Splitting `DurableCaptureRecordDefinition` out of the definition says which half needs a session at all: recovery, finish-recovered, and start preflight terminalize a persisted record with no session in hand. * refactor(capture-kit): move durable-capture resource mechanics out of the daemon The daemon held two halves of one mechanism. capture-kit already owned the durable-resource envelope, JSON, and descriptor codec; the fence, transition, adoption, and recovery mechanics that operate on that envelope still sat in `src/daemon` as eight files. Move them behind the store port and cleanup report the previous commit introduced, exposed through one new `@agent-device/capture-kit/durable-capture` subpath — not the `.` index, which is the eager closure every platform runtime imports. Admission, start preflight, runtime binding, the kind stamps, and the composition root that wires the mechanics to the admission ledger stay daemon policy. The moved tests exercise the mechanics through a resource kind and session type of their own, so what they prove is that the mechanics need neither the daemon's closed kind set nor `SessionState`. The composition root keeps the admission mapping the adoption test used to assert, now in `durable-capture-resource.test.ts` where the ledger lives. * refactor(capture-kit): drop the now-dead durable-envelope decoder re-export The daemon's store and adoption modules were the `.` index's only production consumers of `decodeDurableResourceEnvelope`; both now sit beside the encoder inside capture-kit and import it directly. * chore(gates): approve the durable-capture subpath over the domain-facade ceiling * refactor(daemon): merge the duplicated durable-capture subpath imports * refactor(capture-kit): keep the durable-capture subpath to its consumed surface `tsc -b` cannot name the fixture spy's inferred type across the package boundary, and five re-exported vocabulary types had no consumer. * style(capture-kit): keep package specifiers ahead of relative imports * test(daemon): make the failed-adoption clear mapping effective The confirmed-cleanup test started with an unblocked ledger, so deleting `clearUndurableCleanup` from the relocated composition mapping still left `assertStartAllowed` green. Seed a block first, so the assertion is that the mapping lifted it.
1 parent f30328d commit bcb6c55

39 files changed

Lines changed: 617 additions & 284 deletions

packages/capture-kit/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"types": "./src/index.ts",
1515
"default": "./src/index.ts"
1616
},
17+
"./durable-capture": {
18+
"types": "./src/durable-capture/index.ts",
19+
"default": "./src/durable-capture/index.ts"
20+
},
1721
"./durable-json": {
1822
"types": "./src/durable-json.ts",
1923
"default": "./src/durable-json.ts"

src/daemon/__tests__/durable-capture-resource-adoption.test.ts renamed to packages/capture-kit/src/durable-capture/adoption.test.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,41 @@ import fs from 'node:fs';
22
import path from 'node:path';
33
import { expect, test } from 'vitest';
44
import { AppError } from '@agent-device/kernel/errors';
5+
import { adoptStartedDurableCapture } from './adoption.ts';
56
import {
67
makeDurableCaptureContext,
78
makeDurableCaptureStartResult,
8-
testCaptureResource,
9+
testCaptureDefinition,
910
testCaptureStore,
10-
} from './durable-capture-resource.fixtures.ts';
11+
} from './durable-capture.fixtures.ts';
1112

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

1718
await expect(
18-
testCaptureResource.adoptStarted({
19-
...context,
20-
...start,
21-
throwIfCanceled: () => {
22-
throw cancellation;
19+
adoptStartedDurableCapture(
20+
testCaptureDefinition,
21+
{
22+
...context,
23+
...start,
24+
throwIfCanceled: () => {
25+
throw cancellation;
26+
},
2327
},
24-
}),
28+
context.resourcePath,
29+
),
2530
).rejects.toBe(cancellation);
2631
expect(start.forceCleanup).toHaveBeenCalledOnce();
2732
expect(testCaptureStore.read(context.resourcePath)).toMatchObject({
2833
status: 'decoded',
2934
envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } },
3035
});
36+
expect(context.reportUndurableCleanup).toHaveBeenCalledWith(context.device, { confirmed: true });
3137
});
3238

33-
test('a failed terminal transition preserves the primary error and blocks replacement', async () => {
39+
test('a failed terminal transition preserves the primary error and reports it unconfirmed', async () => {
3440
const context = makeDurableCaptureContext();
3541
const start = makeDurableCaptureStartResult(context, {
3642
cleanup: { status: 'cleanup-pending', reason: 'cleanup-unconfirmed' },
@@ -40,18 +46,25 @@ test('a failed terminal transition preserves the primary error and blocks replac
4046

4147
try {
4248
await expect(
43-
testCaptureResource.adoptStarted({
44-
...context,
45-
...start,
46-
throwIfCanceled: () => {
47-
fs.chmodSync(resourceDir, 0o500);
48-
throw primary;
49+
adoptStartedDurableCapture(
50+
testCaptureDefinition,
51+
{
52+
...context,
53+
...start,
54+
throwIfCanceled: () => {
55+
fs.chmodSync(resourceDir, 0o500);
56+
throw primary;
57+
},
4958
},
50-
}),
59+
context.resourcePath,
60+
),
5161
).rejects.toBe(primary);
5262
} finally {
5363
fs.chmodSync(resourceDir, 0o700);
5464
}
5565

56-
expect(() => context.admissionLedger.assertStartAllowed(context.device)).toThrow(/process-local/);
66+
expect(context.reportUndurableCleanup).toHaveBeenCalledWith(context.device, {
67+
confirmed: false,
68+
reason: expect.stringMatching(/./),
69+
});
5770
});

src/daemon/durable-capture-resource-adoption.ts renamed to packages/capture-kit/src/durable-capture/adoption.ts

Lines changed: 44 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,19 @@ import {
55
type RuntimeOwnerRef,
66
runtimeOwnerKey,
77
} from '@agent-device/contracts/platform-runtime';
8-
import {
9-
createDurableResourceEnvelope,
10-
decodeDurableResourceEnvelope,
11-
} from '@agent-device/capture-kit';
128
import { deviceIdentity, sameDeviceIdentity, type DeviceInfo } from '@agent-device/kernel/device';
139
import { AppError } from '@agent-device/kernel/errors';
1410
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
11+
import {
12+
createDurableResourceEnvelope,
13+
decodeDurableResourceEnvelope,
14+
} from '../durable-resource-envelope.ts';
1515
import type {
1616
AdoptStartedDurableCaptureParams,
17+
DurableCaptureRecordDefinition,
1718
DurableCaptureResourceDefinition,
18-
} from './durable-capture-resource.ts';
19-
import {
20-
capitalizeDurableCaptureLabel,
21-
durableCaptureDiagnosticPrefix,
22-
} from './durable-capture-resource-labels.ts';
19+
} from './definition.ts';
20+
import { capitalizeDurableCaptureLabel, durableCaptureDiagnosticPrefix } from './labels.ts';
2321

2422
type AdoptionState<H extends AsyncDisposable> =
2523
| { kind: 'pending' }
@@ -30,9 +28,10 @@ export async function adoptStartedDurableCapture<
3028
K extends string,
3129
H extends LiveResourceHandle<C>,
3230
C,
31+
S,
3332
>(
34-
definition: DurableCaptureResourceDefinition<K, H, C>,
35-
params: AdoptStartedDurableCaptureParams<K, H>,
33+
definition: DurableCaptureResourceDefinition<K, H, C, S>,
34+
params: AdoptStartedDurableCaptureParams<K, H, S>,
3635
resourcePath: string,
3736
): Promise<void> {
3837
let state: AdoptionState<H> = { kind: 'pending' };
@@ -53,9 +52,9 @@ export async function adoptStartedDurableCapture<
5352
}
5453
}
5554

56-
async function recoverFailedAdoption<K extends string, H extends LiveResourceHandle<C>, C>(
57-
definition: DurableCaptureResourceDefinition<K, H, C>,
58-
params: AdoptStartedDurableCaptureParams<K, H>,
55+
async function recoverFailedAdoption<K extends string, H extends LiveResourceHandle<C>, C, S>(
56+
definition: DurableCaptureResourceDefinition<K, H, C, S>,
57+
params: AdoptStartedDurableCaptureParams<K, H, S>,
5958
resourcePath: string,
6059
state: AdoptionState<H>,
6160
primaryError: unknown,
@@ -69,23 +68,25 @@ async function recoverFailedAdoption<K extends string, H extends LiveResourceHan
6968
resourcePath,
7069
initialCleanupError,
7170
);
72-
if ((!persisted && transition.cleanupError === undefined) || transition.confirmed) {
73-
params.admissionLedger.clearUndurableCleanup(params.device);
74-
} else {
75-
params.admissionLedger.blockUndurableCleanup(
76-
params.device,
77-
transition.cleanupError instanceof Error
78-
? transition.cleanupError.message
79-
: 'The durable cleanup transition could not be confirmed',
80-
);
81-
}
71+
params.reportUndurableCleanup(
72+
params.device,
73+
(!persisted && transition.cleanupError === undefined) || transition.confirmed
74+
? { confirmed: true }
75+
: {
76+
confirmed: false,
77+
reason:
78+
transition.cleanupError instanceof Error
79+
? transition.cleanupError.message
80+
: 'The durable cleanup transition could not be confirmed',
81+
},
82+
);
8283
if (transition.cleanupError !== undefined)
8384
emitCleanupDiagnostic(definition, params, primaryError, transition.cleanupError);
8485
}
8586

86-
function confirmFailedAdoptionTransition<K extends string, H extends LiveResourceHandle<C>, C>(
87-
definition: DurableCaptureResourceDefinition<K, H, C>,
88-
params: Pick<AdoptStartedDurableCaptureParams<K, H>, 'sessionName' | 'fence'>,
87+
function confirmFailedAdoptionTransition<K extends string, H extends LiveResourceHandle<C>, C, S>(
88+
definition: DurableCaptureRecordDefinition<K, C>,
89+
params: Pick<AdoptStartedDurableCaptureParams<K, H, S>, 'sessionName' | 'fence'>,
8990
resourcePath: string,
9091
cleanupError: unknown | undefined,
9192
): { confirmed: boolean; cleanupError: unknown | undefined } {
@@ -108,8 +109,8 @@ function confirmFailedAdoptionTransition<K extends string, H extends LiveResourc
108109
}
109110
}
110111

111-
async function disposeFailedAdoption<K extends string, H extends AsyncDisposable>(
112-
params: AdoptStartedDurableCaptureParams<K, H>,
112+
async function disposeFailedAdoption<K extends string, H extends AsyncDisposable, S>(
113+
params: AdoptStartedDurableCaptureParams<K, H, S>,
113114
state: AdoptionState<H>,
114115
): Promise<unknown | undefined> {
115116
try {
@@ -121,9 +122,9 @@ async function disposeFailedAdoption<K extends string, H extends AsyncDisposable
121122
}
122123
}
123124

124-
function persistRecoveryTombstone<K extends string, H extends LiveResourceHandle<C>, C>(
125-
definition: DurableCaptureResourceDefinition<K, H, C>,
126-
params: AdoptStartedDurableCaptureParams<K, H>,
125+
function persistRecoveryTombstone<K extends string, H extends LiveResourceHandle<C>, C, S>(
126+
definition: DurableCaptureRecordDefinition<K, C>,
127+
params: AdoptStartedDurableCaptureParams<K, H, S>,
127128
resourcePath: string,
128129
): boolean {
129130
try {
@@ -159,10 +160,10 @@ function persistRecoveryTombstone<K extends string, H extends LiveResourceHandle
159160
}
160161
}
161162

162-
function createExpectedEnvelope<K extends string, H extends LiveResourceHandle<C>, C>(
163-
definition: DurableCaptureResourceDefinition<K, H, C>,
163+
function createExpectedEnvelope<K extends string, H extends LiveResourceHandle<C>, C, S>(
164+
definition: DurableCaptureRecordDefinition<K, C>,
164165
params: Pick<
165-
AdoptStartedDurableCaptureParams<K, H>,
166+
AdoptStartedDurableCaptureParams<K, H, S>,
166167
'sessionName' | 'device' | 'owner' | 'fence'
167168
>,
168169
descriptor: DurableResourceEnvelope<K>['descriptor'],
@@ -179,10 +180,10 @@ function createExpectedEnvelope<K extends string, H extends LiveResourceHandle<C
179180
});
180181
}
181182

182-
function validateStartedEnvelope<K extends string, H extends LiveResourceHandle<C>, C>(
183-
definition: DurableCaptureResourceDefinition<K, H, C>,
183+
function validateStartedEnvelope<K extends string, H extends LiveResourceHandle<C>, C, S>(
184+
definition: DurableCaptureRecordDefinition<K, C>,
184185
params: Pick<
185-
AdoptStartedDurableCaptureParams<K, H>,
186+
AdoptStartedDurableCaptureParams<K, H, S>,
186187
'sessionName' | 'device' | 'owner' | 'fence' | 'envelope'
187188
>,
188189
): DurableResourceEnvelope<K> {
@@ -224,8 +225,8 @@ function matchesAuthority(
224225
);
225226
}
226227

227-
function confirmFailedAdoption<K extends string, H extends LiveResourceHandle<C>, C>(
228-
definition: DurableCaptureResourceDefinition<K, H, C>,
228+
function confirmFailedAdoption<K extends string, C>(
229+
definition: DurableCaptureRecordDefinition<K, C>,
229230
resourcePath: string,
230231
expected: ResourceOwnershipFence,
231232
cleanupError: unknown | undefined,
@@ -249,9 +250,9 @@ function confirmFailedAdoption<K extends string, H extends LiveResourceHandle<C>
249250
return true;
250251
}
251252

252-
function emitCleanupDiagnostic<K extends string, H extends LiveResourceHandle<C>, C>(
253-
definition: DurableCaptureResourceDefinition<K, H, C>,
254-
params: Pick<AdoptStartedDurableCaptureParams<K, H>, 'sessionName'>,
253+
function emitCleanupDiagnostic<K extends string, H extends AsyncDisposable, C, S>(
254+
definition: DurableCaptureRecordDefinition<K, C>,
255+
params: Pick<AdoptStartedDurableCaptureParams<K, H, S>, 'sessionName'>,
255256
primaryError: unknown,
256257
cleanupError: unknown,
257258
): void {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { JsonObject } from '@agent-device/contracts/client';
2+
import type { DurableResourceEnvelope } from '@agent-device/contracts/durable-resource-envelope';
3+
import type { LiveResourceHandle } from '@agent-device/contracts/durable-resource';
4+
import type { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle';
5+
import type {
6+
ResourceOwnershipFence,
7+
RuntimeOwnerRef,
8+
} from '@agent-device/contracts/platform-runtime';
9+
import type { DeviceInfo } from '@agent-device/kernel/device';
10+
import type { DurableCaptureResourceStore } from './store.ts';
11+
12+
/**
13+
* The whole of the session store these mechanics touch: where a session's records live, and
14+
* how an updated session record is put back. The session type itself stays opaque — only a
15+
* definition's own `sessionSlot` looks inside it.
16+
*/
17+
export type DurableCaptureSessionStore<S> = Readonly<{
18+
set(name: string, session: S): void;
19+
resolveSessionDir(name: string): string;
20+
}>;
21+
22+
export type DurableCaptureSessionResource<K extends string, H extends AsyncDisposable> = Readonly<{
23+
handle: H;
24+
envelope: DurableResourceEnvelope<K>;
25+
}>;
26+
27+
export type DurableCaptureSessionSlot<K extends string, H extends AsyncDisposable, S> = Readonly<{
28+
read(session: S): DurableCaptureSessionResource<K, H> | undefined;
29+
replace(session: S, resource: DurableCaptureSessionResource<K, H> | undefined): S;
30+
}>;
31+
32+
/**
33+
* The session-free half of a definition. Recovery reattaches and terminalizes a persisted
34+
* record with no session in hand, so it names this and never the session type.
35+
*/
36+
export type DurableCaptureRecordDefinition<K extends string, C> = Readonly<{
37+
resourceKind: K;
38+
displayName: string;
39+
store: DurableCaptureResourceStore<K>;
40+
completionMetadata(result: C): JsonObject;
41+
messages: Readonly<{
42+
noActive: string;
43+
cleanupPendingHint: string;
44+
}>;
45+
}>;
46+
47+
export type DurableCaptureResourceDefinition<
48+
K extends string,
49+
H extends LiveResourceHandle<C>,
50+
C,
51+
S,
52+
> = DurableCaptureRecordDefinition<K, C> &
53+
Readonly<{ sessionSlot: DurableCaptureSessionSlot<K, H, S> }>;
54+
55+
/**
56+
* What the mechanics observed about a failed adoption's cleanup. Reporting it keeps the
57+
* admission decision — block a replacement start, or clear an earlier block — with the caller.
58+
*/
59+
export type DurableCaptureCleanupOutcome =
60+
| { confirmed: true }
61+
| { confirmed: false; reason: string };
62+
63+
export type AdoptStartedDurableCaptureParams<K extends string, H extends AsyncDisposable, S> = {
64+
reportUndurableCleanup(device: DeviceInfo, outcome: DurableCaptureCleanupOutcome): void;
65+
session: S;
66+
sessionName: string;
67+
sessionStore: DurableCaptureSessionStore<S>;
68+
device: DeviceInfo;
69+
owner: RuntimeOwnerRef;
70+
fence: ResourceOwnershipFence;
71+
pendingHandle: PendingTransferGuard<H>;
72+
envelope: DurableResourceEnvelope<K>;
73+
throwIfCanceled(): void;
74+
};

0 commit comments

Comments
 (0)