Repository navigation
refactor: pnpm workspace — extract packages with enforced public APIs, spine-bottom-up #1490
Description
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/kernelpilot - W1a —
@agent-device/contracts+ split theProviderDeviceRuntimecontract from root runtime coordination - W1b —
@agent-device/provider-webdriver— completed in refactor: extract WebDriver provider package #1504 - W1c — limrun seam: package factory receives narrow Android/host adapters — completed in refactor: isolate Limrun construction dependencies #1502
- 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 — completed in refactor: isolate maestro engine package #1506, refactor(replay-test): neutralize the values crossing the scheduler seam (#1478 P3, part 1) #1509/refactor(replay-test): extract packages/replay-test (#1478 P3b) #1525, and refactor(daemon): extract native .ad replay to packages/ad-replay (#1478 P5) #1555 (plus the approved@agent-device/xmlamendment via refactor: extract WebDriver provider package #1504 and the@agent-device/ad-scriptcodec leaf via refactor: extract the .ad script codec into packages/ad-script (#1478) #1536) - W3 go/no-go recorded — not now (2026-08-04): refactor(daemon): extract replay, Maestro, and replay-test modules #1478 P6 deferred at the remeasurement checkpoint, so per this line's own rule W3 stops here; see the decision comment below
Locked rules (every PR)
- Root
src/may import packages. A package may never import rootsrc/— enforced by package resolution plus a cheap lint rejecting escapes frompackages/. - Every consumer outside the owning package uses its package specifier. Relative imports into
packages/*/srcare forbidden even from root source/tests; otherwiseexportscan be bypassed. The sole exception is an R8 zero-dep closure importing the exact source target named by that package'sexportsmap. - 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 TypeScriptpathswildcards that resolve package internals and bypassexports. - 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/utilspackage, 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 packedagent-devicemanifest 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
exportsand 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 intocontracts.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.yamlgainspackages/*.packages/kernel/:package.json(private: true, explicit consumer-earned subpathexports), compositetsconfig.json.- Audit the three low-level helpers webdriver shares with existing consumers —
utils/timeouts.ts,utils/xml.ts, andutils/bounds.ts— and move only those whose domain placement is honest.utils/version.tsstays root-side: it owns filesystem/project-root lookup, so provider package factories receive the resolved client version as an immutable value.utils/exec.tsalso stays root-side. - Codemod every
../kernel/x.tsimport to@agent-device/kernel/xrepo-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:affectedgainspackages/ownership (today those paths fail open as unknown); fallow entries; vitest/oxlint/oxfmt/coverage coverpackages/; 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.tsimports types fromdaemon/request-platform-providers.tsandplatforms/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
ProviderDeviceRuntimecontract and its neutral option/result values intopackages/contracts. KeepAsyncLocalStorage, 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.tsmoves 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-auditedutils/{timeouts,xml,bounds}placements, root-supplied version value, oneutils/exec.tscall site, andplatforms/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 tosrc/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.tsandcloud-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/@agent-device/xml/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_KEYis 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.
Approved amendment — @agent-device/xml (2026-07-31)
W1b discovered that keeping provider-webdriver within the original dependency list would create a second production XML parser. That would violate the initiative-wide STOP condition against moves introducing duplicate production paths and would split security hardening for entity decoding, prototype-pollution guards, nesting limits, and document-size limits. The governing package set is therefore amended to approve @agent-device/xml as the eighth internal package through W2. This is an earned domain package, not a general-purpose utils or foundation package.
@agent-device/xml exports only its package-root facade. Its allowed production consumers are the WebDriver source adapter; Android hierarchy entity decoding; Apple plist, performance, xctestrun, and usbmux XML handling; daemon runtime-hint escaping; and replay-test JUnit escaping. New consumers must demonstrate actual XML parsing, entity decoding, or escaping needs and use the facade; deep imports and parallel XML implementations remain forbidden. provider-webdriver may depend on kernel, contracts, @agent-device/xml, npm dependencies, and Node builtins.
This revises the package-count ceiling from seven to eight through W2: kernel, contracts, xml, provider-webdriver, provider-limrun, maestro, replay-test, and ad-replay. The package count remains a ceiling, not a target.
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-diffpackage — dropped from the schedule (may join opportunistically later; its OCR path has a separate pending decision).metro— deferred: importsdaemon/session-paths.tsand 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 eight named through W2 (kernel, contracts, xml, 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
- refactor(daemon): extract replay, Maestro, and replay-test modules #1478 — daemon extraction plan (W2 amends its destinations; sequencing: W-pre/W0/W1a land after P1/fix: publish replay refs after response projection #1489 merges, before P2 branches)
docs/daemon-modularity-proposal.md§"Conditional workspace layout" — the package constraints adopted here (private: true,workspace:*, single artifact, no foundation dumps); its "only when earned" stance is superseded by this issue- refactor(boundaries): put shared contracts below their consumers, gate the result #1405 / refactor(contracts): declare shared vocabulary below its consumers, ratchet what remains #1435 — contracts-below-consumers groundwork consumed by W1a
- docs: propose daemon module boundaries #1451 review discussion — evidence that zone gates alone did not hold the boundary
Activity
thymikee commentedon Jul 29, 2026
Architecture review (2026-07-29): adopt, with the issue body amended before implementation.
The workspace is a materially stronger enforcement mechanism for the already-earned modules, but package structure must not become a reason to widen their interfaces. Incorporated corrections:
This keeps the package boundary physical, the module interface small, and the execution graph parallel where dependencies genuinely permit it.
thymikee commentedon Jul 29, 2026
Final consistency pass applied:
The issue is now implementation-ready subject to its stated P1 dependency and STOP conditions.
refactor(kernel): relocate non-foundational stragglers before packagi…
thymikee commentedon Jul 30, 2026
W-pre complete — #1491 merged at
46e389f.Machine-verified against the brief's Done criteria: the three stragglers live outside
src/kernel/(audio-probe-support+input-validation→src/contracts/,observability-redaction→ colocated with its sole consumer insrc/commands/observability/runtime/); kernel retains exactlyerrors/device/snapshot/contracts/collections/rect/redaction/daemon-errorwith zero internal imports; no old-path re-exports; size report 0 B on every metric (pure relocation); all gates green including provider-integration (150 tests) and layering R1–R10 with R6 at 7 / R9 at 102 unchanged.Next per sequencing: W0 (scaffold +
@agent-device/kernelpilot with the repo-wide codemod) starts once #1489 (P1) merges — it is the one large-conflict change and waits for that quiet window.Generated by Claude Code
refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0) (#1494)
thymikee commentedon Jul 30, 2026
W1a is implemented in #1499.
What landed in the branch:
@agent-device/contractsas a private workspace packageorigin/main: +1.1 kB raw JS, -502 B gzip, -1.7 kB tarball, -4.9 kB unpackedValidation is green locally:
pnpm checkand the full affected gate (VITEST_MAX_WORKERS=1 pnpm check:affected --run). W1a should remain in progress until #1499 merges.thymikee commentedon Jul 30, 2026
Coordination update after #1499 merged:
@agent-device/provider-webdriver) and W1c (Limrun construction seam) have started in parallel from currentorigin/main.Expected overlap is limited to workspace/gate wiring and root provider composition; each worker owns one branch/PR and must rebase after any dependency merges.
thymikee commentedon Aug 4, 2026
W2 complete; W3 recorded as not now (2026-08-04)
Tracking is updated in the body:
packages/maestro(refactor: isolate maestro engine package #1506),packages/replay-test(refactor(replay-test): neutralize the values crossing the scheduler seam (#1478 P3, part 1) #1509/refactor(replay-test): extract packages/replay-test (#1478 P3b) #1525), andpackages/ad-replay(refactor(daemon): extract native .ad replay to packages/ad-replay (#1478 P5) #1555), alongside the approved@agent-device/xmlamendment (via refactor: extract WebDriver provider package #1504) and the@agent-device/ad-scriptcodec leaf (refactor: extract the .ad script codec into packages/ad-script (#1478) #1536). Current R11 state onmain761317deb: 9 workspace packages behind 29 exported subpaths with zero root back-imports.Note: the S1 selector-package spike (#1557) is a separate decision path, not W3 — it runs after the ad-script exact-symbol façade gate (#1579) lands.
Generated by Claude Code
thymikee commentedon Aug 4, 2026
Closing: all tracked work is complete and the issue's own stopping rule has fired.
packages/ad-replay), alongside the approved@agent-device/xmlamendment and the@agent-device/ad-scriptcodec leaf.main: 9 workspace packages behind 29 exported subpaths, zeropackages/ → src/back-imports; the locked per-PR rules live on as the R11/package-boundary gates, not as open work here.Successor threads:
metroandscreenshot-diffremain explicitly out with their own future seam-brief/decision paths.Generated by Claude Code
refactor(contracts): put host-command vocabulary under ./host, not ./…
chore(layering): document R12/R18/R19, retire R8, make R9 shrink mand…
fix(layering): resolve relative imports inside workspace packages ref…
fix(daemon): fingerprint the workspace packages the daemon loads