Skip to content
Merged
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
8 changes: 7 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ task touches:
(`active`/`expired`), and an issuance scope (`all` for a complete snapshot, or the bounded set of
ref bodies a partial publication emitted). Owned solely by `src/daemon/ref-frame.ts`. A complete
snapshot activates an `all` frame; `find`/settled diff/replay divergence activate a bounded partial
frame that supersedes the prior one; internal read captures never activate or reindex it.
frame that supersedes the prior one; internal read captures never activate or reindex it. Replay
captures return opaque, one-shot lineage evidence, and daemon response composition activates refs
synchronously only after the exact inline or successfully written overflow projection is known.
Every finalization attempt consumes its evidence. The outer replay retains its stable session lock
plus the device lock when known through finalization, so external commands cannot interleave;
nested replay actions reuse that scope and invalidate lineage through capture, ref-frame,
side-effect, or session-lifetime changes.
- Frame expiry seam (ADR 0014): every mutating leaf calls `expireRefFrame` synchronously, immediately
before the device op that may change element identity (after all pre-action guards), so a
post-dispatch failure still leaves the frame expired — there is no success-only rollback. Ref
Expand Down
20 changes: 14 additions & 6 deletions docs/daemon-modularity-proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,9 @@ export interface AdReplayRuntime {
executeStep(input: AdStepExecution): Promise<AdStepOutcome>;

/**
* Capture a replay observation and atomically update the session observation plus
* the exact partial ref frame exposed by the outcome.
* Capture replay evidence and update operational observation state only.
* The returned value may carry opaque, one-shot lineage evidence; it cannot
* publish or replace client ref authority.
*/
observeReplay(input: AdObservationRequest): Promise<AdObservation>;

Expand All @@ -323,8 +324,15 @@ export type AdStepExecution = {
The daemon runtime adapter owns translation to `DaemonRequest`, parent flag merging,
`internal.replayPlanStep`, target/landmark guards, session-scope inheritance through
`internal.resolvedSessionScope`, request-level provider-scope reuse, and response/error projection.
`observeReplay` deliberately combines capture, stored observation update, and exact partial-frame
activation. Splitting those operations would expose an illegal intermediate state under ADR 0014.
`observeReplay` updates the stored operational observation and returns opaque, one-shot
capture-lineage evidence. Daemon response composition projects the exact inline response or
successfully written overflow artifact, consumes and validates that evidence synchronously, and only
then activates the matching partial frame. Every finalization attempt consumes the token, including
empty, cancelled, invalid, and stale outcomes. The engine cannot publish refs or access session/store
authority; no asynchronous work may occur between activation and returning the projected response.
The outer replay keeps its stable session lock plus the device lock when known through this
finalization. Same-session nested actions reuse the admitted scope; their meaningful changes
invalidate lineage through observation, ref-frame, runtime-revision, or session-lifetime checks.

The engine receives no session interface. A daemon-owned coordinator wraps `execute` inside the
already locked scope:
Expand Down Expand Up @@ -854,8 +862,8 @@ This is mostly ownership correction around an already-proven port.
- Keep canonical `.ad` syntax in a pure internal codec shared with session publication. Publication
keeps portability/destination validation, atomic commit, and lifecycle authority.
- Preserve inherited session scope, request-level provider scope, the existing lock, and the
side-effect/ref-frame seam. `observeReplay` atomically couples capture and partial-frame
activation.
side-effect/ref-frame seam. `observeReplay` updates operational observation and returns opaque
lineage evidence; daemon response composition alone activates the exact projected partial frame.
- Delete superseded helpers and handler-mock tests as their interface replacements land.

### Phase 5: narrow platform binding
Expand Down
21 changes: 21 additions & 0 deletions src/compat/maestro/__tests__/daemon-runtime-port-support.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ test('composes operation-specific Maestro flags with the runtime envelope', asyn
);
});

test('marks Maestro hierarchy captures as daemon-private observations', async () => {
const invoke = vi.fn(async () => ({ ok: true as const, data: { nodes: [] } }));

await invokeMaestroPublicOperation(
{
baseReq: makeBaseRequest(),
invoke,
dependencies: makeDependencies(),
platform: 'ios',
},
{ kind: 'snapshot' },
);

expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
command: 'snapshot',
internal: expect.objectContaining({ observationOnly: true }),
}),
);
});

test('preserves diagnostic metadata carried inside daemon error details', async () => {
const invoke = vi.fn(async () => ({
ok: false as const,
Expand Down
9 changes: 6 additions & 3 deletions src/compat/maestro/daemon-runtime-port-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,19 @@ export async function invokeMaestroPublicOperation(
...baseReq
} = options.baseReq;
const effectiveFlags = flagsWith(baseFlags, projected.flags ?? {});
const effectiveInternal =
projected.internal === undefined ? baseInternal : { ...baseInternal, ...projected.internal };
const effectiveInternal = stripUndefined({
...baseInternal,
...projected.internal,
...(operation.kind === 'snapshot' ? { observationOnly: true as const } : {}),
});
const response = await options.invoke(
stripUndefined({
...baseReq,
command: projected.command,
positionals: projected.positionals,
input: projected.input,
flags: effectiveFlags,
internal: effectiveInternal,
internal: Object.keys(effectiveInternal).length > 0 ? effectiveInternal : undefined,
}),
);
if (!response.ok) throw daemonResponseError(response);
Expand Down
20 changes: 12 additions & 8 deletions src/daemon/__tests__/http-server-rpc-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ test('malformed command params (command as number) yield 400 / -32602', async (t
});

// `DaemonRequest.internal` carries semantics-affecting bits stamped inside the
// daemon — `replayPlanStep` (#1271 stage 2) decides whether a read is an
// authored plan step or an out-of-band diagnostic, and so whether it lands in a
// repair heal. Two independent allowlists keep it unreachable from the wire:
// `commandRpcParamsSchema` projects only its eight named fields, and
// daemon — `replayPlanStep` controls recording provenance and
// `observationOnly` suppresses snapshot ref issuance for daemon-composed
// Maestro reads. Two independent allowlists keep both unreachable from the
// HTTP wire: `commandRpcParamsSchema` projects only its eight named fields, and
// `toDaemonRequest` then builds the request field by field. Both would have to
// regress for a caller to stamp its own provenance; this pins the resulting
// regress for a caller to stamp these semantics; this pins the resulting
// boundary contract so neither drifts silently.
test('the rpc boundary never accepts internal request fields from the wire', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;
Expand All @@ -105,9 +105,13 @@ test('the rpc boundary never accepts internal request fields from the wire', asy
id: 'req-1',
method: 'agent_device.command',
params: {
command: 'get',
positionals: ['text', 'id=whatever'],
internal: { replayPlanStep: true, replayTargetGuard: { ref: '@e1' } },
command: 'snapshot',
positionals: [],
internal: {
observationOnly: true,
replayPlanStep: true,
replayTargetGuard: { ref: '@e1' },
},
},
}),
});
Expand Down
197 changes: 197 additions & 0 deletions src/daemon/__tests__/internal-observation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import os from 'node:os';
import path from 'node:path';
import { expect, test } from 'vitest';
import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts';
import type { SnapshotState } from '../../kernel/snapshot.ts';
import { bindInternalObservationAuthority } from '../internal-observation.ts';
import { expireRefFrame } from '../ref-frame.ts';
import { markSessionPartialRefsIssued, setSessionSnapshot } from '../session-snapshot.ts';
import { SessionStore } from '../session-store.ts';

function snapshot(ref: string, label = ref): SnapshotState {
return {
createdAt: Date.now(),
nodes: [
{
index: 0,
depth: 0,
type: 'Button',
ref,
label,
rect: { x: 0, y: 0, width: 100, height: 44 },
hittable: true,
},
],
};
}

function scenario() {
const root = path.join(os.tmpdir(), `agent-device-internal-observation-${crypto.randomUUID()}`);
const sessionStore = new SessionStore(path.join(root, 'sessions'));
const sessionName = 'default';
const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' });
const prior = snapshot('e1', 'Previously published');
setSessionSnapshot(session, prior);
markSessionPartialRefsIssued(session, ['e1']);
sessionStore.set(sessionName, session);
const captured = snapshot('e2', 'Internal capture');
const authority = bindInternalObservationAuthority({
sessionStore,
sessionName,
});
const stored = authority.store(captured);
return { sessionStore, sessionName, session, prior, captured, authority, ...stored };
}

function publishCurrent(input: ReturnType<typeof scenario>, signal?: AbortSignal) {
const authority = bindInternalObservationAuthority({
sessionStore: input.sessionStore,
sessionName: input.sessionName,
...(signal ? { signal } : {}),
});
return authority.finalize(input.evidence, {
refsGeneration: input.refsGeneration,
refs: ['@e2'],
});
}

test('publishes exactly the validated outward refs from a current internal capture', () => {
const input = scenario();

expect(publishCurrent(input)).toEqual({
published: true,
refsGeneration: input.refsGeneration,
refCount: 1,
});
expect(input.session.refFrameState).toBe('active');
expect(input.session.refFrameScope).toEqual(new Set(['e2']));
expect(input.session.refFrameTree).toBe(input.captured);
expect(input.session.refFrameGeneration).toBe(input.refsGeneration);
expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' });
});

test('empty publication never supersedes prior client authority', () => {
const input = scenario();

const result = input.authority.finalize(input.evidence, {
refsGeneration: input.refsGeneration,
refs: [],
});

expect(result).toEqual({ published: false, reason: 'empty' });
expect(input.session.refFrameScope).toEqual(new Set(['e1']));
expect(input.session.refFrameTree).toBe(input.prior);
});

test('an empty finalization consumes its evidence and cannot later publish', () => {
const input = scenario();

const first = input.authority.finalize(input.evidence, {
refsGeneration: input.refsGeneration,
refs: [],
});

expect(first).toEqual({ published: false, reason: 'empty' });
expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' });
expect(input.session.refFrameScope).toEqual(new Set(['e1']));
});

test('cancelled publication leaves prior client authority intact', () => {
const input = scenario();
const controller = new AbortController();
controller.abort();

expect(publishCurrent(input, controller.signal)).toEqual({
published: false,
reason: 'cancelled',
});
expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' });
expect(input.session.refFrameScope).toEqual(new Set(['e1']));
expect(input.session.refFrameTree).toBe(input.prior);
});

test('a newer capture makes older capture evidence stale', () => {
const input = scenario();
setSessionSnapshot(input.session, snapshot('e3', 'Later capture'));

expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' });
expect(input.session.refFrameScope).toEqual(new Set(['e1']));
});

test('a later ref publication prevents an older capture from superseding it', () => {
const input = scenario();
markSessionPartialRefsIssued(input.session, ['e2']);
const laterTree = input.session.refFrameTree;

expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' });
expect(input.session.refFrameTree).toBe(laterTree);
});

test('a runtime side effect invalidates capture evidence without rolling authority back', () => {
const input = scenario();
expireRefFrame(input.session);

expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' });
expect(input.session.refFrameState).toBe('expired');
expect(input.session.refFrameTree).toBe(input.prior);
});

test('runtime revision invalidates evidence even when the ref frame was already expired', () => {
const input = scenario();
expireRefFrame(input.session);
const recaptured = input.authority.store(snapshot('e3', 'Captured after expiry'));
expireRefFrame(input.session);

const result = input.authority.finalize(recaptured.evidence, {
refsGeneration: recaptured.refsGeneration,
refs: ['e3'],
});

expect(result).toEqual({ published: false, reason: 'stale-capture' });
expect(input.session.refFrameState).toBe('expired');
expect(input.session.refFrameTree).toBe(input.prior);
});

test('session close invalidates capture evidence', () => {
const input = scenario();
input.sessionStore.delete(input.sessionName);

expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' });
// Even restoring the exact same session object cannot revive evidence that
// a stale finalization attempt already consumed.
input.sessionStore.set(input.sessionName, input.session);
expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' });
expect(input.session.refFrameScope).toEqual(new Set(['e1']));
});

test('same-name session replacement cannot inherit capture evidence', () => {
const input = scenario();
const replacement = makeIosSession(input.sessionName, { appBundleId: 'com.example.app' });
input.sessionStore.set(input.sessionName, replacement);

expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' });
expect(replacement.refFrameTree).toBeUndefined();
});

test('generation and ref projection must match the exact captured tree', () => {
const wrongGeneration = scenario();
const generationResult = wrongGeneration.authority.finalize(wrongGeneration.evidence, {
refsGeneration: wrongGeneration.refsGeneration + 1,
refs: ['e2'],
});
expect(generationResult).toEqual({ published: false, reason: 'invalid-projection' });
expect(publishCurrent(wrongGeneration)).toEqual({
published: false,
reason: 'stale-capture',
});

const wrongRef = scenario();
const refResult = wrongRef.authority.finalize(wrongRef.evidence, {
refsGeneration: wrongRef.refsGeneration,
refs: ['e999'],
});
expect(refResult).toEqual({ published: false, reason: 'invalid-projection' });
expect(publishCurrent(wrongRef)).toEqual({ published: false, reason: 'stale-capture' });
expect(wrongGeneration.session.refFrameScope).toEqual(new Set(['e1']));
expect(wrongRef.session.refFrameScope).toEqual(new Set(['e1']));
});
55 changes: 55 additions & 0 deletions src/daemon/__tests__/request-execution-locks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { expect, test } from 'vitest';
import { createRequestExecutionLocks } from '../request-execution-locks.ts';

test('a dynamically retained device lock lasts until the owning request completes', async () => {
const locks = new Map<string, Promise<unknown>>();
const replayLocks = createRequestExecutionLocks({
locks,
initialKeys: ['session:replay'],
});
const externalLocks = createRequestExecutionLocks({
locks,
initialKeys: ['session:external', 'device:SIM-001'],
});
let finishReplay: () => void = () => {};
const replayFinished = new Promise<void>((resolve) => {
finishReplay = resolve;
});
let markDeviceRetained: () => void = () => {};
const deviceRetained = new Promise<void>((resolve) => {
markDeviceRetained = resolve;
});

const replayRun = replayLocks.run(async () => {
await replayLocks.retainDevice('SIM-001');
markDeviceRetained();
await replayFinished;
});
await deviceRetained;

let externalEntered = false;
const externalRun = externalLocks.run(async () => {
externalEntered = true;
});
await Promise.resolve();
expect(externalEntered).toBe(false);

finishReplay();
await replayRun;
await externalRun;
expect(externalEntered).toBe(true);
});

test('retaining an initial device lock is reentrant within the request', async () => {
const requestLocks = createRequestExecutionLocks({
locks: new Map(),
initialKeys: ['session:replay', 'device:SIM-001'],
});

await expect(
requestLocks.run(async () => {
await requestLocks.retainDevice('SIM-001');
return 'done';
}),
).resolves.toBe('done');
});
Loading
Loading