Skip to content

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

Closed

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

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/@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_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.

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-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 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

Activity

  1. thymikee commented on Jul 29, 2026

    @thymikee
    MemberAuthor

    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:

    • Deep modules export only one facade at the package root; per-file subpaths are limited to leaf vocabulary packages and demonstrated consumers.
    • ProviderDeviceRuntime is split into a neutral contract and root-owned runtime coordination. AsyncLocalStorage, active registries, and composition do not enter packages/contracts.
    • utils/version.ts stays root-side because it owns filesystem/project-root policy; provider packages receive the resolved version as a value.
    • Limrun/WebDriver host and Android needs enter through narrow package-factory capabilities, not new ProviderDeviceRuntime methods.
    • R8 relative imports may target only files named by a package exports map, closing the otherwise-available deep-import bypass.
    • W0 pins the packed manifest at zero private @agent-device runtime dependencies.
    • Only layering rules physically superseded by package resolution are deleted.
    • The refactor(daemon): extract replay, Maestro, and replay-test modules #1478 replay chain depends on W0 + W1a, not unrelated provider leaves W1b/W1c/W1d.

    This keeps the package boundary physical, the module interface small, and the execution graph parallel where dependencies genuinely permit it.

  2. thymikee commented on Jul 29, 2026

    @thymikee
    MemberAuthor

    Final consistency pass applied:

    • Closed the repo-relative deep-import bypass. Exports enforcement now covers package specifiers, while a gate forbids ordinary relative imports into package source; only R8 zero-dependency jobs may target an exact exported source file.
    • Required workspace dependency declarations and prohibited TypeScript paths wildcards that bypass exports.
    • Added emitted-bundle and packed-manifest checks so private internal dependencies cannot leak into the published artifact.
    • Corrected the WebDriver brief: the implementation is statically loaded today and already has substantial provider-scenario coverage, so the move preserves load behavior and fills only demonstrated transcript gaps.
    • Expanded W1d from mechanical wording into an atomic package cutover with facade-level tests, conditional loading, and live evidence.
    • Corrected the existing-workspace wording, version/helper contradictions, package-count ceiling, validation scope, and W3 ordering after P6a/P6b.

    The issue is now implementation-ready subject to its stated P1 dependency and STOP conditions.

  3. thymikee commented on Jul 30, 2026

    @thymikee
    MemberAuthor

    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 in src/commands/observability/runtime/); kernel retains exactly errors/device/snapshot/contracts/collections/rect/redaction/daemon-error with 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/kernel pilot 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

  4. thymikee commented on Jul 30, 2026

    @thymikee
    MemberAuthor

    W1a is implemented in #1499.

    What landed in the branch:

    • extracted @agent-device/contracts as a private workspace package
    • exposed 12 domain subpaths rather than one broad barrel
    • kept mutable registries and runtime composition in the root package
    • added package-boundary enforcement with zero package back-imports
    • reduced the largest type SCC from 102 to 76
    • verified package artifacts contain no unresolved internal package specifiers or internal runtime dependencies
    • normalized size delta vs origin/main: +1.1 kB raw JS, -502 B gzip, -1.7 kB tarball, -4.9 kB unpacked

    Validation is green locally: pnpm check and the full affected gate (VITEST_MAX_WORKERS=1 pnpm check:affected --run). W1a should remain in progress until #1499 merges.

  5. thymikee commented on Jul 30, 2026

    @thymikee
    MemberAuthor

    Coordination update after #1499 merged:

    • W1a is complete and the tracking checkbox is updated.
    • W1b (@agent-device/provider-webdriver) and W1c (Limrun construction seam) have started in parallel from current origin/main.
    • refactor(daemon): extract replay, Maestro, and replay-test modules #1478 P2 has also started independently; it depends only on W0 + W1a and owns the Maestro/replay routing slice.
    • W1d remains blocked on W1c. P3 remains blocked on P2.

    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.

  6. thymikee commented on Aug 4, 2026

    @thymikee
    MemberAuthor

    W2 complete; W3 recorded as not now (2026-08-04)

    Tracking is updated in the body:

    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

  7. thymikee commented on Aug 4, 2026

    @thymikee
    MemberAuthor

    Closing: all tracked work is complete and the issue's own stopping rule has fired.

    Successor threads:


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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