diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 0f53d8000c..d582a3eba1 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -13,8 +13,8 @@ const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly> = { export const DAEMON_MODULARITY_BASELINE = { sessionState: { - writerOwnedFields: 30, - ownerFileClaims: 42, + writerOwnedFields: 29, + ownerFileClaims: 40, }, largestTypeCycle: { zoneMembers: LARGEST_TYPE_CYCLE_ZONE_CEILINGS, diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index c86571336f..46a53a8eb0 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -73,10 +73,6 @@ export const SESSION_STATE_FIELD_OWNERS: Readonly): SessionState { return makeSession(name, { device: MACOS_DEVICE, ...overrides }); } + +// --- Script-authoring session states --- +// +// The three factories below name the states a session-script session can be in, +// instead of leaving each test to re-derive them from a pile of `saveScript*` +// booleans. They exist because the fields are NOT independent: `recordSession` +// without a boundary is an ordinary recording, a boundary without +// `saveScriptComplete` is an ARMED-but-uncommittable repair, and only the +// COMPLETE combination publishes. Spelling that out per test made the +// distinction the tests are actually about (ordinary vs repair, armed vs +// complete) the hardest thing to see in them. +// +// A test that deliberately exercises an odd combination (a boundary with no +// recording, say) should still build it inline — these are for the states +// production actually produces. + +/** + * ADR 0016 ordinary authoring recording: `recordSession` armed, NO repair + * boundary. This is a plain `open --save-script` / `close --save-script` + * session, and the baseline for every authoring-side handler test (target-v1 + * evidence, parameterized fills, landmark waits) that only needs the session to + * be recording its actions. + * + * "Authoring", not "recording": `session.recording` is the unrelated + * screen-capture state. + */ +export function makeAuthoringSession( + name: string, + overrides?: Partial, +): SessionState { + return makeIosSession(name, { recordSession: true, ...overrides }); +} + +/** + * ADR 0012 decision 6: a session ARMED for repair by `replay --save-script` — + * recording plus the repair-run boundary watermark, which is what the writer's + * `repairArmed` actually keys off (`saveScriptBoundary !== undefined`). + * + * ARMED, not COMPLETE: a writer handed this session ABORTS (publishes no + * prefix) rather than committing. Use `makeRepairCompleteSession` for a + * committable one. + */ +export function makeRepairArmedSession( + name: string, + overrides?: Partial, +): SessionState { + return makeAuthoringSession(name, { saveScriptBoundary: 0, ...overrides }); +} + +/** + * The same repair transaction advanced to COMPLETE — the plan reached its last + * executable step with no outstanding divergence — so a writer commits it + * (ARMED -> COMPLETE -> COMMITTED). + */ +export function makeRepairCompleteSession( + name: string, + overrides?: Partial, +): SessionState { + return makeRepairArmedSession(name, { saveScriptComplete: true, ...overrides }); +} diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index 7e05161601..c5e23caf76 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -21,26 +21,36 @@ import { createRequestHandler } from '../request-router.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { + makeAuthoringSession, + makeRepairCompleteSession, + makeSession, +} from '../../__tests__/test-utils/session-factories.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError, retriableForErrorCode } from '@agent-device/kernel/errors'; import { supportedPlatformsForCommand } from '../../core/capabilities.ts'; const mockDispatch = vi.mocked(dispatchCommand); +/** + * This file pins its own simulator rather than reusing the shared iOS fixture: + * the tenant-scoped `simulatorSetPath` is part of what the router under test + * carries through. Everything else comes from the shared session factories. + */ +const TENANT_SIMULATOR: DeviceInfo = { + platform: 'apple', + target: 'mobile', + id: 'SIM-001', + name: 'iPhone 16', + kind: 'simulator', + booted: true, + simulatorSetPath: '/tmp/tenant-a/set', +}; + +const TENANT_SESSION_DEFAULTS = { device: TENANT_SIMULATOR, createdAt: 1_700_000_000_000 } as const; + function makeIosSession(name: string): SessionState { - return { - name, - createdAt: 1_700_000_000_000, - actions: [], - device: { - platform: 'apple', - target: 'mobile', - id: 'SIM-001', - name: 'iPhone 16', - kind: 'simulator', - booted: true, - simulatorSetPath: '/tmp/tenant-a/set', - }, - }; + return makeSession(name, TENANT_SESSION_DEFAULTS); } function makeHandler(sessionStore = makeSessionStore('agent-device-router-typed-error-')) { @@ -135,11 +145,10 @@ test('deterministic errors (INVALID_ARGS) are returned with the default shape // `error.retriable ?? retriableForErrorCode(error.code)` fallback is caught. test('BLOCKER 2 (second follow-up): a repair-close platform-close failure surfaces retriable:true and diagnosticId/logPath/details at the TOP level through the router', async () => { const { sessionStore, handler } = makeHandler(); - const session = makeIosSession('typed-error'); - session.recordSession = true; - session.saveScriptBoundary = 0; - session.saveScriptComplete = true; - session.actions = [{ ts: 1, command: 'open', positionals: ['Demo'], flags: {} }]; + const session = makeRepairCompleteSession('typed-error', { + ...TENANT_SESSION_DEFAULTS, + actions: [{ ts: 1, command: 'open', positionals: ['Demo'], flags: {} }], + }); sessionStore.set('typed-error', session); // DEVICE_NOT_FOUND is not in `retriableForErrorCode`'s conservative allow @@ -174,8 +183,7 @@ test('BLOCKER 2 (second follow-up): a repair-close platform-close failure surfac // details.retriable hoisting. test('#1391: an ordinary close-time script-save failure surfaces details.reason/path and retriable:false through the router, and the session is torn down', async () => { const { sessionStore, handler } = makeHandler(); - const session = makeIosSession('typed-error'); - session.recordSession = true; + const session = makeAuthoringSession('typed-error', TENANT_SESSION_DEFAULTS); const targetPath = path.join( os.tmpdir(), `agent-device-router-typed-error-${Date.now()}-${Math.random().toString(36).slice(2)}.ad`, diff --git a/src/daemon/__tests__/session-script-writer.test.ts b/src/daemon/__tests__/session-script-writer.test.ts index 648e93417d..9ccebf6d8e 100644 --- a/src/daemon/__tests__/session-script-writer.test.ts +++ b/src/daemon/__tests__/session-script-writer.test.ts @@ -4,7 +4,11 @@ import os from 'node:os'; import path from 'node:path'; import { HEAL_COMPLETE_SENTINEL, SessionScriptWriter } from '../session-script-writer.ts'; import { recordActionEntry } from '../session-action-recorder.ts'; -import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; +import { + makeAuthoringSession, + makeRepairArmedSession, + makeRepairCompleteSession, +} from '../../__tests__/test-utils/session-factories.ts'; import { parseReplayScriptDetailed } from '../../replay/script.ts'; import type { SessionAction } from '../types.ts'; @@ -27,13 +31,11 @@ function writeAndParse( test('write() slices session.actions from saveScriptBoundary onward, excluding pre-watermark actions', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-boundary-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, + // Fix 2: a repair-armed write only publishes once explicitly finalized + // (`close --save-script`) — COMPLETE here to isolate THIS test's own concern + // (boundary slicing), covered separately below. + const session = makeRepairCompleteSession('default', { saveScriptBoundary: 2, - // Fix 2: a repair-armed write only publishes once explicitly finalized - // (`close --save-script`) — set here to isolate THIS test's own concern - // (boundary slicing), covered separately below. - saveScriptComplete: true, actions: [ action({ command: 'open', positionals: ['Demo'] }), action({ command: 'click', positionals: ['label="Old"'] }), @@ -50,8 +52,7 @@ test('write() slices session.actions from saveScriptBoundary onward, excluding p test('write() with no boundary set (ordinary open/close --save-script) serializes the full history, unchanged', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-no-boundary-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, + const session = makeAuthoringSession('default', { actions: [ action({ command: 'open', positionals: ['Demo'] }), action({ command: 'click', positionals: ['label="Save"'] }), @@ -67,10 +68,8 @@ test('write() with no boundary set (ordinary open/close --save-script) serialize test('a boundary-sliced script still strips diagnostic snapshot actions', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-snapshot-strip-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, + const session = makeRepairCompleteSession('default', { saveScriptBoundary: 1, - saveScriptComplete: true, actions: [ action({ command: 'open', positionals: ['Demo'] }), action({ command: 'snapshot', positionals: [] }), @@ -92,10 +91,7 @@ test('a boundary-sliced script still strips diagnostic snapshot actions', () => test('a recorded ref that resolved to a selectorChain writes a clean selector line, never the bare ref', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-resolved-ref-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { actions: [ action({ command: 'press', @@ -114,10 +110,7 @@ test('a recorded ref that resolved to a selectorChain writes a clean selector li test('a recorded ref that never resolved to a selectorChain throws instead of emitting a bare @ref', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-bare-ref-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { actions: [action({ command: 'press', positionals: ['@e7'] })], }); @@ -135,10 +128,7 @@ test('a recorded ref that never resolved to a selectorChain throws instead of em test('a bare-@ref fill action also fails loud, not just click-like commands', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-bare-ref-fill-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { actions: [action({ command: 'fill', positionals: ['@e9', 'hello'] })], }); @@ -150,10 +140,7 @@ test('a bare-@ref fill action also fails loud, not just click-like commands', () test('a bare @ref later in the same session (after a resolved earlier action) still fails loud, writing nothing', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-partial-write-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { actions: [ action({ command: 'open', positionals: ['Demo'] }), action({ @@ -176,10 +163,10 @@ test('an ordinary (non-repair-armed) recording keeps the existing bare-ref fallb path.join(os.tmpdir(), 'agent-device-script-writer-ordinary-bare-ref-'), ); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, - // No saveScriptBoundary: this session was armed by plain `open`/`close - // --save-script`, never by `replay --save-script` — R4 does not apply. + // Ordinary recording (no repair boundary): this session was armed by plain + // `open`/`close --save-script`, never by `replay --save-script` — R4 does + // not apply. + const session = makeAuthoringSession('default', { actions: [action({ command: 'click', positionals: ['@e12'], result: { refLabel: 'Save' } })], }); @@ -201,12 +188,8 @@ test('write() publishes cleanly when the target does not exist yet', () => { const writer = new SessionScriptWriter(path.join(root, 'sessions')); const healedPath = path.join(root, 'flows', 'login.healed.ad'); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { saveScriptPath: healedPath, - saveScriptDefaultedHealedPath: true, actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -235,12 +218,8 @@ test('write() refuses to clobber an existing COMPLETE DEFAULT .healed.ad', () => ); const before = fs.readFileSync(healedPath, 'utf8'); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { saveScriptPath: healedPath, - saveScriptDefaultedHealedPath: true, actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -268,12 +247,8 @@ test('write() now refuses to clobber a stale PARTIAL (non-sentinel) .healed.ad a fs.writeFileSync(healedPath, 'context platform=ios device="x"\nclick id="stale-partial"\n'); const before = fs.readFileSync(healedPath, 'utf8'); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { saveScriptPath: healedPath, - saveScriptDefaultedHealedPath: true, actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -296,12 +271,8 @@ test('write(session, { force: true }) overwrites an existing COMPLETE target ato `context platform=ios device="x"\nclick id="old"\n${HEAL_COMPLETE_SENTINEL}\n`, ); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { saveScriptPath: healedPath, - saveScriptDefaultedHealedPath: true, actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -322,9 +293,8 @@ test('write(session, { force: true }) overwrites an existing target for ORDINARY fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, 'context platform=ios device="x"\nclick id="old"\n'); - const session = makeIosSession('default', { - recordSession: true, - // No saveScriptBoundary: ordinary open/close --save-script, not a repair. + // Ordinary open/close --save-script recording, not a repair. + const session = makeAuthoringSession('default', { saveScriptPath: outPath, actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -343,8 +313,7 @@ test('write(session) without { force: true } still refuses, even when a prior wr fs.writeFileSync(outPath, 'context platform=ios device="x"\nclick id="old"\n'); const before = fs.readFileSync(outPath, 'utf8'); - const session = makeIosSession('default', { - recordSession: true, + const session = makeAuthoringSession('default', { saveScriptPath: outPath, actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -365,20 +334,12 @@ test('two writers racing on the SAME ABSENT target: exactly one linkSync wins, t const healedPath = path.join(root, 'flows', 'login.healed.ad'); fs.mkdirSync(path.dirname(healedPath), { recursive: true }); - const sessionA = makeIosSession('writer-a', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const sessionA = makeRepairCompleteSession('writer-a', { saveScriptPath: healedPath, - saveScriptDefaultedHealedPath: true, actions: [action({ command: 'click', positionals: ['id="from-a"'] })], }); - const sessionB = makeIosSession('writer-b', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const sessionB = makeRepairCompleteSession('writer-b', { saveScriptPath: healedPath, - saveScriptDefaultedHealedPath: true, actions: [action({ command: 'click', positionals: ['id="from-b"'] })], }); @@ -438,13 +399,10 @@ test('write() refuses to clobber an existing COMPLETE artifact at an EXPLICIT -- ); const before = fs.readFileSync(explicitOut, 'utf8'); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + // An explicit, caller-DIRECTED target — the protection must apply here too, + // not just the default healed sibling. + const session = makeRepairCompleteSession('default', { saveScriptPath: explicitOut, - // No saveScriptDefaultedHealedPath: this is an explicit, caller-directed - // target — the protection must apply here too, not just the default path. actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -466,12 +424,10 @@ test('write() now refuses an explicit --save-script= pointing at an existi fs.writeFileSync(outPath, 'context platform=ios device="x"\nclick id="old"\n'); const before = fs.readFileSync(outPath, 'utf8'); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + // The caller directed this path explicitly rather than defaulting to the + // healed sibling. + const session = makeRepairCompleteSession('default', { saveScriptPath: outPath, - // No saveScriptDefaultedHealedPath: the caller directed this path explicitly. actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -500,10 +456,9 @@ test('an ordinary (non-repair) recording now refuses an existing target too (beh fs.writeFileSync(outPath, 'context platform=ios device="x"\nclick id="old"\n'); const before = fs.readFileSync(outPath, 'utf8'); - const session = makeIosSession('default', { - recordSession: true, - // No saveScriptBoundary: an ordinary open/close --save-script recording, - // never armed via `replay --save-script` — this is NOT a repair. + // An ordinary open/close --save-script recording, never armed via `replay + // --save-script` — this is NOT a repair. + const session = makeAuthoringSession('default', { saveScriptPath: outPath, actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -528,9 +483,8 @@ test('an ordinary (non-repair) recording still publishes cleanly when its target const writer = new SessionScriptWriter(path.join(root, 'sessions')); const outPath = path.join(root, 'flows', 'fresh.ad'); - const session = makeIosSession('default', { - recordSession: true, - // No saveScriptBoundary: ordinary recording, not a repair. + // Ordinary recording, not a repair. + const session = makeAuthoringSession('default', { saveScriptPath: outPath, actions: [action({ command: 'click', positionals: ['id="new"'] })], }); @@ -544,36 +498,31 @@ test('an ordinary (non-repair) recording still publishes cleanly when its target expect(fs.readFileSync(outPath, 'utf8')).not.toContain(HEAL_COMPLETE_SENTINEL); }); -test('close --save-script= clears the defaulted marker, and a write to that (absent) explicit path succeeds', () => { +test('close --save-script= re-points a defaulted-healed repair, and a write to that (absent) explicit path succeeds', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-close-explicit-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); const defaultedHealed = path.join(root, 'flows', 'login.healed.ad'); const explicitOut = path.join(root, 'flows', 'promoted.ad'); - // The repair defaulted to `.healed.ad` (marker set). - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, + // The repair defaulted its target to the `.healed.ad` sibling. + const session = makeRepairArmedSession('default', { saveScriptPath: defaultedHealed, - saveScriptDefaultedHealedPath: true, actions: [action({ command: 'click', positionals: ['id="new"'] })], }); - // `close --save-script=` re-points the path AND clears the - // marker (regression: it used to retain the marker and wrongly refuse the - // explicit target). The marker no longer affects the publish decision at - // all (refusal is now uniform), but a redirected session must still - // publish cleanly to its own (absent) explicit target. + // `close --save-script=` re-points the path (regression: it + // used to wrongly refuse the explicit target). Refusal is now uniform + // regardless of how the path was chosen, but a redirected session must + // still publish cleanly to its own (absent) explicit target. recordActionEntry(session, { command: 'close', positionals: [], flags: { saveScript: explicitOut }, }); - expect(session.saveScriptDefaultedHealedPath).toBe(false); expect(session.saveScriptPath).toBe(explicitOut); // `recordActionEntry` is the low-level action recorder `close`'s handler // calls on its way to setting the finalize signal (Fix 2) — set here to - // isolate this test's own concern (defaulted-marker clearing). + // isolate this test's own concern (the retarget). session.saveScriptComplete = true; const result = writer.write(session); @@ -591,12 +540,10 @@ test('close --save-script= clears the defaulted marker, and a wri test('C2 abort-before-complete: a repair-armed but NOT-complete write discards — no file, no prefix', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-incomplete-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - // No saveScriptComplete: the plan never ran to its last executable step - // (a `close`/`close --save-script` reached after a divergence, a daemon - // teardown, or an idle-reap of an in-flight repair). + // ARMED but never COMPLETE: the plan never ran to its last executable step + // (a `close`/`close --save-script` reached after a divergence, a daemon + // teardown, or an idle-reap of an in-flight repair). + const session = makeRepairArmedSession('default', { actions: [action({ command: 'click', positionals: ['id="save"'] })], }); @@ -612,10 +559,7 @@ test('C2 commit-when-complete: a repair-armed COMPLETE write publishes and marks const writer = new SessionScriptWriter(path.join(root, 'sessions')); const outPath = path.join(root, 'flows', 'flow.healed.ad'); fs.mkdirSync(path.dirname(outPath), { recursive: true }); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { saveScriptPath: outPath, actions: [action({ command: 'click', positionals: ['id="save"'] })], }); @@ -631,10 +575,7 @@ test('C2 idempotent post-commit: a second write on a COMMITTED session no-ops (n const writer = new SessionScriptWriter(path.join(root, 'sessions')); const outPath = path.join(root, 'flows', 'flow.healed.ad'); fs.mkdirSync(path.dirname(outPath), { recursive: true }); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { saveScriptPath: outPath, actions: [action({ command: 'click', positionals: ['id="save"'] })], }); @@ -658,10 +599,9 @@ test('write() still emits an ordinary (non-repair) recording on close without -- path.join(os.tmpdir(), 'agent-device-script-writer-ordinary-unfinalized-'), ); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, - // No saveScriptBoundary: an ordinary `open --save-script` recording, not - // a repair — the Fix 2 gate only applies to repair-armed sessions. + // An ordinary `open --save-script` recording, not a repair — the Fix 2 gate + // only applies to repair-armed sessions. + const session = makeAuthoringSession('default', { actions: [action({ command: 'click', positionals: ['id="save"'] })], }); @@ -672,8 +612,7 @@ test('write() still emits an ordinary (non-repair) recording on close without -- test('write() never appends the completeness sentinel to an ordinary (non-repair) recording', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-no-sentinel-')); const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const session = makeIosSession('default', { - recordSession: true, + const session = makeAuthoringSession('default', { actions: [action({ command: 'click', positionals: ['id="save"'] })], }); @@ -686,10 +625,7 @@ test('write() publishes atomically: no stray temp file survives a successful rep const writer = new SessionScriptWriter(path.join(root, 'sessions')); const outPath = path.join(root, 'flows', 'atomic.healed.ad'); fs.mkdirSync(path.dirname(outPath), { recursive: true }); - const session = makeIosSession('default', { - recordSession: true, - saveScriptBoundary: 0, - saveScriptComplete: true, + const session = makeRepairCompleteSession('default', { saveScriptPath: outPath, actions: [action({ command: 'click', positionals: ['id="save"'] })], }); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 71eb3dd451..7d24528ca8 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -777,7 +777,6 @@ test('BLOCKER 2: finalizeRepairTeardown of a COMPLETE transaction whose commit F session.saveScriptBoundary = 0; session.saveScriptComplete = true; session.saveScriptPath = healedPath; - session.saveScriptDefaultedHealedPath = true; session.repairSourcePath = '/flows/login.ad'; session.actions = [{ ts: 1, command: 'open', positionals: ['Demo'], flags: {} }]; @@ -815,7 +814,6 @@ test('BLOCKER 3: finalizeRepairTeardown auto-commit records a terminal close, pr session.saveScriptBoundary = 0; session.saveScriptComplete = true; session.saveScriptPath = healedPath; - session.saveScriptDefaultedHealedPath = true; session.actions = [ { ts: 1, command: 'open', positionals: ['Demo'], flags: {} }, { ts: 2, command: 'click', positionals: ['id="save-v2"'], flags: {} }, diff --git a/src/daemon/handlers/__tests__/find.test.ts b/src/daemon/handlers/__tests__/find.test.ts index 58c0fe3b1d..3ae71189d7 100644 --- a/src/daemon/handlers/__tests__/find.test.ts +++ b/src/daemon/handlers/__tests__/find.test.ts @@ -3,7 +3,10 @@ import { handleFindCommands } from '../find.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; import { buildSnapshotSignatures } from '../../android-snapshot-freshness.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; -import { makeIosSession as makeSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { + makeIosSession as makeSession, + makeAuthoringSession, +} from '../../../__tests__/test-utils/session-factories.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -805,8 +808,7 @@ test('find rejects --record on a mutating action before any device work', async test('read-only find while recording is intentionally deferred from target-v1 evidence (#1349)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; - const session = makeSession(sessionName); - session.recordSession = true; + const session = makeAuthoringSession(sessionName); sessionStore.set(sessionName, session); mockDispatch.mockImplementation(async (_device, command) => { if (command === 'snapshot') { diff --git a/src/daemon/handlers/__tests__/interaction-common.test.ts b/src/daemon/handlers/__tests__/interaction-common.test.ts index 12a62bacc7..a6bd5fbb28 100644 --- a/src/daemon/handlers/__tests__/interaction-common.test.ts +++ b/src/daemon/handlers/__tests__/interaction-common.test.ts @@ -1,5 +1,8 @@ import { beforeEach, expect, test, vi } from 'vitest'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { + makeIosSession, + makeAuthoringSession, +} from '../../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot'; import type { CommandFlags } from '../../../core/dispatch.ts'; @@ -27,8 +30,7 @@ test('parameterized fill scrubs backend and nested settle echoes at the response const secret = 'OpaqueValue1348'; const placeholder = '${PASSWORD}'; const sessionStore = makeSessionStore(); - const session = makeIosSession('parameterized-fill'); - session.recordSession = true; + const session = makeAuthoringSession('parameterized-fill'); sessionStore.set(session.name, session); const payload = { text: secret, @@ -185,8 +187,7 @@ test('parameterized fill collapses whitespace-only backend echoes through the ha const placeholder = '${SPACES}'; const sessionStore = makeSessionStore(); const sessionName = 'parameterized-whitespace-fill-handler-route'; - const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' }); - session.recordSession = true; + const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); session.snapshot = { nodes: attachRefs([ { @@ -254,8 +255,7 @@ test.each([ ({ literal, recordAs }) => { const placeholder = `\${${recordAs}}`; const sessionStore = makeSessionStore(); - const session = makeIosSession(`parameterized-overlap-${recordAs}`); - session.recordSession = true; + const session = makeAuthoringSession(`parameterized-overlap-${recordAs}`); sessionStore.set(session.name, session); const payload = { text: literal, diff --git a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts index da3b5a5bc1..bd6c0a4734 100644 --- a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts +++ b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts @@ -5,7 +5,10 @@ import path from 'node:path'; import { handleInteractionCommands } from '../interaction.ts'; import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { + makeIosSession, + makeAuthoringSession, +} from '../../../__tests__/test-utils/session-factories.ts'; import { SessionScriptWriter } from '../../session-script-writer.ts'; import type { CommandFlags } from '../../../core/dispatch.ts'; import type { SessionState } from '../../types.ts'; @@ -66,8 +69,10 @@ function makeSessionWithSnapshot( sessionName: string, options: { recordSession: boolean }, ): SessionState { - const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' }); - session.recordSession = options.recordSession; + const session = makeIosSession(sessionName, { + appBundleId: 'com.example.app', + recordSession: options.recordSession, + }); session.snapshot = { nodes: attachRefs(SAVE_BUTTON_NODES), createdAt: Date.now(), @@ -178,8 +183,7 @@ test('get text @ref without recording never computes target-v1 evidence', async test('get text simple iOS id selector while recording skips the direct runner query and records evidence from the snapshot path', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-get-direct-gate'; - const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' }); - session.recordSession = true; + const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); sessionStore.set(sessionName, session); mockDispatch.mockImplementation(async (_device, command) => { if (command === 'snapshot') { @@ -318,8 +322,7 @@ test('press on an identity-empty container: container-based daemon response, des // on the runtime resolution path (the direct-iOS fast path is gated during // recording) without Android's real-adb dialog-readiness probes, which // would burn wall-clock time this unit lane must not spend. - const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' }); - session.recordSession = true; + const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); sessionStore.set(sessionName, session); mockDispatch.mockImplementation(async (_device, command) => { if (command === 'snapshot') { diff --git a/src/daemon/handlers/__tests__/session-close-script.test.ts b/src/daemon/handlers/__tests__/session-close-script.test.ts index c14c007809..546042c23e 100644 --- a/src/daemon/handlers/__tests__/session-close-script.test.ts +++ b/src/daemon/handlers/__tests__/session-close-script.test.ts @@ -3,7 +3,10 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { + makeIosSession, + makeRepairCompleteSession, +} from '../../../__tests__/test-utils/session-factories.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest } from '../../types.ts'; import { @@ -19,11 +22,10 @@ afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); -function setup(name: string) { +function setup(name: string, session = makeIosSession(name, { appBundleId: 'com.example.app' })) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-close-script-')); roots.push(root); const sessionStore = new SessionStore(path.join(root, 'sessions')); - const session = makeIosSession(name, { appBundleId: 'com.example.app' }); sessionStore.set(name, session); const req: DaemonRequest = { token: 'token', @@ -36,10 +38,10 @@ function setup(name: string) { } test('failed repair publication removes only its synthetic close before retry', () => { - const { req, session, sessionStore } = setup('repair'); - session.saveScriptBoundary = 0; - session.saveScriptComplete = true; - session.recordSession = true; + const { req, session, sessionStore } = setup( + 'repair', + makeRepairCompleteSession('repair', { appBundleId: 'com.example.app' }), + ); const failure = new AppError('COMMAND_FAILED', 'publish failed'); vi.spyOn(sessionStore, 'writeSessionLog').mockReturnValue({ written: false, error: failure }); diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index 07ce78f1e3..ad11fdfa69 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -39,7 +39,7 @@ import { SessionStore } from '../../session-store.ts'; import { handleCloseCommand } from '../session-close.ts'; import { handleOpenCommand } from '../session-open.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { SessionState } from '../../types.ts'; +import { makeAuthoringSession } from '../../../__tests__/test-utils/session-factories.ts'; import { AppError } from '@agent-device/kernel/errors'; const mockDispatch = vi.mocked(dispatchCommand); @@ -267,15 +267,11 @@ test('#1391: a close-time script save failure still clears the advisory claim an // after `handleCloseCommand` deletes it from the store — `store.delete` only // drops the map entry, it doesn't touch the object this variable still // points at. - const session: SessionState = { - name: 'close-save-script-failure', + const session = makeAuthoringSession('close-save-script-failure', { device: android, deviceClaim: acquired.ownership, - createdAt: Date.now(), - actions: [], - recordSession: true, saveScriptPath: targetPath, - }; + }); store.set('close-save-script-failure', session); mockDispatch.mockResolvedValue({}); diff --git a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts index 5f39f23186..bab8839758 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts @@ -62,7 +62,10 @@ import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { + makeIosSession, + makeRepairCompleteSession, +} from '../../../__tests__/test-utils/session-factories.ts'; import { HEAL_COMPLETE_SENTINEL } from '../../session-script-writer.ts'; import { parseReplayScriptDetailed } from '../../../replay/script.ts'; import type { DaemonRequest } from '../../types.ts'; @@ -426,23 +429,21 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F /** A COMPLETE, committable repair-armed session at the default healed sibling path. */ function makeCompleteRepairSession(sessionStore: SessionStore, sessionName: string, root: string) { - const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' }); - session.recordSession = true; - session.saveScriptBoundary = 0; - session.saveScriptComplete = true; - session.saveScriptPath = path.join(root, 'flow.healed.ad'); - session.saveScriptDefaultedHealedPath = true; - session.actions = [ - { ts: 1, command: 'open', positionals: ['Demo'], flags: {} }, - { - ts: 2, - command: 'press', - positionals: ['@e7'], - flags: {}, - result: { selectorChain: ['id="save-v2"'] }, - targetEvidence: freshEvidence('save-v2', 'Save V2'), - }, - ]; + const session = makeRepairCompleteSession(sessionName, { + appBundleId: 'com.example.app', + saveScriptPath: path.join(root, 'flow.healed.ad'), + actions: [ + { ts: 1, command: 'open', positionals: ['Demo'], flags: {} }, + { + ts: 2, + command: 'press', + positionals: ['@e7'], + flags: {}, + result: { selectorChain: ['id="save-v2"'] }, + targetEvidence: freshEvidence('save-v2', 'Save V2'), + }, + ], + }); sessionStore.set(sessionName, session); return session; } diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index 7026d76bb6..f6f9cb4eac 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -11,7 +11,10 @@ import path from 'node:path'; import { runReplayScriptFile } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { + makeIosSession, + makeRepairArmedSession, +} from '../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, writeReplayFile, @@ -384,9 +387,7 @@ test('Maestro YAML cannot append commands to an active .ad repair session', asyn const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-typed-maestro-active-repair-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - const session = makeIosSession(sessionName); - session.recordSession = true; - session.saveScriptBoundary = 0; + const session = makeRepairArmedSession(sessionName); sessionStore.set(sessionName, session); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); diff --git a/src/daemon/handlers/__tests__/session-script-publication.test.ts b/src/daemon/handlers/__tests__/session-script-publication.test.ts index cff5a86e6d..209cddcced 100644 --- a/src/daemon/handlers/__tests__/session-script-publication.test.ts +++ b/src/daemon/handlers/__tests__/session-script-publication.test.ts @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { beforeEach, expect, test } from 'vitest'; import { INTERNAL_COMMANDS } from '../../../command-catalog.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/index.ts'; +import { makeIosSession, makeAuthoringSession } from '../../../__tests__/test-utils/index.ts'; import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, SessionState } from '../../types.ts'; @@ -39,8 +39,7 @@ beforeEach(() => { }); function armedSession(overrides: Partial = {}): SessionState { - return makeIosSession('authoring', { - recordSession: true, + return makeAuthoringSession('authoring', { scriptRecordingState: 'armed', actions: [ { ts: 1, command: 'open', positionals: ['Demo'], flags: { saveScript: true } }, diff --git a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts index dd8e0ea657..233406f6de 100644 --- a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts +++ b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts @@ -79,8 +79,9 @@ function waitReq(overrides: Partial = {}): DaemonRequest { async function runWait(options: { recordSession?: boolean; req?: DaemonRequest } = {}) { const sessionStore = makeSessionStore(); - const session = makeAndroidSession('default'); - session.recordSession = options.recordSession ?? false; + const session = makeAndroidSession('default', { + recordSession: options.recordSession ?? false, + }); sessionStore.set('default', session); const response = await dispatchWaitViaRuntime({ req: options.req ?? waitReq(), diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 4dc46aab62..3ae666d94d 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -856,18 +856,14 @@ function armReplaySaveScriptStep(params: { if (!session) return; session.recordSession = true; if (typeof saveScript === 'string') { - // An EXPLICIT `--save-script=` clears the defaulted marker - // (invariant: the marker is set iff the current `saveScriptPath` was - // defaulted, not caller-directed). This no longer affects the publish - // decision either way — the writer's refuse-on-exist guard is uniform - // (`publishHealedScriptAtomically`) and refuses ANY pre-existing target, - // an explicit caller-directed path included, exactly like the default - // healed sibling. + // An EXPLICIT `--save-script=` retargets. Which of the two ways the + // path was chosen does not affect the publish decision: the writer's + // refuse-on-exist guard is uniform (`publishHealedScriptAtomically`) and + // refuses ANY pre-existing target, an explicit caller-directed path + // included, exactly like the default healed sibling. applySaveScriptRetarget(session, expandSessionPath(saveScript), force); - session.saveScriptDefaultedHealedPath = false; } else if (session.saveScriptPath === undefined) { session.saveScriptPath = healedScriptSiblingPath(sourcePath); - session.saveScriptDefaultedHealedPath = true; } // #1258: force is per-target — a LIVE `--force`/`--overwrite` persists onto // the session (`saveScriptForce`) so a LATER `--from` continuation leg or an diff --git a/src/daemon/server/daemon-idle-reap.test.ts b/src/daemon/server/daemon-idle-reap.test.ts index adf8cc206d..f87c96b37a 100644 --- a/src/daemon/server/daemon-idle-reap.test.ts +++ b/src/daemon/server/daemon-idle-reap.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, test, vi } from 'vitest'; -import { IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts'; +import { makeIosSession } from '../../__tests__/test-utils/index.ts'; import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; import { @@ -28,13 +28,7 @@ afterEach(() => { }); function makeSession(overrides: Partial = {}): SessionState { - return { - name: 'default', - device: IOS_SIMULATOR, - createdAt: Date.now(), - actions: [], - ...overrides, - }; + return makeIosSession('default', overrides); } test('resolveDaemonIdleReapMs falls back to the 5 minute default', () => { diff --git a/src/daemon/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index 5ec04328e3..941ca1aeb7 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -69,18 +69,17 @@ export function recordActionEntry( session.recordSession = true; if (typeof entry.flags.saveScript === 'string') { // ADR 0012 decision 6: an explicit `--save-script=` (e.g. `close - // --save-script=`) clears the defaulted-healed marker, since this - // path was no longer defaulted to the healed sibling. This marker plays - // no role in the publish decision itself: the writer's refuse-on-exist - // guard is uniform (see `publishHealedScriptAtomically`) and refuses - // ANY pre-existing target — an explicit, caller-DIRECTED path included. - // Directing the path is not the same as authorizing an overwrite. + // --save-script=`) retargets away from the defaulted healed + // sibling. How the path was chosen plays no role in the publish + // decision: the writer's refuse-on-exist guard is uniform (see + // `publishHealedScriptAtomically`) and refuses ANY pre-existing target — + // an explicit, caller-DIRECTED path included. Directing the path is not + // the same as authorizing an overwrite. applySaveScriptRetarget( session, expandSessionPath(entry.flags.saveScript), entry.flags.force, ); - session.saveScriptDefaultedHealedPath = false; } // #1258: persist `--force`/`--overwrite`, like `saveScriptPath`, so a // LATER write that does not repeat the flag (a bare `close` finishing a diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 864d1466a6..d6bddd8e40 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -396,17 +396,6 @@ export type SessionState = { * leak into the healed script. */ saveScriptBoundary?: number; - /** - * ADR 0012 decision 6: set when `saveScriptPath` was DEFAULTED to the - * `.healed.ad` sibling (no explicit `--save-script=`). - * Bookkeeping only — it does not gate the writer's refuse-on-exist - * decision, which is uniform regardless of this flag (see - * `publishHealedScriptAtomically`): a second repair against the same - * original is refused at the default healed sibling exactly like an - * explicit `--save-script=` or an ordinary recording's target would - * be, never destroying an unreviewed prior `.healed.ad` diff. - */ - saveScriptDefaultedHealedPath?: boolean; /** * ADR 0012 decision 6, R7 + commit semantics (C2): the repair TRANSACTION * completion flag. `true` iff the last repair-armed replay run reached its