Skip to content

Commit c551347

Browse files
committed
refactor(replay): declare the dispatch options once in contracts
The daemon's request-private half now extends ReplayDispatchOptions from @agent-device/contracts/replay and the replay port emits the same type, so a key renamed on either side fails to compile. The Maestro mapping keeps its satisfies guard. SessionScope and DaemonInstallSource lose their aliases, doc blocks sit above the functions they describe, and the handler test pins the private-half merge order.
1 parent bf097d5 commit c551347

23 files changed

Lines changed: 241 additions & 192 deletions

‎packages/contracts/src/daemon-wire-request.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import type { CommandFlags } from './command-flags.ts';
22
import type {
3+
DaemonInstallSource,
34
LeaseBackend,
4-
DaemonInstallSource as PublicDaemonInstallSource,
55
DaemonRequestMeta as PublicDaemonRequestMeta,
66
DaemonRequest as WireRequest,
77
} from '@agent-device/kernel/contracts';
88
import type { PlatformSelector } from '@agent-device/kernel/device';
99

10-
export type DaemonInstallSource = PublicDaemonInstallSource;
11-
1210
/**
1311
* Request metadata as a client may send it, with the two fields the daemon narrows past what the
1412
* wire can express and the lease coordinates the router reads. Public throughout: nothing here

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,7 @@ export {
1010
export type { DaemonBatchStep } from '../batch-step.ts';
1111
export type { CliFlags, DaemonExcludedCliFlag } from '../cli-flags.ts';
1212
export type { CommandFlags, MaestroRuntimeFlags } from '../command-flags.ts';
13-
export type {
14-
DaemonInstallSource,
15-
DaemonWireRequest,
16-
DaemonWireRequestMeta,
17-
} from '../daemon-wire-request.ts';
13+
export type { DaemonWireRequest, DaemonWireRequestMeta } from '../daemon-wire-request.ts';
1814
export type { DispatchedCommand } from '../dispatched-command.ts';
1915
export { readOptionalInteger, readOptionalNumber } from '../input-validation.ts';
2016
export {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ 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+
ReplayDispatchOptions,
6+
ReplayOpenLifecycle,
57
ReplayObservationAuthority,
68
ReplayObservationAuthorityBinder,
79
ReplayObservationCapture,

‎packages/contracts/src/replay.ts‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { DaemonError } from '@agent-device/kernel/errors';
2-
import type { SnapshotState } from '@agent-device/kernel/snapshot';
2+
import type { DaemonResponse } from '@agent-device/kernel/contracts';
3+
import type { Rect, SnapshotState } from '@agent-device/kernel/snapshot';
4+
import type { GestureExecutionProfile } from './gesture-plan-types.ts';
5+
import type { SessionScope } from './session-scope.ts';
36
import type { SnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts';
47
import type {
58
LocalIdentity,
69
NodeStructuralDenotation,
710
TargetAncestryEntry,
11+
TargetAnnotationV1,
812
} from './target-annotation.ts';
913
import { WAIT_REASONS } from './wait.ts';
1014

@@ -221,3 +225,65 @@ export type ReplayObservationAuthority = Readonly<{
221225
* session-store pair the binding is drawn from.
222226
*/
223227
export type ReplayObservationAuthorityBinder = (signal?: AbortSignal) => ReplayObservationAuthority;
228+
229+
/** Runs before an `open` dispatches; a failure response aborts the dispatch with it. */
230+
export type ReplayOpenLifecycle = Readonly<{
231+
beforeDispatch: () => Promise<DaemonResponse | undefined>;
232+
}>;
233+
234+
/**
235+
* What a replay-issued dispatch asks the daemon to honor beyond the wire request. Declared once:
236+
* the daemon's request-private half extends this shape, and the replay port emits it, so a key
237+
* renamed on one side fails to compile on the other instead of being silently ignored.
238+
*/
239+
export type ReplayDispatchOptions = Readonly<{
240+
/**
241+
* PROVENANCE, set by the replay runtime on every action it dispatches from a replay plan,
242+
* annotated or not. It marks the action as AUTHORED (it came from the `.ad` under repair) rather
243+
* than typed out-of-band by the agent mid-repair. The repair-segment exclusion keys off its
244+
* ABSENCE: an authored `get`/`is`/`find`/`snapshot` step must survive into its own healed script,
245+
* while an interactive diagnostic read used only to LOCATE the repair target must not.
246+
* Trustworthy because the private half is daemon-only: the transport never copies it off the
247+
* wire, so no client can spoof authored provenance.
248+
*/
249+
replayPlanStep?: boolean;
250+
/**
251+
* Implicit caller scope resolved before a nested dispatch replaces the public session name with
252+
* its effective scoped key.
253+
*/
254+
resolvedSessionScope?: SessionScope;
255+
/**
256+
* ADR 0012 step 4 post-resolution guard: the verified target member's normalized local identity
257+
* AND structural denotation (document order + sibling ordinal), set ONLY by the replay step loop
258+
* when dispatching an annotated action whose pre-action verification passed. Interaction handlers
259+
* thread it into command options as `expectedResolvedTarget`; dispatch's own resolution refuses
260+
* (pre-action) when its winner differs in local identity OR structural position.
261+
*/
262+
replayTargetGuard?: ReplayTargetGuardDenotation;
263+
/** Dual-endpoint counterpart of `replayTargetGuard` for target-authored drag. */
264+
replayTargetGuards?: Readonly<{
265+
source: ReplayTargetGuardDenotation;
266+
destination: ReplayTargetGuardDenotation;
267+
}>;
268+
/**
269+
* ADR 0012 / #1349 deferred (post-resolution) identity verification: the recorded `target-v1`
270+
* landmark of an annotated selector `wait`, set ONLY by the replay step loop. The wait dispatch
271+
* threads it into the polling loop as `recordedLandmark`; success then requires a selector match
272+
* carrying this identity, and a timeout with rejected candidates surfaces the
273+
* `WAIT_LANDMARK_MISMATCH_REASON` refusal the step loop converts into an identity-mismatch
274+
* divergence.
275+
*/
276+
replayLandmarkGuard?: TargetAnnotationV1;
277+
openLifecycle?: ReplayOpenLifecycle;
278+
/** Terminate the targeted app without ending the owning daemon session. */
279+
closeAppOnly?: boolean;
280+
/**
281+
* Daemon-composed hierarchy capture used as operational evidence only. It must not issue or
282+
* replace client ref authority.
283+
*/
284+
observationOnly?: true;
285+
/** Provider-owned viewport already resolved while normalizing a nested gesture command. */
286+
gestureViewport?: Rect;
287+
/** Maestro-compat execution profile for timed coordinate swipes projected to `gesture pan`. */
288+
gestureExecutionProfile?: GestureExecutionProfile;
289+
}>;

‎src/daemon/__tests__/daemon-request-wire.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, test } from 'vitest';
22
import type { DeviceLease } from '@agent-device/contracts/device';
33
import type { DaemonRequest } from '../daemon-request.ts';
44
import type { DaemonWireRequest } from '@agent-device/contracts/command';
5+
import type { ExecutionPlan } from '../execution-plan.ts';
56
import type { SessionState } from '../session-state.ts';
67

78
/**
@@ -53,7 +54,10 @@ const wireCannotReachDeviceLease: IsExactly<Reaches<DaemonWireRequest, DeviceLea
5354

5455
// Positive controls: without these, a walk that simply never finds anything would pass above.
5556
const requestHasInternalKey: IsExactly<Extract<keyof DaemonRequest, 'internal'>, 'internal'> = true;
56-
const requestReachesSessionState: IsExactly<Reaches<DaemonRequest, SessionState>, true> = true;
57+
// The private half no longer names `SessionState` itself (its open-lifecycle hook takes no
58+
// session since the replay dispatch options moved to contracts); the batch execution plan is the
59+
// daemon-only shape it still carries, so it stands as the second positive control.
60+
const requestReachesExecutionPlan: IsExactly<Reaches<DaemonRequest, ExecutionPlan>, true> = true;
5761
const requestReachesDeviceLease: IsExactly<Reaches<DaemonRequest, DeviceLease>, true> = true;
5862

5963
test('the wire request shape carries no daemon-only half', () => {
@@ -65,7 +69,7 @@ test('the wire request shape carries no daemon-only half', () => {
6569
});
6670

6771
test('the daemon request shape does reach both, so the walk above can find them', () => {
68-
expect([requestHasInternalKey, requestReachesSessionState, requestReachesDeviceLease]).toEqual([
72+
expect([requestHasInternalKey, requestReachesExecutionPlan, requestReachesDeviceLease]).toEqual([
6973
true,
7074
true,
7175
true,

‎src/daemon/__tests__/session-routing.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device';
77
import { SessionStore } from '../session-store.ts';
88
import { resolveEffectiveSessionName, resolveSessionScope } from '../session-routing.ts';
99
import type { DaemonRequest } from '../daemon-request.ts';
10-
import type { SessionScope, SessionState } from '../session-state.ts';
10+
import type { SessionState } from '../session-state.ts';
11+
import type { SessionScope } from '@agent-device/contracts/session';
1112
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
1213

1314
function makeSession(name: string): SessionState {

‎src/daemon/daemon-request.ts‎

Lines changed: 8 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
11
import type { ExecutionPlan } from './execution-plan.ts';
2-
import type { GestureExecutionProfile } from '@agent-device/contracts/gesture-plan-types';
32
import type { PreresolvedInteractionTarget } from '@agent-device/contracts/interaction';
43
import type { DeviceLease } from '@agent-device/contracts/device';
5-
import type {
6-
ReplayTargetGuardDenotation,
7-
TargetAnnotationV1,
8-
} from '@agent-device/contracts/replay';
4+
import type { ReplayDispatchOptions } from '@agent-device/contracts/replay';
95
import type {
106
DaemonArtifact as PublicDaemonArtifact,
117
DaemonResponse as PublicDaemonResponse,
128
DaemonResponseData as PublicDaemonResponseData,
139
} from '@agent-device/kernel/contracts';
14-
import type { Rect } from '@agent-device/kernel/snapshot';
1510
import type { DaemonWireRequest } from '@agent-device/contracts/command';
16-
import type { SessionState } from './session-state.ts';
1711

1812
/**
1913
* The daemon's own request and response vocabulary: the wire shape from `@agent-device/contracts/command` (`DaemonWireRequest`)
@@ -24,11 +18,12 @@ import type { SessionState } from './session-state.ts';
2418
export type DaemonArtifact = PublicDaemonArtifact;
2519
export type DaemonResponseData = PublicDaemonResponseData;
2620

27-
export type DaemonOpenLifecycle = {
28-
beforeDispatch?: (session: SessionState) => Promise<DaemonResponse | undefined>;
29-
};
30-
31-
type DaemonRequestInternal = {
21+
/**
22+
* What only the daemon may see on a request: the replay dispatch options (declared once in
23+
* `@agent-device/contracts/replay`, so the port and the daemon cannot drift), plus the callbacks,
24+
* plan and lease the daemon composes for itself.
25+
*/
26+
type DaemonRequestInternal = ReplayDispatchOptions & {
3227
/**
3328
* What an `open` spent from its `--wait` budget while it waited for this device outside the
3429
* device execution lock. The refusal the open ends with has to say the budget was spent, and
@@ -37,7 +32,6 @@ type DaemonRequestInternal = {
3732
*/
3833
openDeviceWait?: { waitedMs: number };
3934
publicNetworkOnly?: true;
40-
openLifecycle?: DaemonOpenLifecycle;
4135
/**
4236
* The steps a batch still has ahead of this one. The open seam derives platform readiness
4337
* policy (runner demand) from it; the transport strips `internal`, so it never arrives from a
@@ -51,50 +45,6 @@ type DaemonRequestInternal = {
5145
*/
5246
retainDeviceExecutionLock?: (deviceId: string) => Promise<void>;
5347
admittedLease?: DeviceLease;
54-
/**
55-
* Daemon-composed hierarchy capture used as operational evidence only.
56-
* It must not issue or replace client ref authority.
57-
*/
58-
observationOnly?: true;
59-
/**
60-
* Implicit caller scope resolved before a nested dispatch replaces the
61-
* public session name with its effective scoped key.
62-
*/
63-
resolvedSessionScope?: SessionState['sessionScope'];
64-
/** Terminate the targeted app without ending the owning daemon session. */
65-
closeAppOnly?: boolean;
66-
/** Provider-owned viewport already resolved while normalizing a nested gesture command. */
67-
gestureViewport?: Rect;
68-
/** Maestro-compat execution profile for timed coordinate swipes projected to `gesture pan`. */
69-
gestureExecutionProfile?: GestureExecutionProfile;
70-
/**
71-
* ADR 0012 step 4 post-resolution guard: the verified target member's
72-
* normalized local identity AND structural denotation (document order +
73-
* sibling ordinal), set ONLY by the replay step loop when dispatching an
74-
* annotated action whose pre-action verification passed. Interaction
75-
* handlers thread it into command options as `expectedResolvedTarget`;
76-
* dispatch's own resolution refuses (pre-action) when its winner differs in
77-
* local identity OR structural position — the latter distinguishes a
78-
* different same-identity duplicate.
79-
*/
80-
replayTargetGuard?: ReplayTargetGuardDenotation;
81-
/** Dual-endpoint counterpart of replayTargetGuard for target-authored drag. */
82-
replayTargetGuards?: {
83-
source: ReplayTargetGuardDenotation;
84-
destination: ReplayTargetGuardDenotation;
85-
};
86-
/**
87-
* ADR 0012 / #1349 deferred (post-resolution) identity verification: the
88-
* recorded `target-v1` landmark of an annotated selector `wait`, set ONLY
89-
* by the replay step loop. The wait dispatch threads it into the polling
90-
* loop as `recordedLandmark`; success then requires a selector match
91-
* carrying this identity, and a timeout with rejected candidates surfaces
92-
* the `WAIT_LANDMARK_MISMATCH_REASON` refusal the step loop converts into
93-
* an identity-mismatch divergence. Never used by the generic pre-dispatch
94-
* verification path — a wait's landmark may legitimately be absent when
95-
* the step starts.
96-
*/
97-
replayLandmarkGuard?: TargetAnnotationV1;
9848
/**
9949
* ADR 0014 / #1654: the complete ref/node/tree target a mutating `find`
10050
* resolved against its fresh capture. Its presence both marks the ref as
@@ -104,32 +54,11 @@ type DaemonRequestInternal = {
10454
* side-effect seam and expires the frame.
10555
*/
10656
findResolvedTarget?: PreresolvedInteractionTarget;
107-
/**
108-
* #1271 stage 2 (ADR 0012 decision 6 amendment): PROVENANCE — set by the
109-
* replay runtime (`invokeResolvedReplayAction`,
110-
* `session-replay-action-runtime.ts`) on every action it dispatches from a
111-
* replay plan, annotated or not. It marks the action as AUTHORED (it came
112-
* from the `.ad` under repair) rather than typed out-of-band by the agent
113-
* mid-repair.
114-
*
115-
* The repair-segment exclusion keys off its ABSENCE: an authored
116-
* `get`/`is`/`find`/`snapshot` step must survive into its own healed script
117-
* (silently dropping it would make the heal quietly stop asserting what the
118-
* original flow asserted), while an interactive diagnostic read used only to
119-
* LOCATE the repair target must not. Command class alone cannot tell those
120-
* apart — they are the same command — so provenance is the discriminator and
121-
* `--record` is only for deliberately inserting an interactive read.
122-
*
123-
* Trustworthy because `internal` is daemon-only: `toDaemonRequest`
124-
* (`server/http-server.ts`) never copies it off the wire, so no client can
125-
* spoof authored provenance. Same channel as `replayTargetGuard` above.
126-
*/
127-
replayPlanStep?: boolean;
12857
};
12958

13059
/**
13160
* The server-side request: `DaemonWireRequest` plus what only the daemon may see. `internal`
132-
* carries `SessionState` callbacks and the admitted lease — which is why this type stays in the
61+
* carries the admitted lease, the execution plan and request-owned callbacks — which is why this type stays in the
13362
* daemon and why the wire half is declared separately. Zones below it that only need to classify
13463
* a command take `contracts/dispatched-command.ts` instead.
13564
*/

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger';
99
import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts';
1010
import type { SessionCommandParams } from '../session-command-input.ts';
11-
import { handleReplayTestCommand } from '../session-replay-command.ts';
11+
import { handleReplayTestCommand, replayInvoke } from '../session-replay-command.ts';
12+
import type { DaemonRequest } from '../../daemon-request.ts';
1213
import { makeSessionStore } from './session-test-harness.ts';
1314
import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts';
1415

@@ -51,3 +52,63 @@ test('replay test handler threads the complete video owner into the application
5152
expect(response.error.code).toBe('INVALID_ARGS');
5253
expect(response.error.message).not.toContain('Screen-recording runtime is not configured');
5354
});
55+
56+
test('replayInvoke re-attaches the originating private half, then folds the dispatch bag over it', async () => {
57+
const base: DaemonRequest = {
58+
token: 'token',
59+
session: 'default',
60+
command: 'replay',
61+
positionals: [],
62+
internal: {
63+
publicNetworkOnly: true,
64+
replayTargetGuard: {
65+
identity: { role: 'button', label: 'Old' },
66+
structural: { documentOrder: 0, sibling: 0 },
67+
},
68+
},
69+
};
70+
const invoked: DaemonRequest[] = [];
71+
const invoke = replayInvoke(async (request) => {
72+
invoked.push(request);
73+
return { ok: true, data: {} };
74+
}, base);
75+
76+
await invoke({
77+
token: 'token',
78+
session: 'default',
79+
command: 'tap',
80+
positionals: ['@e1'],
81+
dispatch: {
82+
replayPlanStep: true,
83+
replayTargetGuard: {
84+
identity: { role: 'button', label: 'New' },
85+
structural: { documentOrder: 1, sibling: 0 },
86+
},
87+
},
88+
});
89+
await invoke({ token: 'token', session: 'default', command: 'snapshot', positionals: [] });
90+
91+
expect(invoked[0]?.internal).toEqual({
92+
publicNetworkOnly: true,
93+
replayPlanStep: true,
94+
replayTargetGuard: {
95+
identity: { role: 'button', label: 'New' },
96+
structural: { documentOrder: 1, sibling: 0 },
97+
},
98+
});
99+
expect(invoked[0]).not.toHaveProperty('dispatch');
100+
expect(invoked[1]?.internal).toEqual(base.internal);
101+
});
102+
103+
test('replayInvoke sends no private half when neither side carries one', async () => {
104+
const invoked: DaemonRequest[] = [];
105+
const invoke = replayInvoke(
106+
async (request) => {
107+
invoked.push(request);
108+
return { ok: true, data: {} };
109+
},
110+
{ token: 'token', session: 'default', command: 'replay', positionals: [] },
111+
);
112+
await invoke({ token: 'token', session: 'default', command: 'snapshot', positionals: [] });
113+
expect(invoked[0]).not.toHaveProperty('internal');
114+
});

‎src/daemon/install-source-resolution.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { AppError } from '@agent-device/kernel/errors';
2-
import type { LocalInstallSource } from '@agent-device/kernel/contracts';
2+
import type { DaemonInstallSource, LocalInstallSource } from '@agent-device/kernel/contracts';
33
import { cleanupUploadedArtifact, prepareUploadedArtifact } from './artifact-tracking.ts';
4-
import type { DaemonInstallSource } from '@agent-device/contracts/command';
54
import type { DaemonRequest } from './daemon-request.ts';
65

76
function assertUnsupportedInstallSource(source: never): never {

‎src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,7 @@ test('test finalizes replay video exactly once when cancellation arrives after s
289289
if (nestedReq.command === 'open') {
290290
const provisionalSession = makeIosSession(nestedReq.session);
291291
sessionStore.set(nestedReq.session, provisionalSession);
292-
const hookResponse =
293-
await nestedReq.internal?.openLifecycle?.beforeDispatch?.(provisionalSession);
292+
const hookResponse = await nestedReq.internal?.openLifecycle?.beforeDispatch?.();
294293
if (hookResponse && !hookResponse.ok) return hookResponse;
295294
events.push('open:dispatch');
296295
}

0 commit comments

Comments
 (0)