diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index 75145d3cc7..143068b171 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -26,14 +26,14 @@ "types": "./src/ios-snapshot-acquisition.ts", "default": "./src/ios-snapshot-acquisition.ts" }, - "./ios-snapshot-planning": { - "types": "./src/ios-snapshot-planning.ts", - "default": "./src/ios-snapshot-planning.ts" - }, "./ios-snapshot-engine": { "types": "./src/ios-snapshot-engine/index.ts", "default": "./src/ios-snapshot-engine/index.ts" }, + "./ios-snapshot-planning": { + "types": "./src/ios-snapshot-planning.ts", + "default": "./src/ios-snapshot-planning.ts" + }, "./mobile-snapshot-semantics": { "types": "./src/mobile-snapshot-semantics.ts", "default": "./src/mobile-snapshot-semantics.ts" @@ -62,6 +62,14 @@ "types": "./src/png-worker-client.ts", "default": "./src/png-worker-client.ts" }, + "./post-gesture-stability": { + "types": "./src/post-gesture-stability.ts", + "default": "./src/post-gesture-stability.ts" + }, + "./react-native-overlay": { + "types": "./src/react-native-overlay.ts", + "default": "./src/react-native-overlay.ts" + }, "./screenshot-density": { "types": "./src/screenshot-density.ts", "default": "./src/screenshot-density.ts" @@ -70,6 +78,10 @@ "types": "./src/screenshot-diff-pixels.ts", "default": "./src/screenshot-diff-pixels.ts" }, + "./screenshot-overlay": { + "types": "./src/screenshot-overlay.ts", + "default": "./src/screenshot-overlay.ts" + }, "./snapshot-desktop-projection": { "types": "./src/snapshot-desktop-projection.ts", "default": "./src/snapshot-desktop-projection.ts" diff --git a/src/daemon/post-gesture-stability.ts b/packages/capture-kit/src/post-gesture-stability.ts similarity index 100% rename from src/daemon/post-gesture-stability.ts rename to packages/capture-kit/src/post-gesture-stability.ts diff --git a/src/core/react-native-overlay.ts b/packages/capture-kit/src/react-native-overlay.ts similarity index 100% rename from src/core/react-native-overlay.ts rename to packages/capture-kit/src/react-native-overlay.ts diff --git a/src/snapshot/__tests__/screenshot-overlay-android.test.ts b/packages/capture-kit/src/screenshot-overlay-android.test.ts similarity index 97% rename from src/snapshot/__tests__/screenshot-overlay-android.test.ts rename to packages/capture-kit/src/screenshot-overlay-android.test.ts index e21f23a0ee..ae4395a49c 100644 --- a/src/snapshot/__tests__/screenshot-overlay-android.test.ts +++ b/packages/capture-kit/src/screenshot-overlay-android.test.ts @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; -import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts'; +import { makeSnapshotState } from './snapshot-state.fixtures.ts'; import { isAndroidUnlabeledClickableSource, resolveAndroidOverlaySourceRect, -} from '../screenshot-overlay/android.ts'; +} from './screenshot-overlay-android.ts'; const SCREEN = { x: 0, y: 0, width: 1080, height: 1920 }; diff --git a/src/snapshot/screenshot-overlay/android.ts b/packages/capture-kit/src/screenshot-overlay-android.ts similarity index 99% rename from src/snapshot/screenshot-overlay/android.ts rename to packages/capture-kit/src/screenshot-overlay-android.ts index 4ac877886e..76663699db 100644 --- a/src/snapshot/screenshot-overlay/android.ts +++ b/packages/capture-kit/src/screenshot-overlay-android.ts @@ -1,6 +1,6 @@ import type { Rect, SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { isViewportRootNode, normalizeType } from '@agent-device/contracts/snapshot'; -import { hasPositiveRect, rectArea, rectContains, unionRects } from './rects.ts'; +import { hasPositiveRect, rectArea, rectContains, unionRects } from './screenshot-overlay-rects.ts'; /** * Android overlay policy (#1983): which Android nodes earn an overlay ref, and what rectangle diff --git a/src/daemon/screenshot-overlay-draw.ts b/packages/capture-kit/src/screenshot-overlay-draw.ts similarity index 97% rename from src/daemon/screenshot-overlay-draw.ts rename to packages/capture-kit/src/screenshot-overlay-draw.ts index 1b01d56fc5..ef6a7ba920 100644 --- a/src/daemon/screenshot-overlay-draw.ts +++ b/packages/capture-kit/src/screenshot-overlay-draw.ts @@ -1,6 +1,6 @@ import type { Rect, ScreenshotOverlayRef } from '@agent-device/kernel/snapshot'; -import type { PNG } from '@agent-device/capture-kit/png'; -import { clamp } from '../snapshot/screenshot-overlay/rects.ts'; +import type { PNG } from './png.ts'; +import { clamp } from './screenshot-overlay-rects.ts'; /** * Rasterizing one overlay ref onto a decoded PNG: border, badge, and the bitmap glyphs the badge diff --git a/src/snapshot/screenshot-overlay/rects.ts b/packages/capture-kit/src/screenshot-overlay-rects.ts similarity index 100% rename from src/snapshot/screenshot-overlay/rects.ts rename to packages/capture-kit/src/screenshot-overlay-rects.ts diff --git a/src/daemon/__tests__/screenshot-overlay.test.ts b/packages/capture-kit/src/screenshot-overlay.test.ts similarity index 98% rename from src/daemon/__tests__/screenshot-overlay.test.ts rename to packages/capture-kit/src/screenshot-overlay.test.ts index 7d75724691..cfd3e9c5e9 100644 --- a/src/daemon/__tests__/screenshot-overlay.test.ts +++ b/packages/capture-kit/src/screenshot-overlay.test.ts @@ -2,10 +2,10 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { PNG } from '@agent-device/capture-kit/png'; -import { annotateScreenshotWithRefs, buildScreenshotOverlayRefs } from '../screenshot-overlay.ts'; -import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts'; -import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { PNG } from './png.ts'; +import { annotateScreenshotWithRefs, buildScreenshotOverlayRefs } from './screenshot-overlay.ts'; +import { makeSnapshotState } from './snapshot-state.fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; function writeSolidPng(filePath: string, width: number, height: number): void { const png = new PNG({ width, height }); diff --git a/src/daemon/screenshot-overlay.ts b/packages/capture-kit/src/screenshot-overlay.ts similarity index 96% rename from src/daemon/screenshot-overlay.ts rename to packages/capture-kit/src/screenshot-overlay.ts index 9266cc4237..be9236679f 100644 --- a/src/daemon/screenshot-overlay.ts +++ b/packages/capture-kit/src/screenshot-overlay.ts @@ -6,12 +6,12 @@ import { type SnapshotNode, type SnapshotState, } from '@agent-device/kernel/snapshot'; -import { decodePngAsync, encodePngAsync } from '@agent-device/capture-kit/png-worker-client'; +import { decodePngAsync, encodePngAsync } from './png-worker-client.ts'; import { projectSnapshotRectToScreenshot, resolveSnapshotBounds, -} from '@agent-device/capture-kit/snapshot-rect-projection'; -import { analyzeReactNativeOverlay } from '../core/react-native-overlay.ts'; +} from './snapshot-rect-projection.ts'; +import { analyzeReactNativeOverlay } from './react-native-overlay.ts'; import { findNearestAncestor, isMeaningfulSignal, @@ -21,14 +21,9 @@ import { import { isAndroidUnlabeledClickableSource, resolveAndroidOverlaySourceRect, -} from '../snapshot/screenshot-overlay/android.ts'; +} from './screenshot-overlay-android.ts'; import { drawOverlayRef } from './screenshot-overlay-draw.ts'; -import { - clamp, - hasPositiveRect, - rectArea, - rectContains, -} from '../snapshot/screenshot-overlay/rects.ts'; +import { clamp, hasPositiveRect, rectArea, rectContains } from './screenshot-overlay-rects.ts'; const MAX_OVERLAY_REFS = 24; const ACTIONABLE_ROLE_TYPES = [ diff --git a/packages/capture-kit/src/snapshot-state.fixtures.ts b/packages/capture-kit/src/snapshot-state.fixtures.ts new file mode 100644 index 0000000000..bcdffadfab --- /dev/null +++ b/packages/capture-kit/src/snapshot-state.fixtures.ts @@ -0,0 +1,19 @@ +import { + attachRefs, + type RawSnapshotNode, + type SnapshotState, + type SnapshotStateProvenance, +} from '@agent-device/kernel/snapshot'; + +export function makeSnapshotState( + raw: RawSnapshotNode[], + // The provenance pair stays correlated: overrides carry it as one value, never as two + // independently typed fields. + overrides?: Omit, 'backend' | 'producer'> & SnapshotStateProvenance, +): SnapshotState { + return { + nodes: attachRefs(raw), + createdAt: Date.now(), + ...overrides, + }; +} diff --git a/packages/host-kit/package.json b/packages/host-kit/package.json index f3c4c3d0fc..48f74206d6 100644 --- a/packages/host-kit/package.json +++ b/packages/host-kit/package.json @@ -19,6 +19,14 @@ "types": "./src/archive.ts", "default": "./src/archive.ts" }, + "./code-signature": { + "types": "./src/code-signature.ts", + "default": "./src/code-signature.ts" + }, + "./code-signature-cache": { + "types": "./src/code-signature-cache.ts", + "default": "./src/code-signature-cache.ts" + }, "./command": { "types": "./src/command.ts", "default": "./src/command.ts" diff --git a/src/daemon/__tests__/code-signature-cache.test.ts b/packages/host-kit/src/code-signature-cache.test.ts similarity index 90% rename from src/daemon/__tests__/code-signature-cache.test.ts rename to packages/host-kit/src/code-signature-cache.test.ts index b53b9100e1..a87810e2c3 100644 --- a/src/daemon/__tests__/code-signature-cache.test.ts +++ b/packages/host-kit/src/code-signature-cache.test.ts @@ -3,9 +3,10 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; -import { computeDaemonCodeSignature } from '../code-signature.ts'; -import { resolveCachedDaemonCodeSignature } from '../code-signature-cache.ts'; -import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { computeDaemonCodeSignature } from './code-signature.ts'; +import { resolveCachedDaemonCodeSignature } from './code-signature-cache.ts'; +import { writeWorkspaceFixture } from './code-signature.fixtures.ts'; +import { mkdtempForTestSync } from './internal/tmp-dir.fixtures.ts'; afterEach(() => { vi.restoreAllMocks(); @@ -323,3 +324,29 @@ test('resolveCachedDaemonCodeSignature reports an unreadable entry as unknown', fs.rmSync(root, { recursive: true, force: true }); } }); + +/** + * The cached arm has to see a package-owned edit for the same reason the walk + * does: a source checkout runs most of its own implementation from workspace + * packages, and a client that replays a stale document reuses a daemon running + * superseded code (#2340). + */ +test('resolveCachedDaemonCodeSignature re-walks when a package-owned module changes', () => { + const { root, entryPath, ownedPath } = writeWorkspaceFixture( + 'agent-device-signature-cache-workspace-', + ); + const cacheHome = path.join(root, 'cache-home'); + fs.mkdirSync(cacheHome, { recursive: true }); + vi.spyOn(os, 'tmpdir').mockReturnValue(cacheHome); + try { + const initial = resolveCachedDaemonCodeSignature(entryPath, root); + + fs.writeFileSync(ownedPath, 'export const owned = 20000;\n', 'utf8'); + const changed = resolveCachedDaemonCodeSignature(entryPath, root); + + assert.notEqual(changed, initial); + assert.equal(changed, computeDaemonCodeSignature(entryPath, root)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/code-signature-cache.ts b/packages/host-kit/src/code-signature-cache.ts similarity index 99% rename from src/daemon/code-signature-cache.ts rename to packages/host-kit/src/code-signature-cache.ts index a361f3f7a4..9b37b051a0 100644 --- a/src/daemon/code-signature-cache.ts +++ b/packages/host-kit/src/code-signature-cache.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { publishFileSync } from '@agent-device/host-kit/file'; +import { publishFileSync } from './file.ts'; import { buildDaemonCodeFileLabel, formatDaemonCodeSignature, diff --git a/packages/host-kit/src/code-signature.fixtures.ts b/packages/host-kit/src/code-signature.fixtures.ts new file mode 100644 index 0000000000..e0822c206c --- /dev/null +++ b/packages/host-kit/src/code-signature.fixtures.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempForTestSync } from './internal/tmp-dir.fixtures.ts'; + +/** + * A source checkout that imports its own implementation the way this one does: + * an entry under `src/`, a workspace package under `packages/`, and the + * `node_modules` link that makes `@scope/pkg` name it. The link is what + * separates a workspace package from an installed dependency, so a fixture + * that wrote the directory in place instead would be testing nothing. + */ +export function writeWorkspaceFixture(prefix: string): { + root: string; + entryPath: string; + packageDir: string; + manifestPath: string; + ownedPath: string; +} { + const root = mkdtempForTestSync(prefix); + const entryPath = path.join(root, 'src', 'daemon.ts'); + const packageDir = path.join(root, 'packages', 'kit'); + const ownedPath = path.join(packageDir, 'src', 'owned.ts'); + const manifestPath = path.join(packageDir, 'package.json'); + + fs.mkdirSync(path.dirname(entryPath), { recursive: true }); + fs.mkdirSync(path.dirname(ownedPath), { recursive: true }); + fs.writeFileSync(entryPath, "import '@scope/kit/owned';\n", 'utf8'); + fs.writeFileSync(ownedPath, 'export const owned = 1;\n', 'utf8'); + fs.writeFileSync( + manifestPath, + JSON.stringify({ + name: '@scope/kit', + exports: { './owned': { types: './src/owned.ts', default: './src/owned.ts' } }, + }), + 'utf8', + ); + + const linkDir = path.join(root, 'node_modules', '@scope'); + fs.mkdirSync(linkDir, { recursive: true }); + fs.symlinkSync(packageDir, path.join(linkDir, 'kit'), 'dir'); + return { root, entryPath, packageDir, manifestPath, ownedPath }; +} diff --git a/packages/host-kit/src/code-signature.test.ts b/packages/host-kit/src/code-signature.test.ts new file mode 100644 index 0000000000..ee6bea0150 --- /dev/null +++ b/packages/host-kit/src/code-signature.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { computeDaemonCodeSignature, walkDaemonCodeGraph } from './code-signature.ts'; +import { writeWorkspaceFixture } from './code-signature.fixtures.ts'; +import { mkdtempForTestSync } from './internal/tmp-dir.fixtures.ts'; + +function labelsOf(entryPath: string, root: string): string[] { + return walkDaemonCodeGraph(entryPath, root).files.map(([label]) => label); +} + +test('a workspace subpath is walked, and its file is stamped under the package path', () => { + const { root, entryPath } = writeWorkspaceFixture('agent-device-signature-workspace-'); + try { + assert.deepEqual(labelsOf(entryPath, root).sort(), [ + 'packages/kit/package.json', + 'packages/kit/src/owned.ts', + 'src/daemon.ts', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('an edit to a package-owned module changes the source signature', () => { + const { root, entryPath, ownedPath } = writeWorkspaceFixture('agent-device-signature-edit-'); + try { + const before = computeDaemonCodeSignature(entryPath, root); + + fs.writeFileSync(ownedPath, 'export const owned = 20000;\n', 'utf8'); + + assert.notEqual(computeDaemonCodeSignature(entryPath, root), before); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('retargeting the exports map moves the edge without either endpoint changing', () => { + const { root, entryPath, packageDir, manifestPath } = writeWorkspaceFixture( + 'agent-device-signature-exports-', + ); + try { + const before = computeDaemonCodeSignature(entryPath, root); + fs.writeFileSync(path.join(packageDir, 'src', 'other.ts'), 'export const other = 1;\n', 'utf8'); + fs.writeFileSync( + manifestPath, + JSON.stringify({ + name: '@scope/kit', + exports: { './owned': { types: './src/other.ts', default: './src/other.ts' } }, + }), + 'utf8', + ); + + assert.notEqual(computeDaemonCodeSignature(entryPath, root), before); + assert.ok(labelsOf(entryPath, root).includes('packages/kit/src/other.ts')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('an installed dependency is not followed into', () => { + const root = mkdtempForTestSync('agent-device-signature-installed-'); + try { + const entryPath = path.join(root, 'src', 'daemon.ts'); + const packageDir = path.join(root, 'node_modules', '@scope', 'vendor'); + fs.mkdirSync(path.dirname(entryPath), { recursive: true }); + fs.mkdirSync(path.join(packageDir, 'src'), { recursive: true }); + fs.writeFileSync(entryPath, "import '@scope/vendor/thing';\n", 'utf8'); + fs.writeFileSync(path.join(packageDir, 'src', 'thing.js'), 'export const thing = 1;\n', 'utf8'); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ name: '@scope/vendor', exports: { './thing': './src/thing.js' } }), + 'utf8', + ); + + assert.deepEqual(labelsOf(entryPath, root), ['src/daemon.ts']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('a workspace package linked after the walk is an absent path, not a silent miss', () => { + const root = mkdtempForTestSync('agent-device-signature-absent-'); + try { + const entryPath = path.join(root, 'src', 'daemon.ts'); + fs.mkdirSync(path.dirname(entryPath), { recursive: true }); + fs.writeFileSync(entryPath, "import '@scope/kit/owned';\n", 'utf8'); + + assert.ok( + walkDaemonCodeGraph(entryPath, root).absentPaths.includes( + path.join('node_modules', '@scope', 'kit', 'package.json'), + ), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +/** + * The cache's format guard covers only its own stored shape; it relies on the + * WALK invalidating every document by being inside the graph it walks. That + * held while the walker sat in `src/daemon`, and workspace resolution is what + * keeps it true now that it is a package (#2340). A stamped label is a + * `size:mtime` input to the signature, so membership is the whole claim. + */ +test('the daemon source graph stamps the walker that produced it', () => { + const repoRoot = path.resolve(import.meta.dirname, '..', '..', '..'); + const entryPath = path.join(repoRoot, 'src', 'daemon.ts'); + if (!fs.existsSync(entryPath)) return; + + assert.ok(labelsOf(entryPath, repoRoot).includes('packages/host-kit/src/code-signature.ts')); +}); diff --git a/packages/host-kit/src/code-signature.ts b/packages/host-kit/src/code-signature.ts new file mode 100644 index 0000000000..c569b7a599 --- /dev/null +++ b/packages/host-kit/src/code-signature.ts @@ -0,0 +1,328 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { findProjectRoot } from './version.ts'; + +// Any quoted, relative-path-shaped string literal is treated as a module +// specifier, rather than matching the `import`/`export`/`from` grammar +// around it: bundlers only ever emit relative string literals for real +// specifiers, but the surrounding syntax varies too much to track reliably — +// formatted source spaces `import { x } from './y'` out, a minified build +// (tsdown/rolldown `minify: true`) squashes it to `import{x}from"./y"`, and a +// keyword-anchored regex tuned for one silently stops matching the other +// (#1545: the daemon's own built entry fingerprinted to just itself, since +// nothing downstream of it ever matched). A literal that isn't really an +// import (e.g. one that shows up inside a comment) simply fails to resolve +// to a file below and gets dropped, so over-matching here is harmless. +const RELATIVE_SPECIFIER_RE = /(['"])(\.\.?\/[^'"]*)\1/g; +// Workspace specifiers are matched separately and only in their scoped form. +// Every literal that looks like a bare name would otherwise be probed against +// `node_modules`, and each miss is an `absentPaths` entry the cache re-stats on +// every invocation — prose strings would fill the document. A scoped shape is +// specific enough to keep that set to the specifiers a module really imports. +const SCOPED_SPECIFIER_RE = /(['"])(@[^'"/\s]+\/[^'"\s]+)\1/g; +const RESOLVABLE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] as const; + +/** + * One visited module's contribution to the fingerprint: the label it is + * recorded under (repository-relative where possible) and the `size:mtime` + * pair that stands in for its contents. Contents are never hashed — a + * same-length rewrite inside one filesystem timestamp tick is deliberately + * out of scope, and `code-signature-cache.ts` reuses exactly this bound. + */ +export type DaemonCodeFileStamp = readonly [label: string, size: number, mtimeMs: number]; + +/** + * One walk of the graph: the stamps that make up its signature, plus the paths + * whose ABSENCE that shape depended on. + * + * A file OUTSIDE the graph can still decide the graph. `./dep` means `dep.js` + * only for as long as `dep.ts` does not exist, because resolution probes the + * extensions in `RESOLVABLE_EXTENSIONS` order — and a specifier that resolves + * to nothing today is an edge whose target merely has not been written yet. + * `absentPaths` therefore carries every candidate probed and missed ahead of a + * specifier's winner, and every candidate of a specifier with no winner at + * all: creating any one of them redirects or adds an edge. A workspace + * specifier rests on one more absence — the package manifest that is not + * installed yet — and, when it IS installed, on the manifest's contents, which + * is why the manifest is stamped in `files` rather than merely probed: an + * `exports` edit retargets the edge without touching either endpoint. + * Together the two are the complete input to this walk, which is what lets + * `code-signature-cache.ts` replay the result from `statSync` alone. + */ +export type DaemonCodeGraphWalk = { + readonly files: readonly DaemonCodeFileStamp[]; + readonly absentPaths: readonly string[]; +}; + +export function resolveDaemonCodeSignature(): string { + const entryPath = process.argv[1]; + if (!entryPath) return 'unknown'; + return computeDaemonCodeSignature(entryPath); +} + +export function computeDaemonCodeSignature( + entryPath: string, + root: string = findProjectRoot(), +): string { + try { + return formatDaemonCodeSignature(walkDaemonCodeGraph(entryPath, root).files); + } catch { + return 'unknown'; + } +} + +/** + * Stamps every module reachable from `entryPath` through relative import + * specifiers and through workspace package subpaths, and records what every + * resolution along the way needed to be missing. Throws when the entry itself + * cannot be read; callers decide whether that is an `'unknown'` signature or a + * cache miss. + * + * Following workspace subpaths is what keeps this a signature of the code the + * daemon RUNS. A source checkout imports most of its own implementation by + * specifier, so a walk that stopped at the first `@scope/name` would stamp a + * shrinking fraction of the daemon and report an unchanged signature after an + * edit to any of it — including an edit to this walker, which the cache's + * format guard relies on being inside the graph it walks. + */ +export function walkDaemonCodeGraph(entryPath: string, root: string): DaemonCodeGraphWalk { + const normalizedRoot = path.resolve(root); + const queue = [path.resolve(entryPath)]; + const visited = new Set(); + const files: DaemonCodeFileStamp[] = []; + const absentPaths = new Set(); + + const context: WalkContext = { + root: normalizedRoot, + packages: new Map(), + absentPaths, + stamp: (filePath) => { + if (visited.has(filePath)) return false; + visited.add(filePath); + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + const label = buildDaemonCodeFileLabel(normalizedRoot, filePath); + files.push([label, stat.size, Math.trunc(stat.mtimeMs)]); + return true; + }, + follow: (resolution) => { + for (const missed of resolution.missedCandidates) { + absentPaths.add(buildDaemonCodeFileLabel(normalizedRoot, missed)); + } + if (resolution.filePath) queue.push(resolution.filePath); + }, + }; + + while (queue.length > 0) { + const currentPath = queue.pop(); + if (currentPath === undefined || visited.has(currentPath)) continue; + if (!context.stamp(currentPath)) continue; + + const content = fs.readFileSync(currentPath, 'utf8'); + for (const specifier of collectRelativeImportSpecifiers(content)) { + context.follow(resolveRelativeImportPath(currentPath, specifier)); + } + followWorkspaceSubpaths(content, context); + } + + return { files, absentPaths: [...absentPaths] }; +} + +/** What the walk hands its resolution helpers: where it is, and how to record what they find. */ +type WalkContext = { + readonly root: string; + /** One answer per package name; a manifest is read and stamped at most once per walk. */ + readonly packages: Map; + readonly absentPaths: Set; + /** Stamps a file, reporting whether this walk had not already visited it. */ + stamp(filePath: string): boolean; + follow(resolution: ImportResolution): void; +}; + +function followWorkspaceSubpaths(content: string, context: WalkContext): void { + for (const specifier of collectScopedSpecifiers(content)) { + const { name, subpath } = splitPackageSpecifier(specifier); + const owner = resolveWorkspaceOwner(name, context); + if (!owner) continue; + const target = readExportTarget(owner.manifest, subpath); + if (target === undefined) continue; + context.follow(resolveRelativeImportPath(owner.manifestPath, target)); + } +} + +/** + * The package that owns `name`, stamping its manifest the first time this walk + * needs it: the manifest decides which file the subpath names, so it belongs in + * the fingerprint of every graph that crosses it. + */ +function resolveWorkspaceOwner(name: string, context: WalkContext): WorkspacePackage | null { + const cached = context.packages.get(name); + if (cached !== undefined) return cached; + const owner = readWorkspacePackage(context.root, name, context.absentPaths); + context.packages.set(name, owner); + if (owner) context.stamp(owner.manifestPath); + return owner; +} + +/** + * The label a visited module is stamped under: repository-relative where + * possible, absolute when it lies outside the root. `code-signature-cache.ts` + * derives the ENTRY's label through this same function, so a stored document + * that does not list it cannot be a document for this graph. + */ +export function buildDaemonCodeFileLabel(root: string, filePath: string): string { + const resolvedPath = path.resolve(filePath); + return path.relative(path.resolve(root), resolvedPath) || resolvedPath; +} + +/** The wire form of a signature; identical for a walked and a cache-validated stamp list. */ +export function formatDaemonCodeSignature(stamps: readonly DaemonCodeFileStamp[]): string { + const fingerprint = stamps + .map(([label, size, mtimeMs]) => `${label}:${size}:${mtimeMs}`) + .sort() + .join('|'); + const hash = crypto.createHash('sha1').update(fingerprint).digest('hex'); + return `graph:${stamps.length}:${hash}`; +} + +function collectRelativeImportSpecifiers(content: string): string[] { + const specifiers = new Set(); + RELATIVE_SPECIFIER_RE.lastIndex = 0; + let match: RegExpExecArray | null = null; + while ((match = RELATIVE_SPECIFIER_RE.exec(content)) !== null) { + specifiers.add(match[2]!); + } + return [...specifiers]; +} + +function collectScopedSpecifiers(content: string): string[] { + const specifiers = new Set(); + SCOPED_SPECIFIER_RE.lastIndex = 0; + let match: RegExpExecArray | null = null; + while ((match = SCOPED_SPECIFIER_RE.exec(content)) !== null) { + specifiers.add(match[2]!); + } + return [...specifiers]; +} + +/** A workspace package this walk may follow into, and the manifest that says how. */ +type WorkspacePackage = { + readonly manifestPath: string; + readonly manifest: unknown; +}; + +/** `@scope/name/deep/path` as the package that owns it and the subpath it asks for. */ +function splitPackageSpecifier(specifier: string): { name: string; subpath: string } { + const segments = specifier.split('/'); + const rest = segments.slice(2).join('/'); + return { name: segments.slice(0, 2).join('/'), subpath: rest ? `./${rest}` : '.' }; +} + +/** + * The workspace package `name` resolves to from this checkout, or `null` for + * anything this walk does not follow. + * + * Resolution starts at the checkout root rather than walking up from the + * importing file: every workspace link in the tree points at the same source + * directory, so the extra probes could only find the same package again while + * adding an `absentPaths` entry per directory level per specifier — a document + * the cache would then re-stat thousands of times per invocation. + * + * An INSTALLED dependency is deliberately not followed. Its contents change on + * install rather than on edit, and its closure dwarfs the graph this signature + * describes. The test is structural: a workspace link resolves out of + * `node_modules`, an installed dependency resolves within it. + */ +function readWorkspacePackage( + root: string, + name: string, + absentPaths: Set, +): WorkspacePackage | null { + const linkedManifestPath = path.join(root, 'node_modules', name, 'package.json'); + if (!isExistingFile(linkedManifestPath)) { + absentPaths.add(buildDaemonCodeFileLabel(root, linkedManifestPath)); + return null; + } + const manifestPath = realManifestPath(linkedManifestPath); + if (isInstalledDependencyPath(root, manifestPath)) return null; + try { + return { manifestPath, manifest: JSON.parse(fs.readFileSync(manifestPath, 'utf8')) }; + } catch { + return null; + } +} + +/** + * The manifest's own path, so a package reached through its workspace link is + * stamped under one label. Both routes name the same inode, so either would + * revalidate; two labels for one file would just inflate every document. + */ +function realManifestPath(manifestPath: string): string { + try { + return fs.realpathSync.native(manifestPath); + } catch { + return manifestPath; + } +} + +function isInstalledDependencyPath(root: string, filePath: string): boolean { + const relative = path.relative(path.join(root, 'node_modules'), filePath); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +/** + * The file an `exports` map names for `subpath`, as a specifier relative to the + * manifest. Conditions are not evaluated: every condition of one subpath in + * this workspace names the same source file, and picking the wrong one of two + * identical answers is not a failure mode worth a resolver for. + */ +function readExportTarget(manifest: unknown, subpath: string): string | undefined { + if (!manifest || typeof manifest !== 'object') return undefined; + const exports = (manifest as { exports?: unknown }).exports; + if (!exports || typeof exports !== 'object') return undefined; + const entry = (exports as Record)[subpath]; + const target = typeof entry === 'string' ? entry : readDefaultCondition(entry); + return typeof target === 'string' && target.startsWith('.') ? target : undefined; +} + +function readDefaultCondition(entry: unknown): unknown { + if (!entry || typeof entry !== 'object') return undefined; + return (entry as { default?: unknown }).default; +} + +/** + * What one specifier resolves to, and what that answer rests on: the + * candidates probed and missed AHEAD of the winner. Candidates behind it are + * never reached, so creating one of those cannot move the resolution and none + * is reported. + */ +type ImportResolution = { + readonly filePath: string | null; + readonly missedCandidates: readonly string[]; +}; + +function resolveRelativeImportPath(fromPath: string, specifier: string): ImportResolution { + const basePath = path.resolve(path.dirname(fromPath), specifier); + const missedCandidates: string[] = []; + for (const candidatePath of resolutionCandidates(basePath)) { + if (isExistingFile(candidatePath)) return { filePath: candidatePath, missedCandidates }; + missedCandidates.push(candidatePath); + } + return { filePath: null, missedCandidates }; +} + +/** Every path the specifier could name, in the order resolution prefers them. */ +function* resolutionCandidates(basePath: string): Generator { + yield basePath; + for (const extension of RESOLVABLE_EXTENSIONS) yield `${basePath}${extension}`; + for (const extension of RESOLVABLE_EXTENSIONS) yield path.join(basePath, `index${extension}`); +} + +function isExistingFile(candidatePath: string): boolean { + try { + return fs.statSync(candidatePath).isFile(); + } catch { + return false; + } +} diff --git a/packages/selectors/package.json b/packages/selectors/package.json index 19a2cd8789..4f48cce059 100644 --- a/packages/selectors/package.json +++ b/packages/selectors/package.json @@ -4,7 +4,7 @@ "private": true, "sideEffects": false, "type": "module", - "description": "Shared selector matching, argument, and replay semantics for agent-device. `.` is string-only; `./ast` is the published parser surface behind `agent-device/selectors`; `./engine` is the resolve/list surface reserved for the selector-pipeline owner (R19).", + "description": "Shared selector matching, argument, and replay semantics for agent-device. `.` is string-only; `./ast` is the published parser surface behind `agent-device/selectors`; `./engine` is the resolve/list surface reserved for the selector-pipeline owner (R19); `./parameterized-recorded-fill` parameterizes recorded fill payloads against their selectors.", "dependencies": { "@agent-device/ad-script": "workspace:*", "@agent-device/contracts": "workspace:*", @@ -22,6 +22,10 @@ "./engine": { "types": "./src/engine.ts", "default": "./src/engine.ts" + }, + "./parameterized-recorded-fill": { + "types": "./src/parameterized-recorded-fill.ts", + "default": "./src/parameterized-recorded-fill.ts" } }, "devDependencies": { diff --git a/src/daemon/parameterized-recorded-fill.ts b/packages/selectors/src/parameterized-recorded-fill.ts similarity index 99% rename from src/daemon/parameterized-recorded-fill.ts rename to packages/selectors/src/parameterized-recorded-fill.ts index 9033c9b54c..93f320ce53 100644 --- a/src/daemon/parameterized-recorded-fill.ts +++ b/packages/selectors/src/parameterized-recorded-fill.ts @@ -1,5 +1,5 @@ import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import { selectorContainsValue } from '@agent-device/selectors'; +import { selectorContainsValue } from './index.ts'; const STRUCTURAL_ROOT_OUTPUT_KEYS = new Set([ 'action', diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 11b015d1af..f325398556 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -404,8 +404,11 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/capture-kit/png-rgb-difference', '@agent-device/capture-kit/png-size', '@agent-device/capture-kit/png-worker-client', + '@agent-device/capture-kit/post-gesture-stability', + '@agent-device/capture-kit/react-native-overlay', '@agent-device/capture-kit/screenshot-density', '@agent-device/capture-kit/screenshot-diff-pixels', + '@agent-device/capture-kit/screenshot-overlay', '@agent-device/capture-kit/snapshot-desktop-projection', '@agent-device/capture-kit/snapshot-occlusion', '@agent-device/capture-kit/snapshot-quality-backend-capabilities', @@ -452,6 +455,8 @@ test('the real tree parses, declares, and passes R11', () => { ); assert.deepEqual([...hostKitPackage.exportTargets.keys()].sort(), [ '@agent-device/host-kit/archive', + '@agent-device/host-kit/code-signature', + '@agent-device/host-kit/code-signature-cache', '@agent-device/host-kit/command', '@agent-device/host-kit/diagnostics', '@agent-device/host-kit/file', @@ -583,17 +588,20 @@ test('the real tree parses, declares, and passes R11', () => { ); const selectorsPackage = packages.find((pkg) => pkg.name === '@agent-device/selectors'); assert.ok(selectorsPackage, 'selectors package must exist'); - // Three subpaths, and each split is the point: `.` is the string-only façade + // Four subpaths, and each split is the point: `.` is the string-only façade // every in-repo consumer uses, `./ast` is the published parser surface that // `agent-device/selectors` has shipped since before the engine moved into - // this package, and `./engine` is the resolve/list surface reserved for the + // this package, `./engine` is the resolve/list surface reserved for the // selector-pipeline owner (R19, #1656) — a route reaching it skips the - // structural stages its policy row declares. A fourth subpath, or the AST - // leaking into `.`, fails here. + // structural stages its policy row declares — and + // `./parameterized-recorded-fill` is the recorded-fill parameterization the + // daemon used to own (#2340). A fifth subpath, or the AST leaking into `.`, + // fails here. assert.deepEqual([...selectorsPackage.exportTargets.keys()].sort(), [ '@agent-device/selectors', '@agent-device/selectors/ast', '@agent-device/selectors/engine', + '@agent-device/selectors/parameterized-recorded-fill', ]); assert.deepEqual([...selectorsPackage.workspaceDependencies].sort(), [ '@agent-device/ad-script', diff --git a/src/commands/react-native/overlay.ts b/src/commands/react-native/overlay.ts index ba1a1cc1db..67bd59b777 100644 --- a/src/commands/react-native/overlay.ts +++ b/src/commands/react-native/overlay.ts @@ -1 +1 @@ -export * from '../../core/react-native-overlay.ts'; +export * from '@agent-device/capture-kit/react-native-overlay'; diff --git a/src/daemon/__tests__/post-gesture-stabilization-verdict.test.ts b/src/daemon/__tests__/post-gesture-stabilization-verdict.test.ts index bc60825a3f..27aa06943a 100644 --- a/src/daemon/__tests__/post-gesture-stabilization-verdict.test.ts +++ b/src/daemon/__tests__/post-gesture-stabilization-verdict.test.ts @@ -5,7 +5,7 @@ import { classifyBaselineSurfaceEvidence, type InteractionSurfaceSignature, } from '../interaction-outcome-policy.ts'; -import { decidePostGestureStabilityVerdict as decideWithHooks } from '../post-gesture-stability.ts'; +import { decidePostGestureStabilityVerdict as decideWithHooks } from '@agent-device/capture-kit/post-gesture-stability'; import { applicationRootNode, keyboardWindowNodes, diff --git a/src/daemon/android-snapshot-timeout-evidence.ts b/src/daemon/android-snapshot-timeout-evidence.ts index d08bb336d3..62f7303aaf 100644 --- a/src/daemon/android-snapshot-timeout-evidence.ts +++ b/src/daemon/android-snapshot-timeout-evidence.ts @@ -14,7 +14,7 @@ import { } from '@agent-device/contracts/snapshot-timeout-evidence'; import { isAndroidSnapshotTimeoutError } from '../snapshot/snapshot-timeout-policy.ts'; import { contextFromFlags } from './context.ts'; -import { annotateScreenshotWithRefs } from './screenshot-overlay.ts'; +import { annotateScreenshotWithRefs } from '@agent-device/capture-kit/screenshot-overlay'; import { screenshotExecutionFromContext } from './screenshot-runtime.ts'; import { resolveBoundScreenshotRuntime, diff --git a/src/daemon/client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon/client/__tests__/daemon-client-lifecycle.test.ts index 00051aa9ea..fe564c76fa 100644 --- a/src/daemon/client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon/client/__tests__/daemon-client-lifecycle.test.ts @@ -21,7 +21,7 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => ({ import { resolveDaemonPaths, type DaemonPaths } from '../../config.ts'; import { sendToDaemon, type DaemonRequest, type DaemonResponse } from '../daemon-client.ts'; import { attachActiveSessionAddressHint } from '../daemon-client-lifecycle.ts'; -import { computeDaemonCodeSignature } from '../../code-signature.ts'; +import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; import { sendRequest } from '../daemon-client-transport.ts'; import { closeLoopbackServer, diff --git a/src/daemon/client/__tests__/daemon-client.test.ts b/src/daemon/client/__tests__/daemon-client.test.ts index 04fb1ae7a8..9b9f6e93fa 100644 --- a/src/daemon/client/__tests__/daemon-client.test.ts +++ b/src/daemon/client/__tests__/daemon-client.test.ts @@ -19,7 +19,7 @@ import { waitForProcessExit, } from '@agent-device/host-kit/process'; import { sendToDaemon } from '../daemon-client.ts'; -import { computeDaemonCodeSignature } from '../../code-signature.ts'; +import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; import { downloadRemoteArtifact } from '../../../remote/daemon-artifacts.ts'; import { cleanupFailedDaemonStartupMetadata, diff --git a/src/daemon/client/__tests__/daemon-launch-spec.test.ts b/src/daemon/client/__tests__/daemon-launch-spec.test.ts index 91644541a6..7d586f1894 100644 --- a/src/daemon/client/__tests__/daemon-launch-spec.test.ts +++ b/src/daemon/client/__tests__/daemon-launch-spec.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; -import { computeDaemonCodeSignature } from '../../code-signature.ts'; +import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; import { resolveDaemonLaunchSpec, resolveLocalDaemonCodeSignature } from '../daemon-launch-spec.ts'; import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; diff --git a/src/daemon/client/daemon-launch-spec.ts b/src/daemon/client/daemon-launch-spec.ts index 1159aa9eea..c9d33a29c9 100644 --- a/src/daemon/client/daemon-launch-spec.ts +++ b/src/daemon/client/daemon-launch-spec.ts @@ -4,7 +4,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { findProjectRoot } from '@agent-device/host-kit/version'; import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; -import { computeDaemonCodeSignature } from '../code-signature.ts'; +import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; export type DaemonLaunchSpec = { root: string; @@ -71,6 +71,7 @@ export async function resolveLocalDaemonCodeSignature(): Promise { if (!launchSpec.useSrc) { return computeDaemonCodeSignature(launchSpec.distPath, launchSpec.root); } - const { resolveCachedDaemonCodeSignature } = await import('../code-signature-cache.ts'); + const { resolveCachedDaemonCodeSignature } = + await import('@agent-device/host-kit/code-signature-cache'); return resolveCachedDaemonCodeSignature(launchSpec.srcPath, launchSpec.root); } diff --git a/src/daemon/code-signature.ts b/src/daemon/code-signature.ts deleted file mode 100644 index 823f72e601..0000000000 --- a/src/daemon/code-signature.ts +++ /dev/null @@ -1,172 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { findProjectRoot } from '@agent-device/host-kit/version'; - -// Any quoted, relative-path-shaped string literal is treated as a module -// specifier, rather than matching the `import`/`export`/`from` grammar -// around it: bundlers only ever emit relative string literals for real -// specifiers, but the surrounding syntax varies too much to track reliably — -// formatted source spaces `import { x } from './y'` out, a minified build -// (tsdown/rolldown `minify: true`) squashes it to `import{x}from"./y"`, and a -// keyword-anchored regex tuned for one silently stops matching the other -// (#1545: the daemon's own built entry fingerprinted to just itself, since -// nothing downstream of it ever matched). A literal that isn't really an -// import (e.g. one that shows up inside a comment) simply fails to resolve -// to a file below and gets dropped, so over-matching here is harmless. -const RELATIVE_SPECIFIER_RE = /(['"])(\.\.?\/[^'"]*)\1/g; -const RESOLVABLE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] as const; - -/** - * One visited module's contribution to the fingerprint: the label it is - * recorded under (repository-relative where possible) and the `size:mtime` - * pair that stands in for its contents. Contents are never hashed — a - * same-length rewrite inside one filesystem timestamp tick is deliberately - * out of scope, and `code-signature-cache.ts` reuses exactly this bound. - */ -export type DaemonCodeFileStamp = readonly [label: string, size: number, mtimeMs: number]; - -/** - * One walk of the graph: the stamps that make up its signature, plus the paths - * whose ABSENCE that shape depended on. - * - * A file OUTSIDE the graph can still decide the graph. `./dep` means `dep.js` - * only for as long as `dep.ts` does not exist, because resolution probes the - * extensions in `RESOLVABLE_EXTENSIONS` order — and a specifier that resolves - * to nothing today is an edge whose target merely has not been written yet. - * `absentPaths` therefore carries every candidate probed and missed ahead of a - * specifier's winner, and every candidate of a specifier with no winner at - * all: creating any one of them redirects or adds an edge. Together with the - * stamps it is the complete input to this walk, which is what lets - * `code-signature-cache.ts` replay the result from `statSync` alone. - */ -export type DaemonCodeGraphWalk = { - readonly files: readonly DaemonCodeFileStamp[]; - readonly absentPaths: readonly string[]; -}; - -export function resolveDaemonCodeSignature(): string { - const entryPath = process.argv[1]; - if (!entryPath) return 'unknown'; - return computeDaemonCodeSignature(entryPath); -} - -export function computeDaemonCodeSignature( - entryPath: string, - root: string = findProjectRoot(), -): string { - try { - return formatDaemonCodeSignature(walkDaemonCodeGraph(entryPath, root).files); - } catch { - return 'unknown'; - } -} - -/** - * Stamps every module reachable from `entryPath` through relative import - * specifiers, and records what every resolution along the way needed to be - * missing. Throws when the entry itself cannot be read; callers decide whether - * that is an `'unknown'` signature or a cache miss. - */ -export function walkDaemonCodeGraph(entryPath: string, root: string): DaemonCodeGraphWalk { - const normalizedRoot = path.resolve(root); - const queue = [path.resolve(entryPath)]; - const visited = new Set(); - const files: DaemonCodeFileStamp[] = []; - const absentPaths = new Set(); - - while (queue.length > 0) { - const currentPath = queue.pop(); - if (!currentPath || visited.has(currentPath)) continue; - visited.add(currentPath); - - const stat = fs.statSync(currentPath); - if (!stat.isFile()) continue; - - files.push([ - buildDaemonCodeFileLabel(normalizedRoot, currentPath), - stat.size, - Math.trunc(stat.mtimeMs), - ]); - - const content = fs.readFileSync(currentPath, 'utf8'); - for (const specifier of collectRelativeImportSpecifiers(content)) { - const resolution = resolveRelativeImportPath(currentPath, specifier); - for (const missed of resolution.missedCandidates) { - absentPaths.add(buildDaemonCodeFileLabel(normalizedRoot, missed)); - } - if (resolution.filePath) { - queue.push(resolution.filePath); - } - } - } - - return { files, absentPaths: [...absentPaths] }; -} - -/** - * The label a visited module is stamped under: repository-relative where - * possible, absolute when it lies outside the root. `code-signature-cache.ts` - * derives the ENTRY's label through this same function, so a stored document - * that does not list it cannot be a document for this graph. - */ -export function buildDaemonCodeFileLabel(root: string, filePath: string): string { - const resolvedPath = path.resolve(filePath); - return path.relative(path.resolve(root), resolvedPath) || resolvedPath; -} - -/** The wire form of a signature; identical for a walked and a cache-validated stamp list. */ -export function formatDaemonCodeSignature(stamps: readonly DaemonCodeFileStamp[]): string { - const fingerprint = stamps - .map(([label, size, mtimeMs]) => `${label}:${size}:${mtimeMs}`) - .sort() - .join('|'); - const hash = crypto.createHash('sha1').update(fingerprint).digest('hex'); - return `graph:${stamps.length}:${hash}`; -} - -function collectRelativeImportSpecifiers(content: string): string[] { - const specifiers = new Set(); - RELATIVE_SPECIFIER_RE.lastIndex = 0; - let match: RegExpExecArray | null = null; - while ((match = RELATIVE_SPECIFIER_RE.exec(content)) !== null) { - specifiers.add(match[2]!); - } - return [...specifiers]; -} - -/** - * What one specifier resolves to, and what that answer rests on: the - * candidates probed and missed AHEAD of the winner. Candidates behind it are - * never reached, so creating one of those cannot move the resolution and none - * is reported. - */ -type ImportResolution = { - readonly filePath: string | null; - readonly missedCandidates: readonly string[]; -}; - -function resolveRelativeImportPath(fromPath: string, specifier: string): ImportResolution { - const basePath = path.resolve(path.dirname(fromPath), specifier); - const missedCandidates: string[] = []; - for (const candidatePath of resolutionCandidates(basePath)) { - if (isExistingFile(candidatePath)) return { filePath: candidatePath, missedCandidates }; - missedCandidates.push(candidatePath); - } - return { filePath: null, missedCandidates }; -} - -/** Every path the specifier could name, in the order resolution prefers them. */ -function* resolutionCandidates(basePath: string): Generator { - yield basePath; - for (const extension of RESOLVABLE_EXTENSIONS) yield `${basePath}${extension}`; - for (const extension of RESOLVABLE_EXTENSIONS) yield path.join(basePath, `index${extension}`); -} - -function isExistingFile(candidatePath: string): boolean { - try { - return fs.statSync(candidatePath).isFile(); - } catch { - return false; - } -} diff --git a/src/daemon/deferred-interaction-outcome.ts b/src/daemon/deferred-interaction-outcome.ts index a7549cb1ab..1d1a213dbd 100644 --- a/src/daemon/deferred-interaction-outcome.ts +++ b/src/daemon/deferred-interaction-outcome.ts @@ -29,7 +29,7 @@ import { retryPendingInteractionOutcome, type InteractionRetryTap, } from './interaction-outcome-policy.ts'; -import { runPostGestureStabilityLoop } from './post-gesture-stability.ts'; +import { runPostGestureStabilityLoop } from '@agent-device/capture-kit/post-gesture-stability'; import type { SessionState } from './types.ts'; /** diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 0f720336e5..cbdc507135 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -7,7 +7,7 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { analyzeReactNativeOverlay, type ReactNativeOverlayDismissTarget, -} from '../../core/react-native-overlay.ts'; +} from '@agent-device/capture-kit/react-native-overlay'; import { normalizeError } from '@agent-device/kernel/errors'; import { stripUndefined } from '@agent-device/kernel/record'; import { successText } from '@agent-device/kernel/success-text'; diff --git a/src/daemon/interaction/internal/interaction-common.ts b/src/daemon/interaction/internal/interaction-common.ts index 8b8f278095..f57ec9f895 100644 --- a/src/daemon/interaction/internal/interaction-common.ts +++ b/src/daemon/interaction/internal/interaction-common.ts @@ -9,7 +9,7 @@ import { import type { MultiTargetAnnotationV1 } from '@agent-device/contracts/replay'; import { inferFillText } from '../../action-utils.ts'; import { recordedInputPlaceholder } from '@agent-device/ad-script'; -import { parameterizeRecordedFillPayload } from '../../parameterized-recorded-fill.ts'; +import { parameterizeRecordedFillPayload } from '@agent-device/selectors/parameterized-recorded-fill'; import type { InteractionFinalizationOperations } from './types.ts'; export function finalizeTouchInteraction(params: { diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index 220d0313dd..b5c696f3c1 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -29,7 +29,7 @@ import type { import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; import { assertScreenshotCropPolicy } from './screenshot-crop-target.ts'; import { buildScreenshotCropWarnings, cropScreenshotToSelector } from './screenshot-crop.ts'; -import { annotateScreenshotWithRefs } from './screenshot-overlay.ts'; +import { annotateScreenshotWithRefs } from '@agent-device/capture-kit/screenshot-overlay'; import { resolveBoundScreenshotRuntime, type BoundScreenshotRuntime, diff --git a/src/daemon/server/server-lifecycle.ts b/src/daemon/server/server-lifecycle.ts index 992b27f425..a976a0701a 100644 --- a/src/daemon/server/server-lifecycle.ts +++ b/src/daemon/server/server-lifecycle.ts @@ -3,7 +3,7 @@ import { isAgentDeviceDaemonProcess } from '../daemon-process.ts'; export { readVersion } from '@agent-device/host-kit/version'; export { readProcessStartTime } from '@agent-device/host-kit/process'; -export { resolveDaemonCodeSignature } from '../code-signature.ts'; +export { resolveDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; export type DaemonLockInfo = { pid: number; diff --git a/src/daemon/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index 977d0051d8..784546bfee 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -17,7 +17,7 @@ import { parameterizeRecordedResultEcho, parameterizeTargetEvidenceEcho, targetEvidenceCarriesAnyLiteral, -} from './parameterized-recorded-fill.ts'; +} from '@agent-device/selectors/parameterized-recorded-fill'; import type { TargetEvidenceMode } from './session-target-evidence.ts'; export type RecordActionEntry = {