From e2bb8914aae0f1209a7c8aef4df399b88e175dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 21 Jul 2026 18:46:12 +0200 Subject: [PATCH 1/3] feat: publish scripts from active sessions --- .../0016-active-session-script-publication.md | 2 +- src/__tests__/cli-client-commands.test.ts | 1 + src/agent-device-client.ts | 26 +++ .../parser/__tests__/cli-help-topics.test.ts | 2 +- src/cli/parser/cli-help.ts | 9 + src/client/client-types.ts | 14 ++ src/commands/management/output.ts | 9 +- src/commands/management/session.test.ts | 63 ++++++ src/commands/management/session.ts | 34 ++-- .../__tests__/parity.test.ts | 41 ++++ src/core/command-descriptor/registry.ts | 180 ++++++++++++------ src/core/command-descriptor/types.ts | 27 ++- .../session-script-active-publication.test.ts | 87 +++++++++ .../__tests__/session-script-writer.test.ts | 4 +- src/daemon/daemon-command-registry.ts | 2 +- .../session-script-publication.test.ts | 165 ++++++++++++++++ src/daemon/handlers/session-close.ts | 12 ++ src/daemon/handlers/session-open.ts | 36 ++++ src/daemon/handlers/session-replay-runtime.ts | 9 + .../handlers/session-script-publication.ts | 104 ++++++++++ src/daemon/handlers/session.ts | 5 + .../session-script-active-publication.ts | 131 +++++++++++++ src/daemon/session-script-writer.ts | 69 +++++-- src/daemon/session-store.ts | 11 +- src/daemon/types.ts | 2 + src/mcp/__tests__/command-tools.test.ts | 4 +- .../active-session-script-publication.test.ts | 89 +++++++++ 27 files changed, 1045 insertions(+), 93 deletions(-) create mode 100644 src/commands/management/session.test.ts create mode 100644 src/daemon/__tests__/session-script-active-publication.test.ts create mode 100644 src/daemon/handlers/__tests__/session-script-publication.test.ts create mode 100644 src/daemon/handlers/session-script-publication.ts create mode 100644 src/daemon/session-script-active-publication.ts create mode 100644 test/integration/provider-scenarios/active-session-script-publication.test.ts diff --git a/docs/adr/0016-active-session-script-publication.md b/docs/adr/0016-active-session-script-publication.md index db7160a28..fc14468fe 100644 --- a/docs/adr/0016-active-session-script-publication.md +++ b/docs/adr/0016-active-session-script-publication.md @@ -2,7 +2,7 @@ ## Status -Proposed +Accepted ## Context diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 8a9fc113f..de719c213 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -1145,6 +1145,7 @@ function createStubClient(params: { list: async () => [], stateDir: async () => '/tmp/agent-device-state', close: async () => ({ session: 'default', identifiers: { session: 'default' } }), + saveScript: unexpectedCommandCall, artifacts: unexpectedCommandCall, }, apps: { diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index f003d55a7..6fc910a5d 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -60,6 +60,8 @@ import type { SwipeGestureOptions, PinchOptions, RotateGestureOptions, + SessionSaveScriptOptions, + SessionSaveScriptResult, TransformGestureOptions, } from './client/client-types.ts'; import type { CommandResult } from './core/command-descriptor/command-result.ts'; @@ -195,6 +197,30 @@ export function createAgentDeviceClient( clearMetroSessionHintsQuietly(config, options); } }, + saveScript: async ( + options: SessionSaveScriptOptions = {}, + ): Promise => { + const data = await execute( + INTERNAL_COMMANDS.sessionSaveScript, + options.path ? [options.path] : [], + options, + { force: options.force }, + ); + const actionCount = data.actionCount; + if (typeof actionCount !== 'number' || !Number.isInteger(actionCount) || actionCount < 0) { + throw new AppError( + 'COMMAND_FAILED', + 'Daemon returned an invalid saved-script action count.', + ); + } + const publishedSession = readRequiredString(data, 'session'); + return { + session: publishedSession, + savedScript: readRequiredString(data, 'savedScript'), + actionCount, + identifiers: { session: publishedSession }, + }; + }, artifacts: async (options = {}) => await executeCommand('artifacts', options), }, diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index f1f16cb06..b1334335b 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -644,7 +644,7 @@ test('usage renders concise commands inline with descriptions', async () => { ); assert.match( help, - / session\s{2,}List active sessions or print the effective daemon state directory/, + / session\s{2,}List active sessions, print the effective daemon state directory, or publish/, ); assert.doesNotMatch(help, / metro prepare[^\n]*--project-root/); assert.doesNotMatch(help, /\n batch\s{2,}Run multiple commands/); diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index de6b15310..30f2fc79c 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -228,6 +228,15 @@ Bootstrap: CI may cache ~/.agent-device/apple-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. Runner build/start output is written to the session's runner.log; daemon.log is for daemon lifecycle/startup issues. Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop. +Reusable open-to-destination scripts: + Arm recording on the first open, perform the full journey, verify the ready destination with a selective selector-targeted wait, then publish without closing: + agent-device open com.example.app --relaunch --save-script=screen-x.ad + agent-device press 'id="continue"' --settle + agent-device wait 'role="heading" label="Screen X"' + agent-device session save-script + session save-script [path] [--force] publishes the sole recorded open through the destination guard, omits close, and leaves the session active. A duration wait, wait stable, or wait @ref is not a destination guard. A second successful open aborts publication; start a fresh session to author again. + Recorded fill/type inputs are written literally to the .ad file. Do not record passwords, tokens, or other secrets; use pre-authenticated test state or non-secret fixture credentials until parameterized input authoring is available. + Snapshots and refs: snapshot reads visible state. snapshot -i gets current interactive refs only; it is the fast path when the next step is an interaction. Default snapshot text is an agent-facing, token-efficient view for planning and targeting actions; use --raw or --json only when you need the full provider tree. diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 81d3ff9dc..dbff839a7 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -261,6 +261,19 @@ export type SessionCloseResult = { identifiers: AgentDeviceIdentifiers; }; +export type SessionSaveScriptOptions = AgentDeviceRequestOverrides & { + path?: string; + /** Atomically replace an existing target instead of refusing publication. */ + force?: boolean; +}; + +export type SessionSaveScriptResult = { + session: string; + savedScript: string; + actionCount: number; + identifiers: AgentDeviceIdentifiers; +}; + export type CloudArtifactsOptions = AgentDeviceRequestOverrides & { provider?: string; providerSessionId?: string; @@ -1162,6 +1175,7 @@ export type AgentDeviceClient = { force?: boolean; }, ) => Promise; + saveScript: (options?: SessionSaveScriptOptions) => Promise; artifacts: (options?: CloudArtifactsOptions) => Promise; }; apps: { diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 9a788f92b..3db088853 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -16,6 +16,7 @@ import type { AppOpenResult, CommandRequestResult, SessionCloseResult, + SessionSaveScriptResult, } from '../../client/client-types.ts'; import type { AgentArtifactsResult, @@ -76,8 +77,14 @@ function appsCliOutput(params: { } function sessionCliOutput( - result: { sessions: AgentDeviceSession[] } | { stateDir: string }, + result: { sessions: AgentDeviceSession[] } | { stateDir: string } | SessionSaveScriptResult, ): CliOutput { + if ('savedScript' in result) { + return { + data: result, + text: `Published script: ${result.savedScript}\nSession remains active: ${result.session}\nActions: ${result.actionCount}`, + }; + } if ('stateDir' in result) { return { data: result, text: result.stateDir }; } diff --git a/src/commands/management/session.test.ts b/src/commands/management/session.test.ts new file mode 100644 index 000000000..ff629cfa3 --- /dev/null +++ b/src/commands/management/session.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from 'vitest'; +import { createAgentDeviceClient } from '../../agent-device-client.ts'; +import { parseArgs } from '../../cli/parser/args.ts'; +import { buildCommandUsageText } from '../../cli/parser/cli-help.ts'; +import type { DaemonRequest, DaemonResponse } from '../../kernel/contracts.ts'; +import type { CliFlags } from '../cli-grammar/flag-types.ts'; +import { sessionCommandFacet } from './session.ts'; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +test('session save-script reads path/force and invokes the typed client surface', async () => { + const calls: Array> = []; + const client = createAgentDeviceClient( + { session: 'authoring' }, + { + transport: async (req) => { + calls.push(req); + return { + ok: true, + data: { session: 'authoring', savedScript: '/tmp/screen-x.ad', actionCount: 3 }, + } satisfies DaemonResponse; + }, + }, + ); + + const input = sessionCommandFacet.cliReader( + ['save-script', '/tmp/screen-x.ad'], + flags({ force: true, session: 'authoring' }), + ); + const result = await sessionCommandFacet.definition.invoke(client, input); + + expect(calls).toEqual([ + expect.objectContaining({ + command: 'session_save_script', + session: 'authoring', + positionals: ['/tmp/screen-x.ad'], + flags: expect.objectContaining({ force: true }), + }), + ]); + expect(result).toMatchObject({ + session: 'authoring', + savedScript: '/tmp/screen-x.ad', + actionCount: 3, + }); +}); + +test('strict CLI parsing accepts session save-script path and --force', () => { + const parsed = parseArgs(['session', 'save-script', './screen-x.ad', '--force'], { + strictFlags: true, + }); + expect(parsed.positionals).toEqual(['save-script', './screen-x.ad']); + expect(parsed.flags.force).toBe(true); +}); + +test('session and workflow help expose active publication and literal-secret warning', () => { + expect(buildCommandUsageText('session')).toMatch(/session save-script \[path\] \[--force\]/); + const workflow = buildCommandUsageText('workflow'); + expect(workflow).toMatch(/open-to-destination scripts/); + expect(workflow).toMatch(/session save-script/); + expect(workflow).toMatch(/Do not record passwords, tokens, or other secrets/); +}); diff --git a/src/commands/management/session.ts b/src/commands/management/session.ts index 7b2f57f6a..d6b486de3 100644 --- a/src/commands/management/session.ts +++ b/src/commands/management/session.ts @@ -1,6 +1,6 @@ import { AppError } from '../../kernel/errors.ts'; import type { CommandSchemaOverride } from '../../cli-schema/types.ts'; -import { enumField } from '../command-input.ts'; +import { booleanField, enumField, stringField } from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { commonInputFromFlags } from '../cli-grammar/common.ts'; import type { CliReader } from '../cli-grammar/types.ts'; @@ -13,30 +13,39 @@ const sessionCommandMetadata = defineFieldCommandMetadata( 'List active sessions or print daemon state directory.', { action: enumField( - ['list', 'state-dir'], - 'list shows active sessions; state-dir prints the resolved daemon state directory without contacting the daemon.', + ['list', 'state-dir', 'save-script'], + 'list shows active sessions; state-dir prints the daemon state directory; save-script publishes an armed recording without teardown.', ), + path: stringField('Optional .ad output path for save-script.'), + force: booleanField('Atomically replace an existing save-script target.'), }, ); const sessionCommandDefinition = defineExecutableCommand( sessionCommandMetadata, - async (client, { action, ...input }) => - action === 'state-dir' - ? { stateDir: await client.sessions.stateDir(input) } - : { sessions: await client.sessions.list(input) }, + async (client, { action, path, force, ...input }) => { + if (action === 'state-dir') return { stateDir: await client.sessions.stateDir(input) }; + if (action === 'save-script') { + return await client.sessions.saveScript({ ...input, path, force }); + } + return { sessions: await client.sessions.list(input) }; + }, ); const sessionCliSchema = { - usageOverride: 'session list | session state-dir', + usageOverride: 'session list | session state-dir | session save-script [path] [--force]', listUsageOverride: 'session', - helpDescription: 'List active sessions or print the effective daemon state directory', - positionalArgs: ['list|state-dir?'], + helpDescription: + 'List active sessions, print the effective daemon state directory, or publish an armed open-to-destination script without closing its session', + positionalArgs: ['list|state-dir|save-script?', 'path?'], + allowedFlags: ['force'], } as const satisfies CommandSchemaOverride; const sessionCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), action: readSessionAction(positionals[0]), + path: positionals[1], + force: flags.force, }); export const sessionCommandFacet = defineCommandFacet({ @@ -48,9 +57,10 @@ export const sessionCommandFacet = defineCommandFacet({ cliOutputFormatter: managementCliOutputFormatters.session, }); -function readSessionAction(value: string | undefined): 'list' | 'state-dir' { +function readSessionAction(value: string | undefined): 'list' | 'state-dir' | 'save-script' { const action = value ?? 'list'; if (action === 'list') return action; if (action === 'state-dir') return action; - throw new AppError('INVALID_ARGS', 'session only supports list or state-dir'); + if (action === 'save-script') return action; + throw new AppError('INVALID_ARGS', 'session only supports list, state-dir, or save-script'); } diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index f2b168189..9fe3bfd3e 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -22,6 +22,7 @@ import { listCapabilityCheckedCommandNames, listMcpExposedCommandNames, resolveCommandRecordsSessionAction, + resolveCommandRecordingEffect, RAW_COMMAND_DESCRIPTORS, } from '../registry.ts'; @@ -306,6 +307,11 @@ test('recordsSessionAction is explicit on every raw descriptor and drives daemon 'boolean', `${descriptor.name} declares an explicit recordsSessionAction boolean`, ); + assert.equal( + 'recordingEffect' in descriptor, + descriptor.recordsSessionAction, + `${descriptor.name} classifies app-state effect iff it records session actions`, + ); const daemon = ('daemon' in descriptor ? descriptor.daemon : undefined) as | { replayScopedAction?: boolean } | undefined; @@ -347,3 +353,38 @@ test('recordsSessionAction is explicit on every raw descriptor and drives daemon ); assert.ok(!recordable.includes('devices'), 'devices is not classified as recordable'); }); + +test('recordingEffect resolves request-sensitive observation and mutation subcommands', () => { + assert.equal( + resolveCommandRecordingEffect({ command: 'keyboard', positionals: ['status'], flags: {} }), + 'observes-app', + ); + assert.equal( + resolveCommandRecordingEffect({ command: 'keyboard', positionals: ['dismiss'], flags: {} }), + 'mutates-app', + ); + assert.equal( + resolveCommandRecordingEffect({ command: 'alert', positionals: ['get'], flags: {} }), + 'observes-app', + ); + assert.equal( + resolveCommandRecordingEffect({ command: 'alert', positionals: ['accept'], flags: {} }), + 'mutates-app', + ); + assert.equal( + resolveCommandRecordingEffect({ + command: 'find', + positionals: ['text', 'Ready', 'exists'], + flags: {}, + }), + 'observes-app', + ); + assert.equal( + resolveCommandRecordingEffect({ + command: 'find', + positionals: ['text', 'Continue', 'click'], + flags: {}, + }), + 'mutates-app', + ); +}); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 66b7b14b3..baf789656 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -3,6 +3,7 @@ import type { DaemonRequest } from '../../daemon/types.ts'; // Type-only back-edge, erased at runtime (same pattern as derive.ts importing // DaemonCommandDescriptor); no runtime import cycle. import type { RefFrameEffect } from '../../daemon/daemon-command-registry.ts'; +import { isReadOnlyFindAction, parseFindArgs } from '../../selectors/find.ts'; import { resolveWaitBudgetMs } from '../wait-positionals.ts'; import { DEFAULT_TIMEOUT_POLICY, @@ -14,14 +15,18 @@ import type { PostActionObservationSupport } from './post-action-observation.ts' import type { CommandCatalogGroup, CommandDescriptor, + RecordingEffect, CommandResponseDataTransform, CommandTimeoutPolicy, } from './types.ts'; -type RawCommandDescriptor = Omit & { - mcpExposed?: boolean; - ownerFiles?: readonly [string, ...string[]]; -}; +type RawCommandDescriptorShape = T extends CommandDescriptor + ? Omit & { + mcpExposed?: boolean; + ownerFiles?: readonly [string, ...string[]]; + } + : never; +type RawCommandDescriptor = RawCommandDescriptorShape; type RawCommandCatalogGroup = T extends { catalog: { group: infer Group } } ? Group : never; @@ -103,6 +108,25 @@ const ALERT_READ_ONLY_ACTIONS = new Set(['get', 'wait']); const alertRefFrameEffect = (req: DaemonRequest): RefFrameEffect => readOnlySubactionRefFrameEffect(req, ALERT_READ_ONLY_ACTIONS, 'get'); +type RecordingEffectRequest = Pick; + +const keyboardRecordingEffect = (req: RecordingEffectRequest): RecordingEffect => + readOnlySubactionRecordingEffect(req, KEYBOARD_READ_ONLY_ACTIONS, 'status'); + +const alertRecordingEffect = (req: RecordingEffectRequest): RecordingEffect => + readOnlySubactionRecordingEffect(req, ALERT_READ_ONLY_ACTIONS, 'get'); + +const findRecordingEffect = (req: RecordingEffectRequest): RecordingEffect => { + try { + return isReadOnlyFindAction(parseFindArgs(req.positionals ?? []).action) + ? 'observes-app' + : 'mutates-app'; + } catch { + // Invalid requests never record, but classify the unknown shape conservatively. + return 'mutates-app'; + } +}; + function readOnlySubactionRefFrameEffect( req: DaemonRequest, readOnlyActions: ReadonlySet, @@ -113,6 +137,16 @@ function readOnlySubactionRefFrameEffect( : 'may-invalidate'; } +function readOnlySubactionRecordingEffect( + req: RecordingEffectRequest, + readOnlyActions: ReadonlySet, + defaultAction: string, +): RecordingEffect { + return readOnlyActions.has((req.positionals?.[0] ?? defaultAction).toLowerCase()) + ? 'observes-app' + : 'mutates-app'; +} + // --------------------------------------------------------------------------- // Capability matrices — platform/kind buckets, copied VERBATIM from // src/core/capabilities.ts (BASE_COMMAND_CAPABILITY_MATRIX). @@ -148,6 +182,29 @@ const APP_INSTALL_CAPABILITY = { linux: LINUX_NONE, } satisfies CommandCapability; +const GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS = { + recordsSessionAction: true, + recordingEffect: 'mutates-app', + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + androidBlockingDialogGuard: true, + }, + dispatch: {}, + capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: true, +} as const satisfies Pick< + Extract, + | 'recordsSessionAction' + | 'recordingEffect' + | 'daemon' + | 'dispatch' + | 'capability' + | 'timeoutPolicy' + | 'batchable' +>; + // --------------------------------------------------------------------------- // Timeout policies — descriptor-owned request-envelope budget source and // on-timeout daemon policy (ADR 0008). This replaces the two deleted client @@ -289,6 +346,21 @@ export const RAW_COMMAND_DESCRIPTORS = [ timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, + { + name: 'session_save_script', + ...(ownerFilesEnabled + ? { ownerFiles: ['src/daemon/handlers/session-script-publication.ts'] as const } + : {}), + catalog: { group: 'internal', key: 'sessionSaveScript' }, + recordsSessionAction: false, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + sessionKind: 'publication', + }, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + }, { name: 'devices', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), @@ -394,6 +466,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/perf/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -495,6 +568,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'session', refFrameEffect: 'preserve' }, dispatch: {}, capability: { @@ -510,6 +584,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: keyboardRecordingEffect, daemon: { route: 'session', refFrameEffect: keyboardRefFrameEffect, @@ -529,6 +604,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, capability: APP_INSTALL_CAPABILITY, timeoutPolicy: INSTALL_TIMEOUT_POLICY, @@ -539,6 +615,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, capability: APP_INSTALL_CAPABILITY, timeoutPolicy: INSTALL_TIMEOUT_POLICY, @@ -551,6 +628,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ : {}), catalog: { group: 'internal', key: 'installSource' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: false, @@ -571,6 +649,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { @@ -586,6 +665,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public', key: 'triggerAppEvent' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, @@ -597,6 +677,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate', @@ -636,6 +717,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate', allowInvalidRecording: true }, dispatch: {}, capability: APP_RUNTIME_CAPABILITY, @@ -649,6 +731,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/snapshot.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, dispatch: {}, capability: ALL_DEVICE_COMMAND_CAPABILITY, @@ -662,6 +745,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/diff.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -672,6 +756,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/wait.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, capability: ALL_DEVICE_COMMAND_CAPABILITY, // The wait budget travels as a positional, not a flag; parse it the same @@ -687,6 +772,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/alert.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: alertRecordingEffect, daemon: { route: 'snapshot', refFrameEffect: alertRefFrameEffect }, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -701,6 +787,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/settings.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'snapshot', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { @@ -718,6 +805,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/react-native/index.ts'] as const } : {}), catalog: { group: 'public', key: 'reactNative' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'reactNative', refFrameEffect: 'may-invalidate' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -728,6 +816,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'recordTrace', refFrameEffect: 'preserve', @@ -743,6 +832,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'recordTrace', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -752,6 +842,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: findRecordingEffect, daemon: { route: 'find', refFrameEffect: 'may-invalidate' }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: PRESERVE_DAEMON_TIMEOUT_POLICY, @@ -770,6 +861,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'interaction', refFrameEffect: 'may-invalidate', @@ -786,6 +878,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'interaction', refFrameEffect: 'may-invalidate', @@ -803,6 +896,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public', key: 'longPress' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'interaction', refFrameEffect: 'may-invalidate', @@ -825,6 +919,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'interaction', refFrameEffect: 'may-invalidate', @@ -842,6 +937,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'interaction', refFrameEffect: 'may-invalidate', @@ -857,6 +953,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'interaction', refFrameEffect: 'preserve' }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: interactionTimeoutPolicy('get'), @@ -876,6 +973,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'interaction', refFrameEffect: 'preserve' }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: interactionTimeoutPolicy('is'), @@ -887,22 +985,14 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'back', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - recordsSessionAction: true, - daemon: { - route: 'generic', - refFrameEffect: 'may-invalidate', - androidBlockingDialogGuard: true, - }, - dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, - batchable: true, + ...GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, }, { name: 'gesture', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'interaction', refFrameEffect: 'may-invalidate', @@ -916,26 +1006,14 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'home', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - recordsSessionAction: true, - daemon: { - route: 'generic', - refFrameEffect: 'may-invalidate', - androidBlockingDialogGuard: true, - }, - dispatch: {}, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_DEVICE, - }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, - batchable: true, + ...GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, }, { name: 'tv-remote', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'tvRemote' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'generic', refFrameEffect: 'may-invalidate', @@ -955,6 +1033,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'generic', refFrameEffect: 'may-invalidate', @@ -973,22 +1052,14 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'scroll', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - recordsSessionAction: true, - daemon: { - route: 'generic', - refFrameEffect: 'may-invalidate', - androidBlockingDialogGuard: true, - }, - dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, - batchable: true, + ...GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, }, { name: 'swipe', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'interaction', refFrameEffect: 'may-invalidate', @@ -1002,22 +1073,14 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'focus', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - recordsSessionAction: true, - daemon: { - route: 'generic', - refFrameEffect: 'may-invalidate', - androidBlockingDialogGuard: true, - }, - dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, - batchable: true, + ...GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, }, { name: 'screenshot', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/screenshot.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'observes-app', daemon: { route: 'generic', refFrameEffect: 'preserve' }, dispatch: {}, capability: ALL_DEVICE_COMMAND_CAPABILITY, @@ -1029,6 +1092,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/viewport.ts'] as const } : {}), catalog: { group: 'public' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, @@ -1041,6 +1105,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appSwitcher' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', // ADR 0014: app-switcher previously reached the generic daemon leaf via the // registry's generic fallback with no daemon facet, so it could not be // classified. Add the facet (route unchanged) so its device mutation is @@ -1061,6 +1126,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public', key: 'installFromSource' }, recordsSessionAction: true, + recordingEffect: 'mutates-app', capability: APP_INSTALL_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -1219,12 +1285,10 @@ const CLI_COMMAND_NAMES = new Set( * union below a precise set of command-name literals rather than `string`. */ export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => { - const recordsSessionAction = resolveRecordsSessionAction(descriptor); if (!ownerFilesEnabled) { return { ...descriptor, mcpExposed: resolveMcpExposure(descriptor), - recordsSessionAction, }; } @@ -1232,7 +1296,6 @@ export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => { return { ...runtimeDescriptor, mcpExposed: resolveMcpExposure(descriptor), - recordsSessionAction, }; }) satisfies readonly CommandDescriptor[]; @@ -1303,10 +1366,6 @@ function resolveMcpExposure(descriptor: RawCommandDescriptor): boolean { return descriptor.mcpExposed ?? CLI_COMMAND_NAMES.has(descriptor.name); } -function resolveRecordsSessionAction(descriptor: RawCommandDescriptor): boolean { - return descriptor.recordsSessionAction; -} - function isMcpExposedCliCommand(descriptor: CommandDescriptor): boolean { return descriptor.mcpExposed && isCliCommandName(descriptor.name); } @@ -1380,6 +1439,17 @@ export function resolveCommandRecordsSessionAction(command: string | undefined): return COMMAND_DESCRIPTOR_BY_NAME.get(command)?.recordsSessionAction ?? false; } +/** ADR 0016 request-sensitive app-state effect for one recorded request. */ +export function resolveCommandRecordingEffect( + req: Pick, +): RecordingEffect | undefined { + const descriptor = COMMAND_DESCRIPTOR_BY_NAME.get(req.command); + if (!descriptor?.recordsSessionAction) return undefined; + return typeof descriptor.recordingEffect === 'function' + ? descriptor.recordingEffect(req) + : descriptor.recordingEffect; +} + /** * @internal Introspection helper used by parity tests. */ diff --git a/src/core/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index d075a375c..6efd5e8cc 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -1,5 +1,6 @@ import type { CommandCapability } from '../capabilities.ts'; import type { DaemonCommandDescriptor } from '../../daemon/daemon-command-registry.ts'; +import type { DaemonRequest } from '../../daemon/types.ts'; import type { PostActionObservationSupport } from './post-action-observation.ts'; export type ResponseDataFieldTransform = { @@ -92,6 +93,16 @@ export type CommandDispatchFacet = { */ } & Record; +/** + * ADR 0016: whether a recorded request changes app-visible state or only + * observes it. The resolver form keeps subcommand-sensitive decisions on the + * descriptor (for example, `alert get` versus `alert accept`). + */ +export type RecordingEffect = 'mutates-app' | 'observes-app'; +export type CommandRecordingEffect = + | RecordingEffect + | ((req: Pick) => RecordingEffect); + /** * The single additive command-descriptor shape (ADR-0008, Phase 1 step 1). * @@ -127,7 +138,7 @@ export type CommandDispatchFacet = { * matrix, batch allowlist, and the daemon client's timeout policy are all * built from it. */ -export type CommandDescriptor = { +type CommandDescriptorBase = { name: string; daemon?: DaemonCommandTraits; capability?: CommandCapability; @@ -144,5 +155,17 @@ export type CommandDescriptor = { * so the recording decision is explicit; the daemon `replayScopedAction` trait and * MCP `noRecord` schema projection are both derived from this value. */ - recordsSessionAction: boolean; }; + +export type CommandDescriptor = CommandDescriptorBase & + ( + | { + recordsSessionAction: true; + /** Required for every recordable command; publication ordering consumes only this trait. */ + recordingEffect: CommandRecordingEffect; + } + | { + recordsSessionAction: false; + recordingEffect?: never; + } + ); diff --git a/src/daemon/__tests__/session-script-active-publication.test.ts b/src/daemon/__tests__/session-script-active-publication.test.ts new file mode 100644 index 000000000..aa359e80f --- /dev/null +++ b/src/daemon/__tests__/session-script-active-publication.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'vitest'; +import type { TargetAnnotationV1 } from '../../replay/target-identity.ts'; +import type { SessionAction } from '../types.ts'; +import { + assertActivePublicationPortability, + validateActivePublicationActions, +} from '../session-script-active-publication.ts'; + +const TARGET_EVIDENCE: TargetAnnotationV1 = { + id: 'continue', + role: 'button', + label: 'Continue', + ancestry: [], + sibling: 0, + viewportOrder: 0, + verification: 'verified', +}; + +function action(command: string, positionals: string[] = []): SessionAction { + return { ts: 1, command, positionals, flags: {} }; +} + +describe('ADR 0016 active publication contract', () => { + test('accepts exactly one initial open and a selector guard after the final mutation', () => { + const actions = [ + action('open', ['Demo']), + { ...action('press', ['id="continue"']), targetEvidence: TARGET_EVIDENCE }, + action('wait', ['role="heading" label="Screen X"']), + action('wait', ['stable']), + ]; + + expect(() => validateActivePublicationActions(actions)).not.toThrow(); + expect(() => assertActivePublicationPortability(actions)).not.toThrow(); + }); + + test.each([ + ['duration', ['100']], + ['stable', ['stable']], + ['ref', ['@e7']], + ['text', ['text', 'Screen X']], + ])('rejects %s wait as the destination guard', (_kind, waitPositionals) => { + expect(() => + validateActivePublicationActions([action('open', ['Demo']), action('wait', waitPositionals)]), + ).toThrow(/portable destination guard/); + }); + + test('requires the guard after request-sensitive mutations', () => { + expect(() => + validateActivePublicationActions([ + action('open', ['Demo']), + action('wait', ['id="screen-x"']), + action('alert', ['accept']), + ]), + ).toThrow(/after the final mutating action/); + + expect(() => + validateActivePublicationActions([ + action('open', ['Demo']), + action('keyboard', ['status']), + action('wait', ['id="screen-x"']), + ]), + ).not.toThrow(); + }); + + test('rejects multiple opens, close, bare refs, and missing target-v1 evidence', () => { + expect(() => + validateActivePublicationActions([ + action('open', ['Demo']), + action('open', ['Other']), + action('wait', ['id="screen-x"']), + ]), + ).toThrow(/exactly one initial recorded open/); + expect(() => + validateActivePublicationActions([ + action('open', ['Demo']), + action('close'), + action('wait', ['id="screen-x"']), + ]), + ).toThrow(/containing close/); + expect(() => assertActivePublicationPortability([action('wait', ['@e7'])])).toThrow( + /session-local ref/, + ); + expect(() => assertActivePublicationPortability([action('press', ['id="continue"'])])).toThrow( + /target identity evidence is missing/, + ); + }); +}); diff --git a/src/daemon/__tests__/session-script-writer.test.ts b/src/daemon/__tests__/session-script-writer.test.ts index 6c41076a9..648e93417 100644 --- a/src/daemon/__tests__/session-script-writer.test.ts +++ b/src/daemon/__tests__/session-script-writer.test.ts @@ -129,7 +129,7 @@ test('a recorded ref that never resolved to a selectorChain throws instead of em expect(result.written).toBe(false); expect(result.written === false && result.error?.message).toMatch(/never resolved to a selector/); expect(fs.existsSync(scriptPath)).toBe(false); - expect(fs.readdirSync(path.join(root, 'sessions')).length).toBe(0); + expect(fs.existsSync(path.join(root, 'sessions'))).toBe(false); }); test('a bare-@ref fill action also fails loud, not just click-like commands', () => { @@ -168,7 +168,7 @@ test('a bare @ref later in the same session (after a resolved earlier action) st const result = writer.write(session); expect(result.written).toBe(false); expect(result.written === false && result.error?.message).toMatch(/never resolved to a selector/); - expect(fs.readdirSync(path.join(root, 'sessions')).length).toBe(0); + expect(fs.existsSync(path.join(root, 'sessions'))).toBe(false); }); test('an ordinary (non-repair-armed) recording keeps the existing bare-ref fallback, never throws', () => { diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index 4aa1d328c..6e9320093 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -5,7 +5,7 @@ import type { DaemonRequest } from './types.ts'; export type { DaemonCommandRoute } from './request-handler-chain.ts'; -export type SessionCommandKind = 'inventory' | 'state' | 'observability' | 'replay'; +export type SessionCommandKind = 'inventory' | 'state' | 'observability' | 'publication' | 'replay'; /** * ADR 0014 session ref-frame lifetime. Declares how a daemon command relates to diff --git a/src/daemon/handlers/__tests__/session-script-publication.test.ts b/src/daemon/handlers/__tests__/session-script-publication.test.ts new file mode 100644 index 000000000..4131fd6d6 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-script-publication.test.ts @@ -0,0 +1,165 @@ +import fs from 'node:fs'; +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 type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import { SessionStore } from '../../session-store.ts'; +import type { DaemonRequest, SessionState } from '../../types.ts'; +import { handleSessionScriptPublication } from '../session-script-publication.ts'; + +const TARGET_EVIDENCE: TargetAnnotationV1 = { + id: 'continue', + role: 'button', + label: 'Continue', + ancestry: [], + sibling: 0, + viewportOrder: 0, + verification: 'verified', +}; + +let root: string; +let store: SessionStore; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-active-publication-')); + store = new SessionStore(path.join(root, 'sessions')); +}); + +function armedSession(overrides: Partial = {}): SessionState { + return makeIosSession('authoring', { + recordSession: true, + scriptRecordingState: 'armed', + actions: [ + { ts: 1, command: 'open', positionals: ['Demo'], flags: { saveScript: true } }, + { + ts: 2, + command: 'press', + positionals: ['id="continue"'], + flags: {}, + targetEvidence: TARGET_EVIDENCE, + }, + { ts: 3, command: 'wait', positionals: ['id="screen-x"'], flags: {} }, + ], + ...overrides, + }); +} + +function request(outputPath?: string, force?: boolean): DaemonRequest { + return { + token: 'test', + session: 'authoring', + command: INTERNAL_COMMANDS.sessionSaveScript, + positionals: outputPath ? [outputPath] : [], + flags: force ? { force: true } : {}, + }; +} + +test('publishes without close, returns the path/count, and leaves a terminal live session', () => { + const outputPath = path.join(root, 'screen-x.ad'); + const session = armedSession(); + store.set('authoring', session); + + const response = handleSessionScriptPublication({ + req: request(outputPath), + sessionName: 'authoring', + sessionStore: store, + }); + + expect(response).toMatchObject({ + ok: true, + data: { session: 'authoring', savedScript: outputPath, actionCount: 3 }, + }); + expect(fs.readFileSync(outputPath, 'utf8')).toContain('wait "id=\\"screen-x\\""'); + expect(fs.readFileSync(outputPath, 'utf8')).not.toContain('\nclose'); + expect(store.get('authoring')).toBe(session); + expect(session.scriptRecordingState).toBe('published'); + expect(session.recordSession).toBe(false); + + const repeated = handleSessionScriptPublication({ + req: request(), + sessionName: 'authoring', + sessionStore: store, + }); + expect(repeated).toMatchObject({ + ok: false, + error: { message: expect.stringMatching(/already/) }, + }); +}); + +test('no-clobber failure preserves bytes and armed state, then --force retries successfully', () => { + const outputPath = path.join(root, 'screen-x.ad'); + fs.writeFileSync(outputPath, 'original\n'); + const session = armedSession(); + store.set('authoring', session); + + const refused = handleSessionScriptPublication({ + req: request(outputPath), + sessionName: 'authoring', + sessionStore: store, + }); + expect(refused).toMatchObject({ ok: false, error: { retriable: true } }); + expect(fs.readFileSync(outputPath, 'utf8')).toBe('original\n'); + expect(session.scriptRecordingState).toBe('armed'); + expect(session.saveScriptPath).toBe(outputPath); + + const replaced = handleSessionScriptPublication({ + req: request(outputPath, true), + sessionName: 'authoring', + sessionStore: store, + }); + expect(replaced?.ok).toBe(true); + expect(fs.readFileSync(outputPath, 'utf8')).toContain('context platform=ios'); + expect(session.scriptRecordingState).toBe('published'); +}); + +test('refuses unarmed and repair-owned sessions before filesystem work', () => { + const outputPath = path.join(root, 'missing', 'screen-x.ad'); + store.set('authoring', makeIosSession('authoring')); + const unarmed = handleSessionScriptPublication({ + req: request(outputPath), + sessionName: 'authoring', + sessionStore: store, + }); + expect(unarmed).toMatchObject({ + ok: false, + error: { message: expect.stringMatching(/not armed/) }, + }); + expect(fs.existsSync(path.dirname(outputPath))).toBe(false); + + store.set('authoring', armedSession({ saveScriptBoundary: 0 })); + const repair = handleSessionScriptPublication({ + req: request(outputPath), + sessionName: 'authoring', + sessionStore: store, + }); + expect(repair).toMatchObject({ + ok: false, + error: { message: expect.stringMatching(/repair transaction/) }, + }); + expect(fs.existsSync(path.dirname(outputPath))).toBe(false); +}); + +test('invalid destination guard remains armed and creates no target directory', () => { + const outputPath = path.join(root, 'missing', 'screen-x.ad'); + const session = armedSession({ + actions: [ + { ts: 1, command: 'open', positionals: ['Demo'], flags: { saveScript: true } }, + { ts: 2, command: 'wait', positionals: ['stable'], flags: {} }, + ], + }); + store.set('authoring', session); + + const response = handleSessionScriptPublication({ + req: request(outputPath), + sessionName: 'authoring', + sessionStore: store, + }); + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringMatching(/destination guard/), retriable: true }, + }); + expect(session.scriptRecordingState).toBe('armed'); + expect(fs.existsSync(path.dirname(outputPath))).toBe(false); +}); diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 64e06e94b..664849b7c 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -415,6 +415,17 @@ async function stopOrRetainAppleRunnerAfterClose( scheduleIosRunnerIdleStop(session.device.id); } +function assertTerminalRecordingCloseAllowed(req: DaemonRequest, session: SessionState): void { + if (!req.flags?.saveScript) return; + if (session.scriptRecordingState !== 'aborted' && session.scriptRecordingState !== 'published') { + return; + } + throw new AppError( + 'INVALID_ARGS', + `close --save-script cannot ${session.scriptRecordingState === 'published' ? 're-publish' : 'publish'} this terminal recording. Retry with plain close; it will tear down the session without writing.`, + ); +} + export async function handleCloseCommand(params: { req: DaemonRequest; sessionName: string; @@ -428,6 +439,7 @@ export async function handleCloseCommand(params: { if (!session) { return await closeWithoutSession(req, logPath); } + assertTerminalRecordingCloseAllowed(req, session); if (req.internal?.closeAppOnly === true) { return await closeAppWithoutEndingSession({ req, session, logPath }); } diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 1f86cd16f..995f6897c 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -78,6 +78,30 @@ type OpenTiming = { type NewSessionOpenEffects = { mayHaveStarted: boolean }; +function applyOrdinaryScriptRecordingOpenOutcome(params: { + session: SessionState; + existingSession: SessionState | undefined; + saveScriptRequested: boolean; + responseData: Record; +}): void { + const { session, existingSession, saveScriptRequested, responseData } = params; + if (!existingSession && saveScriptRequested) { + session.scriptRecordingState = 'armed'; + session.recordSession = true; + return; + } + if (existingSession?.scriptRecordingState !== 'armed') return; + session.scriptRecordingState = 'aborted'; + session.recordSession = false; + const warnings = Array.isArray(responseData.warnings) + ? responseData.warnings.filter((warning): warning is string => typeof warning === 'string') + : []; + responseData.warnings = [ + ...warnings, + 'Script publication was aborted because this session completed a second open. Start a fresh session with open --save-script to author another script.', + ]; +} + async function relaunchCloseApp(params: { device: DeviceInfo; closeTarget: string; @@ -448,6 +472,12 @@ async function completeOpenCommand(params: { runtimeHintCount: countConfiguredRuntimeHints, sessionReused: existingSession !== undefined, }); + applyOrdinaryScriptRecordingOpenOutcome({ + session: nextSession, + existingSession, + saveScriptRequested: Boolean(req.flags?.saveScript), + responseData: openResult, + }); sessionStore.set(sessionName, nextSession); sessionStore.recordAction(nextSession, { command: 'open', @@ -636,6 +666,12 @@ export async function handleOpenCommand(params: { const session = sessionStore.get(sessionName); if (session) { + if (req.flags?.saveScript) { + return errorResponse( + 'INVALID_ARGS', + 'open --save-script can only arm a fresh session. Use the current session without --save-script, or close it and start a fresh session.', + ); + } const shouldRelaunch = req.flags?.relaunch === true; const requestedOpenTarget = req.positionals?.[0]; const openTarget = requestedOpenTarget ?? (shouldRelaunch ? session.appName : undefined); diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 6353cfa70..62e5576da 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -619,6 +619,15 @@ function prepareSaveScriptSession(params: { saveScriptPath: existingSaveScriptPath, saveScriptBoundary, } = preRunSession ?? {}; + if (saveScript && preRunSession?.scriptRecordingState !== undefined) { + return { + ok: false, + response: errorResponse( + 'INVALID_ARGS', + `replay --save-script cannot re-arm an ordinary recording in terminal/active state ${preRunSession.scriptRecordingState}. Close this session and use a fresh one for repair authoring.`, + ), + }; + } const saveScriptPreflight = preflightSaveScriptTarget({ saveScript, liveForce: force, diff --git a/src/daemon/handlers/session-script-publication.ts b/src/daemon/handlers/session-script-publication.ts new file mode 100644 index 000000000..ecf8687ab --- /dev/null +++ b/src/daemon/handlers/session-script-publication.ts @@ -0,0 +1,104 @@ +import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; +import { AppError, normalizeError } from '../../kernel/errors.ts'; +import { successText } from '../../utils/success-text.ts'; +import { applySaveScriptRetarget } from '../session-action-recorder.ts'; +import { expandSessionPath } from '../session-paths.ts'; +import { SessionStore } from '../session-store.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; + +export function handleSessionScriptPublication(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; +}): DaemonResponse | null { + const { req, sessionName, sessionStore } = params; + if (req.command !== INTERNAL_COMMANDS.sessionSaveScript) return null; + if ((req.positionals?.length ?? 0) > 1) { + return failure( + new AppError('INVALID_ARGS', 'session save-script accepts at most one output path.'), + ); + } + + const session = sessionStore.get(sessionName); + if (!session) { + return failure( + new AppError('SESSION_NOT_FOUND', `No active session "${sessionName}".`, { + hint: 'Start a fresh journey with open --save-script[=], then retry.', + }), + ); + } + const eligibilityError = validatePublicationEligibility(session); + if (eligibilityError) return failure(eligibilityError); + + const explicitPath = req.positionals?.[0]?.trim(); + if (req.positionals?.[0] !== undefined && !explicitPath) { + return failure(new AppError('INVALID_ARGS', 'session save-script path cannot be empty.')); + } + if (explicitPath) { + applySaveScriptRetarget(session, expandSessionPath(explicitPath), req.flags?.force); + } + if (req.flags?.force) session.saveScriptForce = true; + + const result = sessionStore.writeSessionLog(session, { + force: Boolean(req.flags?.force || session.saveScriptForce), + publication: 'active', + }); + if (!result.written) { + return failure( + result.error ?? + new AppError('COMMAND_FAILED', 'The active session script was not published.', { + retriable: true, + hint: 'The session remains armed; retry session save-script.', + }), + ); + } + + session.scriptRecordingState = 'published'; + session.recordSession = false; + session.saveScriptPath = result.path; + return { + ok: true, + data: { + session: session.name, + savedScript: result.path, + actionCount: result.actionCount, + ...successText(`Published script: ${result.path}`), + }, + }; +} + +function validatePublicationEligibility(session: SessionState): AppError | undefined { + if (session.saveScriptBoundary !== undefined) { + return new AppError( + 'COMMAND_FAILED', + 'This session has an active .ad repair transaction and cannot use ordinary active-session publication.', + { + hint: 'Finish or abort the repair through replay --from and its existing close/teardown protocol.', + }, + ); + } + if (session.scriptRecordingState === 'aborted') { + return new AppError( + 'COMMAND_FAILED', + 'This script recording was aborted by a second successful open and cannot be published.', + { hint: 'Close this session and start a fresh one with open --save-script[=].' }, + ); + } + if (session.scriptRecordingState === 'published') { + return new AppError('COMMAND_FAILED', 'This script recording has already been published.', { + hint: 'Continue using the live session, or close it and start a fresh authoring session.', + }); + } + if (session.scriptRecordingState !== 'armed' || !session.recordSession) { + return new AppError( + 'COMMAND_FAILED', + 'Script recording was not armed before this journey began; session history cannot be published without recording-time target evidence.', + { hint: 'Close this session and start a fresh one with open --save-script[=].' }, + ); + } + return undefined; +} + +function failure(error: AppError): Extract { + return { ok: false, error: normalizeError(error) }; +} diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index af383f2a1..6c12ddd9f 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -36,6 +36,7 @@ import { handleSessionInventoryCommands } from './session-inventory.ts'; import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; +import { handleSessionScriptPublication } from './session-script-publication.ts'; import { handleDoctorCommand } from './session-doctor.ts'; import { getSessionCommandKind, resolveRefFrameEffect } from '../daemon-command-registry.ts'; import { expireRefFrame } from '../ref-frame.ts'; @@ -325,6 +326,10 @@ export async function handleSessionCommands(params: { }); } + if (getSessionCommandKind(req.command) === 'publication') { + return handleSessionScriptPublication({ req, sessionName, sessionStore }); + } + if (req.command === PUBLIC_COMMANDS.clipboard) { return await handleClipboardCommand({ req, diff --git a/src/daemon/session-script-active-publication.ts b/src/daemon/session-script-active-publication.ts new file mode 100644 index 000000000..afdd5de9e --- /dev/null +++ b/src/daemon/session-script-active-publication.ts @@ -0,0 +1,131 @@ +import { resolveCommandRecordingEffect } from '../core/command-descriptor/registry.ts'; +import { parseWaitPositionals } from '../core/wait-positionals.ts'; +import { AppError } from '../kernel/errors.ts'; +import { isTouchTargetCommand } from '../replay/script-utils.ts'; +import { tryParseSelectorChain } from '../selectors/parse.ts'; +import type { SessionAction } from './types.ts'; + +export function validateActivePublicationActions(actions: SessionAction[]): void { + const openIndexes = actions.flatMap((action, index) => + action.command === 'open' ? [index] : [], + ); + if (openIndexes.length !== 1 || openIndexes[0] !== 0) { + throw new AppError( + 'COMMAND_FAILED', + 'Cannot publish this session: an open-to-destination script requires exactly one initial recorded open.', + { hint: 'Close this session and start a fresh one with open --save-script[=].' }, + ); + } + if (actions.some((action) => action.command === 'close')) { + throw new AppError( + 'COMMAND_FAILED', + 'Cannot publish an active-session script containing close.', + { + hint: 'Close this session and record the journey again from a fresh open --save-script session.', + }, + ); + } + + let lastMutationIndex = -1; + for (const [index, action] of actions.entries()) { + if ( + resolveCommandRecordingEffect({ + command: action.command, + positionals: action.positionals, + flags: action.flags, + }) === 'mutates-app' + ) { + lastMutationIndex = index; + } + } + if (actions.slice(lastMutationIndex + 1).some(isPortableDestinationGuard)) return; + throw new AppError( + 'COMMAND_FAILED', + 'Cannot publish this session without a portable destination guard after the final mutating action.', + { + hint: 'Record a selective selector-targeted wait, for example wait \'role="heading" label="Screen X"\', then retry session save-script.', + }, + ); +} + +export function assertActivePublicationPortability(actions: SessionAction[]): void { + for (const action of actions) { + const ref = action.positionals.find((positional) => positional.startsWith('@')); + if (ref) { + throw new AppError( + 'COMMAND_FAILED', + `Cannot publish recorded step "${action.command} ${ref}": the session-local ref was not converted to a portable selector.`, + { + hint: 'Close this session and record the journey again using selectors or resolvable refs.', + }, + ); + } + const token = readTargetBindingToken(action); + if (!token || !tryParseSelectorChain(token) || action.targetEvidence) continue; + throw new AppError( + 'COMMAND_FAILED', + `Cannot publish recorded step "${action.command} ${token}": recording-time target identity evidence is missing.`, + { + hint: 'Close this session and record the journey again from open --save-script so target-v1 evidence is captured before each interaction.', + }, + ); + } +} + +export function toActivePublicationFailure( + error: unknown, + scriptPath: string | undefined, +): AppError { + if (error instanceof AppError) { + if (error.details?.reason === 'script_target_exists') { + return new AppError(error.code, `A file already exists at ${String(error.details.path)}.`, { + ...error.details, + retriable: true, + hint: 'Retry session save-script with another path, or pass --force to replace the existing file. The session remains armed.', + }); + } + return new AppError(error.code, error.message, { + ...error.details, + retriable: true, + hint: + error.details?.hint ?? + 'Fix the recorded journey or target, then retry session save-script. The session remains armed.', + }); + } + const detail = error instanceof Error ? error.message : String(error); + return new AppError( + 'COMMAND_FAILED', + `Failed to publish the active session script${scriptPath ? ` to ${scriptPath}` : ''}: ${detail}`, + { + retriable: true, + hint: 'Check the target path and permissions, then retry session save-script. The session remains armed.', + }, + ); +} + +function isPortableDestinationGuard(action: SessionAction): boolean { + if (action.command !== 'wait') return false; + const parsed = parseWaitPositionals(action.positionals); + return parsed?.kind === 'selector' && tryParseSelectorChain(parsed.selectorExpression) !== null; +} + +function readTargetBindingToken(action: SessionAction): string | undefined { + if (action.command === 'get') return action.positionals[1]; + if (isTouchTargetCommand(action.command) || action.command === 'fill') { + const [first, second] = action.positionals; + if ( + first !== undefined && + second !== undefined && + isFiniteNumber(first) && + isFiniteNumber(second) + ) { + return undefined; + } + return first; + } + return undefined; +} + +function isFiniteNumber(value: string): boolean { + return value.trim().length > 0 && Number.isFinite(Number(value)); +} diff --git a/src/daemon/session-script-writer.ts b/src/daemon/session-script-writer.ts index 6dfcb60f6..a8cfb3625 100644 --- a/src/daemon/session-script-writer.ts +++ b/src/daemon/session-script-writer.ts @@ -18,6 +18,11 @@ import { stripRecordedRefGeneration, } from '../replay/script-utils.ts'; import type { SessionAction, SessionState } from './types.ts'; +import { + assertActivePublicationPortability, + toActivePublicationFailure, + validateActivePublicationActions, +} from './session-script-active-publication.ts'; /** * `{ written: true; path }` — committed. `{ written: false }` (no `error`) — @@ -30,9 +35,15 @@ import type { SessionAction, SessionState } from './types.ts'; * retry, never swallowed into a silent skip. */ export type SessionScriptWriteResult = - | { written: true; path: string } + | { written: true; path: string; actionCount: number } | { written: false; error?: AppError }; +export type SessionScriptWriteOptions = { + force?: boolean; + /** ADR 0016 publishes an armed ordinary recording while keeping its session live. */ + publication?: 'teardown' | 'active'; +}; + /** * ADR 0012 decision 6 (Fix 4, C2): trailer comment marking a healed `.ad` as a * COMPLETE, review-worthy repair artifact. An ordinary `#` comment to every @@ -75,26 +86,40 @@ export class SessionScriptWriter { this.sessionsDir = sessionsDir; } - write(session: SessionState, options?: { force?: boolean }): SessionScriptWriteResult { + write(session: SessionState, options?: SessionScriptWriteOptions): SessionScriptWriteResult { const repairArmed = session.saveScriptBoundary !== undefined; + const activePublication = options?.publication === 'active'; let scriptPath: string | undefined; try { if (!session.recordSession) return { written: false }; if (isRepairArmedWriteBlocked(session)) return { written: false }; + const prepared = prepareSessionScript(session, { + appendCompleteSentinel: repairArmed, + activePublication, + }); scriptPath = this.resolveScriptPath(session); const scriptDir = path.dirname(scriptPath); if (!fs.existsSync(scriptDir)) fs.mkdirSync(scriptDir, { recursive: true }); - const script = formatSessionScript(session, repairArmed); // #1258: `options.force` is the caller's already-merged decision // (typically `req.flags?.force || session.saveScriptForce` — see // `session-close.ts`/`session-store.ts`), not read from `session` // directly here, so this stays a pure formatting+publish step. - publishHealedScriptAtomically({ scriptPath, script, force: options?.force }); + publishHealedScriptAtomically({ + scriptPath, + script: prepared.script, + force: options?.force, + }); // COMMITTED: idempotent guard above + teardown's abort/tombstone routing. if (repairArmed) session.saveScriptCommitted = true; - return { written: true, path: scriptPath }; + return { written: true, path: scriptPath, actionCount: prepared.actionCount }; } catch (error) { - return handleSessionScriptWriteFailure({ session, error, scriptPath, repairArmed }); + return handleSessionScriptWriteFailure({ + session, + error, + scriptPath, + repairArmed, + activePublication, + }); } } @@ -102,13 +127,24 @@ export class SessionScriptWriter { if (session.saveScriptPath) { return expandSessionPath(session.saveScriptPath); } - if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true }); const safeName = safeSessionName(session.name); const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-'); return path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`); } } +function prepareSessionScript( + session: SessionState, + options: { appendCompleteSentinel: boolean; activePublication: boolean }, +): { script: string; actionCount: number } { + const actions = buildOptimizedActions(session, { strictPortableRefs: options.activePublication }); + if (options.activePublication) validateActivePublicationActions(actions); + return { + script: formatScript(session, actions, options.appendCompleteSentinel), + actionCount: actions.length, + }; +} + /** * `write()`'s catch-block classifier, extracted verbatim (no behavior change): * diagnose, then route by whether the session is repair-armed. @@ -132,8 +168,9 @@ function handleSessionScriptWriteFailure(params: { error: unknown; scriptPath: string | undefined; repairArmed: boolean; + activePublication: boolean; }): SessionScriptWriteResult { - const { session, error, scriptPath, repairArmed } = params; + const { session, error, scriptPath, repairArmed, activePublication } = params; emitDiagnostic({ level: 'warn', phase: 'session_script_write_failed', @@ -146,6 +183,9 @@ function handleSessionScriptWriteFailure(params: { if (repairArmed) { return { written: false, error: toRepairCommitFailure(error, scriptPath) }; } + if (activePublication) { + return { written: false, error: toActivePublicationFailure(error, scriptPath) }; + } if (error instanceof AppError) throw error; return { written: false }; } @@ -169,10 +209,6 @@ function toRepairCommitFailure(error: unknown, scriptPath: string | undefined): ); } -function formatSessionScript(session: SessionState, appendCompleteSentinel: boolean): string { - return formatScript(session, buildOptimizedActions(session), appendCompleteSentinel); -} - /** * ADR 0012 decision 6, no-clobber (maintainer-approved simplification): * publishes `script` to `scriptPath` atomically, refusing ANY pre-existing @@ -229,6 +265,7 @@ function publishHealedScriptAtomically(params: { throw new AppError( 'COMMAND_FAILED', `A file already exists at ${scriptPath}; remove it, pass replay --save-script=, or pass --force/--overwrite to replace it.`, + { reason: 'script_target_exists', path: scriptPath }, ); } finally { // linkSync leaves the temp hard-link behind on success; an error leaves @@ -239,7 +276,10 @@ function publishHealedScriptAtomically(params: { } } -function buildOptimizedActions(session: SessionState): SessionAction[] { +function buildOptimizedActions( + session: SessionState, + options: { strictPortableRefs?: boolean } = {}, +): SessionAction[] { // ADR 0012 decision 6, R6: a repair-armed session (`saveScriptBoundary` set // by `replay --save-script`) serializes only the actions from that // watermark onward — the repair run's own execution path — never the @@ -257,11 +297,12 @@ function buildOptimizedActions(session: SessionState): SessionAction[] { } // R4 is scoped to a repair-armed session, not the existing refLabel/ // scoped-snapshot fallback ordinary `open`/`close --save-script` keeps. - if (repairArmed) assertNoUnresolvedRefFallback(action); + if (repairArmed || options.strictPortableRefs) assertNoUnresolvedRefFallback(action); const scopedSnapshot = buildScopedSnapshotAction(session, action); if (scopedSnapshot) optimized.push(scopedSnapshot); optimized.push(action); } + if (options.strictPortableRefs) assertActivePublicationPortability(optimized); return optimized; } diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index d6c64f2cb..c20130774 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -4,7 +4,11 @@ import { emitDiagnostic } from '../utils/diagnostics.ts'; import type { SessionRuntimeHints, SessionState } from './types.ts'; import { recordActionEntry, type RecordActionEntry } from './session-action-recorder.ts'; import { expandSessionPath, safeSessionName } from './session-paths.ts'; -import { SessionScriptWriter, type SessionScriptWriteResult } from './session-script-writer.ts'; +import { + SessionScriptWriter, + type SessionScriptWriteOptions, + type SessionScriptWriteResult, +} from './session-script-writer.ts'; import { successText } from '../utils/success-text.ts'; import { appendActionEvent, @@ -110,7 +114,10 @@ export class SessionStore { ); } - writeSessionLog(session: SessionState, options?: { force?: boolean }): SessionScriptWriteResult { + writeSessionLog( + session: SessionState, + options?: SessionScriptWriteOptions, + ): SessionScriptWriteResult { const result = this.scriptWriter.write(session, options); if (result.written) { emitDiagnostic({ diff --git a/src/daemon/types.ts b/src/daemon/types.ts index b8560e03c..c986f2217 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -341,6 +341,8 @@ export type SessionState = { /** Session was created by record start and should be released when recording stops. */ recordOnlySession?: boolean; recordSession?: boolean; + /** ADR 0016 ordinary open-to-destination authoring lifecycle. Repair state is separate. */ + scriptRecordingState?: 'armed' | 'aborted' | 'published'; saveScriptPath?: string; /** * #1258: `--force`/`--overwrite` captured at the moment `--save-script` was diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts index 9c1401958..24e324142 100644 --- a/src/mcp/__tests__/command-tools.test.ts +++ b/src/mcp/__tests__/command-tools.test.ts @@ -576,12 +576,12 @@ test('MCP prepare schema mirrors its PublicPlatform contract', () => { ); }); -test('MCP session tool exposes state-dir resolution without a daemon round-trip', async () => { +test('MCP session tool exposes publication and resolves state-dir without a daemon round-trip', async () => { const sessionTool = listCommandTools().find((tool) => tool.name === 'session'); assert.ok(sessionTool); assert.deepEqual( (sessionTool.inputSchema.properties?.action as { enum?: unknown[] } | undefined)?.enum, - ['list', 'state-dir'], + ['list', 'state-dir', 'save-script'], ); const executor = createCommandToolExecutor({ diff --git a/test/integration/provider-scenarios/active-session-script-publication.test.ts b/test/integration/provider-scenarios/active-session-script-publication.test.ts new file mode 100644 index 000000000..ee5a68c7e --- /dev/null +++ b/test/integration/provider-scenarios/active-session-script-publication.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'vitest'; +import { assertRpcError, assertRpcOk } from './assertions.ts'; +import { createAndroidSettingsWorld } from './android-world.ts'; +import { withProviderScenarioResource } from './harness.ts'; + +test('provider route publishes and replays an open-to-destination script with a live handoff', async () => { + await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-active-script-provider-')); + const scriptPath = path.join(root, 'settings-search.ad'); + const client = world.daemon.client(); + try { + await client.apps.open({ + app: 'settings', + saveScript: scriptPath, + ...world.selection, + }); + await client.command.wait({ selector: 'label="Search"', ...world.selection }); + + const published = await client.sessions.saveScript({ path: scriptPath }); + assert.equal(published.savedScript, scriptPath); + assert.equal(published.session, 'default'); + assert.equal(published.actionCount, 2); + assert.equal(world.daemon.session()?.scriptRecordingState, 'published'); + + const liveSnapshot = await client.capture.snapshot({ interactiveOnly: true }); + assert.ok(liveSnapshot.nodes.some((node) => node.label === 'Search')); + const flaggedClose = await world.daemon.callCommand('close', [], { saveScript: scriptPath }); + assertRpcError(flaggedClose, 'INVALID_ARGS', /cannot re-publish/); + assert.ok(world.daemon.session(), 'flagged close must preserve the published live session'); + await client.sessions.close(); + + const replay = await world.daemon.callCommand('replay', [scriptPath], world.selection); + assert.equal(assertRpcOk<{ session?: string }>(replay).session, 'default'); + const replaySnapshot = await client.capture.snapshot({ interactiveOnly: true }); + assert.ok(replaySnapshot.nodes.some((node) => node.label === 'Search')); + await client.sessions.close(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}, 20_000); + +test('a second successful open aborts publication and terminal save flags fail before close', async () => { + await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-aborted-script-provider-')); + const scriptPath = path.join(root, 'aborted.ad'); + try { + const first = await world.daemon.callCommand('open', ['settings'], { + ...world.selection, + saveScript: scriptPath, + }); + assertRpcOk(first); + + const rearm = await world.daemon.callCommand('open', ['settings'], { + ...world.selection, + relaunch: true, + saveScript: scriptPath, + }); + assertRpcError(rearm, 'INVALID_ARGS', /only arm a fresh session/); + assert.equal(world.daemon.session()?.scriptRecordingState, 'armed'); + + const second = await world.daemon.callCommand('open', ['settings'], { + ...world.selection, + relaunch: true, + }); + const secondData = assertRpcOk<{ warnings?: string[] }>(second); + assert.match(String(secondData.warnings), /publication was aborted/i); + assert.equal(world.daemon.session()?.scriptRecordingState, 'aborted'); + + const publication = await world.daemon.callCommand('session_save_script', [scriptPath]); + assertRpcError(publication, 'COMMAND_FAILED', /aborted by a second successful open/); + + const flaggedClose = await world.daemon.callCommand('close', [], { saveScript: scriptPath }); + assertRpcError(flaggedClose, 'INVALID_ARGS', /terminal recording/); + assert.ok(world.daemon.session(), 'flagged close must not tear down the session'); + assert.equal(fs.existsSync(scriptPath), false); + + const plainClose = await world.daemon.callCommand('close'); + assertRpcOk(plainClose); + assert.equal(world.daemon.session(), undefined); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}, 20_000); From bcc08ae68958d7c6497dadbca7f4fec03dc713c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 21 Jul 2026 19:41:45 +0200 Subject: [PATCH 2/3] test: cover save-script force retargeting --- .../session-script-publication.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/daemon/handlers/__tests__/session-script-publication.test.ts b/src/daemon/handlers/__tests__/session-script-publication.test.ts index 4131fd6d6..0718c2de5 100644 --- a/src/daemon/handlers/__tests__/session-script-publication.test.ts +++ b/src/daemon/handlers/__tests__/session-script-publication.test.ts @@ -114,6 +114,29 @@ test('no-clobber failure preserves bytes and armed state, then --force retries s expect(session.scriptRecordingState).toBe('published'); }); +test('retargeting without --force clears force authorization from the previous target', () => { + const originalPath = path.join(root, 'original.ad'); + const retargetPath = path.join(root, 'retarget.ad'); + fs.writeFileSync(retargetPath, 'protected\n'); + const session = armedSession({ + saveScriptPath: originalPath, + saveScriptForce: true, + }); + store.set('authoring', session); + + const response = handleSessionScriptPublication({ + req: request(retargetPath), + sessionName: 'authoring', + sessionStore: store, + }); + + expect(response).toMatchObject({ ok: false, error: { retriable: true } }); + expect(fs.readFileSync(retargetPath, 'utf8')).toBe('protected\n'); + expect(session.scriptRecordingState).toBe('armed'); + expect(session.saveScriptPath).toBe(retargetPath); + expect(session.saveScriptForce).toBeUndefined(); +}); + test('refuses unarmed and repair-owned sessions before filesystem work', () => { const outputPath = path.join(root, 'missing', 'screen-x.ad'); store.set('authoring', makeIosSession('authoring')); From 8f5ff18ef855df1b7974f8c42098a2e350b9a0d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 21 Jul 2026 19:56:38 +0200 Subject: [PATCH 3/3] fix: address active publication review findings --- src/__tests__/client.test.ts | 4 + src/agent-device-client.ts | 6 +- src/client/client-types.ts | 1 + src/commands/management/output.test.ts | 13 ++ src/commands/management/output.ts | 3 + src/commands/management/session.test.ts | 45 +++++++ src/commands/management/session.ts | 21 +++- src/contracts/result-serialization.ts | 1 + .../session-script-active-publication.test.ts | 19 +++ .../session-script-publication.test.ts | 113 +++++++++++++++++- .../session-script-active-publication.ts | 33 ++++- 11 files changed, 249 insertions(+), 10 deletions(-) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index ce0395bb5..ce921b124 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -215,6 +215,7 @@ test('apps.open resolves session device identifiers from open response', async ( kind: 'simulator', device_udid: 'SIM-001', ios_simulator_device_set: '/tmp/sim-set', + warnings: ['Script publication was aborted by a second successful open.', 42], startup: { durationMs: 1234, measuredAt: '2026-03-13T10:00:00.000Z', @@ -244,6 +245,9 @@ test('apps.open resolves session device identifiers from open response', async ( assert.equal(result.identifiers.appId, 'com.apple.Preferences'); assert.equal(result.device?.name, 'iPhone 16'); assert.equal(result.device?.ios?.simulatorSetPath, '/tmp/sim-set'); + assert.deepEqual(result.warnings, [ + 'Script publication was aborted by a second successful open.', + ]); }); test('apps.open forwards explicit runtime hints through the daemon request', async () => { diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 6fc910a5d..740c5b364 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -202,7 +202,7 @@ export function createAgentDeviceClient( ): Promise => { const data = await execute( INTERNAL_COMMANDS.sessionSaveScript, - options.path ? [options.path] : [], + options.path !== undefined ? [options.path] : [], options, { force: options.force }, ); @@ -258,8 +258,12 @@ export function createAgentDeviceClient( const device = normalizeOpenDevice(data); const appBundleId = readOptionalString(data, 'appBundleId'); const appId = appBundleId; + const warnings = Array.isArray(data.warnings) + ? data.warnings.filter((warning): warning is string => typeof warning === 'string') + : []; return { session, + ...(warnings.length > 0 ? { warnings } : {}), sessionStateDir: readOptionalString(data, 'sessionStateDir'), eventLogPath: readOptionalString(data, 'eventLogPath'), appName: readOptionalString(data, 'appName'), diff --git a/src/client/client-types.ts b/src/client/client-types.ts index dbff839a7..71a468d23 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -321,6 +321,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides & export type AppOpenResult = { session: string; + warnings?: string[]; sessionStateDir?: string; runnerLogPath?: string; requestLogPath?: string; diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index e4b0e6df7..cb9331ebf 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -32,6 +32,19 @@ describe('openCliOutput', () => { expect(output.data).not.toHaveProperty('timing'); }); + + test('preserves open warnings in JSON data and renders them immediately', () => { + const warning = + 'Script publication was aborted by a second successful open; start a fresh session.'; + const output = openCliOutput({ + session: 'authoring', + warnings: [warning], + identifiers: { session: 'authoring' }, + }); + + expect(output.data).toMatchObject({ warnings: [warning] }); + expect(output.text).toBe(`Opened: authoring\nWarning: ${warning}`); + }); }); describe('artifactsCliOutput', () => { diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 3db088853..c5934fdba 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -98,6 +98,9 @@ export function openCliOutput(result: AppOpenResult): CliOutput { if (typeof data.sessionStateDir === 'string') { lines.push(`Session state: ${data.sessionStateDir}`); } + for (const warning of result.warnings ?? []) { + lines.push(`Warning: ${warning}`); + } return { data, text: lines.join('\n') || null }; } diff --git a/src/commands/management/session.test.ts b/src/commands/management/session.test.ts index ff629cfa3..2cd746261 100644 --- a/src/commands/management/session.test.ts +++ b/src/commands/management/session.test.ts @@ -54,6 +54,51 @@ test('strict CLI parsing accepts session save-script path and --force', () => { expect(parsed.flags.force).toBe(true); }); +test('typed save-script preserves an explicitly empty path for daemon validation', async () => { + const calls: Array> = []; + const client = createAgentDeviceClient( + { session: 'authoring' }, + { + transport: async (req) => { + calls.push(req); + return { + ok: true, + data: { session: 'authoring', savedScript: '/tmp/default.ad', actionCount: 1 }, + } satisfies DaemonResponse; + }, + }, + ); + + await client.sessions.saveScript({ path: '' }); + + expect(calls[0]?.positionals).toEqual(['']); +}); + +test.each([ + { positionals: ['list', './unexpected.ad'], flags: {} }, + { positionals: ['state-dir'], flags: { force: true } }, +])( + 'rejects save-script-only options on session siblings', + async ({ positionals, flags: inputFlags }) => { + const calls: Array> = []; + const client = createAgentDeviceClient( + {}, + { + transport: async (req) => { + calls.push(req); + return { ok: true, data: {} } satisfies DaemonResponse; + }, + }, + ); + const input = sessionCommandFacet.cliReader(positionals, flags(inputFlags)); + + await expect(sessionCommandFacet.definition.invoke(client, input)).rejects.toThrow( + /does not accept a path or --force/, + ); + expect(calls).toHaveLength(0); + }, +); + test('session and workflow help expose active publication and literal-secret warning', () => { expect(buildCommandUsageText('session')).toMatch(/session save-script \[path\] \[--force\]/); const workflow = buildCommandUsageText('workflow'); diff --git a/src/commands/management/session.ts b/src/commands/management/session.ts index d6b486de3..93832a8ec 100644 --- a/src/commands/management/session.ts +++ b/src/commands/management/session.ts @@ -24,8 +24,12 @@ const sessionCommandMetadata = defineFieldCommandMetadata( const sessionCommandDefinition = defineExecutableCommand( sessionCommandMetadata, async (client, { action, path, force, ...input }) => { - if (action === 'state-dir') return { stateDir: await client.sessions.stateDir(input) }; - if (action === 'save-script') { + const effectiveAction = action ?? 'list'; + assertSessionActionOptions(effectiveAction, path, force); + if (effectiveAction === 'state-dir') { + return { stateDir: await client.sessions.stateDir(input) }; + } + if (effectiveAction === 'save-script') { return await client.sessions.saveScript({ ...input, path, force }); } return { sessions: await client.sessions.list(input) }; @@ -64,3 +68,16 @@ function readSessionAction(value: string | undefined): 'list' | 'state-dir' | 's if (action === 'save-script') return action; throw new AppError('INVALID_ARGS', 'session only supports list, state-dir, or save-script'); } + +function assertSessionActionOptions( + action: 'list' | 'state-dir' | 'save-script', + path: string | undefined, + force: boolean | undefined, +): void { + if (action === 'save-script') return; + if (path === undefined && force !== true) return; + throw new AppError( + 'INVALID_ARGS', + `session ${action} does not accept a path or --force; use session save-script [path] [--force] to publish a recording.`, + ); +} diff --git a/src/contracts/result-serialization.ts b/src/contracts/result-serialization.ts index df38a8943..bece74c86 100644 --- a/src/contracts/result-serialization.ts +++ b/src/contracts/result-serialization.ts @@ -147,6 +147,7 @@ export function serializeOpenResult(result: AppOpenResult): Record { /target identity evidence is missing/, ); }); + + test('treats leading-at user text as data rather than a session ref', () => { + expect(() => + assertActivePublicationPortability([ + action('type', ['@thymikee']), + { + ...action('fill', ['id="handle"', '@someone']), + targetEvidence: TARGET_EVIDENCE, + }, + action('find', ['text', '@handle', 'get', 'text']), + ]), + ).not.toThrow(); + }); + + test('refuses mutating find actions until they record target identity evidence', () => { + expect(() => + assertActivePublicationPortability([action('find', ['text', 'Continue', 'click'])]), + ).toThrow(/mutating find.*target identity is not replay-verifiable/); + }); }); diff --git a/src/daemon/handlers/__tests__/session-script-publication.test.ts b/src/daemon/handlers/__tests__/session-script-publication.test.ts index 0718c2de5..4569b545a 100644 --- a/src/daemon/handlers/__tests__/session-script-publication.test.ts +++ b/src/daemon/handlers/__tests__/session-script-publication.test.ts @@ -51,7 +51,7 @@ function request(outputPath?: string, force?: boolean): DaemonRequest { token: 'test', session: 'authoring', command: INTERNAL_COMMANDS.sessionSaveScript, - positionals: outputPath ? [outputPath] : [], + positionals: outputPath !== undefined ? [outputPath] : [], flags: force ? { force: true } : {}, }; } @@ -164,6 +164,23 @@ test('refuses unarmed and repair-owned sessions before filesystem work', () => { expect(fs.existsSync(path.dirname(outputPath))).toBe(false); }); +test('rejects an explicitly empty destination path', () => { + const session = armedSession(); + store.set('authoring', session); + + const response = handleSessionScriptPublication({ + req: request(''), + sessionName: 'authoring', + sessionStore: store, + }); + + expect(response).toMatchObject({ + ok: false, + error: { code: 'INVALID_ARGS', message: expect.stringMatching(/path cannot be empty/) }, + }); + expect(session.scriptRecordingState).toBe('armed'); +}); + test('invalid destination guard remains armed and creates no target directory', () => { const outputPath = path.join(root, 'missing', 'screen-x.ad'); const session = armedSession({ @@ -186,3 +203,97 @@ test('invalid destination guard remains armed and creates no target directory', expect(session.scriptRecordingState).toBe('armed'); expect(fs.existsSync(path.dirname(outputPath))).toBe(false); }); + +test('missing initial open is non-retriable within the armed session', () => { + const outputPath = path.join(root, 'missing', 'screen-x.ad'); + const session = armedSession({ + actions: [{ ts: 1, command: 'wait', positionals: ['id="screen-x"'], flags: {} }], + }); + store.set('authoring', session); + + const response = handleSessionScriptPublication({ + req: request(outputPath), + sessionName: 'authoring', + sessionStore: store, + }); + + expect(response).toMatchObject({ + ok: false, + error: { + message: expect.stringMatching(/exactly one initial recorded open/), + hint: expect.stringMatching(/start a fresh one/), + retriable: false, + }, + }); + expect(session.scriptRecordingState).toBe('armed'); + expect(fs.existsSync(path.dirname(outputPath))).toBe(false); +}); + +test('publishes leading-at typed values without mistaking them for session refs', () => { + const outputPath = path.join(root, 'social-handle.ad'); + const session = armedSession({ + actions: [ + { ts: 1, command: 'open', positionals: ['Demo'], flags: { saveScript: true } }, + { ts: 2, command: 'type', positionals: ['@thymikee'], flags: {} }, + { + ts: 3, + command: 'fill', + positionals: ['id="handle"', '@someone'], + flags: {}, + targetEvidence: TARGET_EVIDENCE, + }, + { + ts: 4, + command: 'find', + positionals: ['text', '@handle', 'get', 'text'], + flags: {}, + }, + { ts: 5, command: 'wait', positionals: ['id="screen-x"'], flags: {} }, + ], + }); + store.set('authoring', session); + + const response = handleSessionScriptPublication({ + req: request(outputPath), + sessionName: 'authoring', + sessionStore: store, + }); + + expect(response?.ok).toBe(true); + const script = fs.readFileSync(outputPath, 'utf8'); + expect(script).toContain('@thymikee'); + expect(script).toContain('@someone'); + expect(script).toContain('@handle'); +}); + +test('refuses mutating find steps that cannot enforce target identity on replay', () => { + const outputPath = path.join(root, 'missing', 'find-flow.ad'); + const session = armedSession({ + actions: [ + { ts: 1, command: 'open', positionals: ['Demo'], flags: { saveScript: true } }, + { + ts: 2, + command: 'find', + positionals: ['text', 'Continue', 'click'], + flags: {}, + }, + { ts: 3, command: 'wait', positionals: ['id="screen-x"'], flags: {} }, + ], + }); + store.set('authoring', session); + + const response = handleSessionScriptPublication({ + req: request(outputPath), + sessionName: 'authoring', + sessionStore: store, + }); + + expect(response).toMatchObject({ + ok: false, + error: { + message: expect.stringMatching(/mutating find.*not replay-verifiable/), + retriable: false, + }, + }); + expect(fs.existsSync(path.dirname(outputPath))).toBe(false); +}); diff --git a/src/daemon/session-script-active-publication.ts b/src/daemon/session-script-active-publication.ts index afdd5de9e..c241e68c1 100644 --- a/src/daemon/session-script-active-publication.ts +++ b/src/daemon/session-script-active-publication.ts @@ -13,7 +13,10 @@ export function validateActivePublicationActions(actions: SessionAction[]): void throw new AppError( 'COMMAND_FAILED', 'Cannot publish this session: an open-to-destination script requires exactly one initial recorded open.', - { hint: 'Close this session and start a fresh one with open --save-script[=].' }, + { + retriable: false, + hint: 'Close this session and start a fresh one with open --save-script[=].', + }, ); } if (actions.some((action) => action.command === 'close')) { @@ -21,6 +24,7 @@ export function validateActivePublicationActions(actions: SessionAction[]): void 'COMMAND_FAILED', 'Cannot publish an active-session script containing close.', { + retriable: false, hint: 'Close this session and record the journey again from a fresh open --save-script session.', }, ); @@ -43,6 +47,7 @@ export function validateActivePublicationActions(actions: SessionAction[]): void 'COMMAND_FAILED', 'Cannot publish this session without a portable destination guard after the final mutating action.', { + retriable: true, hint: 'Record a selective selector-targeted wait, for example wait \'role="heading" label="Screen X"\', then retry session save-script.', }, ); @@ -50,22 +55,35 @@ export function validateActivePublicationActions(actions: SessionAction[]): void export function assertActivePublicationPortability(actions: SessionAction[]): void { for (const action of actions) { - const ref = action.positionals.find((positional) => positional.startsWith('@')); - if (ref) { + const targetToken = readTargetBindingToken(action); + const ref = targetToken ?? (action.command === 'wait' ? action.positionals[0] : undefined); + if (ref?.startsWith('@')) { throw new AppError( 'COMMAND_FAILED', `Cannot publish recorded step "${action.command} ${ref}": the session-local ref was not converted to a portable selector.`, { + retriable: false, hint: 'Close this session and record the journey again using selectors or resolvable refs.', }, ); } - const token = readTargetBindingToken(action); + if (action.command === 'find' && resolveCommandRecordingEffect(action) === 'mutates-app') { + throw new AppError( + 'COMMAND_FAILED', + 'Cannot publish a recorded mutating find step because its target identity is not replay-verifiable.', + { + retriable: false, + hint: 'Close this session and record the journey again with an explicit selector-targeted click, press, fill, or focus action.', + }, + ); + } + const token = targetToken; if (!token || !tryParseSelectorChain(token) || action.targetEvidence) continue; throw new AppError( 'COMMAND_FAILED', `Cannot publish recorded step "${action.command} ${token}": recording-time target identity evidence is missing.`, { + retriable: false, hint: 'Close this session and record the journey again from open --save-script so target-v1 evidence is captured before each interaction.', }, ); @@ -84,12 +102,15 @@ export function toActivePublicationFailure( hint: 'Retry session save-script with another path, or pass --force to replace the existing file. The session remains armed.', }); } + const retriable = error.details?.retriable === true; return new AppError(error.code, error.message, { ...error.details, - retriable: true, + retriable, hint: error.details?.hint ?? - 'Fix the recorded journey or target, then retry session save-script. The session remains armed.', + (retriable + ? 'Fix the recorded journey or target, then retry session save-script. The session remains armed.' + : 'Close this session and start a fresh one with open --save-script[=].'), }); } const detail = error instanceof Error ? error.message : String(error);