diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2e80568bea..7997a6bb4d 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -7,9 +7,6 @@ "dependencies": { "@agent-device/kernel": "workspace:*" }, - "devDependencies": { - "fast-check": "^4.9.0" - }, "exports": { "./alert-contract": { "types": "./src/alert-contract.ts", @@ -23,10 +20,6 @@ "types": "./src/android-clipboard-support.ts", "default": "./src/android-clipboard-support.ts" }, - "./android-helper-artifacts": { - "types": "./src/android-helper-artifacts.ts", - "default": "./src/android-helper-artifacts.ts" - }, "./android-input-ownership": { "types": "./src/android-input-ownership.ts", "default": "./src/android-input-ownership.ts" @@ -43,10 +36,6 @@ "types": "./src/android-system-chrome.ts", "default": "./src/android-system-chrome.ts" }, - "./android-touch-plan": { - "types": "./src/android-touch-plan.ts", - "default": "./src/android-touch-plan.ts" - }, "./app-deployment-runtime": { "types": "./src/app-deployment-runtime.ts", "default": "./src/app-deployment-runtime.ts" @@ -75,10 +64,6 @@ "types": "./src/app-switcher-runtime.ts", "default": "./src/app-switcher-runtime.ts" }, - "./apple-multitouch-support": { - "types": "./src/apple-multitouch-support.ts", - "default": "./src/apple-multitouch-support.ts" - }, "./apple-runner-request": { "types": "./src/apple-runner-request.ts", "default": "./src/apple-runner-request.ts" @@ -167,10 +152,6 @@ "types": "./src/daemon-http.ts", "default": "./src/daemon-http.ts" }, - "./daemon-owner-cleanup": { - "types": "./src/daemon-owner-cleanup.ts", - "default": "./src/daemon-owner-cleanup.ts" - }, "./device": { "types": "./src/facades/device.ts", "default": "./src/facades/device.ts" @@ -239,10 +220,6 @@ "types": "./src/interaction.ts", "default": "./src/interaction.ts" }, - "./interaction-error": { - "types": "./src/interaction-error.ts", - "default": "./src/interaction-error.ts" - }, "./is-predicate": { "types": "./src/is-predicate.ts", "default": "./src/is-predicate.ts" @@ -323,10 +300,6 @@ "types": "./src/perf-runtime-operation-builder.ts", "default": "./src/perf-runtime-operation-builder.ts" }, - "./perf-runtime-plan": { - "types": "./src/perf-runtime-plan.ts", - "default": "./src/perf-runtime-plan.ts" - }, "./platform-module": { "types": "./src/platform-module.ts", "default": "./src/platform-module.ts" @@ -339,10 +312,6 @@ "types": "./src/platform-providers.ts", "default": "./src/platform-providers.ts" }, - "./platform-resource-cleanup": { - "types": "./src/platform-resource-cleanup.ts", - "default": "./src/platform-resource-cleanup.ts" - }, "./platform-runtime": { "types": "./src/platform-runtime.ts", "default": "./src/platform-runtime.ts" @@ -435,10 +404,6 @@ "types": "./src/facades/snapshot.ts", "default": "./src/facades/snapshot.ts" }, - "./snapshot-presentation": { - "types": "./src/snapshot-presentation.ts", - "default": "./src/snapshot-presentation.ts" - }, "./snapshot-runtime": { "types": "./src/snapshot-runtime.ts", "default": "./src/snapshot-runtime.ts" @@ -447,10 +412,6 @@ "types": "./src/snapshot-scope.ts", "default": "./src/snapshot-scope.ts" }, - "./snapshot-timeout-evidence": { - "types": "./src/snapshot-timeout-evidence.ts", - "default": "./src/snapshot-timeout-evidence.ts" - }, "./startup-recovery-fence": { "types": "./src/startup-recovery-fence.ts", "default": "./src/startup-recovery-fence.ts" diff --git a/packages/contracts/src/apple-os-display-names.ts b/packages/contracts/src/apple-os-display-names.ts deleted file mode 100644 index eb680b0187..0000000000 --- a/packages/contracts/src/apple-os-display-names.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { AppleOS } from '@agent-device/kernel/device'; - -/** - * How each Apple OS names itself in agent-facing prose. - * - * Its own module because two callers need it — the defensive adapter check in - * `apple-multitouch-support.ts` and the gesture refusal subject in `gesture-admission.ts` — and - * neither the display table nor the wording it produces is a public contracts surface. Keeping it - * out of a façade-re-exported module is what lets both callers share ONE copy of the wording. - */ -export const APPLE_OS_DISPLAY_NAMES: Record = { - ios: 'iOS', - ipados: 'iPadOS', - tvos: 'tvOS', - watchos: 'watchOS', - visionos: 'visionOS', - macos: 'macOS', -}; diff --git a/packages/contracts/src/facades/divergence.ts b/packages/contracts/src/facades/divergence.ts index dad3ca7fda..96696f2307 100644 --- a/packages/contracts/src/facades/divergence.ts +++ b/packages/contracts/src/facades/divergence.ts @@ -1,18 +1,3 @@ -export { - REPLAY_DIVERGENCE_DEFAULT_REF_LIMIT, - REPLAY_DIVERGENCE_DIGEST_REF_LIMIT, - REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS, - REPLAY_DIVERGENCE_SUGGESTION_LIMIT, - applyReplayDivergenceLevelCaps, - boundReplayDivergence, - createReplayDivergenceSanitizer, - formatReplayDivergenceReport, - measureReplayDivergenceBytes, - readReplayDivergenceResume, - sanitizeReplayDivergenceField, - scrubReplayVarValues, - truncateUtf8Field, -} from '../replay-divergence.ts'; export type { ReplayDivergence, ReplayDivergenceCause, @@ -30,5 +15,4 @@ export type { ReplayDivergenceTargetCandidate, ReplayDivergenceTargetIdentity, ReplayRepairHint, - ReplayVarScrubEntry, } from '../replay-divergence.ts'; diff --git a/packages/contracts/src/gesture-admission.ts b/packages/contracts/src/gesture-admission.ts index 884368745b..d45e166135 100644 --- a/packages/contracts/src/gesture-admission.ts +++ b/packages/contracts/src/gesture-admission.ts @@ -1,12 +1,28 @@ import { isApplePlatform, resolveDeviceAppleOs, + type AppleOS, type DeviceInfo, } from '@agent-device/kernel/device'; -import { APPLE_OS_DISPLAY_NAMES } from './apple-os-display-names.ts'; import type { GestureCommandInput } from './gesture-plan-types.ts'; import type { GestureRuntimeTier } from './gesture-tier.ts'; +/** + * How each Apple OS names itself in agent-facing prose. + * + * Owned here because every subject that needs the wording is a gesture refusal: the refusal + * subject below and the Apple adapter's own check (`packages/platform-apple/src/ + * multitouch-support.ts`). One table keeps both refusals spelling an OS the same way. + */ +export const APPLE_OS_DISPLAY_NAMES: Record = { + ios: 'iOS', + ipados: 'iPadOS', + tvos: 'tvOS', + watchos: 'watchOS', + visionos: 'visionOS', + macos: 'macOS', +}; + /** The hint an owner states when it cannot preserve a target-authored drag's timing. */ export const TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT = 'Target-authored drag requires an adapter that preserves source hold, timed movement, and destination hold; it is supported on Android touch devices and iOS/iPadOS.'; diff --git a/packages/contracts/src/replay-divergence.ts b/packages/contracts/src/replay-divergence.ts index 0c36135e2b..be4efbe917 100644 --- a/packages/contracts/src/replay-divergence.ts +++ b/packages/contracts/src/replay-divergence.ts @@ -1,6 +1,3 @@ -import type { ResponseLevel } from '@agent-device/kernel/contracts'; -import { redactDiagnosticData } from '@agent-device/kernel/redaction'; - /** * ADR 0012 migration steps 2 + 4: structured replay divergence report. * @@ -176,40 +173,6 @@ export type ReplayDivergenceResume = } | { allowed: false; from: number; planDigest: string; reason: string; repairSessionHeld?: true }; -/** - * The `resume` record carried by a divergence-shaped payload, or `undefined` - * when the payload does not carry a contract-shaped one. - * - * Returns the LIVE record rather than a copy: the daemon stamps - * `repairSessionHeld` onto it in place (`session-replay-coordinator.ts`) - * through this same reader. That is what lets the client key its keep-alive - * on the narrowed record too — a payload this reader rejects cannot be - * carrying the R7 liveness signal, because the stamp is only ever written to - * a record it accepted. - * - * Every field the returned type declares is checked, the two optional ones - * included: this is the owning interface for the shape, so a payload it - * accepts cannot type as `repairSessionHeld: true` while carrying something - * else. The narrowing is structural and local, like this module's other - * wire readers: the divergence façade is pinned at the modules it evaluates, - * and `json.ts` is not otherwise one of them. - */ -export function readReplayDivergenceResume( - divergence: unknown, -): ReplayDivergenceResume | undefined { - const resume = (divergence as Record | null | undefined)?.resume as - | Record - | undefined; - if (typeof resume?.allowed !== 'boolean') return undefined; - if (!Number.isInteger(resume.from) || typeof resume.planDigest !== 'string') return undefined; - if (!resume.allowed && typeof resume.reason !== 'string') return undefined; - if (resume.repairSessionHeld !== undefined && resume.repairSessionHeld !== true) return undefined; - if (resume.alternateFrom !== undefined && !Number.isInteger(resume.alternateFrom)) { - return undefined; - } - return resume as ReplayDivergenceResume; -} - export type ReplayDivergenceOverflow = { omittedBytes: number; artifactPath: string; @@ -242,476 +205,3 @@ export type ReplayDivergence = { /** Present iff `kind` is a target-binding kind; `targetBinding.classification === kind`. */ targetBinding?: ReplayDivergenceTargetBinding; }; - -type BoundedResponseLevel = 'digest' | 'default' | 'full'; - -export const REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS: Record = { - digest: 8 * 1024, - default: 24 * 1024, - full: 64 * 1024, -}; - -export const REPLAY_DIVERGENCE_DEFAULT_REF_LIMIT = 20; -export const REPLAY_DIVERGENCE_DIGEST_REF_LIMIT = 8; -export const REPLAY_DIVERGENCE_SUGGESTION_LIMIT = 5; -// ADR 0012's 256-UTF-8-byte per-field cap; reached only through the field -// sanitizers below so it is enforced in one place. -const REPLAY_DIVERGENCE_FIELD_BYTE_LIMIT = 256; - -function levelForResponseLevel(level: ResponseLevel | undefined): BoundedResponseLevel { - return level === 'digest' || level === 'full' ? level : 'default'; -} - -/** - * UTF-8 byte-accurate truncation with a marker, never splitting a multi-byte - * codepoint. Used for every individual string field the ADR caps at 256 bytes - * (labels, ids, selectors, source paths, mismatch values, cause messages, - * hints). - */ -export function truncateUtf8Field( - value: string, - limit = REPLAY_DIVERGENCE_FIELD_BYTE_LIMIT, -): string { - const bytes = Buffer.from(value, 'utf8'); - if (bytes.length <= limit) return value; - const marker = '…'; - const markerBytes = Buffer.byteLength(marker, 'utf8'); - const budget = Math.max(0, limit - markerBytes); - let sliceEnd = budget; - // Back off until we are not mid-codepoint (UTF-8 continuation bytes are 10xxxxxx). - while (sliceEnd > 0 && (bytes[sliceEnd]! & 0xc0) === 0x80) sliceEnd -= 1; - return `${bytes.subarray(0, sliceEnd).toString('utf8')}${marker}`; -} - -/** Field sanitizer in the ADR-mandated order: redact first, then truncate. */ -export function sanitizeReplayDivergenceField( - value: string, - limit = REPLAY_DIVERGENCE_FIELD_BYTE_LIMIT, -): string { - return truncateUtf8Field(redactDiagnosticData(value), limit); -} - -export type ReplayVarScrubEntry = { name: string; value: string }; - -/** - * Categorical expanded-variable exclusion (ADR 0012): every occurrence of a - * replay-scope value is replaced with a `` marker, whatever the - * value looks like — this is not shape-based secret redaction. - */ -export function scrubReplayVarValues( - value: string, - entries: readonly ReplayVarScrubEntry[], -): string { - let output = value; - for (const entry of entries) { - if (!entry.value) continue; - output = output.split(entry.value).join(``); - } - return output; -} - -/** Per-report field sanitizer: variable scrub, then redact, then truncate. */ -export function createReplayDivergenceSanitizer( - scrubVars: readonly ReplayVarScrubEntry[], -): (value: string, limit?: number) => string { - return (value, limit) => - sanitizeReplayDivergenceField(scrubReplayVarValues(value, scrubVars), limit); -} - -function boundScreenRefs(screen: ReplayDivergenceScreen, limit: number): ReplayDivergenceScreen { - if (screen.state !== 'available' || screen.refs.length <= limit) return screen; - return { ...screen, refs: screen.refs.slice(0, limit), truncated: true }; -} - -/** - * Applies one level's array caps only (ref count, suggestion presence/count). - * Field-level 256-byte truncation is expected to already be applied by the - * caller at construction time — this function only bounds array shape. - */ -export function applyReplayDivergenceLevelCaps( - divergence: ReplayDivergence, - level: ResponseLevel | undefined, -): ReplayDivergence { - const bounded = levelForResponseLevel(level); - const refLimit = - bounded === 'digest' ? REPLAY_DIVERGENCE_DIGEST_REF_LIMIT : REPLAY_DIVERGENCE_DEFAULT_REF_LIMIT; - const screen = boundScreenRefs(divergence.screen, refLimit); - const suggestions = - bounded === 'digest' ? [] : divergence.suggestions.slice(0, REPLAY_DIVERGENCE_SUGGESTION_LIMIT); - return { ...divergence, screen, suggestions }; -} - -export function measureReplayDivergenceBytes(divergence: ReplayDivergence): number { - return Buffer.byteLength(JSON.stringify(divergence), 'utf8'); -} - -/** - * Bounds the divergence to the response level's byte ceiling. On overflow, - * the fuller detail goes to a session-scoped artifact and a minimal - * divergence is returned; the cause is never dropped, only the screen digest - * and suggestions. - */ -export function boundReplayDivergence(params: { - divergence: ReplayDivergence; - level: ResponseLevel | undefined; - writeOverflowArtifact: ( - fullDivergence: ReplayDivergence, - ) => { artifactPath: string } | { artifactUnavailable: true }; -}): ReplayDivergence { - const { divergence, level, writeOverflowArtifact } = params; - const bounded = levelForResponseLevel(level); - const limit = REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS[bounded]; - const capped = applyReplayDivergenceLevelCaps(divergence, level); - const cappedBytes = measureReplayDivergenceBytes(capped); - if (cappedBytes <= limit) return capped; - - const omittedBytes = cappedBytes - limit; - const full = applyReplayDivergenceLevelCaps(divergence, 'full'); - const artifactResult = writeOverflowArtifact(full); - const minimal = buildMinimalReplayDivergence(capped); - return 'artifactPath' in artifactResult - ? { ...minimal, overflow: { omittedBytes, artifactPath: artifactResult.artifactPath } } - : { ...minimal, artifactUnavailable: true }; -} - -// Owns the "the minimal fallback always fits the budget" guarantee, so it -// sanitizes every field itself rather than trusting the caller did. -function buildMinimalReplayDivergence(capped: ReplayDivergence): ReplayDivergence { - return { - version: capped.version, - kind: capped.kind, - step: { - index: capped.step.index, - source: { - path: sanitizeReplayDivergenceField(capped.step.source.path), - line: capped.step.source.line, - }, - }, - action: sanitizeReplayDivergenceField(capped.action), - cause: { - code: capped.cause.code, - message: sanitizeReplayDivergenceField(capped.cause.message), - ...(capped.cause.hint ? { hint: sanitizeReplayDivergenceField(capped.cause.hint) } : {}), - }, - screen: { - state: 'unavailable', - reason: 'omitted-for-size', - hint: - 'The screen digest and suggestions were omitted to stay within the response byte budget. ' + - 'See overflow.artifactPath (or retry at --level full) for the complete report.', - }, - suggestions: [], - suggestionCount: capped.suggestionCount, - resume: capped.resume, - repairHint: capped.repairHint, - // targetBinding is the actual repair value of a target-binding - // divergence and is small relative to a full screen digest — keep it on - // the minimal fallback rather than dropping it with the screen/suggestions. - ...(capped.targetBinding ? { targetBinding: capped.targetBinding } : {}), - }; -} - -// Compact human-readable divergence report for text surfaces (CLI, MCP text, -// `test` failures). Repair data (step location, screen availability, ranked -// suggestions, overflow pointer) that the --json/structuredContent paths -// carry must not be dropped on a text path. Reads the loose `details` bag so -// every surface (which holds an error `details` record) can share it. -export function formatReplayDivergenceReport( - details: Record | undefined, -): string | null { - const divergence = details?.divergence; - if (!divergence || typeof divergence !== 'object') return null; - const record = divergence as Record; - const lines = [ - ...divergenceStepLine(record.step), - ...divergenceTargetBindingLines(record.kind, record.targetBinding), - ...divergenceRepairHintLine(record.repairHint, record.resume), - ...divergenceScreenLine(record.screen), - ...divergenceSuggestionLines(record.suggestions, record.suggestionCount), - ...divergenceOverflowLine(record.overflow, record.artifactUnavailable), - ]; - return lines.length > 0 ? lines.join('\n') : null; -} - -/** - * ADR 0012 decision 6, extended per #1262: the repair-routing hint rendered - * on every text surface (CLI, MCP text, `test` failures) — the same field - * that rides `structuredContent`/JSON, so a text-only caller still learns - * which repair sub-flow applies. `record-and-heal`/`state-repair` guidance - * embeds the CONCRETE `resume.from`/`planDigest` values (computed by - * `buildReplayDivergenceResume`, decision 6 R2) when `resume.allowed` is - * true, so a text-only or JSON/MCP-first caller reads the identical next - * command instead of deriving it. `caution`/`manual` are genuinely dual-path - * (the daemon cannot know at divergence time which repair applies), so their - * guidance embeds `--from resume.from` (`N`, unshifted — a `--no-record` - * app-state fix) whenever `resume.allowed` is true, AND `--from - * resume.alternateFrom` (`N + 1`, a record-and-heal-shaped recorded corrective - * action) IFF the wire carries `alternateFrom` — the daemon's own verdict that - * a `--from N + 1` request would be accepted (`computeReplayResumeAlternateFrom`, - * `session-replay-resume.ts`). The renderer gates the second command on that - * field's PRESENCE and never re-derives resumability, so text and the - * structured wire never disagree on the advertised next command. When - * `resume.allowed` is false, a resume command is never rendered for any hint, - * and the reported `reason` is surfaced instead. - * - * #1271 stage 2 (ADR 0012 amendment): whenever `resume.repairSessionHeld` is - * `true` (this divergence is from a repair-armed `--save-script` replay), - * every hint's guidance also appends - * `REPAIR_DIAGNOSTICS_DEFAULT_EXCLUSION_CLAUSE` — read-only diagnostics used - * to locate the repair target (`snapshot -i`, `get attrs`, `find`, `is`) are - * excluded from the healed script by default (no `--no-record` needed), and - * an agent whose CORRECTIVE action is itself a read must pass `--record` on - * that one command so it lands in the heal. See `buildRepairHintGuidance`. - */ -function divergenceRepairHintLine(repairHint: unknown, resume: unknown): string[] { - if (typeof repairHint !== 'string') return []; - const guidance = buildRepairHintGuidance(repairHint, resume); - return [`Repair hint: ${repairHint}${guidance ? ` — ${guidance}` : ''}`]; -} - -type ResumeGuidance = - | { allowed: true; from: number; planDigest: string; alternateFrom: number | undefined } - | { allowed: false; reason: string | undefined }; - -/** Reads the parts of `resume` the repair-hint guidance needs; `undefined` when the shape is unreadable. */ -function readResumeGuidance(resume: unknown): ResumeGuidance | undefined { - const record = resume as Record | undefined; - if (!record || typeof record.allowed !== 'boolean') return undefined; - if (!record.allowed) { - return { - allowed: false, - reason: typeof record.reason === 'string' ? record.reason : undefined, - }; - } - const { from, planDigest, alternateFrom } = record; - if (typeof from !== 'number' || typeof planDigest !== 'string' || planDigest.length === 0) { - return undefined; - } - return { - allowed: true, - from, - planDigest, - // Rendered VERBATIM from the wire; the daemon already proved a `--from - // alternateFrom` request would be accepted (#1262). The renderer must NOT - // re-derive it — that is precisely the bug the wire field fixed. - alternateFrom: typeof alternateFrom === 'number' ? alternateFrom : undefined, - }; -} - -function formatResumeCommand(from: number, planDigest: string): string { - return `replay --from ${from} --plan-digest ${planDigest}`; -} - -/** - * #1271 stage 2 (ADR 0012 amendment; supersedes stage 1's interim - * "use --no-record" guidance now that the daemon enforces default - * exclusion itself — `isExcludedRepairSegmentObservation`, - * `session-action-recorder.ts`): read-only diagnostics an agent runs to - * LOCATE the repair target (`snapshot -i`, `get attrs`, `find`, `is`) are, - * by default, excluded from the healed script — the wave-3 E3 experiment's - * 0/4 clean-heal rate motivated the exclusion, but a blanket "exclude every - * read" would silently drop a diverged step whose OWN correction is itself - * a read (the E3 case). `--record` is the opt-in that forces exactly that - * one action through. Distinct from the existing `--no-record` mentions - * above (`state-repair`'s "fix app state with --no-record actions", and - * `buildDualPathRepairHintGuidance`'s state-fix clause), which are about - * correcting APP STATE via a MUTATING action, not about inspection reads — - * both clauses can legitimately apply to the same divergence. - */ -const REPAIR_DIAGNOSTICS_DEFAULT_EXCLUSION_CLAUSE = - 'Read-only inspection while armed (snapshot -i, get attrs, find, is) is excluded from the healed script by default — no --no-record needed. If the step you are repairing is itself a read, add --record to that command so it lands in the heal.'; - -/** - * Gated on `resume.repairSessionHeld === true` (decision 6, R7 C1): that is - * the ONLY signal that this divergence came from a repair-armed - * (`--save-script`) replay, where recorded diagnostics can actually pollute a - * healed script. It is absent (never `false`) on a plain non-repair - * divergence, so the clause must never render there — it would be pure noise. - */ -function isRepairSessionHeld(resume: unknown): boolean { - const record = resume as Record | undefined; - return record?.repairSessionHeld === true; -} - -function buildRepairHintGuidance(repairHint: string, resume: unknown): string | undefined { - const guidance = readResumeGuidance(resume); - const core = buildRepairHintGuidanceCore(repairHint, guidance); - if (core === undefined) return undefined; - return isRepairSessionHeld(resume) - ? `${core} ${REPAIR_DIAGNOSTICS_DEFAULT_EXCLUSION_CLAUSE}` - : core; -} - -function buildRepairHintGuidanceCore( - repairHint: string, - guidance: ResumeGuidance | undefined, -): string | undefined { - switch (repairHint) { - case 'record-and-heal': - return guidance?.allowed - ? `press the correct control via a blessed @ref from screen.refs (recorded), then ${formatResumeCommand(guidance.from, guidance.planDigest)}.` - : `press the correct control via a blessed @ref from screen.refs (recorded). ${resumeUnavailableSentence(guidance)}`; - case 'state-repair': - return guidance?.allowed - ? `fix app state with --no-record actions, then ${formatResumeCommand(guidance.from, guidance.planDigest)} to re-run it.` - : `fix app state with --no-record actions. ${resumeUnavailableSentence(guidance)}`; - case 'caution': - return buildDualPathRepairHintGuidance({ - lead: 'something already matches the recorded selector; a blind re-press may repeat the mistake.', - guidance, - }); - case 'manual': - return buildDualPathRepairHintGuidance({ - lead: 'no safe automated repair could be proven; inspect the screen and repair by hand.', - guidance, - }); - default: - return undefined; - } -} - -/** - * `caution`/`manual` guidance (#1262): the daemon cannot know at divergence - * time which of two repairs applies. Always offers `--from N` (an app-state - * fix via `--no-record` actions, then re-run the unchanged step; `resume.from` - * itself, never shifted for these hints — authorized by `resume.allowed`). - * ADDITIONALLY offers `--from alternateFrom` (a record-and-heal-shaped - * recorded corrective action, resuming PAST the diverged step) IFF the wire - * carries `resume.alternateFrom` — the daemon has already proven such a - * request would be accepted (`computeReplayResumeAlternateFrom`, - * `session-replay-resume.ts`). The renderer gates on the field's PRESENCE and - * never re-derives resumability, so it can never advertise a `--from` the - * daemon would then refuse (the parity bug #1262's review flagged: `N + 1` is - * unsafe exactly when the diverged step is a `runScript`/control-flow action, - * which `resume.allowed` for `N` does not detect). When the alternate is - * absent, only the state-fix command is shown. Neither command is rendered - * when `resume.allowed` is false — `N` itself is not resumable, so its - * alternate is moot. - */ -function buildDualPathRepairHintGuidance(params: { - lead: string; - guidance: ResumeGuidance | undefined; -}): string { - const { lead, guidance } = params; - if (!guidance?.allowed) return `${lead} ${resumeUnavailableSentence(guidance)}`; - const stateFixClause = `if you fixed app state with --no-record actions: ${formatResumeCommand(guidance.from, guidance.planDigest)}`; - if (guidance.alternateFrom === undefined) return `${lead} ${stateFixClause}.`; - const recordedActionClause = `if you performed the step's intent as a recorded action: ${formatResumeCommand(guidance.alternateFrom, guidance.planDigest)}`; - return `${lead} ${stateFixClause}; ${recordedActionClause}.`; -} - -/** Never renders a `--from` command — only reached when `resume.allowed` is false (or unreadable). */ -function resumeUnavailableSentence(guidance: ResumeGuidance | undefined): string { - if (guidance && !guidance.allowed && guidance.reason) { - return `This step cannot currently be resumed automatically (${guidance.reason}) — run a fresh full replay instead.`; - } - return 'This step cannot currently be resumed automatically — run a fresh full replay instead.'; -} - -function divergenceTargetBindingLines(kind: unknown, targetBinding: unknown): string[] { - if (typeof kind !== 'string' || kind === 'action-failure') return []; - const record = targetBinding as Record | undefined; - if (!record) return []; - return [ - divergenceTargetBindingHeaderLine(kind, record.matchCount), - ...divergenceTargetBindingMismatchLines(record.mismatches), - ...divergenceTargetBindingCandidateLines(record.candidates), - ]; -} - -function divergenceTargetBindingHeaderLine(kind: string, matchCount: unknown): string { - const suffix = typeof matchCount === 'number' ? ` (matchCount ${matchCount})` : ''; - return `Target binding: ${kind}${suffix} — recorded target evidence did not verify.`; -} - -function divergenceTargetBindingMismatchLines(mismatches: unknown): string[] { - if (!Array.isArray(mismatches) || mismatches.length === 0) return []; - return [` mismatches: ${mismatches.slice(0, 5).join('; ')}`]; -} - -function divergenceTargetBindingCandidateLines(candidates: unknown): string[] { - if (!Array.isArray(candidates) || candidates.length === 0) return []; - return [ - ` ${candidates.length} candidate(s) shared the recorded identity:`, - ...candidates.slice(0, 5).map((candidate) => ` ${divergenceScreenRefLine(candidate)}`), - ]; -} - -function divergenceStepLine(step: unknown): string[] { - const record = step as Record | undefined; - if (typeof record?.index !== 'number') return []; - const source = record.source as Record | undefined; - const location = - typeof source?.path === 'string' && typeof source.line === 'number' - ? ` (${source.path}:${source.line})` - : ''; - return [`Divergence at step ${record.index}${location}`]; -} - -// Bound on ref lines in the TEXT report (matches the digest ref cap); the -// full list rides in the structured payload. -const TEXT_REPORT_REF_LINE_LIMIT = 8; - -function divergenceScreenLine(screen: unknown): string[] { - const record = screen as Record | undefined; - if (record?.state === 'available' && Array.isArray(record.refs)) { - return availableScreenLines(record.refs, record.refsGeneration); - } - if (record?.state === 'unavailable') { - return [unavailableScreenLine(record)]; - } - return []; -} - -function availableScreenLines(refs: unknown[], refsGeneration: unknown): string[] { - const shown = refs.slice(0, TEXT_REPORT_REF_LINE_LIMIT).map(divergenceScreenRefLine); - const remaining = refs.length - shown.length; - return [ - `Screen: ${refs.length} actionable ref(s) captured (refsGeneration ${refsGeneration}).`, - ...shown, - ...(remaining > 0 ? [` ... ${remaining} more`] : []), - ]; -} - -function unavailableScreenLine(record: Record): string { - const hint = typeof record.hint === 'string' && record.hint.length > 0 ? ` ${record.hint}` : ''; - return `Screen: unavailable (${String(record.reason ?? 'unknown')}).${hint}`; -} - -function divergenceScreenRefLine(entry: unknown): string { - const ref = entry as Record; - const label = typeof ref.label === 'string' ? ` "${ref.label}"` : ''; - return ` @${String(ref.ref)} [${String(ref.role)}]${label}`; -} - -function divergenceSuggestionLines(suggestions: unknown, suggestionCount: unknown): string[] { - if (Array.isArray(suggestions) && suggestions.length > 0) { - return ['Suggestions:', ...suggestions.slice(0, 5).map(divergenceSuggestionLine)]; - } - if (typeof suggestionCount === 'number' && suggestionCount > 0) { - return [ - `Suggestions: ${suggestionCount} available (omitted at this response level; rerun with --json for the full report).`, - ]; - } - return []; -} - -function divergenceSuggestionLine(entry: unknown): string { - const suggestion = entry as Record; - const label = typeof suggestion.label === 'string' ? ` "${suggestion.label}"` : ''; - return ` - [${String(suggestion.basis)}]${label} ${String(suggestion.selector)}`; -} - -function divergenceOverflowLine(overflow: unknown, artifactUnavailable: unknown): string[] { - if (overflow && typeof overflow === 'object') { - return [ - `Full report written to ${String((overflow as Record).artifactPath)}.`, - ]; - } - if (artifactUnavailable === true) { - return [ - 'Full report exceeded the response budget and the overflow artifact could not be written.', - ]; - } - return []; -} diff --git a/packages/platform-android/package.json b/packages/platform-android/package.json index 15e3c4b803..c9a03184e2 100644 --- a/packages/platform-android/package.json +++ b/packages/platform-android/package.json @@ -12,6 +12,9 @@ "@agent-device/provision-kit": "workspace:*", "@agent-device/xml": "workspace:*" }, + "devDependencies": { + "fast-check": "^4.9.0" + }, "exports": { ".": { "types": "./src/index.ts", diff --git a/packages/contracts/src/snapshot-presentation.test.ts b/packages/platform-android/src/__tests__/snapshot-presentation-node.test.ts similarity index 98% rename from packages/contracts/src/snapshot-presentation.test.ts rename to packages/platform-android/src/__tests__/snapshot-presentation-node.test.ts index daf819f5cc..888b03f533 100644 --- a/packages/contracts/src/snapshot-presentation.test.ts +++ b/packages/platform-android/src/__tests__/snapshot-presentation-node.test.ts @@ -5,7 +5,7 @@ import { createSnapshotPresentationNode, foldSnapshotRect, serializeRegularSnapshotPresentationNode, -} from './snapshot-presentation.ts'; +} from '../snapshot-presentation-node.ts'; const rawNode: RawSnapshotNode = { index: 7, diff --git a/packages/platform-android/src/__tests__/touch-helper-session.test.ts b/packages/platform-android/src/__tests__/touch-helper-session.test.ts index bad7953dc2..ac22694379 100644 --- a/packages/platform-android/src/__tests__/touch-helper-session.test.ts +++ b/packages/platform-android/src/__tests__/touch-helper-session.test.ts @@ -21,7 +21,7 @@ import { import { captureAndroidSnapshotWithHelperSession } from '../snapshot-helper-session.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; import { getAndroidSnapshotHelperSessionDeviceKey } from '../snapshot-helper-retirement.ts'; -import { lowerAndroidTouchPlan } from '../touch-plan.ts'; +import { lowerAndroidTouchPlan } from '../touch-plan-lowering.ts'; import { executeAndroidTouchHelperPlan, readAndroidTouchHelperViewport } from '../touch-helper.ts'; import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from './test-utils/android-snapshot-helper.ts'; import { diff --git a/packages/platform-android/src/__tests__/touch-helper.fixtures.ts b/packages/platform-android/src/__tests__/touch-helper.fixtures.ts index 47a95c9fd5..eb79ad311b 100644 --- a/packages/platform-android/src/__tests__/touch-helper.fixtures.ts +++ b/packages/platform-android/src/__tests__/touch-helper.fixtures.ts @@ -2,7 +2,7 @@ import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts'; import { buildGesturePlan } from '@agent-device/contracts/gesture-plan'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AndroidAdbExecutor } from '../adb-executor.ts'; -import type { AndroidLongPressTouchPlan } from '../touch-plan.ts'; +import type { AndroidLongPressTouchPlan } from '../touch-plan-lowering.ts'; // The one-shot touch helper path now runs through the snapshot-helper APK/runner // (issue #1275 consolidation), so these fixtures mirror the snapshot helper's diff --git a/packages/platform-android/src/__tests__/touch-helper.test.ts b/packages/platform-android/src/__tests__/touch-helper.test.ts index c2e8ec5355..afebafee6f 100644 --- a/packages/platform-android/src/__tests__/touch-helper.test.ts +++ b/packages/platform-android/src/__tests__/touch-helper.test.ts @@ -6,7 +6,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { withAndroidAdbProvider } from '../adb-executor.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; import { executeAndroidTouchPlan } from '../touch-executor.ts'; -import { lowerAndroidTouchPlan } from '../touch-plan.ts'; +import { lowerAndroidTouchPlan } from '../touch-plan-lowering.ts'; import { ANDROID_TOUCH_PLAN_PROTOCOL, executeAndroidTouchHelperPlan, diff --git a/packages/platform-android/src/__tests__/touch-plan.test.ts b/packages/platform-android/src/__tests__/touch-plan-lowering.test.ts similarity index 99% rename from packages/platform-android/src/__tests__/touch-plan.test.ts rename to packages/platform-android/src/__tests__/touch-plan-lowering.test.ts index b511b0b89f..5046ff1cda 100644 --- a/packages/platform-android/src/__tests__/touch-plan.test.ts +++ b/packages/platform-android/src/__tests__/touch-plan-lowering.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { expectTypeOf, test } from 'vitest'; import { buildDragGesturePlan, buildGesturePlan } from '@agent-device/contracts/gesture-plan'; import type { SinglePointerGesturePlan } from '@agent-device/contracts/gesture-plan-types'; -import { lowerAndroidTouchPlan, type AndroidLoweredTouchPlan } from '../touch-plan.ts'; +import { lowerAndroidTouchPlan, type AndroidLoweredTouchPlan } from '../touch-plan-lowering.ts'; import { longPressPlan } from './touch-helper.fixtures.ts'; const viewport = { x: 0, y: 0, width: 400, height: 800 }; diff --git a/packages/platform-android/src/adb-host.ts b/packages/platform-android/src/adb-host.ts index e3c38f6728..d6873fa030 100644 --- a/packages/platform-android/src/adb-host.ts +++ b/packages/platform-android/src/adb-host.ts @@ -1,8 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -import type { - AndroidHelperInstallDecision, - AndroidImeHelperArtifact, -} from '@agent-device/contracts/android-helper-artifacts'; +import type { AndroidHelperInstallDecision, AndroidImeHelperArtifact } from './helper-artifacts.ts'; import type { AndroidAdbExecutor, AndroidAdbExecutorOptions, diff --git a/packages/platform-android/src/adb-transport.ts b/packages/platform-android/src/adb-transport.ts index 8bb68b98f0..f1591c79cb 100644 --- a/packages/platform-android/src/adb-transport.ts +++ b/packages/platform-android/src/adb-transport.ts @@ -3,8 +3,8 @@ import type { Rect } from '@agent-device/kernel/snapshot'; import type { AndroidImeHelperArtifact, AndroidSnapshotHelperArtifact, -} from '@agent-device/contracts/android-helper-artifacts'; -import type { AndroidProviderTouchPlan } from '@agent-device/contracts/android-touch-plan'; +} from './helper-artifacts.ts'; +import type { AndroidProviderTouchPlan } from './touch-plan-lowering.ts'; // The adb transport vocabulary: the executor/provider shapes every module of the cluster (and // the SDK, through the root shim) speaks, plus the one pure lowering from semantic install diff --git a/packages/contracts/src/android-helper-artifacts.ts b/packages/platform-android/src/helper-artifacts.ts similarity index 100% rename from packages/contracts/src/android-helper-artifacts.ts rename to packages/platform-android/src/helper-artifacts.ts diff --git a/packages/platform-android/src/helper-package-install.ts b/packages/platform-android/src/helper-package-install.ts index 4f05969066..02191ee5f9 100644 --- a/packages/platform-android/src/helper-package-install.ts +++ b/packages/platform-android/src/helper-package-install.ts @@ -12,7 +12,7 @@ import { requireAndroidAdbHost } from './adb-host.ts'; import type { AndroidHelperInstallDecision, InstalledAndroidHelperState, -} from '@agent-device/contracts/android-helper-artifacts'; +} from './helper-artifacts.ts'; // Shared install/version-check/checksum lifecycle for the three Android helper APKs. diff --git a/packages/platform-android/src/ime-activation.test.ts b/packages/platform-android/src/ime-activation.test.ts index c8352cf4fa..300468973d 100644 --- a/packages/platform-android/src/ime-activation.test.ts +++ b/packages/platform-android/src/ime-activation.test.ts @@ -1,6 +1,6 @@ import { beforeEach, expect, test } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { AndroidImeHelperArtifact } from '@agent-device/contracts/android-helper-artifacts'; +import type { AndroidImeHelperArtifact } from './helper-artifacts.ts'; import { bindAndroidAdbHostStub, type AndroidAdbHostStub } from './adb-host.fixtures.ts'; import { withAndroidAdbProvider } from './adb-provider-scope.ts'; import { activateAndroidTestIme } from './ime-activation.ts'; diff --git a/packages/platform-android/src/ime-helper.ts b/packages/platform-android/src/ime-helper.ts index ab8621c673..90bd86184c 100644 --- a/packages/platform-android/src/ime-helper.ts +++ b/packages/platform-android/src/ime-helper.ts @@ -8,7 +8,7 @@ import { type AndroidHelperInstallDecision, type AndroidImeHelperArtifact, type AndroidImeHelperManifest, -} from '@agent-device/contracts/android-helper-artifacts'; +} from './helper-artifacts.ts'; import { androidAdbResultError } from './adb-failure.ts'; import type { AndroidAdbExecutor, AndroidAdbProvider } from './adb-transport.ts'; import { requireAndroidAdbHost } from './adb-host.ts'; diff --git a/packages/platform-android/src/instrumentation-helper.ts b/packages/platform-android/src/instrumentation-helper.ts index 167182a465..50a21b611e 100644 --- a/packages/platform-android/src/instrumentation-helper.ts +++ b/packages/platform-android/src/instrumentation-helper.ts @@ -105,4 +105,4 @@ export function readInstrumentationResultBoolean(value: string | undefined): boo export { readAndroidHelperManifestInteger, readAndroidHelperManifestLiteral, -} from '@agent-device/contracts/android-helper-artifacts'; +} from './helper-artifacts.ts'; diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index 6e1899dc3e..adad0225ec 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -296,10 +296,7 @@ export type { AndroidSnapshotHelperMetadata, AndroidSnapshotHelperTransport, } from './snapshot-helper-types.ts'; -export type { - AndroidImeHelperArtifact, - AndroidImeHelperManifest, -} from '@agent-device/contracts/android-helper-artifacts'; +export type { AndroidImeHelperArtifact, AndroidImeHelperManifest } from './helper-artifacts.ts'; export type { AndroidAdbEnvironment, AndroidAdbFileHost, AndroidAdbHost } from './adb-host.ts'; export { ensureAndroidSdkPathConfigured } from './sdk.ts'; export { fillAndroid, typeAndroid } from './text-input.ts'; @@ -316,7 +313,7 @@ export { type AndroidLongPressTouchPlan, type AndroidLoweredTouchPlan, type AndroidTouchPlan, -} from './touch-plan.ts'; +} from './touch-plan-lowering.ts'; export { androidUiNodes, buildUiHierarchySnapshot, diff --git a/packages/platform-android/src/snapshot-helper-types.ts b/packages/platform-android/src/snapshot-helper-types.ts index 282a247b38..7ca8b74558 100644 --- a/packages/platform-android/src/snapshot-helper-types.ts +++ b/packages/platform-android/src/snapshot-helper-types.ts @@ -43,7 +43,7 @@ export type { AndroidAdbExecutor } from './adb-executor.ts'; export type { AndroidSnapshotHelperArtifact, AndroidSnapshotHelperManifest, -} from '@agent-device/contracts/android-helper-artifacts'; +} from './helper-artifacts.ts'; export type AndroidSnapshotHelperInstallPolicy = 'missing-or-outdated' | 'always' | 'never'; diff --git a/packages/contracts/src/snapshot-presentation.ts b/packages/platform-android/src/snapshot-presentation-node.ts similarity index 100% rename from packages/contracts/src/snapshot-presentation.ts rename to packages/platform-android/src/snapshot-presentation-node.ts diff --git a/packages/platform-android/src/snapshot-presentation.ts b/packages/platform-android/src/snapshot-presentation.ts index a3b829c0e2..aeca8e5c5e 100644 --- a/packages/platform-android/src/snapshot-presentation.ts +++ b/packages/platform-android/src/snapshot-presentation.ts @@ -3,8 +3,8 @@ import { isPositiveFiniteRect } from '@agent-device/kernel/rect'; import { createSnapshotPresentationNode, serializeRegularSnapshotPresentationNode, -} from '@agent-device/contracts/snapshot-presentation'; -import type { SnapshotPresentationNode } from '@agent-device/contracts/snapshot-presentation'; +} from './snapshot-presentation-node.ts'; +import type { SnapshotPresentationNode } from './snapshot-presentation-node.ts'; export type AndroidSnapshotPresentationNode = SnapshotPresentationNode & { clipsDescendants?: boolean; diff --git a/packages/platform-android/src/touch-executor.ts b/packages/platform-android/src/touch-executor.ts index 409ac6b899..93bbe70e68 100644 --- a/packages/platform-android/src/touch-executor.ts +++ b/packages/platform-android/src/touch-executor.ts @@ -3,7 +3,7 @@ import type { Rect } from '@agent-device/kernel/snapshot'; import { resolveAndroidTouchProvider } from './adb-executor.ts'; import { executeAndroidTouchHelperPlan, readAndroidTouchHelperViewport } from './touch-helper.ts'; import { validateAndroidGestureViewport } from './gesture-viewport.ts'; -import { lowerAndroidTouchPlan, type AndroidTouchPlan } from './touch-plan.ts'; +import { lowerAndroidTouchPlan, type AndroidTouchPlan } from './touch-plan-lowering.ts'; import type { AndroidHelperSessionOptions } from './snapshot-helper-types.ts'; export async function executeAndroidTouchPlan( diff --git a/packages/platform-android/src/touch-helper.ts b/packages/platform-android/src/touch-helper.ts index 89052de745..c5a1c5619c 100644 --- a/packages/platform-android/src/touch-helper.ts +++ b/packages/platform-android/src/touch-helper.ts @@ -15,7 +15,7 @@ import { readInstrumentationResultNumber, } from './instrumentation-helper.ts'; import { validateAndroidGestureViewport } from './gesture-viewport.ts'; -import type { AndroidLoweredTouchPlan } from './touch-plan.ts'; +import type { AndroidLoweredTouchPlan } from './touch-plan-lowering.ts'; import { resolveAndroidHelperArtifact } from './helper-package-install.ts'; import { parseAndroidSnapshotHelperManifest } from './snapshot-helper-artifact.ts'; import { ensureAndroidSnapshotHelper } from './snapshot-helper-install.ts'; diff --git a/packages/contracts/src/android-touch-plan.ts b/packages/platform-android/src/touch-plan-lowering.ts similarity index 94% rename from packages/contracts/src/android-touch-plan.ts rename to packages/platform-android/src/touch-plan-lowering.ts index 7ca9a69174..20a6214259 100644 --- a/packages/contracts/src/android-touch-plan.ts +++ b/packages/platform-android/src/touch-plan-lowering.ts @@ -1,11 +1,14 @@ -import { interpolateGesturePoint, sampleGestureOffsets } from './gesture-plan.ts'; +import { + interpolateGesturePoint, + sampleGestureOffsets, +} from '@agent-device/contracts/gesture-plan'; import type { GesturePlan, MultiTouchGesturePlan, PointerTrajectory, PointerTrajectorySample, SinglePointerGesturePlan, -} from './gesture-plan-types.ts'; +} from '@agent-device/contracts/gesture-plan-types'; import type { Rect } from '@agent-device/kernel/snapshot'; export type AndroidLongPressTouchPlan = { diff --git a/packages/platform-android/src/touch-plan.ts b/packages/platform-android/src/touch-plan.ts deleted file mode 100644 index 07c3deb807..0000000000 --- a/packages/platform-android/src/touch-plan.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - lowerAndroidTouchPlan, - type AndroidLongPressTouchPlan, - type AndroidLoweredTouchPlan, - type AndroidTouchPlan, -} from '@agent-device/contracts/android-touch-plan'; diff --git a/packages/platform-android/src/ui-hierarchy-builder.ts b/packages/platform-android/src/ui-hierarchy-builder.ts index 3c0b956392..cd318d3714 100644 --- a/packages/platform-android/src/ui-hierarchy-builder.ts +++ b/packages/platform-android/src/ui-hierarchy-builder.ts @@ -7,7 +7,7 @@ import { isScrollableType, normalizeSnapshotScope } from '@agent-device/contract import { foldSnapshotRect, serializeRegularSnapshotPresentationNode, -} from '@agent-device/contracts/snapshot-presentation'; +} from './snapshot-presentation-node.ts'; import type { RawSnapshotNode, Rect, SnapshotOptions } from '@agent-device/kernel/snapshot'; import { isPositiveFiniteRect, pickLargestRect } from '@agent-device/kernel/rect'; import { diff --git a/packages/platform-apple/src/gesture-facts.ts b/packages/platform-apple/src/gesture-facts.ts index 32459e7166..c740c81f1e 100644 --- a/packages/platform-apple/src/gesture-facts.ts +++ b/packages/platform-apple/src/gesture-facts.ts @@ -1,4 +1,4 @@ -import { APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS } from '@agent-device/contracts/apple-multitouch-support'; +import { APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS } from './multitouch-support.ts'; import { PHYSICAL_IOS_MULTI_TOUCH_UNSUPPORTED_HINT, TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT, diff --git a/packages/platform-apple/src/interactions.ts b/packages/platform-apple/src/interactions.ts index ddaada8c12..515b4189cb 100644 --- a/packages/platform-apple/src/interactions.ts +++ b/packages/platform-apple/src/interactions.ts @@ -18,7 +18,7 @@ import { type ScrollDirection, assertScrollGestureInput, } from '@agent-device/contracts/scroll-gesture'; -import { assertAppleMultiTouchSupported } from '@agent-device/contracts/apple-multitouch-support'; +import { assertAppleMultiTouchSupported } from './multitouch-support.ts'; import { isIosFamily, isMacOs, isTvOsDevice, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { runAppleRunnerCommand, runApplePressSeries } from './core/runner-client.ts'; diff --git a/packages/contracts/src/apple-multitouch-support.ts b/packages/platform-apple/src/multitouch-support.ts similarity index 91% rename from packages/contracts/src/apple-multitouch-support.ts rename to packages/platform-apple/src/multitouch-support.ts index 81b6c5fc90..10644b5202 100644 --- a/packages/contracts/src/apple-multitouch-support.ts +++ b/packages/platform-apple/src/multitouch-support.ts @@ -4,9 +4,9 @@ import { type AppleOS, type DeviceInfo, } from '@agent-device/kernel/device'; -import { APPLE_OS_DISPLAY_NAMES } from './apple-os-display-names.ts'; +import { APPLE_OS_DISPLAY_NAMES } from '@agent-device/contracts/gesture-admission'; import { AppError } from '@agent-device/kernel/errors'; -import type { GesturePlan } from './gesture-plan-types.ts'; +import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types'; export const APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS: Partial> = { visionos: 'visionOS uses spatial input and does not support two-finger touch synthesis.', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5a1e0d117..bedab6ee19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,10 +237,6 @@ importers: '@agent-device/kernel': specifier: workspace:* version: link:../kernel - devDependencies: - fast-check: - specifier: ^4.9.0 - version: 4.9.0 packages/host-kit: dependencies: @@ -317,6 +313,10 @@ importers: '@agent-device/xml': specifier: workspace:* version: link:../xml + devDependencies: + fast-check: + specifier: ^4.9.0 + version: 4.9.0 packages/platform-apple: dependencies: diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 8a04d0ce76..615a36938f 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -2,12 +2,10 @@ "@agent-device/contracts/alert-contract", "@agent-device/contracts/alert-runtime", "@agent-device/contracts/android-clipboard-support", - "@agent-device/contracts/android-helper-artifacts", "@agent-device/contracts/android-input-ownership", "@agent-device/contracts/android-observation", "@agent-device/contracts/android-snapshot-quality", "@agent-device/contracts/android-system-chrome", - "@agent-device/contracts/android-touch-plan", "@agent-device/contracts/app-deployment-runtime", "@agent-device/contracts/app-deployment-runtime-plan", "@agent-device/contracts/app-event-runtime", @@ -15,7 +13,6 @@ "@agent-device/contracts/app-log-runtime", "@agent-device/contracts/app-state-runtime", "@agent-device/contracts/app-switcher-runtime", - "@agent-device/contracts/apple-multitouch-support", "@agent-device/contracts/apple-runner-request", "@agent-device/contracts/application-lifecycle-interaction", "@agent-device/contracts/application-lifecycle-runtime", @@ -38,7 +35,6 @@ "@agent-device/contracts/command", "@agent-device/contracts/command-platform-execution", "@agent-device/contracts/daemon-http", - "@agent-device/contracts/daemon-owner-cleanup", "@agent-device/contracts/device", "@agent-device/contracts/device-readiness-runtime", "@agent-device/contracts/device-shutdown-runtime", @@ -56,7 +52,6 @@ "@agent-device/contracts/home-runtime", "@agent-device/contracts/host-diagnostics", "@agent-device/contracts/interaction", - "@agent-device/contracts/interaction-error", "@agent-device/contracts/interaction-guarantees", "@agent-device/contracts/interactor-operation-catalog", "@agent-device/contracts/interactor-types", @@ -77,11 +72,9 @@ "@agent-device/contracts/perf-runtime", "@agent-device/contracts/perf-runtime-host", "@agent-device/contracts/perf-runtime-operation-builder", - "@agent-device/contracts/perf-runtime-plan", "@agent-device/contracts/platform-module", "@agent-device/contracts/platform-plugin", "@agent-device/contracts/platform-providers", - "@agent-device/contracts/platform-resource-cleanup", "@agent-device/contracts/platform-runtime", "@agent-device/contracts/platform-runtime-host", "@agent-device/contracts/platform-runtime-operations", @@ -105,10 +98,8 @@ "@agent-device/contracts/settings", "@agent-device/contracts/settings-runtime", "@agent-device/contracts/snapshot", - "@agent-device/contracts/snapshot-presentation", "@agent-device/contracts/snapshot-runtime", "@agent-device/contracts/snapshot-scope", - "@agent-device/contracts/snapshot-timeout-evidence", "@agent-device/contracts/startup-recovery-fence", "@agent-device/contracts/touch-runtime", "@agent-device/contracts/tv-remote", diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index f325398556..1b41a644d5 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -778,28 +778,12 @@ test('Node resolution enforces the exports map at runtime', () => { contractsSnapshotResolved.endsWith('packages/contracts/src/facades/snapshot.ts'), contractsSnapshotResolved, ); - const contractsSnapshotPresentationResolved = import.meta - .resolve('@agent-device/contracts/snapshot-presentation'); - assert.ok( - contractsSnapshotPresentationResolved.endsWith( - 'packages/contracts/src/snapshot-presentation.ts', - ), - contractsSnapshotPresentationResolved, - ); const contractsReactNativeOverlayResolved = import.meta .resolve('@agent-device/contracts/react-native-overlay'); assert.ok( contractsReactNativeOverlayResolved.endsWith('packages/contracts/src/react-native-overlay.ts'), contractsReactNativeOverlayResolved, ); - const contractsSnapshotTimeoutEvidenceResolved = import.meta - .resolve('@agent-device/contracts/snapshot-timeout-evidence'); - assert.ok( - contractsSnapshotTimeoutEvidenceResolved.endsWith( - 'packages/contracts/src/snapshot-timeout-evidence.ts', - ), - contractsSnapshotTimeoutEvidenceResolved, - ); const providerWebDriverResolved = import.meta.resolve('@agent-device/provider-webdriver'); assert.ok( providerWebDriverResolved.endsWith('packages/provider-webdriver/src/index.ts'), diff --git a/src/cli/replay-test/reporters/default.ts b/src/cli/replay-test/reporters/default.ts index 1e1f2665b8..07dd4cb3b1 100644 --- a/src/cli/replay-test/reporters/default.ts +++ b/src/cli/replay-test/reporters/default.ts @@ -6,7 +6,7 @@ import { } from '../progress.ts'; import { formatDurationSeconds } from '../duration-format.ts'; import { colorize, supportsColor } from '../../../commands/output/color.ts'; -import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence'; +import { formatReplayDivergenceReport } from '../../../core/replay-divergence.ts'; import type { ReplayTestReporter, ReplayTestReporterContext, diff --git a/src/commands/interaction/runtime/resolution.test.ts b/src/commands/interaction/runtime/resolution.test.ts index 74956e7ae4..bf264a82bc 100644 --- a/src/commands/interaction/runtime/resolution.test.ts +++ b/src/commands/interaction/runtime/resolution.test.ts @@ -10,7 +10,7 @@ import { import { resolveRecordedTarget } from '@agent-device/selectors'; import { makeSnapshotState } from '../../../__tests__/test-utils/snapshot-builders.ts'; import type { Point } from '@agent-device/kernel/snapshot'; -import { INTERACTION_ERROR_REASONS } from '@agent-device/contracts/interaction-error'; +import { INTERACTION_ERROR_REASONS } from '../../../core/interaction-error.ts'; import { clickRefE2, coveredByTabBarSnapshot, diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index bbcdfac32a..473e33dfad 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -43,7 +43,7 @@ import type { ResolutionDisclosure, ResolvedInteractionTarget, } from '@agent-device/contracts/interaction'; -import { INTERACTION_ERROR_REASONS } from '@agent-device/contracts/interaction-error'; +import { INTERACTION_ERROR_REASONS } from '../../../core/interaction-error.ts'; import type { BackendActionResult, BackendCommandContext, diff --git a/src/commands/interaction/runtime/selector-is.ts b/src/commands/interaction/runtime/selector-is.ts index f9e6c5ba3b..58fd29cfc1 100644 --- a/src/commands/interaction/runtime/selector-is.ts +++ b/src/commands/interaction/runtime/selector-is.ts @@ -13,7 +13,7 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import type { SelectorTarget } from '@agent-device/contracts/interaction'; -import { INTERACTION_ERROR_REASONS } from '@agent-device/contracts/interaction-error'; +import { INTERACTION_ERROR_REASONS } from '../../../core/interaction-error.ts'; import type { RuntimeCommand } from '../../runtime-types.ts'; import { assertExpectedResolvedTarget, type ExpectedResolvedTarget } from './resolution.ts'; import { diff --git a/src/commands/output/error.ts b/src/commands/output/error.ts index cfd3337481..afe80397ab 100644 --- a/src/commands/output/error.ts +++ b/src/commands/output/error.ts @@ -5,7 +5,7 @@ import { type ErrorCandidateView, type NormalizedError, } from '@agent-device/kernel/errors'; -import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence'; +import { formatReplayDivergenceReport } from '../../core/replay-divergence.ts'; export function printHumanError( err: AppError | NormalizedError, diff --git a/src/core/absence-observation-errors.ts b/src/core/absence-observation-errors.ts index bfa2e0b201..24f977fe83 100644 --- a/src/core/absence-observation-errors.ts +++ b/src/core/absence-observation-errors.ts @@ -1,5 +1,5 @@ import { asAppError, AppError } from '@agent-device/kernel/errors'; -import { INTERACTION_ERROR_REASONS } from '@agent-device/contracts/interaction-error'; +import { INTERACTION_ERROR_REASONS } from './interaction-error.ts'; import { absenceCaptureOptionMessage, type AbsenceCaptureOption, diff --git a/packages/contracts/src/interaction-error.ts b/src/core/interaction-error.ts similarity index 100% rename from packages/contracts/src/interaction-error.ts rename to src/core/interaction-error.ts diff --git a/packages/contracts/src/replay-divergence.test.ts b/src/core/replay-divergence.test.ts similarity index 99% rename from packages/contracts/src/replay-divergence.test.ts rename to src/core/replay-divergence.test.ts index daeae5772c..af85a73569 100644 --- a/packages/contracts/src/replay-divergence.test.ts +++ b/src/core/replay-divergence.test.ts @@ -11,8 +11,8 @@ import { REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS, REPLAY_DIVERGENCE_SUGGESTION_LIMIT, truncateUtf8Field, - type ReplayDivergence, } from './replay-divergence.ts'; +import type { ReplayDivergence } from '@agent-device/contracts/divergence'; function buildDivergence(overrides: Partial = {}): ReplayDivergence { return { diff --git a/src/core/replay-divergence.ts b/src/core/replay-divergence.ts new file mode 100644 index 0000000000..1d2c07dfe8 --- /dev/null +++ b/src/core/replay-divergence.ts @@ -0,0 +1,513 @@ +import type { ResponseLevel } from '@agent-device/kernel/contracts'; +import { redactDiagnosticData } from '@agent-device/kernel/redaction'; +import type { + ReplayDivergence, + ReplayDivergenceResume, + ReplayDivergenceScreen, +} from '@agent-device/contracts/divergence'; + +/** + * The `resume` record carried by a divergence-shaped payload, or `undefined` + * when the payload does not carry a contract-shaped one. + * + * Returns the LIVE record rather than a copy: the daemon stamps + * `repairSessionHeld` onto it in place (`session-replay-coordinator.ts`) + * through this same reader. That is what lets the client key its keep-alive + * on the narrowed record too — a payload this reader rejects cannot be + * carrying the R7 liveness signal, because the stamp is only ever written to + * a record it accepted. + * + * Every field the returned type declares is checked, the two optional ones + * included: this is the owning interface for the shape, so a payload it + * accepts cannot type as `repairSessionHeld: true` while carrying something + * else. The narrowing is structural and local, like this module's other + * wire readers: the divergence façade is pinned at the modules it evaluates, + * and `json.ts` is not otherwise one of them. + */ +export function readReplayDivergenceResume( + divergence: unknown, +): ReplayDivergenceResume | undefined { + const resume = (divergence as Record | null | undefined)?.resume as + | Record + | undefined; + if (typeof resume?.allowed !== 'boolean') return undefined; + if (!Number.isInteger(resume.from) || typeof resume.planDigest !== 'string') return undefined; + if (!resume.allowed && typeof resume.reason !== 'string') return undefined; + if (resume.repairSessionHeld !== undefined && resume.repairSessionHeld !== true) return undefined; + if (resume.alternateFrom !== undefined && !Number.isInteger(resume.alternateFrom)) { + return undefined; + } + return resume as ReplayDivergenceResume; +} +type BoundedResponseLevel = 'digest' | 'default' | 'full'; + +export const REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS: Record = { + digest: 8 * 1024, + default: 24 * 1024, + full: 64 * 1024, +}; + +export const REPLAY_DIVERGENCE_DEFAULT_REF_LIMIT = 20; +export const REPLAY_DIVERGENCE_DIGEST_REF_LIMIT = 8; +export const REPLAY_DIVERGENCE_SUGGESTION_LIMIT = 5; +// ADR 0012's 256-UTF-8-byte per-field cap; reached only through the field +// sanitizers below so it is enforced in one place. +const REPLAY_DIVERGENCE_FIELD_BYTE_LIMIT = 256; + +function levelForResponseLevel(level: ResponseLevel | undefined): BoundedResponseLevel { + return level === 'digest' || level === 'full' ? level : 'default'; +} + +/** + * UTF-8 byte-accurate truncation with a marker, never splitting a multi-byte + * codepoint. Used for every individual string field the ADR caps at 256 bytes + * (labels, ids, selectors, source paths, mismatch values, cause messages, + * hints). + */ +export function truncateUtf8Field( + value: string, + limit = REPLAY_DIVERGENCE_FIELD_BYTE_LIMIT, +): string { + const bytes = Buffer.from(value, 'utf8'); + if (bytes.length <= limit) return value; + const marker = '…'; + const markerBytes = Buffer.byteLength(marker, 'utf8'); + const budget = Math.max(0, limit - markerBytes); + let sliceEnd = budget; + // Back off until we are not mid-codepoint (UTF-8 continuation bytes are 10xxxxxx). + while (sliceEnd > 0 && (bytes[sliceEnd]! & 0xc0) === 0x80) sliceEnd -= 1; + return `${bytes.subarray(0, sliceEnd).toString('utf8')}${marker}`; +} + +/** Field sanitizer in the ADR-mandated order: redact first, then truncate. */ +export function sanitizeReplayDivergenceField( + value: string, + limit = REPLAY_DIVERGENCE_FIELD_BYTE_LIMIT, +): string { + return truncateUtf8Field(redactDiagnosticData(value), limit); +} + +export type ReplayVarScrubEntry = { name: string; value: string }; + +/** + * Categorical expanded-variable exclusion (ADR 0012): every occurrence of a + * replay-scope value is replaced with a `` marker, whatever the + * value looks like — this is not shape-based secret redaction. + */ +export function scrubReplayVarValues( + value: string, + entries: readonly ReplayVarScrubEntry[], +): string { + let output = value; + for (const entry of entries) { + if (!entry.value) continue; + output = output.split(entry.value).join(``); + } + return output; +} + +/** Per-report field sanitizer: variable scrub, then redact, then truncate. */ +export function createReplayDivergenceSanitizer( + scrubVars: readonly ReplayVarScrubEntry[], +): (value: string, limit?: number) => string { + return (value, limit) => + sanitizeReplayDivergenceField(scrubReplayVarValues(value, scrubVars), limit); +} + +function boundScreenRefs(screen: ReplayDivergenceScreen, limit: number): ReplayDivergenceScreen { + if (screen.state !== 'available' || screen.refs.length <= limit) return screen; + return { ...screen, refs: screen.refs.slice(0, limit), truncated: true }; +} + +/** + * Applies one level's array caps only (ref count, suggestion presence/count). + * Field-level 256-byte truncation is expected to already be applied by the + * caller at construction time — this function only bounds array shape. + */ +export function applyReplayDivergenceLevelCaps( + divergence: ReplayDivergence, + level: ResponseLevel | undefined, +): ReplayDivergence { + const bounded = levelForResponseLevel(level); + const refLimit = + bounded === 'digest' ? REPLAY_DIVERGENCE_DIGEST_REF_LIMIT : REPLAY_DIVERGENCE_DEFAULT_REF_LIMIT; + const screen = boundScreenRefs(divergence.screen, refLimit); + const suggestions = + bounded === 'digest' ? [] : divergence.suggestions.slice(0, REPLAY_DIVERGENCE_SUGGESTION_LIMIT); + return { ...divergence, screen, suggestions }; +} + +export function measureReplayDivergenceBytes(divergence: ReplayDivergence): number { + return Buffer.byteLength(JSON.stringify(divergence), 'utf8'); +} + +/** + * Bounds the divergence to the response level's byte ceiling. On overflow, + * the fuller detail goes to a session-scoped artifact and a minimal + * divergence is returned; the cause is never dropped, only the screen digest + * and suggestions. + */ +export function boundReplayDivergence(params: { + divergence: ReplayDivergence; + level: ResponseLevel | undefined; + writeOverflowArtifact: ( + fullDivergence: ReplayDivergence, + ) => { artifactPath: string } | { artifactUnavailable: true }; +}): ReplayDivergence { + const { divergence, level, writeOverflowArtifact } = params; + const bounded = levelForResponseLevel(level); + const limit = REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS[bounded]; + const capped = applyReplayDivergenceLevelCaps(divergence, level); + const cappedBytes = measureReplayDivergenceBytes(capped); + if (cappedBytes <= limit) return capped; + + const omittedBytes = cappedBytes - limit; + const full = applyReplayDivergenceLevelCaps(divergence, 'full'); + const artifactResult = writeOverflowArtifact(full); + const minimal = buildMinimalReplayDivergence(capped); + return 'artifactPath' in artifactResult + ? { ...minimal, overflow: { omittedBytes, artifactPath: artifactResult.artifactPath } } + : { ...minimal, artifactUnavailable: true }; +} + +// Owns the "the minimal fallback always fits the budget" guarantee, so it +// sanitizes every field itself rather than trusting the caller did. +function buildMinimalReplayDivergence(capped: ReplayDivergence): ReplayDivergence { + return { + version: capped.version, + kind: capped.kind, + step: { + index: capped.step.index, + source: { + path: sanitizeReplayDivergenceField(capped.step.source.path), + line: capped.step.source.line, + }, + }, + action: sanitizeReplayDivergenceField(capped.action), + cause: { + code: capped.cause.code, + message: sanitizeReplayDivergenceField(capped.cause.message), + ...(capped.cause.hint ? { hint: sanitizeReplayDivergenceField(capped.cause.hint) } : {}), + }, + screen: { + state: 'unavailable', + reason: 'omitted-for-size', + hint: + 'The screen digest and suggestions were omitted to stay within the response byte budget. ' + + 'See overflow.artifactPath (or retry at --level full) for the complete report.', + }, + suggestions: [], + suggestionCount: capped.suggestionCount, + resume: capped.resume, + repairHint: capped.repairHint, + // targetBinding is the actual repair value of a target-binding + // divergence and is small relative to a full screen digest — keep it on + // the minimal fallback rather than dropping it with the screen/suggestions. + ...(capped.targetBinding ? { targetBinding: capped.targetBinding } : {}), + }; +} + +// Compact human-readable divergence report for text surfaces (CLI, MCP text, +// `test` failures). Repair data (step location, screen availability, ranked +// suggestions, overflow pointer) that the --json/structuredContent paths +// carry must not be dropped on a text path. Reads the loose `details` bag so +// every surface (which holds an error `details` record) can share it. +export function formatReplayDivergenceReport( + details: Record | undefined, +): string | null { + const divergence = details?.divergence; + if (!divergence || typeof divergence !== 'object') return null; + const record = divergence as Record; + const lines = [ + ...divergenceStepLine(record.step), + ...divergenceTargetBindingLines(record.kind, record.targetBinding), + ...divergenceRepairHintLine(record.repairHint, record.resume), + ...divergenceScreenLine(record.screen), + ...divergenceSuggestionLines(record.suggestions, record.suggestionCount), + ...divergenceOverflowLine(record.overflow, record.artifactUnavailable), + ]; + return lines.length > 0 ? lines.join('\n') : null; +} + +/** + * ADR 0012 decision 6, extended per #1262: the repair-routing hint rendered + * on every text surface (CLI, MCP text, `test` failures) — the same field + * that rides `structuredContent`/JSON, so a text-only caller still learns + * which repair sub-flow applies. `record-and-heal`/`state-repair` guidance + * embeds the CONCRETE `resume.from`/`planDigest` values (computed by + * `buildReplayDivergenceResume`, decision 6 R2) when `resume.allowed` is + * true, so a text-only or JSON/MCP-first caller reads the identical next + * command instead of deriving it. `caution`/`manual` are genuinely dual-path + * (the daemon cannot know at divergence time which repair applies), so their + * guidance embeds `--from resume.from` (`N`, unshifted — a `--no-record` + * app-state fix) whenever `resume.allowed` is true, AND `--from + * resume.alternateFrom` (`N + 1`, a record-and-heal-shaped recorded corrective + * action) IFF the wire carries `alternateFrom` — the daemon's own verdict that + * a `--from N + 1` request would be accepted (`computeReplayResumeAlternateFrom`, + * `session-replay-resume.ts`). The renderer gates the second command on that + * field's PRESENCE and never re-derives resumability, so text and the + * structured wire never disagree on the advertised next command. When + * `resume.allowed` is false, a resume command is never rendered for any hint, + * and the reported `reason` is surfaced instead. + * + * #1271 stage 2 (ADR 0012 amendment): whenever `resume.repairSessionHeld` is + * `true` (this divergence is from a repair-armed `--save-script` replay), + * every hint's guidance also appends + * `REPAIR_DIAGNOSTICS_DEFAULT_EXCLUSION_CLAUSE` — read-only diagnostics used + * to locate the repair target (`snapshot -i`, `get attrs`, `find`, `is`) are + * excluded from the healed script by default (no `--no-record` needed), and + * an agent whose CORRECTIVE action is itself a read must pass `--record` on + * that one command so it lands in the heal. See `buildRepairHintGuidance`. + */ +function divergenceRepairHintLine(repairHint: unknown, resume: unknown): string[] { + if (typeof repairHint !== 'string') return []; + const guidance = buildRepairHintGuidance(repairHint, resume); + return [`Repair hint: ${repairHint}${guidance ? ` — ${guidance}` : ''}`]; +} + +type ResumeGuidance = + | { allowed: true; from: number; planDigest: string; alternateFrom: number | undefined } + | { allowed: false; reason: string | undefined }; + +/** Reads the parts of `resume` the repair-hint guidance needs; `undefined` when the shape is unreadable. */ +function readResumeGuidance(resume: unknown): ResumeGuidance | undefined { + const record = resume as Record | undefined; + if (!record || typeof record.allowed !== 'boolean') return undefined; + if (!record.allowed) { + return { + allowed: false, + reason: typeof record.reason === 'string' ? record.reason : undefined, + }; + } + const { from, planDigest, alternateFrom } = record; + if (typeof from !== 'number' || typeof planDigest !== 'string' || planDigest.length === 0) { + return undefined; + } + return { + allowed: true, + from, + planDigest, + // Rendered VERBATIM from the wire; the daemon already proved a `--from + // alternateFrom` request would be accepted (#1262). The renderer must NOT + // re-derive it — that is precisely the bug the wire field fixed. + alternateFrom: typeof alternateFrom === 'number' ? alternateFrom : undefined, + }; +} + +function formatResumeCommand(from: number, planDigest: string): string { + return `replay --from ${from} --plan-digest ${planDigest}`; +} + +/** + * #1271 stage 2 (ADR 0012 amendment; supersedes stage 1's interim + * "use --no-record" guidance now that the daemon enforces default + * exclusion itself — `isExcludedRepairSegmentObservation`, + * `session-action-recorder.ts`): read-only diagnostics an agent runs to + * LOCATE the repair target (`snapshot -i`, `get attrs`, `find`, `is`) are, + * by default, excluded from the healed script — the wave-3 E3 experiment's + * 0/4 clean-heal rate motivated the exclusion, but a blanket "exclude every + * read" would silently drop a diverged step whose OWN correction is itself + * a read (the E3 case). `--record` is the opt-in that forces exactly that + * one action through. Distinct from the existing `--no-record` mentions + * above (`state-repair`'s "fix app state with --no-record actions", and + * `buildDualPathRepairHintGuidance`'s state-fix clause), which are about + * correcting APP STATE via a MUTATING action, not about inspection reads — + * both clauses can legitimately apply to the same divergence. + */ +const REPAIR_DIAGNOSTICS_DEFAULT_EXCLUSION_CLAUSE = + 'Read-only inspection while armed (snapshot -i, get attrs, find, is) is excluded from the healed script by default — no --no-record needed. If the step you are repairing is itself a read, add --record to that command so it lands in the heal.'; + +/** + * Gated on `resume.repairSessionHeld === true` (decision 6, R7 C1): that is + * the ONLY signal that this divergence came from a repair-armed + * (`--save-script`) replay, where recorded diagnostics can actually pollute a + * healed script. It is absent (never `false`) on a plain non-repair + * divergence, so the clause must never render there — it would be pure noise. + */ +function isRepairSessionHeld(resume: unknown): boolean { + const record = resume as Record | undefined; + return record?.repairSessionHeld === true; +} + +function buildRepairHintGuidance(repairHint: string, resume: unknown): string | undefined { + const guidance = readResumeGuidance(resume); + const core = buildRepairHintGuidanceCore(repairHint, guidance); + if (core === undefined) return undefined; + return isRepairSessionHeld(resume) + ? `${core} ${REPAIR_DIAGNOSTICS_DEFAULT_EXCLUSION_CLAUSE}` + : core; +} + +function buildRepairHintGuidanceCore( + repairHint: string, + guidance: ResumeGuidance | undefined, +): string | undefined { + switch (repairHint) { + case 'record-and-heal': + return guidance?.allowed + ? `press the correct control via a blessed @ref from screen.refs (recorded), then ${formatResumeCommand(guidance.from, guidance.planDigest)}.` + : `press the correct control via a blessed @ref from screen.refs (recorded). ${resumeUnavailableSentence(guidance)}`; + case 'state-repair': + return guidance?.allowed + ? `fix app state with --no-record actions, then ${formatResumeCommand(guidance.from, guidance.planDigest)} to re-run it.` + : `fix app state with --no-record actions. ${resumeUnavailableSentence(guidance)}`; + case 'caution': + return buildDualPathRepairHintGuidance({ + lead: 'something already matches the recorded selector; a blind re-press may repeat the mistake.', + guidance, + }); + case 'manual': + return buildDualPathRepairHintGuidance({ + lead: 'no safe automated repair could be proven; inspect the screen and repair by hand.', + guidance, + }); + default: + return undefined; + } +} + +/** + * `caution`/`manual` guidance (#1262): the daemon cannot know at divergence + * time which of two repairs applies. Always offers `--from N` (an app-state + * fix via `--no-record` actions, then re-run the unchanged step; `resume.from` + * itself, never shifted for these hints — authorized by `resume.allowed`). + * ADDITIONALLY offers `--from alternateFrom` (a record-and-heal-shaped + * recorded corrective action, resuming PAST the diverged step) IFF the wire + * carries `resume.alternateFrom` — the daemon has already proven such a + * request would be accepted (`computeReplayResumeAlternateFrom`, + * `session-replay-resume.ts`). The renderer gates on the field's PRESENCE and + * never re-derives resumability, so it can never advertise a `--from` the + * daemon would then refuse (the parity bug #1262's review flagged: `N + 1` is + * unsafe exactly when the diverged step is a `runScript`/control-flow action, + * which `resume.allowed` for `N` does not detect). When the alternate is + * absent, only the state-fix command is shown. Neither command is rendered + * when `resume.allowed` is false — `N` itself is not resumable, so its + * alternate is moot. + */ +function buildDualPathRepairHintGuidance(params: { + lead: string; + guidance: ResumeGuidance | undefined; +}): string { + const { lead, guidance } = params; + if (!guidance?.allowed) return `${lead} ${resumeUnavailableSentence(guidance)}`; + const stateFixClause = `if you fixed app state with --no-record actions: ${formatResumeCommand(guidance.from, guidance.planDigest)}`; + if (guidance.alternateFrom === undefined) return `${lead} ${stateFixClause}.`; + const recordedActionClause = `if you performed the step's intent as a recorded action: ${formatResumeCommand(guidance.alternateFrom, guidance.planDigest)}`; + return `${lead} ${stateFixClause}; ${recordedActionClause}.`; +} + +/** Never renders a `--from` command — only reached when `resume.allowed` is false (or unreadable). */ +function resumeUnavailableSentence(guidance: ResumeGuidance | undefined): string { + if (guidance && !guidance.allowed && guidance.reason) { + return `This step cannot currently be resumed automatically (${guidance.reason}) — run a fresh full replay instead.`; + } + return 'This step cannot currently be resumed automatically — run a fresh full replay instead.'; +} + +function divergenceTargetBindingLines(kind: unknown, targetBinding: unknown): string[] { + if (typeof kind !== 'string' || kind === 'action-failure') return []; + const record = targetBinding as Record | undefined; + if (!record) return []; + return [ + divergenceTargetBindingHeaderLine(kind, record.matchCount), + ...divergenceTargetBindingMismatchLines(record.mismatches), + ...divergenceTargetBindingCandidateLines(record.candidates), + ]; +} + +function divergenceTargetBindingHeaderLine(kind: string, matchCount: unknown): string { + const suffix = typeof matchCount === 'number' ? ` (matchCount ${matchCount})` : ''; + return `Target binding: ${kind}${suffix} — recorded target evidence did not verify.`; +} + +function divergenceTargetBindingMismatchLines(mismatches: unknown): string[] { + if (!Array.isArray(mismatches) || mismatches.length === 0) return []; + return [` mismatches: ${mismatches.slice(0, 5).join('; ')}`]; +} + +function divergenceTargetBindingCandidateLines(candidates: unknown): string[] { + if (!Array.isArray(candidates) || candidates.length === 0) return []; + return [ + ` ${candidates.length} candidate(s) shared the recorded identity:`, + ...candidates.slice(0, 5).map((candidate) => ` ${divergenceScreenRefLine(candidate)}`), + ]; +} + +function divergenceStepLine(step: unknown): string[] { + const record = step as Record | undefined; + if (typeof record?.index !== 'number') return []; + const source = record.source as Record | undefined; + const location = + typeof source?.path === 'string' && typeof source.line === 'number' + ? ` (${source.path}:${source.line})` + : ''; + return [`Divergence at step ${record.index}${location}`]; +} + +// Bound on ref lines in the TEXT report (matches the digest ref cap); the +// full list rides in the structured payload. +const TEXT_REPORT_REF_LINE_LIMIT = 8; + +function divergenceScreenLine(screen: unknown): string[] { + const record = screen as Record | undefined; + if (record?.state === 'available' && Array.isArray(record.refs)) { + return availableScreenLines(record.refs, record.refsGeneration); + } + if (record?.state === 'unavailable') { + return [unavailableScreenLine(record)]; + } + return []; +} + +function availableScreenLines(refs: unknown[], refsGeneration: unknown): string[] { + const shown = refs.slice(0, TEXT_REPORT_REF_LINE_LIMIT).map(divergenceScreenRefLine); + const remaining = refs.length - shown.length; + return [ + `Screen: ${refs.length} actionable ref(s) captured (refsGeneration ${refsGeneration}).`, + ...shown, + ...(remaining > 0 ? [` ... ${remaining} more`] : []), + ]; +} + +function unavailableScreenLine(record: Record): string { + const hint = typeof record.hint === 'string' && record.hint.length > 0 ? ` ${record.hint}` : ''; + return `Screen: unavailable (${String(record.reason ?? 'unknown')}).${hint}`; +} + +function divergenceScreenRefLine(entry: unknown): string { + const ref = entry as Record; + const label = typeof ref.label === 'string' ? ` "${ref.label}"` : ''; + return ` @${String(ref.ref)} [${String(ref.role)}]${label}`; +} + +function divergenceSuggestionLines(suggestions: unknown, suggestionCount: unknown): string[] { + if (Array.isArray(suggestions) && suggestions.length > 0) { + return ['Suggestions:', ...suggestions.slice(0, 5).map(divergenceSuggestionLine)]; + } + if (typeof suggestionCount === 'number' && suggestionCount > 0) { + return [ + `Suggestions: ${suggestionCount} available (omitted at this response level; rerun with --json for the full report).`, + ]; + } + return []; +} + +function divergenceSuggestionLine(entry: unknown): string { + const suggestion = entry as Record; + const label = typeof suggestion.label === 'string' ? ` "${suggestion.label}"` : ''; + return ` - [${String(suggestion.basis)}]${label} ${String(suggestion.selector)}`; +} + +function divergenceOverflowLine(overflow: unknown, artifactUnavailable: unknown): string[] { + if (overflow && typeof overflow === 'object') { + return [ + `Full report written to ${String((overflow as Record).artifactPath)}.`, + ]; + } + if (artifactUnavailable === true) { + return [ + 'Full report exceeded the response budget and the overflow artifact could not be written.', + ]; + } + return []; +} diff --git a/packages/contracts/src/daemon-owner-cleanup.ts b/src/daemon-owner-cleanup.ts similarity index 100% rename from packages/contracts/src/daemon-owner-cleanup.ts rename to src/daemon-owner-cleanup.ts diff --git a/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-selector-fallback.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-selector-fallback.test.ts index 1bb0521e88..105b9453e4 100644 --- a/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-selector-fallback.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-selector-fallback.test.ts @@ -1,4 +1,4 @@ -import { INTERACTION_ERROR_REASONS } from '@agent-device/contracts/interaction-error'; +import { INTERACTION_ERROR_REASONS } from '../../../../core/interaction-error.ts'; import type { DaemonError } from '@agent-device/kernel/errors'; import { expect, test } from 'vitest'; import type { DaemonRequest } from '../../../daemon-request.ts'; diff --git a/src/daemon/adapters/maestro/daemon-runtime-tap.ts b/src/daemon/adapters/maestro/daemon-runtime-tap.ts index 31d5036985..51a635f03d 100644 --- a/src/daemon/adapters/maestro/daemon-runtime-tap.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-tap.ts @@ -1,5 +1,5 @@ import { AppError, asAppError } from '@agent-device/kernel/errors'; -import { INTERACTION_ERROR_REASONS } from '@agent-device/contracts/interaction-error'; +import { INTERACTION_ERROR_REASONS } from '../../../core/interaction-error.ts'; import type { Rect } from '@agent-device/kernel/snapshot'; import { MAESTRO_RUNTIME_ADAPTER_POLICY, diff --git a/src/daemon/android-snapshot-timeout-evidence.ts b/src/daemon/android-snapshot-timeout-evidence.ts index 79341d12cd..09fc192eda 100644 --- a/src/daemon/android-snapshot-timeout-evidence.ts +++ b/src/daemon/android-snapshot-timeout-evidence.ts @@ -12,7 +12,7 @@ import { snapshotTimeoutEvidenceWithOverlayRefs, snapshotTimeoutEvidenceWithoutOverlaySource, type SnapshotTimeoutEvidence, -} from '@agent-device/contracts/snapshot-timeout-evidence'; +} from './snapshot-timeout-evidence.ts'; import { isAndroidSnapshotTimeoutError } from '../snapshot/snapshot-timeout-policy.ts'; import { contextFromFlags } from './context.ts'; import { annotateScreenshotWithRefs } from '@agent-device/capture-kit/screenshot-overlay'; @@ -28,8 +28,8 @@ import { * The two things that are not daemon assembly moved out: whether a failure is the * accessibility-timeout shape is a policy * (`src/snapshot/snapshot-timeout-policy.ts`), and the published evidence shape is - * vocabulary (`@agent-device/contracts/snapshot-timeout-evidence`, which has its own subpath so - * it stays out of the shared capture facade's eager closure). What remains here is the ordering that + * vocabulary (`src/daemon/snapshot-timeout-evidence.ts`, whose only consumer is this + * module). What remains here is the ordering that * genuinely needs the daemon: resolving a bound screenshot runtime, writing the artifact, * annotating it from the stored observation, and emitting the diagnostics. */ diff --git a/src/daemon/client/daemon-client-lifecycle.ts b/src/daemon/client/daemon-client-lifecycle.ts index f8e3748e04..23a9f57e54 100644 --- a/src/daemon/client/daemon-client-lifecycle.ts +++ b/src/daemon/client/daemon-client-lifecycle.ts @@ -3,7 +3,7 @@ import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; -import { readReplayDivergenceResume } from '@agent-device/contracts/divergence'; +import { readReplayDivergenceResume } from '../../core/replay-divergence.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { runCmdDetachedMonitored, diff --git a/src/daemon/handlers/session-command-input.ts b/src/daemon/handlers/session-command-input.ts index 01dba87897..af08d2855e 100644 --- a/src/daemon/handlers/session-command-input.ts +++ b/src/daemon/handlers/session-command-input.ts @@ -1,6 +1,6 @@ import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from '../platform-resource-cleanup.ts'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import type { AppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts index 2318fe64a3..1668f3fece 100644 --- a/src/daemon/handlers/session-replay-command.ts +++ b/src/daemon/handlers/session-replay-command.ts @@ -4,7 +4,7 @@ import type { SessionState } from '../session-state.ts'; import type { SessionStore } from '../session-store.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from '../platform-resource-cleanup.ts'; import { runReplayCommand, runReplayTestCommand, type ReplaySession } from '../replay/index.ts'; import { handleSessionCloseCommands } from '../session-lifecycle/index.ts'; import { createReplayTestVideoOwner } from './session-replay-video-owner.ts'; diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index 02d4c507a4..bcf2854d93 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -8,7 +8,7 @@ import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; import { dispatchWaitViaRuntime } from '../wait-runtime.ts'; import { resolveSessionDevice, withSessionlessRunnerCleanup } from '../snapshot-session.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from '../platform-resource-cleanup.ts'; type SnapshotCommandParams = { req: DaemonRequest; diff --git a/src/daemon/perf-capture-resource-recovery.ts b/src/daemon/perf-capture-resource-recovery.ts index bbf51a5915..4da02440bd 100644 --- a/src/daemon/perf-capture-resource-recovery.ts +++ b/src/daemon/perf-capture-resource-recovery.ts @@ -3,7 +3,7 @@ import type { PerfNativeCaptureLiveHandle, PerfNativeCaptureRecoveryInput, } from '@agent-device/contracts/perf-runtime'; -import { perfNativeCaptureRecoveryUse } from '@agent-device/contracts/perf-runtime-plan'; +import { perfNativeCaptureRecoveryUse } from './perf-runtime-plan.ts'; import type { DurableResourceEnvelope } from '@agent-device/contracts/durable-resource-envelope'; import { type BoundDeviceRuntime, diff --git a/packages/contracts/src/perf-runtime-plan.test.ts b/src/daemon/perf-runtime-plan.test.ts similarity index 100% rename from packages/contracts/src/perf-runtime-plan.test.ts rename to src/daemon/perf-runtime-plan.test.ts diff --git a/packages/contracts/src/perf-runtime-plan.ts b/src/daemon/perf-runtime-plan.ts similarity index 97% rename from packages/contracts/src/perf-runtime-plan.ts rename to src/daemon/perf-runtime-plan.ts index e72a8141a8..cddccc9d56 100644 --- a/packages/contracts/src/perf-runtime-plan.ts +++ b/src/daemon/perf-runtime-plan.ts @@ -1,6 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; -import type { PerfKind } from './facades/observability.ts'; -import { isPerfMemoryKind } from './perf.ts'; +import { isPerfMemoryKind, type PerfKind } from '@agent-device/contracts/observability'; import { perfFramesUse, perfMemorySampleUse, @@ -8,7 +7,7 @@ import { perfNativeCaptureRecoveryUse, perfNativeCaptureStartUse, perfProfileReportUse, -} from './platform-runtime-operations.ts'; +} from '@agent-device/contracts/platform-runtime-operations'; export type PerfRuntimeRequest = | Readonly<{ area: 'frames'; action: 'sample' }> diff --git a/packages/contracts/src/platform-resource-cleanup.ts b/src/daemon/platform-resource-cleanup.ts similarity index 100% rename from packages/contracts/src/platform-resource-cleanup.ts rename to src/daemon/platform-resource-cleanup.ts diff --git a/src/daemon/replay/internal/__tests__/session-replay-runtime-failure.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-failure.test.ts index ed0b678769..a0dcb2b077 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-runtime-failure.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-failure.test.ts @@ -14,7 +14,7 @@ import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; import { SessionStore } from '../../../session-store.ts'; import type { DaemonResponse } from '../../../daemon-request.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; -import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence'; +import { formatReplayDivergenceReport } from '../../../../core/replay-divergence.ts'; import { maestroScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { captureSnapshotThroughLegacyDispatchFixture, diff --git a/src/daemon/replay/internal/session-replay-divergence-publication.ts b/src/daemon/replay/internal/session-replay-divergence-publication.ts index a5f0113bcc..7fada834f5 100644 --- a/src/daemon/replay/internal/session-replay-divergence-publication.ts +++ b/src/daemon/replay/internal/session-replay-divergence-publication.ts @@ -2,7 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import type { ResponseLevel } from '@agent-device/kernel/contracts'; import { redactDiagnosticData } from '@agent-device/kernel/redaction'; -import { boundReplayDivergence, type ReplayDivergence } from '@agent-device/contracts/divergence'; +import { type ReplayDivergence } from '@agent-device/contracts/divergence'; +import { boundReplayDivergence } from '../../../core/replay-divergence.ts'; import { bindInternalObservationAuthority, type InternalObservationEvidence, diff --git a/src/daemon/replay/internal/session-replay-divergence.ts b/src/daemon/replay/internal/session-replay-divergence.ts index 5ff504265d..006f13f1ef 100644 --- a/src/daemon/replay/internal/session-replay-divergence.ts +++ b/src/daemon/replay/internal/session-replay-divergence.ts @@ -28,15 +28,17 @@ import type { ReplayReportAction } from './session-replay-report-action.ts'; import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import type { SessionState } from '../../session-state.ts'; import { - REPLAY_DIVERGENCE_SUGGESTION_LIMIT, - createReplayDivergenceSanitizer, type ReplayDivergence, type ReplayDivergenceScreen, type ReplayDivergenceScreenRef, type ReplayDivergenceSuggestion, type ReplayDivergenceSuggestionBasis, - type ReplayVarScrubEntry, } from '@agent-device/contracts/divergence'; +import { + REPLAY_DIVERGENCE_SUGGESTION_LIMIT, + createReplayDivergenceSanitizer, + type ReplayVarScrubEntry, +} from '../../../core/replay-divergence.ts'; export type DivergenceFieldSanitizer = (value: string, limit?: number) => string; diff --git a/src/daemon/replay/internal/session-replay-maestro-failure.ts b/src/daemon/replay/internal/session-replay-maestro-failure.ts index 4e278be870..16a2bb2d3e 100644 --- a/src/daemon/replay/internal/session-replay-maestro-failure.ts +++ b/src/daemon/replay/internal/session-replay-maestro-failure.ts @@ -2,12 +2,12 @@ import type { MaestroFailedAction } from '@agent-device/maestro'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { DaemonError } from '@agent-device/kernel/errors'; import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture'; +import { type ReplayDivergence } from '@agent-device/contracts/divergence'; import { REPLAY_DIVERGENCE_SUGGESTION_LIMIT, createReplayDivergenceSanitizer, - type ReplayDivergence, type ReplayVarScrubEntry, -} from '@agent-device/contracts/divergence'; +} from '../../../core/replay-divergence.ts'; import { formatScriptArg } from '@agent-device/ad-script'; import { getRequestSignal } from '@agent-device/host-kit/request'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; diff --git a/src/daemon/replay/internal/session-replay-runtime-failure-response.ts b/src/daemon/replay/internal/session-replay-runtime-failure-response.ts index 5279daf5e3..046eeef848 100644 --- a/src/daemon/replay/internal/session-replay-runtime-failure-response.ts +++ b/src/daemon/replay/internal/session-replay-runtime-failure-response.ts @@ -1,5 +1,5 @@ import type { SessionAction } from '@agent-device/contracts/session'; -import { scrubReplayVarValues, type ReplayVarScrubEntry } from '@agent-device/contracts/divergence'; +import { scrubReplayVarValues, type ReplayVarScrubEntry } from '../../../core/replay-divergence.ts'; import { formatDivergenceActionLabel } from '@agent-device/ad-script'; import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture'; import { buildDisplayPositionals } from '../../session-event-action.ts'; diff --git a/src/daemon/replay/internal/session-replay-target-verification.ts b/src/daemon/replay/internal/session-replay-target-verification.ts index 2e8365219d..b0e4764d8e 100644 --- a/src/daemon/replay/internal/session-replay-target-verification.ts +++ b/src/daemon/replay/internal/session-replay-target-verification.ts @@ -18,12 +18,12 @@ import type { AdReplayVerificationEntry, } from '@agent-device/ad-replay'; import { - createReplayDivergenceSanitizer, type ReplayDivergence, type ReplayDivergenceTargetBindingKind, type ReplayDivergenceTargetCandidate, type ReplayDivergenceTargetIdentity, } from '@agent-device/contracts/divergence'; +import { createReplayDivergenceSanitizer } from '../../../core/replay-divergence.ts'; import { REPLAY_TARGET_GUARD_MISMATCH_REASON, WAIT_LANDMARK_MISMATCH_REASON, diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 7f07303504..fd1e01f205 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -60,7 +60,7 @@ import { import { createDeviceClaimAdmission, type DeviceClaimAdmission } from './device-claim-admission.ts'; import { createOwnerScopedDeviceClaimReconciler } from './device-claim-owner-recovery.ts'; import { resolveCommandDeviceClaimPolicy } from '@agent-device/command-registry/registry'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 06fed43e79..80c411a0e8 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -23,7 +23,7 @@ import type { ScreenRecordingAdmissionLedger } from './screen-recording-admissio import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { RequestPlatformProviderScope } from '@agent-device/contracts/platform-providers'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; type RequestHandlerChainParams = { req: DaemonRequest; diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 377b8f0c13..fcb1d0aee7 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -72,7 +72,7 @@ import { } from './screen-recording-admission-ledger.ts'; import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; // --------------------------------------------------------------------------- // Request handler API diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index dc948107d4..55129016d6 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -25,7 +25,7 @@ import { } from './selector-capture-binding.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; import { snapshotOptionsToFlags } from '../backend-snapshot-options.ts'; import { checkIsArgs } from '@agent-device/selectors'; diff --git a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts index 827446c908..c90b765e9a 100644 --- a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts +++ b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts @@ -13,7 +13,7 @@ import { stopSessionPerfCapture, type SessionCleanupFailure, } from '../../session-teardown.ts'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from '../../platform-resource-cleanup.ts'; import { hasRuntimeTransportHints, runtimeHintValues } from '../../session-runtime.ts'; import type { CloseRuntime, diff --git a/src/daemon/session-lifecycle/internal/session-close.ts b/src/daemon/session-lifecycle/internal/session-close.ts index 4cf4a8fcb3..fd32508e5d 100644 --- a/src/daemon/session-lifecycle/internal/session-close.ts +++ b/src/daemon/session-lifecycle/internal/session-close.ts @@ -37,7 +37,7 @@ import { type RuntimeHintClearOperation, } from './session-close-runtime-admission.ts'; import { closeCleanupError, runSessionCloseTeardown } from './session-close-lifecycle-teardown.ts'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from '../../platform-resource-cleanup.ts'; export type SessionCloseCommandInput = Readonly<{ req: DaemonRequest; diff --git a/src/daemon/session-observability/internal/session-perf-runtime.ts b/src/daemon/session-observability/internal/session-perf-runtime.ts index 1cd14ba700..534edf090f 100644 --- a/src/daemon/session-observability/internal/session-perf-runtime.ts +++ b/src/daemon/session-observability/internal/session-perf-runtime.ts @@ -4,7 +4,7 @@ import { resolvePerfRuntimePlan, type PerfRuntimePlan, type PerfRuntimeRequest, -} from '@agent-device/contracts/perf-runtime-plan'; +} from '../../perf-runtime-plan.ts'; import type { PerfData, PerfNativeCaptureSnapshot, diff --git a/src/daemon/session-replay-coordinator.ts b/src/daemon/session-replay-coordinator.ts index 5dc21ef1ae..58ed4ae512 100644 --- a/src/daemon/session-replay-coordinator.ts +++ b/src/daemon/session-replay-coordinator.ts @@ -1,10 +1,10 @@ import type { SessionAction } from '@agent-device/contracts/session'; import path from 'node:path'; import { - readReplayDivergenceResume, type ReplayDivergenceResume, type ReplayRepairHint, } from '@agent-device/contracts/divergence'; +import { readReplayDivergenceResume } from '../core/replay-divergence.ts'; import type { DaemonResponse } from './daemon-request.ts'; import type { SessionRuntimeHints, SessionState } from './session-state.ts'; import { diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index a361fe6947..36f52034f4 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -9,7 +9,7 @@ import { finishLiveScreenRecording } from './screen-recording-session-resource.t import { finishLiveAudioProbe } from './audio-probe-session-resource.ts'; import { finishLivePerfCapture } from './perf-capture-session-resource.ts'; import { openWebSessionNames } from './web-session-names.ts'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; export async function stopSessionAppLog(params: { session: SessionState; diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index d00cc635fe..f82483dfbb 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -17,7 +17,7 @@ import { type InspectDeviceRuntimeFacts, } from './request-runtime-binding.ts'; import type { DeviceReadyOptions } from './device-ready.ts'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; import type { SessionState } from './session-state.ts'; diff --git a/src/daemon/snapshot-session.ts b/src/daemon/snapshot-session.ts index 2d81fad88c..8ba08d21aa 100644 --- a/src/daemon/snapshot-session.ts +++ b/src/daemon/snapshot-session.ts @@ -1,5 +1,5 @@ import { resolveTargetDevice } from '../core/dispatch-resolve.ts'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import type { DaemonRequest } from './daemon-request.ts'; import type { SessionScope, SessionState } from './session-state.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; diff --git a/packages/contracts/src/snapshot-timeout-evidence.test.ts b/src/daemon/snapshot-timeout-evidence.test.ts similarity index 100% rename from packages/contracts/src/snapshot-timeout-evidence.test.ts rename to src/daemon/snapshot-timeout-evidence.test.ts diff --git a/packages/contracts/src/snapshot-timeout-evidence.ts b/src/daemon/snapshot-timeout-evidence.ts similarity index 100% rename from packages/contracts/src/snapshot-timeout-evidence.ts rename to src/daemon/snapshot-timeout-evidence.ts diff --git a/src/mcp/tool-error.ts b/src/mcp/tool-error.ts index e7d950bac1..17e69f7dc8 100644 --- a/src/mcp/tool-error.ts +++ b/src/mcp/tool-error.ts @@ -4,7 +4,7 @@ import { type ErrorCandidateView, type NormalizedError, } from '@agent-device/kernel/errors'; -import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence'; +import { formatReplayDivergenceReport } from '../core/replay-divergence.ts'; export function normalizeToolError(error: unknown): NormalizedError { return normalizeError(error); diff --git a/src/platform-runtime-daemon-owner-cleanup.ts b/src/platform-runtime-daemon-owner-cleanup.ts index c6a98c8f72..6e8ed29443 100644 --- a/src/platform-runtime-daemon-owner-cleanup.ts +++ b/src/platform-runtime-daemon-owner-cleanup.ts @@ -1,4 +1,4 @@ -import type { DaemonOwnerCleanup } from '@agent-device/contracts/daemon-owner-cleanup'; +import type { DaemonOwnerCleanup } from './daemon-owner-cleanup.ts'; /** * Root composition for owner-scoped host cleanup. The CLI names only the neutral service; Apple diff --git a/src/platform-runtime-resource-cleanup.ts b/src/platform-runtime-resource-cleanup.ts index 8d0f6b1b88..104a503b6a 100644 --- a/src/platform-runtime-resource-cleanup.ts +++ b/src/platform-runtime-resource-cleanup.ts @@ -1,6 +1,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { isIosFamily } from '@agent-device/kernel/device'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformResourceCleanup } from './daemon/platform-resource-cleanup.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { type OwnedProcessRecordStore } from '@agent-device/host-kit/process'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts';