Skip to content

refactor: pnpm workspace — extract packages with enforced public APIs, spine-bottom-up #1490

Description

@thymikee

Outcome

Extend the existing pnpm workspace (which already contains website) into an internal modular monorepo: new packages/* members with enforced public interfaces (exports maps + workspace:* deps), extracted incrementally, bottom-up along the layering spine. A deep package-specifier import becomes a resolution error, and a repo-relative bypass becomes a gate failure. The published surface stays exactly one npm artifact (agent-device); every new internal packages/* member is "private": true, while the root package remains publishable.

Maintainer decision (thymikee, 2026-07-29): the workspace is the target state, superseding the "workspaces only when earned" stance in docs/daemon-modularity-proposal.md — by that document's own trigger list, the third trigger (repeated boundary regressions that physical package resolution would prevent more reliably) is already met: 72 static imports reach past the PlatformPlugin seam, the R6 type-inversion ledger started at 61, and the largest type-cycle SCC spans 102 files, all accumulated while the zone gates were in force.

Extraction follows the layering dependency order. When a zone leaves src/, delete only the layering-lint rows whose guarantee is physically subsumed by package resolution; semantic, ownership, cycle, and package-interface policies remain. A package boundary enforces an already-earned module interface — it does not by itself make the interface deep or earn a new port.

Tracking

  • W-pre — relocate kernel stragglers (no workspace yet)
  • W0 — workspace scaffold + @agent-device/kernel pilot
  • W1a — @agent-device/contracts + split the ProviderDeviceRuntime contract from root runtime coordination
  • W1b — @agent-device/provider-webdriver
  • W1c — limrun seam: package factory receives narrow Android/host adapters
  • W1d — @agent-device/provider-limrun
  • W2 — after W0 + W1a, refactor(daemon): extract replay, Maestro, and replay-test modules #1478 P2/P3/P5 land as packages/maestro, packages/replay-test, packages/ad-replay; W1b/W1c/W1d do not block that replay chain
  • W3 go/no-go recorded — if refactor(daemon): extract replay, Maestro, and replay-test modules #1478 P6 defers, record "not now"; if it proceeds, decide platform packages only after P6a/P6b establish the logical seams

Locked rules (every PR)

  • Root src/ may import packages. A package may never import root src/ — enforced by package resolution plus a cheap lint rejecting escapes from packages/.
  • Every consumer outside the owning package uses its package specifier. Relative imports into packages/*/src are forbidden even from root source/tests; otherwise exports can be bypassed. The sole exception is an R8 zero-dep closure importing the exact source target named by that package's exports map.
  • Every internal package-to-package dependency is declared as workspace:*. Root build-time links to private internal packages stay out of the packed runtime dependency manifest. Do not add TypeScript paths wildcards that resolve package internals and bypass exports.
  • A module moves only when its entire remaining dependency set is packages / npm deps / node builtins. No compatibility shims, no re-export barrels left at the old path — atomic cutover, same rule as refactor(daemon): extract replay, Maestro, and replay-test modules #1478's internal-interface policy.
  • No common / foundation / utils package, ever. A shared package is named by domain, and every file in it has a package consumer at the time it moves.
  • Export shape follows module shape, not source-file count. Leaf vocabulary packages may expose consumer-earned domain subpaths such as @agent-device/kernel/errors; deep modules (provider-webdriver, provider-limrun, maestro, replay-test, ad-replay) export only their façade at .. No mega-barrels and no automatic export for every source file.
  • tsdown bundles @agent-device/* (noExternal) into the single published artifact; lazy platform loading is preserved. The packed agent-device manifest must contain zero @agent-device/* runtime dependencies because those packages are private and unpublished. Publishing any package separately is a separate future decision.
  • TypeScript project references per package; root typecheck becomes tsc -b.
  • R8 zero-dep jobs cannot resolve workspace package specifiers. If such a job genuinely needs extracted code, it may use the sole relative-import exception above; the gate derives permitted targets from exports and rejects every non-exported/internal target. Otherwise make that job install dependencies.
  • One worker, one branch, one PR per brief. Rebase from the dependency's merged commit.

Worker briefs

W-pre — relocate kernel stragglers

Goal: kernel's file set is its public API before it becomes a package.

Audit (importer counts across src/scripts/test): errors 493, device 264, snapshot 208, contracts 81 — plus collections 4, rect 9, redaction 5, daemon-error 3, all coherent kernel vocabulary. Three files drifted into the sink because R1 pushes shared code down, not because they are foundational:

  • audio-probe-support.ts (4 importers, all platform/plugin code) — feature policy ("which devices support host audio probing"); move beside its consumers (contracts or platforms).
  • observability-redaction.ts (1 importer) — colocate with its consumer or fold into contracts.
  • input-validation.ts (3 importers) — same treatment.

Done: the three files live outside src/kernel/; kernel has zero internal imports (already true — R1); all gates green; no behavior change.

W0 — scaffold + @agent-device/kernel pilot

Goal: prove the whole toolchain on the module the spine already certified as dependency-free.

  • pnpm-workspace.yaml gains packages/*. packages/kernel/: package.json (private: true, explicit consumer-earned subpath exports), composite tsconfig.json.
  • Audit the three low-level helpers webdriver shares with existing consumers — utils/timeouts.ts, utils/xml.ts, and utils/bounds.ts — and move only those whose domain placement is honest. utils/version.ts stays root-side: it owns filesystem/project-root lookup, so provider package factories receive the resolved client version as an immutable value. utils/exec.ts also stays root-side.
  • Codemod every ../kernel/x.ts import to @agent-device/kernel/x repo-wide. ⚠️ This is the one large-conflict change of the initiative — land in a quiet window (after fix: publish replay refs after response projection #1489/P1 merges, before P2 branches).
  • Gate wiring in the same PR: layering drops only kernel rows physically replaced by resolution and gains package-to-root, cross-package-relative-import, undeclared-workspace-dependency, and export-target checks; check:affected gains packages/ ownership (today those paths fail open as unknown); fallow entries; vitest/oxlint/oxfmt/coverage cover packages/; Testing Matrix row updated.

Done: packaged-CLI and export-subpath smokes green; emitted JavaScript contains no unresolved @agent-device/* specifiers; the packed manifest has zero @agent-device/* runtime dependencies; size report ≈ 0 delta; tests assert package-specifier, ordinary repo-relative, cross-package-relative, and R8-relative deep-import rules; pnpm check green.

STOP if: tsdown cannot preserve the single artifact or lazy platform loading; emitted or packed output retains an internal package dependency; any old-path re-export is needed; a zero-dep script needs a package specifier or a non-exported source target.

W1a — @agent-device/contracts + ProviderDeviceRuntime inversion

Goal: the shared-vocabulary zone (already "declared below its consumers" per #1405/#1435) becomes a package, and the provider interface becomes clean enough to live in it.

  • Audited blocker: src/provider-device-runtime.ts imports types from daemon/request-platform-providers.ts and platforms/apple/core/runner/runner-provider.ts — the interface has downward type-deps into daemon and Apple internals. Invert them (kin to the remaining R6 baseline entries; shrink that ledger where the fix overlaps).
  • Split the value/type-only ProviderDeviceRuntime contract and its neutral option/result values into packages/contracts. Keep AsyncLocalStorage, the active-runtime registry, request scoping, provider composition, and test-isolation mutation in root composition code; the contracts package must not acquire ambient mutable runtime coordination.
  • Move src/contracts/** with consumer-earned domain subpaths. platforms/permission-utils.ts moves here only if the inversion audit confirms it is shared vocabulary rather than WebDriver/platform policy.

Done: contracts package has imports only from @agent-device/kernel/node and contains no mutable runtime registry/composition; R6 baseline does not grow (shrinks where touched); only physically superseded layering rows are deleted; all parity/coverage gates green.

STOP if: an inversion requires a runtime (non-type) dependency moving down, or DaemonRequest would need to enter contracts.

W1b — @agent-device/provider-webdriver

Goal: first product leaf. BrowserStack + AWS Device Farm adapters behind the packaged interface.

  • Audited deps: kernel (4 files), contracts (9 files), ProviderDeviceRuntime, the W0-audited utils/{timeouts,xml,bounds} placements, root-supplied version value, one utils/exec.ts call site, and platforms/permission-utils.ts (in contracts after W1a only if confirmed as shared vocabulary).
  • Refactor the single exec call site to receive a narrow host-command capability through the package factory rather than importing root exec or widening ProviderDeviceRuntime. The root production adapter delegates to src/utils/exec.ts; the package's deterministic adapter runs the same focused contract.
  • Root provider composition targets the package while preserving current startup/load behavior. Cloud WebDriver is statically imported today; do not claim or require new laziness in this PR. Preserve Limrun's existing conditional dynamic load independently.
  • Current evidence is not only 17 colocated unit-test lines: provider scenarios already include roughly 1,159 lines across cloud-webdriver-provider-adapters.test.ts and cloud-webdriver-runtime.test.ts, plus surface tests. Move those tests with their subject where appropriate, identify behavior gaps at the package façade, and add provider-transcript coverage only for unpinned adapter contracts. Replace superseded implementation-coupled tests rather than layering duplicates.

Done: package exports only . and imports only kernel/contracts/npm/node; existing provider-scenario behavior remains covered through the package façade; missing adapter contracts gain provider transcripts; cloud connect flows remain green in provider-integration.

W1c — limrun seam (blocker discovered in audit; no package yet)

Goal: remove limrun's deep imports so it can move without widening the provider contract.

src/providers/limrun/ currently imports core/interactors/android.ts, platforms/android/adb-executor.ts, platforms/android/device-input-state.ts, and utils/exec.ts. Define a narrow dependency object at the createLimrunRuntime package-factory seam for the demonstrated Android interactor/ADB/reverse operations and host commands. Root composition supplies adapters backed by the existing Android platform and src/utils/exec.ts; a deterministic in-memory adapter exercises the same contract. These capabilities are construction dependencies of the Limrun implementation, not new methods on ProviderDeviceRuntime.

Done: limrun's imports reduce to kernel/contracts/@limrun/api/factory-injected capabilities; ProviderDeviceRuntime does not widen; live limrun session evidence (device-verification doc applies — this is device-facing).

STOP if: the seam needs a universal command dispatcher, exposes raw platform modules, or widens ProviderDeviceRuntime beyond what its existing consumers demonstrably need.

W1d — @agent-device/provider-limrun

Goal: atomically move the W1c-clean Limrun implementation behind one package façade.

  • Cut root composition over to the package and delete the old path without a re-export.
  • Move or replace tests so they exercise the package façade and its injected capability contracts, not implementation files.
  • Preserve conditional loading when LIMRUN_API_KEY is absent and preserve provider/device behavior when present.

Done: package exports only ., imports only kernel/contracts/@limrun/api/node, and has zero root src/ imports; provider scenarios plus live Limrun evidence pass; packed output and startup behavior remain compatible.

W2 — #1478 phases land as packages

After W0 and W1a establish the workspace and shared vocabulary, amend #1478 so P2/P3/P5 land directly in packages/{maestro,replay-test,ad-replay}; do not first create src/<module>/ and move it again. W1b/W1c/W1d are sibling provider work and do not block this replay chain. Each brief's Done gains "lands as a workspace package; zero root imports; only . is exported." The package graph's acyclicity makes P5's engine-out-of-the-SCC requirement non-optional. If both engines need the same snapshot/selector values, that earns a focused addition to @agent-device/contracts — never a new grab-bag.

W3 — platforms

If #1478's P6 checkpoint defers platform modularity, record "not now" here and stop. If it proceeds, complete P6a/P6b first, then make a fresh package go/no-go from the resulting logical seams. Packaging must not be used to design those seams. A "go" additionally requires open capability unions, plugin-contributed capability matrices, and verified per-package native asset resolution. Not scheduled here.

Explicitly out

  • @agent-device/host — rejected; only W0-audited low-level helpers may move to kernel, while version/project-root policy and exec stay in root composition.
  • screenshot-diff package — dropped from the schedule (may join opportunistically later; its OCR path has a separate pending decision).
  • metro — deferred: imports daemon/session-paths.ts and the companion tunnel; needs its own seam brief before it can satisfy the no-shims rule.
  • Publishing packages to npm individually.

STOP conditions (initiative-wide)

Published artifact or packed manifest loses byte/behavior/dependency parity; a move needs a compatibility re-export at the old path; a package needs a root src/ import; a zero-dep script needs a non-exported package source; tsdown cannot keep one artifact + lazy platform loading; CI wall-clock regresses materially; a shared package would be created without a named domain and current consumers.

Success metrics

  • Deep import of a non-exported package file = resolution error, pinned by test.
  • Layering zone table shrinks monotonically with each extraction; R6/R9 never grow.
  • Size report neutral through W1; packaged-CLI + export-subpath smokes stay green throughout.
  • No internal package outside the seven named through W2 (kernel, contracts, provider-webdriver, provider-limrun, maestro, replay-test, ad-replay); package count is a ceiling, not a success target.
  • Zero re-export shims; zero packages/ → src/ imports.
  • W1b closes the cloud-webdriver test gap (2,631 prod LOC / 17 test lines → transcript-covered).

Validation

Every worker: pnpm check:affected --run. W0 and W1a run pnpm check because they rewire the workspace, type graph, and gates; every package cutover (W1b, W1d, and #1478 P2/P3/P5) also runs pnpm check because path topology and published bundling change broadly. W1b/W1c/W1d additionally run provider-integration and the applicable live device/cloud evidence per docs/agents/device-verification.md. CI stays authoritative.

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions