Skip to content

Commit 2ddfe58

Browse files
committed
refactor(replay): move the response and observation vocabulary out of the daemon
First half of PR A for #2663: the replay unit stops importing two daemon modules, with no file moved and no behaviour changed. - `errorResponse`/`noActiveSessionError`/`DaemonFailureResponse` move to `@agent-device/kernel/contracts` beside the `DaemonResponse` shape they build (#2663's preferred disposition: they carry no daemon semantics). `src/daemon/ response.ts` keeps a daemon-local alias so the 58 non-replay handlers are untouched; the unit imports the owning specifier. `DaemonResponse`/ `DaemonResponseData` now come from kernel/contracts too, cutting all 22 `daemon-request.ts` response-type edges. - The ref-publication authority vocabulary (opaque evidence, capture, projection, result, authority) moves to `@agent-device/contracts/replay`, the cross-layer contract home both `internal-observation.ts` and the replay unit already sit above. `src/daemon/replay/**` no longer imports `src/daemon/internal-observation.ts`: `ReplaySession.observationStore` carries a `bindAuthority(signal)` binder the handler draws from the bound session store, so the unit gets the capability without naming the store pair or the session name. `sessionName` threaded through four capture helpers purely to re-bind that authority is now dead and removed. Two findings against the issue, both verified: - `req.internal` IS read in the unit (3 sites: `session-replay-action-runtime.ts:154`, `session-replay-maestro-runtime.ts:132,330`, `test-command.ts:237`), contrary to "No file in the unit reads `req.internal`". Those, plus the 10 remaining `DaemonRequest`/`DaemonInvokeFn` edges, need the neutral dispatch envelope #2652 established — the next step of PR A. - The stated destination `packages/ad-replay/src/daemon-port/` conflicts with `LOGICAL_MODULE_POLICIES`: `ad-replay` forbids `packages/maestro/`, and the six Maestro-side replay files import `@agent-device/maestro`, while `session-replay-maestro-failure.ts` also imports `@agent-device/ad-replay/ divergence`, so no existing engine package can host the port as written. Part of #2663
1 parent 91652a8 commit 2ddfe58

29 files changed

Lines changed: 203 additions & 193 deletions

‎packages/contracts/src/facades/replay.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ export type { RefFrameEffect } from '../ref-frame-effect.ts';
22
export { REPLAY_TARGET_GUARD_MISMATCH_REASON, WAIT_LANDMARK_MISMATCH_REASON } from '../replay.ts';
33
export type {
44
ReplayCommandResult,
5+
ReplayObservationAuthority,
6+
ReplayObservationAuthorityBinder,
7+
ReplayObservationCapture,
8+
ReplayObservationEvidence,
59
ReplayScriptSourceBundle,
610
ReplaySuiteAttemptFailure,
711
ReplaySuiteResult,
@@ -10,6 +14,8 @@ export type {
1014
ReplaySuiteTestResult,
1115
ReplaySuiteTestSkipReason,
1216
ReplaySuiteTestSkipped,
17+
ReplayRefPublicationProjection,
18+
ReplayRefPublicationResult,
1319
ReplayTargetGuardDenotation,
1420
WaitLandmarkMismatchEvidence,
1521
} from '../replay.ts';

‎packages/contracts/src/replay.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DaemonError } from '@agent-device/kernel/errors';
2+
import type { SnapshotState } from '@agent-device/kernel/snapshot';
23
import type { SnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts';
34
import type {
45
LocalIdentity,
@@ -174,3 +175,49 @@ export type ReplayScriptSourceBundle = Readonly<{
174175
entry: string;
175176
files: Readonly<Record<string, string>>;
176177
}>;
178+
179+
declare const REPLAY_OBSERVATION_EVIDENCE: unique symbol;
180+
181+
/**
182+
* Opaque capture lineage a replay engine may carry as data but cannot use to publish refs.
183+
* Only the daemon-owned finalizer behind {@link ReplayObservationAuthority} can resolve it.
184+
*/
185+
export type ReplayObservationEvidence = {
186+
readonly [REPLAY_OBSERVATION_EVIDENCE]: true;
187+
};
188+
189+
export type ReplayObservationCapture = Readonly<{
190+
evidence: ReplayObservationEvidence;
191+
refsGeneration: number;
192+
}>;
193+
194+
export type ReplayRefPublicationProjection = Readonly<{
195+
refsGeneration: number | undefined;
196+
refs: readonly string[];
197+
}>;
198+
199+
export type ReplayRefPublicationResult =
200+
| Readonly<{ published: true; refsGeneration: number; refCount: number }>
201+
| Readonly<{
202+
published: false;
203+
reason: 'empty' | 'cancelled' | 'stale-capture' | 'invalid-projection';
204+
}>;
205+
206+
/**
207+
* The daemon-owned authority a replay run uses to record an operational capture and, once its
208+
* own projection is exact, to activate exactly the refs that projection exposed. The capability is
209+
* already bound to one admitted session: a holder cannot name or reacquire another one.
210+
*/
211+
export type ReplayObservationAuthority = Readonly<{
212+
store(snapshot: SnapshotState): ReplayObservationCapture;
213+
finalize(
214+
evidence: ReplayObservationEvidence,
215+
projection: ReplayRefPublicationProjection,
216+
): ReplayRefPublicationResult;
217+
}>;
218+
219+
/**
220+
* Binds that authority for one request scope. A replay run receives the binder, never the
221+
* session-store pair the binding is drawn from.
222+
*/
223+
export type ReplayObservationAuthorityBinder = (signal?: AbortSignal) => ReplayObservationAuthority;

‎packages/kernel/src/contracts.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,41 @@ export type DaemonResponse =
171171
error: DaemonError;
172172
};
173173

174+
export type DaemonFailureResponse = Extract<DaemonResponse, { ok: false }>;
175+
176+
/**
177+
* The one `DaemonResponse` failure constructor. Declared beside the response type so a
178+
* command-side port that returns — rather than throws — a dispatch failure builds the same
179+
* wire shape as a daemon handler without importing the daemon.
180+
*/
181+
export function errorResponse(
182+
code: string,
183+
message: string,
184+
details?: Record<string, unknown>,
185+
options?: { hint?: string; retriable?: boolean },
186+
): DaemonFailureResponse {
187+
return {
188+
ok: false,
189+
error: {
190+
code,
191+
message,
192+
...(options?.hint ? { hint: options.hint } : {}),
193+
...(options?.retriable === undefined ? {} : { retriable: options.retriable }),
194+
...(details ? { details } : {}),
195+
},
196+
};
197+
}
198+
199+
export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Run open first.';
200+
201+
/**
202+
* Shared "No active session. Run open first." failure used by handlers that require
203+
* an open session before dispatching.
204+
*/
205+
export function noActiveSessionError(): DaemonFailureResponse {
206+
return errorResponse('SESSION_NOT_FOUND', NO_ACTIVE_SESSION_MESSAGE);
207+
}
208+
174209
export type JsonRpcId = string | number | null;
175210

176211
export type JsonRpcRequestEnvelope<TParams = unknown> = {

‎src/daemon/handlers/session-replay-command.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AppError } from '@agent-device/kernel/errors';
22
import type { DaemonRequest } from '../daemon-request.ts';
33
import type { SessionState } from '../session-state.ts';
44
import type { SessionStore } from '../session-store.ts';
5+
import { bindInternalObservationAuthority } from '../internal-observation.ts';
56
import type { LeaseRegistry } from '../lease-registry.ts';
67
import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts';
78
import type { PlatformResourceCleanup } from '../platform-resource-cleanup.ts';
@@ -104,7 +105,12 @@ export function createReplaySession(
104105
},
105106
observationStore: {
106107
get: () => store.get(name),
107-
update: updateSession,
108+
bindAuthority: (signal) =>
109+
bindInternalObservationAuthority({
110+
sessionStore: { get: () => store.get(name), update: updateSession },
111+
sessionName: name,
112+
...(signal ? { signal } : {}),
113+
}),
108114
},
109115
};
110116
}

‎src/daemon/internal-observation.ts‎

Lines changed: 13 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
11
import type { SnapshotState } from '@agent-device/kernel/snapshot';
2+
import type {
3+
ReplayObservationAuthority,
4+
ReplayObservationCapture,
5+
ReplayObservationEvidence,
6+
ReplayRefPublicationProjection,
7+
ReplayRefPublicationResult,
8+
} from '@agent-device/contracts/replay';
29
import { readSessionRuntimeRevision, refFrame } from './ref-frame.ts';
310
import type { RefFrame } from './ref-frame-slot.ts';
411
import { markSessionPartialRefsIssued, setSessionSnapshot } from './session-snapshot.ts';
512
import type { SessionState } from './session-state.ts';
613

7-
declare const INTERNAL_OBSERVATION_EVIDENCE: unique symbol;
8-
9-
/**
10-
* Opaque capture lineage that engines may carry as data but cannot use to
11-
* publish refs. Only the daemon-owned finalizer in this module can resolve it.
12-
*/
13-
export type InternalObservationEvidence = {
14-
readonly [INTERNAL_OBSERVATION_EVIDENCE]: true;
15-
};
16-
1714
type InternalObservationLineage = Readonly<{
1815
sessionName: string;
1916
session: SessionState;
@@ -26,31 +23,6 @@ type InternalObservationLineage = Readonly<{
2623

2724
const evidenceLineage = new WeakMap<object, InternalObservationLineage>();
2825

29-
type StoredInternalObservation = Readonly<{
30-
evidence: InternalObservationEvidence;
31-
refsGeneration: number;
32-
}>;
33-
34-
type ClientRefPublicationProjection = Readonly<{
35-
refsGeneration: number | undefined;
36-
refs: readonly string[];
37-
}>;
38-
39-
type ClientRefPublicationResult =
40-
| Readonly<{ published: true; refsGeneration: number; refCount: number }>
41-
| Readonly<{
42-
published: false;
43-
reason: 'empty' | 'cancelled' | 'stale-capture' | 'invalid-projection';
44-
}>;
45-
46-
type InternalObservationAuthority = Readonly<{
47-
store(snapshot: SnapshotState): StoredInternalObservation;
48-
finalize(
49-
evidence: InternalObservationEvidence,
50-
projection: ClientRefPublicationProjection,
51-
): ClientRefPublicationResult;
52-
}>;
53-
5426
type BoundInternalObservationSession = Readonly<{
5527
sessionStore: InternalObservationSessionStore;
5628
sessionName: string;
@@ -69,7 +41,7 @@ type InternalObservationSessionStore = Readonly<{
6941
*/
7042
export function bindInternalObservationAuthority(
7143
params: BoundInternalObservationSession,
72-
): InternalObservationAuthority {
44+
): ReplayObservationAuthority {
7345
return {
7446
store: (snapshot) => storeInternalObservation(params, snapshot),
7547
finalize: (evidence, projection) =>
@@ -88,7 +60,7 @@ export function bindInternalObservationAuthority(
8860
function storeInternalObservation(
8961
params: Pick<BoundInternalObservationSession, 'sessionStore' | 'sessionName'>,
9062
snapshot: SnapshotState,
91-
): StoredInternalObservation {
63+
): ReplayObservationCapture {
9264
const { sessionStore, sessionName } = params;
9365
let storedSession: SessionState | undefined;
9466
if (
@@ -106,7 +78,7 @@ function storeInternalObservation(
10678
throw new Error('Internal observation did not establish a snapshot generation.');
10779
}
10880

109-
const evidence = {} as InternalObservationEvidence;
81+
const evidence = {} as ReplayObservationEvidence;
11082
evidenceLineage.set(evidence, {
11183
sessionName,
11284
session,
@@ -130,10 +102,10 @@ function storeInternalObservation(
130102
function finalizeClientRefPublication(params: {
131103
sessionStore: InternalObservationSessionStore;
132104
sessionName: string;
133-
evidence: InternalObservationEvidence;
134-
projection: ClientRefPublicationProjection;
105+
evidence: ReplayObservationEvidence;
106+
projection: ReplayRefPublicationProjection;
135107
signal?: AbortSignal;
136-
}): ClientRefPublicationResult {
108+
}): ReplayRefPublicationResult {
137109
const lineage = evidenceLineage.get(params.evidence);
138110
// Evidence is a one-shot capability. Consume it before every outcome,
139111
// including empty, cancelled, invalid, and stale attempts, so request-end

‎src/daemon/replay/internal/__tests__/session-replay-divergence-observation.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ test('captureDivergenceObservation retryLaunchRace: the 12s deadline bounds retr
6767

6868
const observation = await captureDivergenceObservation({
6969
session: replayDivergenceForTest(sessionStore, sessionName).session!,
70-
sessionName,
7170
observationStore: replayDivergenceForTest(sessionStore, sessionName).observationStore,
7271
logPath: path.join(root, 'daemon.log'),
7372
action,

‎src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts‎

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capt
1111
import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts';
1212
import type { SnapshotState } from '@agent-device/kernel/snapshot';
1313
import type { ReplayDivergence } from '@agent-device/contracts/divergence';
14-
import { bindInternalObservationAuthority } from '../../../internal-observation.ts';
1514
import { expireRefFrame, refFrameScope, refFrameState, refFrameTree } from '../../../ref-frame.ts';
1615
import { markSessionPartialRefsIssued, setSessionSnapshot } from '../../../session-snapshot.ts';
1716
import { SessionStore } from '../../../session-store.ts';
@@ -58,10 +57,7 @@ function scenario(refCount = 20) {
5857
hittable: true,
5958
})),
6059
};
61-
const authority = bindInternalObservationAuthority({
62-
sessionStore: replaySession.observationStore,
63-
sessionName,
64-
});
60+
const authority = replaySession.observationStore.bindAuthority();
6561
const observation = authority.store(snapshot);
6662
return {
6763
root,
@@ -121,7 +117,6 @@ test('internal divergence capture updates observation without publishing client
121117

122118
const observation = await captureDivergenceObservation({
123119
session: input.session,
124-
sessionName: input.sessionName,
125120
observationStore: input.replaySession.observationStore,
126121
logPath: path.join(input.root, 'daemon.log'),
127122
action: {
@@ -148,7 +143,6 @@ test.each([
148143
const result = boundReplayDivergenceForSession({
149144
sessionStore: input.replaySession.store,
150145
observationStore: input.replaySession.observationStore,
151-
sessionName: input.sessionName,
152146
divergence: divergence(input),
153147
responseLevel: level,
154148
evidence: input.evidence,
@@ -168,7 +162,6 @@ test('missing capture evidence fails closed instead of exposing unauthorized ref
168162
const result = boundReplayDivergenceForSession({
169163
sessionStore: input.replaySession.store,
170164
observationStore: input.replaySession.observationStore,
171-
sessionName: input.sessionName,
172165
divergence: divergence(input),
173166
responseLevel: 'default',
174167
evidence: undefined,
@@ -189,7 +182,6 @@ test('degenerate projected refs fail closed when publication normalizes to empty
189182
const result = boundReplayDivergenceForSession({
190183
sessionStore: input.replaySession.store,
191184
observationStore: input.replaySession.observationStore,
192-
sessionName: input.sessionName,
193185
divergence: divergence(input, {
194186
screen: {
195187
state: 'available',
@@ -236,15 +228,13 @@ test('an empty outward projection consumes its one-shot capture evidence', () =>
236228
const first = boundReplayDivergenceForSession({
237229
sessionStore: input.replaySession.store,
238230
observationStore: input.replaySession.observationStore,
239-
sessionName: input.sessionName,
240231
divergence: empty,
241232
responseLevel: 'default',
242233
evidence: input.evidence,
243234
});
244235
const second = boundReplayDivergenceForSession({
245236
sessionStore: input.replaySession.store,
246237
observationStore: input.replaySession.observationStore,
247-
sessionName: input.sessionName,
248238
divergence: divergence(input),
249239
responseLevel: 'default',
250240
evidence: input.evidence,
@@ -266,7 +256,6 @@ test('successful overflow publishes the refs exposed by the exact artifact proje
266256
const result = boundReplayDivergenceForSession({
267257
sessionStore: input.replaySession.store,
268258
observationStore: input.replaySession.observationStore,
269-
sessionName: input.sessionName,
270259
divergence: divergence(input, {
271260
cause: { code: 'COMMAND_FAILED', message: 'x'.repeat(40_000) },
272261
}),
@@ -295,7 +284,6 @@ test('failed overflow artifact with no inline refs publishes nothing', () => {
295284
const result = boundReplayDivergenceForSession({
296285
sessionStore: input.replaySession.store,
297286
observationStore: input.replaySession.observationStore,
298-
sessionName: input.sessionName,
299287
divergence: divergence(input, {
300288
cause: { code: 'COMMAND_FAILED', message: 'x'.repeat(40_000) },
301289
}),
@@ -319,7 +307,6 @@ test('stale capture suppresses outward refs and preserves newer authority', () =
319307
const result = boundReplayDivergenceForSession({
320308
sessionStore: input.replaySession.store,
321309
observationStore: input.replaySession.observationStore,
322-
sessionName: input.sessionName,
323310
divergence: divergence(input),
324311
responseLevel: 'default',
325312
evidence: input.evidence,
@@ -338,7 +325,6 @@ test('cancellation after capture suppresses outward refs without reactivating an
338325
const result = boundReplayDivergenceForSession({
339326
sessionStore: input.replaySession.store,
340327
observationStore: input.replaySession.observationStore,
341-
sessionName: input.sessionName,
342328
divergence: divergence(input),
343329
responseLevel: 'default',
344330
evidence: input.evidence,

‎src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ async function buildFailureScenario(
6868
failure: options.warnings ? { ...failure, warnings: options.warnings } : failure,
6969
replayPath: path.join(root, 'flow.yaml'),
7070
req: baseReq({ flags: { replayBackend: 'maestro', platform: 'ios' } }),
71-
sessionName,
7271
sessionStore: replaySession.store,
7372
observationStore: replaySession.observationStore,
7473
logPath: path.join(root, 'daemon.log'),

‎src/daemon/replay/internal/command-types.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1+
import type { ReplayObservationAuthorityBinder } from '@agent-device/contracts/replay';
12
import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test';
2-
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../daemon-request.ts';
3+
import type { DaemonInvokeFn, DaemonRequest } from '../../daemon-request.ts';
34
import type { SessionState } from '../../session-state.ts';
45
import type {
56
ReplaySessionMutationStore as ReplaySessionMutationStoreCapability,
67
ReplaySessionStore as ReplaySessionStoreCapability,
78
} from '../../session-replay-coordinator.ts';
9+
import { type DaemonResponse } from '@agent-device/kernel/contracts';
810

911
export type ReplaySessionStore = ReplaySessionStoreCapability;
1012
export type ReplaySessionMutationStore = ReplaySessionMutationStoreCapability;
11-
export type ReplaySessionObservationStore = Readonly<{
13+
/**
14+
* The replay side of the ref-publication owner: the operational capture it reads, and a binder for
15+
* the daemon-side authority that records a capture and publishes exactly its own projection. The
16+
* session-store pair the authority is drawn from stays in the daemon.
17+
*/
18+
export type ReplaySessionObservation = Readonly<{
1219
get: () => SessionState | undefined;
13-
update: (mutate: (session: SessionState) => void) => boolean;
20+
bindAuthority: ReplayObservationAuthorityBinder;
1421
}>;
1522

1623
export type ReplayTestSessionFactory = (sessionName: string, logPath: string) => ReplaySession;
@@ -23,7 +30,7 @@ export type ReplaySession = Readonly<{
2330
/** Bound repair writes; replay internals never receive an unbound SessionStore setter. */
2431
mutationStore: ReplaySessionMutationStore;
2532
/** Bound observation writes used only by the existing ref-publication owner. */
26-
observationStore: ReplaySessionObservationStore;
33+
observationStore: ReplaySessionObservation;
2734
}>;
2835

2936
export type ReplayCommand = Readonly<{

0 commit comments

Comments
 (0)