diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index a9b82fa75d..49fbf69e06 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -833,12 +833,15 @@ therefore structurally enforced rather than periodically measured. Some request-path work asks the host machine something without ever taking a device runtime: concrete local readiness, boot-time and runner-session observation, and open-target classification. Those asks now cross the daemon boundary as one required dependency, -`DaemonPlatformServices` (`src/daemon/platform-services.ts`), composed by the root in -`src/platform-runtime-daemon-services.ts` and threaded root → `RequestRouterDeps` → the request -execution scope → the handlers and consumers. Stale-claim reconciliation gets the same treatment -for the one act it may not perform itself: the root supplies a `ClaimRecoveryGatewayFactory` -(`src/daemon/device/claim-recovery-gateway.ts`), and the daemon keeps the transaction — what to -rebuild from (the dead owner's recorded state dir), in what order, and when to dispose it. +`DaemonPlatformServices` (`src/daemon/platform-services.ts`), assembled by the daemon runtime's +root-composition module in `src/daemon-composition.ts` — which reaches the concrete adapters +through `src/platform-runtime-daemon-services.ts` behind a function-scoped `await import` — and +threaded composition → `startDaemonRuntime` deps → `RequestRouterDeps` → the request execution +scope → the handlers and consumers. Stale-claim reconciliation gets the same treatment +for the one act it may not perform itself: the composition supplies the `ClaimRecoveryGatewayFactory` +the daemon declares in `src/daemon/device/claim-recovery-gateway.ts`, built from +`src/platform-runtime-claim-recovery.ts` (likewise deferred), and the daemon keeps the transaction — +what to rebuild from (the dead owner's recorded state dir), in what order, and when to dispose it. The port is not a raw escape hatch back into platform mechanics. Its observation members are the already-published neutral contract types from `@agent-device/contracts`, readiness is a boolean, diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index fad980cd10..7ffb1f9fff 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -6,6 +6,14 @@ Accepted (2026-09-06). Implements the audit deliverables of issue #2278. The cla inventory and the structural no-regrowth checks are gate-enforced; this ADR records the decisions and points at the gates rather than restating the inventory. +Amended 2026-10-11 by #3431: the daemon-runtime composition edges this +ADR classified (the gateway/diagnostics/resource-cleanup/lifecycle edges, the provider-runtime +hubs, and the platform-services and claim-recovery dynamic edges) moved out of `src/daemon/` into +the process-root composition module `src/daemon-composition.ts`, which R76 does not see because it +is no longer a daemon-zone importer. The R76 inventory now carries only the leaked +selector-query edge (`src/daemon/direct-ios-selector.ts`); the section-2 outcomes below remain the +record of how each was classified. + ## 1. Context The platform-package migration removed direct production dependencies from `src/daemon/**` to diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index 4baf38c59f..e559470837 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -150,7 +150,7 @@ optimal. | `src/platform-runtime-daemon-lifecycle.ts` | retain for this batch | Same path-keyed R13/ADR 0022 seam: relocation is possible only with coordinated changes to those declarations. | | `src/platform-runtime-device-boot.ts` | retain for this batch | Same path-keyed R13/ADR 0022 seam. | | `src/platform-runtime-resource-cleanup.ts` | retain for this batch | Same path-keyed R13/ADR 0022 seam. | -| `src/provider-credential-fingerprint.ts` | keep | Daemon ⇄ client shared contract from the same #2559 decision as `daemon-policy-file.ts`; #3288 declares its zone beside the provider composition that reads the credentials it fingerprints. | +| `src/provider-credential-fingerprint.ts` | keep (type extracted) | Daemon ⇄ client shared contract from the same #2559 decision as `daemon-policy-file.ts`; #3288 declares its zone beside the provider composition that reads the credentials it fingerprints. PR 6 of the daemon extraction moved only the `DaemonProviderCredentials` declaration to `packages/daemon-contracts` (`./provider-credentials`), the same shared-below-both landing as the policy file; the fingerprinting implementation stays here because it reads the installed-plugin manifest. | | `src/provider-limrun-runtime.ts` | keep | Proposed → provider-limrun, but there is no seam: the class's constructor self-builds the root dependency factory (`src/sdk/limrun-runtime-dependencies.ts`), which is the ADR 0019 composition seam — it loads the root's adb-host binder, core Android interactor, and platform-runtime app-state helpers that the package must not import. Moving the class means moving that composition, which is design, not a move. | | `src/request-progress-protocol.ts` | moved → `packages/daemon-contracts/src/request-progress-protocol.ts` | Daemon ⇄ client shared contract from the same #2559 decision — the client reads it statically (`daemon-client-progress.ts`) and the server through `src/daemon/server/`, so only #2559's shared-placement boundary and the wire-compat ledgers pin it; declared `daemon-contracts` by #3288 and now physically placed by the extraction. Contracts was considered and R18 keeps contracts free of envelope validation mechanics. The wire-ledger keys moved with the file. | | `src/runtime-command-surface.ts` | moved → `src/command-runtime/runtime-command-surface.ts` ([#3299](https://github.com/callstack/agent-device/pull/3299)) | Physical move landed with `runtime-factory.ts` as one assembly group: the command-surface binding sits beside the factory it composes, under the folder that derives its `command-runtime` zone, so `topFolder` replaces the per-file `ROOT_MODULE_ZONES` row. | diff --git a/packages/daemon-contracts/package.json b/packages/daemon-contracts/package.json index 7dfa565d62..a915e7da02 100644 --- a/packages/daemon-contracts/package.json +++ b/packages/daemon-contracts/package.json @@ -47,6 +47,10 @@ "types": "./src/daemon-shutdown-report.ts", "default": "./src/daemon-shutdown-report.ts" }, + "./provider-credentials": { + "types": "./src/provider-credentials.ts", + "default": "./src/provider-credentials.ts" + }, "./request-progress-protocol": { "types": "./src/request-progress-protocol.ts", "default": "./src/request-progress-protocol.ts" diff --git a/packages/daemon-contracts/src/provider-credentials.ts b/packages/daemon-contracts/src/provider-credentials.ts new file mode 100644 index 0000000000..09e2df548e --- /dev/null +++ b/packages/daemon-contracts/src/provider-credentials.ts @@ -0,0 +1,15 @@ +/** + * The provider credentials a daemon started with, and the state dir that names that daemon. + * + * A `lease_allocate` request carries the caller's own fingerprint so a lease can be fenced to one + * credential set (#2559): the daemon compares the request's value against the fingerprint it + * computed from its own startup environment, and both sides read this one declaration — the + * daemon's implementation stays in the process root (`src/provider-credential-fingerprint.ts`), + * which the daemon client imports; the vocabulary lives here because that fingerprint module must + * not carry a type edge into `src/daemon` (#2559). + */ +export type DaemonProviderCredentials = Readonly<{ + /** Undefined when the daemon's environment holds none of the credentials such a lease reads. */ + fingerprint(provider: string, leaseBackend?: string): string | undefined; + stateDir: string; +}>; diff --git a/scripts/layering/daemon-platform-runtime-inventory.test.ts b/scripts/layering/daemon-platform-runtime-inventory.test.ts index e58b87f4de..48503a4ec4 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.test.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.test.ts @@ -8,11 +8,12 @@ import { isRootPlatformRuntimeTarget, } from './daemon-platform-runtime-inventory.ts'; -// The classified pair these fixtures plant is the claim-recovery gateway factory: the composition -// edge this port introduced, and the shortest one whose symbols the rule still classifies. -const CLASSIFIED_TARGET = 'src/platform-runtime-claim-recovery.ts'; -const CLASSIFIED_STUB = 'export const createClaimRecoveryGateway = () => ({});\n'; -const CLASSIFIED_IMPORTER = 'src/daemon/server/daemon-runtime.ts'; +// The classified pair these fixtures plant is the direct-iOS selector query: the one edge the +// PR-6 composition extraction left between the daemon and the root platform-runtime family, and +// the shortest live one whose symbols the rule still classifies. +const CLASSIFIED_TARGET = 'src/platform-runtime-apple-resources.ts'; +const CLASSIFIED_STUB = 'export const queryAppleRuntimeSelector = () => ({});\n'; +const CLASSIFIED_IMPORTER = 'src/daemon/direct-ios-selector.ts'; function violations(sources: Record): LayeringViolation[] { return checkDaemonPlatformRuntimeInventory(resolveImportEdges(new Map(Object.entries(sources)))); @@ -26,8 +27,8 @@ test('R76 accepts a classified edge with the exact recorded symbols', () => { const sources = { [CLASSIFIED_TARGET]: CLASSIFIED_STUB, [CLASSIFIED_IMPORTER]: - "import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" + - 'void createClaimRecoveryGateway;\n', + "import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + + 'void queryAppleRuntimeSelector;\n', }; assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); }); @@ -54,8 +55,8 @@ test('R76 reports every classified edge missing from the tree as stale, not the const sources = { [CLASSIFIED_TARGET]: CLASSIFIED_STUB, [CLASSIFIED_IMPORTER]: - "import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" + - 'void createClaimRecoveryGateway;\n', + "import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + + 'void queryAppleRuntimeSelector;\n', }; const stale = violations(sources).filter( (violation) => violation.file === 'scripts/layering/daemon-platform-runtime-inventory.ts', @@ -88,100 +89,97 @@ test('R76 rejects new symbols on a classified edge', () => { const sources = { [CLASSIFIED_TARGET]: CLASSIFIED_STUB + 'export function extraGatewayKnob() {}\n', [CLASSIFIED_IMPORTER]: - "import { createClaimRecoveryGateway, extraGatewayKnob } from '../../platform-runtime-claim-recovery.ts';\n" + - 'void [createClaimRecoveryGateway, extraGatewayKnob];\n', + "import { queryAppleRuntimeSelector, extraGatewayKnob } from '../platform-runtime-apple-resources.ts';\n" + + 'void [queryAppleRuntimeSelector, extraGatewayKnob];\n', }; const found = edgeViolations(sources, CLASSIFIED_IMPORTER); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /createClaimRecoveryGateway, extraGatewayKnob/); + assert.match(found[0]!.message, /extraGatewayKnob, queryAppleRuntimeSelector/); }); -test('R76 rejects a gateway assembled from an expanded recovery edge', () => { +test('R76 rejects a selector fast path assembled from an expanded query edge', () => { const sources = { - 'src/platform-runtime-claim-recovery.ts': - 'export const createClaimRecoveryGateway = () => ({});\n' + - 'export const createProviderRecoveryGateway = () => ({});\n', - 'src/daemon/server/daemon-runtime.ts': - "import {\n createClaimRecoveryGateway,\n createProviderRecoveryGateway,\n} from '../../platform-runtime-claim-recovery.ts';\n" + - 'void [createClaimRecoveryGateway, createProviderRecoveryGateway];\n', + 'src/platform-runtime-apple-resources.ts': + 'export const queryAppleRuntimeSelector = () => ({});\n' + + 'export const queryProviderRuntimeSelector = () => ({});\n', + 'src/daemon/direct-ios-selector.ts': + "import {\n queryAppleRuntimeSelector,\n queryProviderRuntimeSelector,\n} from '../platform-runtime-apple-resources.ts';\n" + + 'void [queryAppleRuntimeSelector, queryProviderRuntimeSelector];\n', }; - const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /createClaimRecoveryGateway, createProviderRecoveryGateway/); + assert.match(found[0]!.message, /queryAppleRuntimeSelector, queryProviderRuntimeSelector/); }); test('R76 matches a destructured dynamic import by target with the recorded bindings', () => { const sources = { - 'src/platform-runtime-daemon-lifecycle.ts': - 'export const platformDaemonLifecycleOwners = {};\n', - 'src/daemon/server/daemon-runtime.ts': - "const { platformDaemonLifecycleOwners } = await import('../../platform-runtime-daemon-lifecycle.ts');\n" + - 'void platformDaemonLifecycleOwners;\n', + 'src/platform-runtime-apple-resources.ts': 'export const queryAppleRuntimeSelector = {};\n', + 'src/daemon/direct-ios-selector.ts': + "const { queryAppleRuntimeSelector } = await import('../platform-runtime-apple-resources.ts');\n" + + 'void queryAppleRuntimeSelector;\n', }; - assert.deepEqual(edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'), []); + assert.deepEqual(edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'), []); }); test('R76 rejects an expanded destructured dynamic import on a classified edge', () => { const sources = { - 'src/platform-runtime-daemon-lifecycle.ts': - 'export const platformDaemonLifecycleOwners = {};\n' + + 'src/platform-runtime-apple-resources.ts': + 'export const queryAppleRuntimeSelector = {};\n' + 'export const extraLifecycleParticipant = {};\n', - 'src/daemon/server/daemon-runtime.ts': - "const { platformDaemonLifecycleOwners, extraLifecycleParticipant } = await import('../../platform-runtime-daemon-lifecycle.ts');\n" + - 'void [platformDaemonLifecycleOwners, extraLifecycleParticipant];\n', + 'src/daemon/direct-ios-selector.ts': + "const { queryAppleRuntimeSelector, extraLifecycleParticipant } = await import('../platform-runtime-apple-resources.ts');\n" + + 'void [queryAppleRuntimeSelector, extraLifecycleParticipant];\n', }; - const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /extraLifecycleParticipant, platformDaemonLifecycleOwners/); + assert.match(found[0]!.message, /extraLifecycleParticipant, queryAppleRuntimeSelector/); }); test('R76 rejects a rest binding next to a recorded dynamic-import binding', () => { const sources = { - 'src/platform-runtime-daemon-lifecycle.ts': - 'export const platformDaemonLifecycleOwners = {};\n' + + 'src/platform-runtime-apple-resources.ts': + 'export const queryAppleRuntimeSelector = {};\n' + 'export const sweepLifecycleParticipants = {};\n', - 'src/daemon/server/daemon-runtime.ts': - "const { platformDaemonLifecycleOwners, ...lifecycleModule } = await import('../../platform-runtime-daemon-lifecycle.ts');\n" + - 'void [platformDaemonLifecycleOwners, lifecycleModule];\n', + 'src/daemon/direct-ios-selector.ts': + "const { queryAppleRuntimeSelector, ...lifecycleModule } = await import('../platform-runtime-apple-resources.ts');\n" + + 'void [queryAppleRuntimeSelector, lifecycleModule];\n', }; - const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /unnameable dynamic-import binding/); assert.match( found[0]!.message, - /src\/daemon\/server\/daemon-runtime\.ts -> src\/platform-runtime-daemon-lifecycle\.ts/, + /src\/daemon\/direct-ios-selector\.ts -> src\/platform-runtime-apple-resources\.ts/, ); }); test('R76 rejects a computed destructure key on a classified dynamic import', () => { const sources = { - 'src/platform-runtime-daemon-lifecycle.ts': - 'export const platformDaemonLifecycleOwners = {};\n', - 'src/daemon/server/daemon-runtime.ts': - "const ownersName = 'platformDaemonLifecycleOwners';\n" + - 'const { [ownersName]: owners } = await import("../../platform-runtime-daemon-lifecycle.ts");\n' + + 'src/platform-runtime-apple-resources.ts': 'export const queryAppleRuntimeSelector = {};\n', + 'src/daemon/direct-ios-selector.ts': + "const ownersName = 'queryAppleRuntimeSelector';\n" + + 'const { [ownersName]: owners } = await import("../platform-runtime-apple-resources.ts");\n' + 'void owners;\n', }; - const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); assert.equal(found.length, 1); assert.match(found[0]!.message, /unnameable dynamic-import binding/); }); test('R76 rejects a namespace-form dynamic import that hides the recorded bindings', () => { const sources = { - 'src/platform-runtime-daemon-lifecycle.ts': - 'export const platformDaemonLifecycleOwners = {};\n', - 'src/daemon/server/daemon-runtime.ts': - "const mod = await import('../../platform-runtime-daemon-lifecycle.ts');\nvoid mod;\n", + 'src/platform-runtime-apple-resources.ts': 'export const queryAppleRuntimeSelector = {};\n', + 'src/daemon/direct-ios-selector.ts': + "const mod = await import('../platform-runtime-apple-resources.ts');\nvoid mod;\n", }; - const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /open-ended dynamic import/); @@ -189,20 +187,19 @@ test('R76 rejects a namespace-form dynamic import that hides the recorded bindin test('R76 rejects a namespace import alongside the recorded named binding on the same pair', () => { const sources = { - 'src/platform-runtime-daemon-lifecycle.ts': - 'export const platformDaemonLifecycleOwners = {};\n', - 'src/daemon/server/daemon-runtime.ts': - "const { platformDaemonLifecycleOwners } = await import('../../platform-runtime-daemon-lifecycle.ts');\n" + - "const lifecycleModule = await import('../../platform-runtime-daemon-lifecycle.ts');\n" + - 'void [platformDaemonLifecycleOwners, lifecycleModule];\n', + 'src/platform-runtime-apple-resources.ts': 'export const queryAppleRuntimeSelector = {};\n', + 'src/daemon/direct-ios-selector.ts': + "const { queryAppleRuntimeSelector } = await import('../platform-runtime-apple-resources.ts');\n" + + "const lifecycleModule = await import('../platform-runtime-apple-resources.ts');\n" + + 'void [queryAppleRuntimeSelector, lifecycleModule];\n', }; - const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + const found = edgeViolations(sources, 'src/daemon/direct-ios-selector.ts'); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /open-ended dynamic import/); assert.match( found[0]!.message, - /src\/daemon\/server\/daemon-runtime\.ts -> src\/platform-runtime-daemon-lifecycle\.ts/, + /src\/daemon\/direct-ios-selector\.ts -> src\/platform-runtime-apple-resources\.ts/, ); }); @@ -210,9 +207,9 @@ test('R76 treats the import and re-export of one classified pair as one entry', const sources = { [CLASSIFIED_TARGET]: CLASSIFIED_STUB, [CLASSIFIED_IMPORTER]: - "import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" + - "export { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" + - 'void createClaimRecoveryGateway;\n', + "import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + + "export { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts';\n" + + 'void queryAppleRuntimeSelector;\n', }; assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); }); @@ -254,12 +251,6 @@ test('the root composition family is src/platform-runtime.ts plus src/platform-r }); const ROOT_PLATFORM_STUB = 'export const gateway = 1;\nvoid gateway;\n'; -const PROVIDER_HUB_STUB = - "import { gateway } from './platform-runtime.ts';\n" + - 'void gateway;\n' + - 'export function createProviderDeviceRuntimeRequestProviders() {}\n' + - 'export function isActiveProviderDevice() { return false; }\n'; - test('R76 catches a daemon import of a root hub that reaches the platform family', () => { const sources = { 'src/platform-runtime.ts': ROOT_PLATFORM_STUB, @@ -274,69 +265,6 @@ test('R76 catches a daemon import of a root hub that reaches the platform family assert.match(found[0]!.message, /src\/daemon\/fixture\.ts -> src\/hub-runtime\.ts/); }); -test('R76 accepts the classified provider-runtime hub edge at the composition site', () => { - const sources = { - 'src/platform-runtime.ts': ROOT_PLATFORM_STUB, - 'src/provider-device-runtime.ts': PROVIDER_HUB_STUB, - 'src/daemon/server/daemon-runtime.ts': - 'import {\n' + - ' createProviderDeviceRuntimeRequestProviders,\n' + - ' isActiveProviderDevice,\n' + - "} from '../../provider-device-runtime.ts';\n" + - 'void [createProviderDeviceRuntimeRequestProviders, isActiveProviderDevice];\n', - }; - assert.deepEqual(edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'), []); -}); - -test('R76 catches a symbol added to the classified provider-runtime hub edge', () => { - const sources = { - 'src/platform-runtime.ts': ROOT_PLATFORM_STUB, - 'src/provider-device-runtime.ts': `${PROVIDER_HUB_STUB}export function setProviderDevice() {}\n`, - 'src/daemon/server/daemon-runtime.ts': - 'import {\n' + - ' createProviderDeviceRuntimeRequestProviders,\n' + - ' isActiveProviderDevice,\n' + - ' setProviderDevice,\n' + - "} from '../../provider-device-runtime.ts';\n" + - 'void [createProviderDeviceRuntimeRequestProviders, isActiveProviderDevice, setProviderDevice];\n', - }; - const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); - assert.equal(found.length, 1); - assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /setProviderDevice/); -}); - -test('R76 classifies a dynamic interactor lookup on a classified pair instead of skipping it', () => { - const sources = { - 'src/platform-runtime.ts': ROOT_PLATFORM_STUB, - 'src/core/interactors.ts': - "import { gateway } from '../platform-runtime.ts';\n" + - 'void gateway;\n' + - 'export async function getInteractor(device: unknown) {}\n', - 'src/daemon/server/daemon-runtime.ts': - "const { getInteractor } = await import('../../core/interactors.ts');\nvoid getInteractor;\n", - }; - assert.deepEqual(edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'), []); -}); - -test('R76 catches a binding added to a dynamic interactor lookup', () => { - const sources = { - 'src/platform-runtime.ts': ROOT_PLATFORM_STUB, - 'src/core/interactors.ts': - "import { gateway } from '../platform-runtime.ts';\n" + - 'void gateway;\n' + - 'export async function getInteractor(device: unknown) {}\n' + - 'export async function getRetryInteractor(device: unknown) {}\n', - 'src/daemon/server/daemon-runtime.ts': - "const { getInteractor, getRetryInteractor } = await import('../../core/interactors.ts');\n" + - 'void [getInteractor, getRetryInteractor];\n', - }; - const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); - assert.equal(found.length, 1); - assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); - assert.match(found[0]!.message, /classified symbols drifted/); -}); - test('R76 does not turn an intra-daemon hop into a classified hub edge', () => { const sources = { 'src/platform-runtime.ts': ROOT_PLATFORM_STUB, diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index f8da37b2dd..55debe1871 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -75,54 +75,6 @@ export function computePlatformMechanicsHubs( * The current classified edges from the #2278 audit, ratcheted as owning interfaces land. */ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] = [ - { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/platform-runtime.ts', - symbols: [ - 'androidObservation', - 'createPlatformRuntimeGateway', - 'createPlatformDeviceInventoryGateways', - 'createRequestPlatformProviders', - ], - classification: 'composition-essential', - rationale: - 'process-root assembly of the neutral runtime gateway, device-inventory gateways, and ' + - 'request platform providers (ADR 0019 section 1/2 boundary); the daemon holds no ' + - 'platform mechanics at this site, only the composition the process root owns.', - }, - { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/platform-runtime-host-diagnostics.ts', - symbols: ['createHostDiagnostics'], - classification: 'composition-essential', - rationale: - 'process-root assembly of the neutral HostDiagnostics contract capability; the ' + - 'per-family probes load lazily inside the root module, so the daemon consumes only ' + - 'the contract surface.', - }, - { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/platform-runtime-resource-cleanup.ts', - symbols: ['platformResourceCleanup'], - classification: 'composition-essential', - rationale: - 'process-root assembly of the neutral PlatformResourceCleanup contract capability; the ' + - 'Android snapshot-helper reset and Web orphan cleanup that used to be named directly on ' + - 'this edge now sit behind the typed lifecycle-participation surface (#2333, see the ' + - 'platform-runtime-daemon-lifecycle.ts edge below).', - }, - { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/platform-runtime-daemon-lifecycle.ts', - symbols: ['platformDaemonLifecycleOwners'], - classification: 'composition-essential', - rationale: - 'process-root assembly of the typed PlatformOwnerLifecycle contract capability (#2333): ' + - 'the daemon keeps ordering, cancellation, and best-effort failure policy for its ' + - 'startup/shutdown platform-owner participation, while this composition module is the ' + - 'sole place that names the Apple runner owner, the Android snapshot-helper and Web ' + - 'orphan cleanups, and legacy app-log marker recovery.', - }, { file: 'src/daemon/direct-ios-selector.ts', target: 'src/platform-runtime-apple-resources.ts', @@ -134,60 +86,6 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] 'audit deliberately adds no second selector producer.', deepenedBy: '#2273, #2274', }, - { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/core/interactors.ts', - symbols: ['getInteractor'], - classification: 'composition-essential', - rationale: - 'process-root assembly of the neutral InteractorResolution capability the legacy snapshot ' + - 'capture consumes (#2555): this site hands the shared interactor lookup to the daemon next ' + - "to the provider-device admission it shares a request scope with, and is the daemon zone's " + - 'only edge into src/core/interactors.ts. The lookup it replaced was a dynamic import out of ' + - 'the daemon zone, a direction the ranked spine cannot see.', - }, - { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/provider-device-runtimes.ts', - symbols: ['createDaemonProviderRuntimeComposition', 'DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS'], - classification: 'composition-essential', - rationale: - 'process-root assembly of the default provider runtime composition and the ids whose runtime ' + - 'must be present for a request, which is what makes a cloud or remote lease holder visible to ' + - 'the daemon at all; the daemon holds no provider mechanics at this site.', - }, - { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/provider-device-runtime.ts', - symbols: ['createProviderDeviceRuntimeRequestProviders', 'isActiveProviderDevice'], - classification: 'composition-essential', - rationale: - 'process-root assembly of the request-scoped provider runtime providers, and the one site that ' + - 'hands the resulting device-ownership fact to the daemon through its own typed admission seam ' + - '(#2541); the ten leaf call sites that used to name this hub now read ' + - 'src/daemon/provider-device-admission.ts, so this edge is the composition and nothing else.', - }, - { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/platform-runtime-daemon-services.ts', - symbols: ['createDaemonPlatformServices'], - classification: 'composition-essential', - rationale: - 'process-root assembly of the daemon platform-services port (ADR 0019): local readiness, ' + - 'boot and runner-session observation, and open-target classification reach the request ' + - 'path only as this frozen object, so no daemon module names a platform adapter. Device ' + - 'execution never enters the port; it stays behind the request-bound runtime binding.', - }, - { - file: 'src/daemon/server/daemon-runtime.ts', - target: 'src/platform-runtime-claim-recovery.ts', - symbols: ['createClaimRecoveryGateway'], - classification: 'composition-essential', - rationale: - 'process-root assembly of the per-transaction recovery gateway factory (#2168): the daemon ' + - 'owns which dead owner state dir a recovery is rebuilt from and when the gateway is ' + - 'disposed, and this composition module is the sole place that assembles the gateway itself.', - }, ] as const; function keyOf(file: string, target: string): string { @@ -210,7 +108,10 @@ function sorted(symbols: readonly string[]): string[] { * Evidence: #2278 measured 14 production edges in 9 daemon files at origin/main 6e22e266d7; * this table is that measurement, classified per ADR 0022, plus the 3 edges the hub widening and * dynamic-edge pass made visible at #2541 (2 provider-runtime hubs, 1 dynamic interactor lookup - * that #2555 has since composed away into the daemon-runtime edge above). + * that #2555 has since composed away). The daemon-runtime composition edges this table carried + * through #3402 moved to the process root with the PR-6 extraction (`src/daemon-composition.ts` + * is outside the daemon zone, so R76 no longer sees them); only the leaked selector-query edge + * below remains. * Cost: attributed to the R76 rule registration in check.ts; not a standalone CI job. * Kill criterion: the daemon reaches the platform only through the gateway and declared * contract capabilities (the inventory empty), or a maintainer decision retires the diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index 6826894b45..032f4d448b 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -26,8 +26,11 @@ export const ROOT_MODULE_ZONES: Readonly> = { // @agent-device/contracts, so the daemon reads that vocabulary from below the daemon. 'command-runtime': ['src/io.ts', 'src/runtime.ts'], // The platform composition the daemon, CLI and SDK import eagerly. It shares the zone with its - // private `src/platform-runtime/` submodule. + // private `src/platform-runtime/` submodule, and with the daemon runtime's dependency + // construction, which names these same runtime families and keeps the same value-free direction + // toward `src/daemon/` (PR 6 of #3276's daemon extraction). 'platform-runtime': [ + 'src/daemon-composition.ts', 'src/platform-runtime-android-mechanics.ts', 'src/platform-runtime-android-observation-host.ts', 'src/platform-runtime-apple-resources.ts', diff --git a/src/__tests__/daemon-composition-assembly.test.ts b/src/__tests__/daemon-composition-assembly.test.ts new file mode 100644 index 0000000000..1fa27ef2bd --- /dev/null +++ b/src/__tests__/daemon-composition-assembly.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test, vi } from 'vitest'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { WEB_DESKTOP_DEVICE } from './test-utils/device-fixtures.ts'; +import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; +import { createDaemonRuntimeDeps } from '../daemon-composition.ts'; +import { startDaemonRuntimeFixture } from '../daemon/__tests__/daemon-runtime-fixture.ts'; +import type { DaemonRuntimeDepsInput } from '../daemon/server/daemon-runtime.ts'; + +// The server builds its own store, so capture that instance the same way the shutdown tests do: +// the proof is about the store the server hands to the composition callbacks, not one the test +// fabricates. +const storeProbe = vi.hoisted(() => ({ + store: undefined as import('../daemon/session-store.ts').SessionStore | undefined, +})); +vi.mock('../daemon/session-store.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SessionStore: class extends actual.SessionStore { + constructor(...args: ConstructorParameters) { + super(...args); + storeProbe.store = this; + } + }, + }; +}); + +/** + * The one-assembly guarantee (#3431 review): the server owns the paths, the ADR 0029 policy load, + * the session store, and the `assertShutdownAllowed` derivation, and passes them to the root's + * factory. This boots the real `startDaemonRuntime` with the real `createDaemonRuntimeDeps` — + * wrapped only to capture what the server derived — so a server that dropped the derivation, + * loaded no policy, or wired the session callbacks to a different store fails here. + */ +test('the server derives the policy gate and session callbacks and hands them to the root factory', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-assembly-'); + const policyPath = path.join(stateDir, 'policy.json'); + fs.writeFileSync( + policyPath, + JSON.stringify({ version: 1, capabilities: { deny: ['device-shutdown'] } }), + ); + const paths = resolveDaemonPaths(stateDir); + const exits: number[] = []; + let composed: DaemonRuntimeDepsInput | undefined; + const composeDeps = async (input: DaemonRuntimeDepsInput) => { + composed = input; + return await createDaemonRuntimeDeps(input); + }; + let runtime: Awaited> = null; + try { + runtime = await startDaemonRuntimeFixture({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_POLICY: policyPath, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: (code) => exits.push(code), + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps, + }); + assert.notEqual(runtime, null, 'the real assembly must boot'); + assert.deepEqual(exits, []); + assert.ok(composed, 'the server must call the factory'); + // The server resolved the paths and loaded the operator's policy before composing anything. + assert.equal(composed.baseDir, paths.baseDir); + assert.equal(composed.sessionsDir, paths.sessionsDir); + // The shutdown gate the root threads into the runtime gateway refuses the denied capability. + assert.throws( + () => composed?.assertShutdownAllowed?.(), + (error: unknown) => + (error as { details?: { reason?: string; rule?: string } }).details?.reason === + 'DAEMON_POLICY_DENIED' && + (error as { details?: { rule?: string } }).details?.rule === 'capability', + ); + // The web-session callback reads the one store the server built, so the deps closures and the + // server can never disagree about which sessions are open. + const store = storeProbe.store; + assert.ok(store, 'the server must construct the store the callbacks read'); + store.publish('web-assembly', { + name: 'web-assembly', + device: WEB_DESKTOP_DEVICE, + createdAt: Date.now(), + actions: [], + }); + assert.deepEqual([...composed.openWebSessionNames()], ['web-assembly']); + assert.equal( + composed.resolveSessionDir('web-assembly'), + store.resolveSessionDir('web-assembly'), + ); + assert.deepEqual(composed.resolveSessionArtifacts('web-assembly'), { + outputPath: store.resolveAppLogPath('web-assembly'), + pidPath: store.resolveAppLogPidPath('web-assembly'), + }); + await runtime?.shutdown(); + runtime = null; + assert.deepEqual(exits, [0]); + } finally { + await runtime?.shutdown(); + } +}); + +/** + * A policy the operator names but that cannot be read stops startup through the same server, + * before any runtime is composed: the typed stderr and exit are the server's, not the entry's. + */ +test('an unreadable policy stops the assembly before the factory is called', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-assembly-bad-policy-'); + const exits: number[] = []; + const stderr: string[] = []; + let factoryCalls = 0; + let runtime: Awaited> = null; + try { + runtime = await startDaemonRuntimeFixture({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_POLICY: path.join(stateDir, 'missing-policy.json'), + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: (code) => exits.push(code), + stderr: { write: (chunk) => stderr.push(chunk) }, + stdout: { write: () => {} }, + registerProcessHandlers: false, + composeDeps: async (input) => { + factoryCalls += 1; + return await createDaemonRuntimeDeps(input); + }, + }); + assert.equal(runtime, null); + assert.deepEqual(exits, [1]); + assert.equal(factoryCalls, 0, 'no runtime may be built under an unreadable policy'); + assert.match(stderr.join(''), /Daemon error: .*/); + } finally { + await runtime?.shutdown(); + } +}); diff --git a/src/daemon/server/daemon-runtime-web-cleanup.test.ts b/src/__tests__/daemon-composition-web-cleanup.test.ts similarity index 73% rename from src/daemon/server/daemon-runtime-web-cleanup.test.ts rename to src/__tests__/daemon-composition-web-cleanup.test.ts index 0e20e81ea1..178600a979 100644 --- a/src/daemon/server/daemon-runtime-web-cleanup.test.ts +++ b/src/__tests__/daemon-composition-web-cleanup.test.ts @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -import { installFakeManagedAgentBrowser } from '../../__tests__/test-utils/web-managed-agent-browser.ts'; +import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; +import { installFakeManagedAgentBrowser } from './test-utils/web-managed-agent-browser.ts'; const { cleanupManagedAgentBrowserOrphansMock } = vi.hoisted(() => ({ cleanupManagedAgentBrowserOrphansMock: vi.fn(), @@ -17,9 +17,10 @@ vi.mock('@agent-device/platform-web', async (importOriginal) => { }; }); -import { WEB_DESKTOP_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; -import { SessionStore } from '../session-store.ts'; -import { cleanupWebBrowserOrphansForDaemonStartup } from './daemon-runtime.ts'; +import { WEB_DESKTOP_DEVICE } from './test-utils/device-fixtures.ts'; +import { SessionStore } from '../daemon/session-store.ts'; +import { openWebSessionNames } from '../daemon/web-session-names.ts'; +import { cleanupWebBrowserOrphansForDaemonStartup } from '../daemon-composition.ts'; const mockCleanupManagedAgentBrowserOrphans = vi.mocked(cleanupManagedAgentBrowserOrphansMock); @@ -39,7 +40,10 @@ test('daemon-startup web cleanup passes open web sessions to the reaper', async actions: [], }); - await cleanupWebBrowserOrphansForDaemonStartup({ stateDir, sessionStore }); + await cleanupWebBrowserOrphansForDaemonStartup({ + stateDir, + openWebSessionNames: openWebSessionNames(sessionStore), + }); assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls.length, 1); assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls[0]?.[1], 'daemon-startup'); @@ -56,7 +60,10 @@ test('daemon-startup web cleanup does not run when the managed backend is absent try { const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); - await cleanupWebBrowserOrphansForDaemonStartup({ stateDir, sessionStore }); + await cleanupWebBrowserOrphansForDaemonStartup({ + stateDir, + openWebSessionNames: openWebSessionNames(sessionStore), + }); assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls.length, 0); } finally { diff --git a/src/__tests__/daemon-entrypoint.test.ts b/src/__tests__/daemon-entrypoint.test.ts index fe078fcdb6..945b37e336 100644 --- a/src/__tests__/daemon-entrypoint.test.ts +++ b/src/__tests__/daemon-entrypoint.test.ts @@ -6,7 +6,8 @@ import http from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { startDaemonRuntime } from '../daemon/server/daemon-runtime.ts'; +import { startDaemonRuntimeFixture } from '../daemon/__tests__/daemon-runtime-fixture.ts'; +import type { DaemonRuntimeController } from '../daemon/server/daemon-runtime.ts'; import { cleanupDownloadableArtifact, trackDownloadableArtifact, @@ -24,7 +25,6 @@ import { screenRecordingDurableResource } from '@agent-device/capture-kit/screen import { closeLoopbackServer, listenOnLoopback, waitForHttpOk } from './test-utils/loopback.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; -import { testCreateCommandSurface } from '../daemon/__tests__/command-surface-fixture.ts'; type DaemonInfoFile = { httpPort?: number; @@ -87,8 +87,7 @@ test('daemon runtime starts HTTP transport in-process and shuts down cleanly', a let exitCode: number | undefined; try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -147,8 +146,7 @@ test('daemon runtime publishes dual transport metadata', async () => { let exitCode: number | undefined; try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -195,11 +193,10 @@ test('daemon rejects unowned cloud artifacts over RPC', async () => { ); }); const providerPort = await listenOnLoopback(providerServer); - let runtime: Awaited> = null; + let runtime: DaemonRuntimeController | null = null; try { - runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -329,8 +326,7 @@ test('daemon runtime records startup device-claim reconciliation in daemon.log', }), ); - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -399,7 +395,7 @@ test('startup sweep settles a foreign dead owner without touching a live same-na command: readProcessCommand(orphanPid) ?? '', }; assert.ok(orphanMarker.startTime.length > 0 && orphanMarker.command.length > 0); - let runtime: Awaited> = null; + let runtime: DaemonRuntimeController | null = null; try { const foreignSessionDir = path.join(resolveDaemonPaths(foreignStateDir).sessionsDir, 'shared'); @@ -455,8 +451,7 @@ test('startup sweep settles a foreign dead owner without touching a live same-na }), ); - runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: daemonStateDir, diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts new file mode 100644 index 0000000000..8ce83f5649 --- /dev/null +++ b/src/daemon-composition.ts @@ -0,0 +1,165 @@ +// The daemon runtime's dependency construction, extracted from `src/daemon/server/daemon-runtime.ts` +// so the operational server owns sequencing and this module owns naming runtimes (ADR 0019). +// Everything here that names a platform runtime, a provider runtime, an interactor, host +// diagnostics, credential fingerprinting, resource cleanup, the platform lifecycle owners, the +// platform-services composition, or the claim-recovery gateway lives in the process root; the +// server calls `createDaemonRuntimeDeps` as a factory once it owns its paths, policy, and session +// store, so exactly one assembly builds the SessionStore and deps pair. +// +// The static import set is the family the operational server already evaluated eagerly, and the +// entry reaches this module through a function-scoped `await import`, so the daemon entry's eager +// closure does not grow; the platform-services and claim-recovery +// compositions stay behind function-scoped `await import`, and the command surface loads on first +// request use. Nothing here value-imports `src/daemon/`: the daemon's session data enters through +// plain callbacks, so the platform-runtime zone keeps its value-free direction toward the daemon. + +import { + createProviderDeviceRuntimeRequestProviders, + isActiveProviderDevice, +} from './provider-device-runtime.ts'; +import { + createDaemonProviderRuntimeComposition, + DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, +} from './provider-device-runtimes.ts'; +import { readDaemonProviderCredentials } from './provider-credential-fingerprint.ts'; +import { getInteractor } from './core/interactors.ts'; +import { + androidObservation, + createPlatformRuntimeGateway, + createPlatformDeviceInventoryGateways, + createRequestPlatformProviders, +} from './platform-runtime.ts'; +import { createHostDiagnostics } from './platform-runtime-host-diagnostics.ts'; +import { platformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; +import { platformDaemonLifecycleOwners } from './platform-runtime-daemon-lifecycle.ts'; +import { + createOwnedProcessRecordStore, + type OwnedProcessRecordStore, +} from '@agent-device/host-kit/process'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { AgentDeviceRuntimeConfig } from '@agent-device/contracts/runtime-contract'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + ComposeDaemonRuntimeDeps, + DaemonRuntimeDeps, +} from './daemon/server/daemon-runtime.ts'; + +/** + * Best-effort startup cleanup of managed-agent-browser orphans left by a previous daemon. It runs + * only after this process holds the daemon lock, and it never fails startup: a cleanup the host + * refuses is a diagnostic, not a reason to keep the next daemon from serving. + */ +export async function cleanupWebBrowserOrphansForDaemonStartup(params: { + stateDir: string; + openWebSessionNames: readonly string[]; + ownedProcessRecords?: OwnedProcessRecordStore; +}): Promise { + try { + await platformDaemonLifecycleOwners.cleanupManagedWebOrphans(params); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'web_agent_browser_orphan_cleanup_failed', + data: { error: error instanceof Error ? error.message : String(error) }, + }); + } +} + +/** + * Builds every capability the daemon runtime cannot construct for itself, in the order the + * runtime used to build them. The server calls it as a factory with the paths, the operator's + * policy, and the session-store callbacks it owns; all returned members are required, so a + * daemon process either receives this composition or cannot start, and a test passes the same + * factory (ADR 0019). The session-scoped callbacks are the daemon's own data seen as questions — + * recording artifact paths, session directories, and which web sessions are open — so no daemon + * value crosses here. + */ +export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( + input, +): Promise => { + const { + env, + baseDir, + sessionsDir, + assertShutdownAllowed, + resolveSessionDir, + resolveSessionArtifacts, + openWebSessionNames, + } = input; + const ownedProcessRecords = createOwnedProcessRecordStore({ + stateDir: baseDir, + sessionsDir, + resolveSessionDir, + }); + const hostDiagnostics = createHostDiagnostics(); + const providerComposition = await createDaemonProviderRuntimeComposition(env); + const providerDeviceRuntimes = [...providerComposition.runtimes]; + const deviceRuntimeGateway = createPlatformRuntimeGateway({ + assertShutdownAllowed, + providerRuntimes: providerDeviceRuntimes, + providerModules: providerComposition.platformModules, + sessionsDir, + ownedProcesses: ownedProcessRecords, + resolveSessionArtifacts, + }); + const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders( + providerDeviceRuntimes, + { providerRuntimeRequiredIds: DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS }, + ); + const requestPlatformProviders = createRequestPlatformProviders({ + providers: { + appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider, + appleRunnerScreenRecordingTransport: + providerRuntimeProviders.appleRunnerScreenRecordingTransport, + }, + defaultWebProvider: { + stateDir: baseDir, + openWebSessionNames, + ownedProcessRecords, + }, + }); + const deviceInventoryGateways = createPlatformDeviceInventoryGateways( + providerRuntimeProviders.deviceInventorySource, + ); + // Root composition of the platform-services port and the owner-scoped claim-recovery gateway. + // platform-runtime.ts is already static in this module, so the deferred imports keep these thin + // composition modules (and the readiness/open-target adapters only the services module pulls) + // off `src/daemon.ts`'s eager closure; what each adapter itself loads stays as lazy as it was. + const { createDaemonPlatformServices } = await import('./platform-runtime-daemon-services.ts'); + const { createClaimRecoveryGateway } = await import('./platform-runtime-claim-recovery.ts'); + + return Object.freeze({ + ownedProcessRecords, + hostDiagnostics, + ownerLifecycle: platformDaemonLifecycleOwners, + providerDeviceRuntimes, + skippedProviderRuntimes: Object.freeze([...(providerComposition.skipped ?? [])]), + leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, + providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, + recoverExpiredLease: providerRuntimeProviders.recoverExpiredLease, + recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, + deviceRuntimeGateway, + platformResourceCleanup, + claimRecoveryGateway: createClaimRecoveryGateway, + interactorResolution: Object.freeze({ resolve: getInteractor }), + providerDeviceAdmission: Object.freeze({ + isActive: (device: DeviceInfo) => isActiveProviderDevice(device), + }), + cleanupManagedWebOrphans: cleanupWebBrowserOrphansForDaemonStartup, + requestRouter: Object.freeze({ + requestPlatformProviders, + deviceInventoryGateways, + platformServices: createDaemonPlatformServices(), + androidObservation, + providerCredentials: readDaemonProviderCredentials(env, baseDir), + createCommandSurface: async (config: AgentDeviceRuntimeConfig) => + ( + await import('./command-runtime/runtime-command-surface.ts') + ).createCommandSurfaceAgentDevice(config), + cloudArtifactProvider: providerRuntimeProviders.cloudArtifactProvider, + providerAppCatalog: providerRuntimeProviders.providerAppCatalog, + providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, + providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, + }), + }); +}; diff --git a/src/daemon.ts b/src/daemon.ts index 4a71442e3f..d331ae5d49 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,12 +1,12 @@ -import { startDaemonRuntime } from './daemon/server/daemon-runtime.ts'; import { asAppError } from '@agent-device/kernel/errors'; +import { startDaemonRuntime } from './daemon/server/daemon-runtime.ts'; +// The root's composition enters as a factory behind a function-scoped `await import`: the server +// calls it before it acquires the registration, so startup order is unchanged, and the eager +// closure of this entry stays exactly the operational server's (ADR 0019). void startDaemonRuntime({ - // The composition root's surface stays behind `await import`, loaded on first request use. - createCommandSurface: async (config) => - (await import('./command-runtime/runtime-command-surface.ts')).createCommandSurfaceAgentDevice( - config, - ), + composeDeps: async (input) => + (await import('./daemon-composition.ts')).createDaemonRuntimeDeps(input), }).catch((error) => { const appErr = asAppError(error); process.stderr.write(`Daemon error: ${appErr.message}\n`); diff --git a/src/daemon/__tests__/daemon-runtime-app-log.test.ts b/src/daemon/__tests__/daemon-runtime-app-log.test.ts index b91d679861..2760840153 100644 --- a/src/daemon/__tests__/daemon-runtime-app-log.test.ts +++ b/src/daemon/__tests__/daemon-runtime-app-log.test.ts @@ -1,3 +1,4 @@ +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; @@ -19,9 +20,7 @@ import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.t test('daemon startup awaits app-log recovery after acquiring the lock and before opening servers', () => { const source = fs.readFileSync(new URL('../server/daemon-runtime.ts', import.meta.url), 'utf8'); const acquiredLock = source.indexOf("if (acquisition.status !== 'acquired')"); - const legacyRecovery = source.indexOf( - 'await platformDaemonLifecycleOwners.recoverLegacyAppLogMarkers(', - ); + const legacyRecovery = source.indexOf('await deps.ownerLifecycle.recoverLegacyAppLogMarkers('); const recovery = source.indexOf('await recoverAppLogResourcesAfterDaemonLock('); const openedServers = source.indexOf('const opened = await openDaemonServers()'); @@ -37,9 +36,7 @@ test('daemon startup configures the Apple runner owner after acquiring the lock, // configures a global platform owner it does not own. const source = fs.readFileSync(new URL('../server/daemon-runtime.ts', import.meta.url), 'utf8'); const acquiredLock = source.indexOf("if (acquisition.status !== 'acquired')"); - const runnerOwnerConfigured = source.indexOf( - 'await platformDaemonLifecycleOwners.configureForDaemonLock(', - ); + const runnerOwnerConfigured = source.indexOf('await deps.ownerLifecycle.configureForDaemonLock('); const lockFailureExit = source.indexOf('exit(', acquiredLock); expect(acquiredLock).toBeGreaterThanOrEqual(0); @@ -134,6 +131,8 @@ test('daemon shutdown settles fenced app-log cleanup before finalization can rel ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: () => {} }, + + platformCleanup: platformResourceCleanup, }); await cleanupStarted; diff --git a/src/daemon/__tests__/daemon-runtime-fixture.ts b/src/daemon/__tests__/daemon-runtime-fixture.ts new file mode 100644 index 0000000000..9fb8517752 --- /dev/null +++ b/src/daemon/__tests__/daemon-runtime-fixture.ts @@ -0,0 +1,34 @@ +import { createDaemonRuntimeDeps } from '../../daemon-composition.ts'; +import { + startDaemonRuntime, + type ComposeDaemonRuntimeDeps, + type DaemonRuntimeController, +} from '../server/daemon-runtime.ts'; + +type WritableOutput = { write: (chunk: string) => unknown }; + +/** + * The runtime a test boots through the one assembly the process root uses: `startDaemonRuntime` + * with the real composition factory from `src/daemon-composition.ts`, so the paths, the policy + * load, the session store, and the `assertShutdownAllowed` derivation under test are exactly the + * production ones. A test replaces a composed capability the same way it always has — a + * `vi.mock` of the root module the composition imports — or passes its own `composeDeps` factory + * when it needs to fake the composed half outright. + */ +export async function startDaemonRuntimeFixture(options: { + env: NodeJS.ProcessEnv; + exit: (code: number) => void; + stderr: WritableOutput; + stdout?: WritableOutput; + registerProcessHandlers?: boolean; + composeDeps?: ComposeDaemonRuntimeDeps; +}): Promise { + return await startDaemonRuntime({ + env: options.env, + exit: options.exit, + stderr: options.stderr, + stdout: options.stdout, + registerProcessHandlers: options.registerProcessHandlers, + composeDeps: options.composeDeps ?? createDaemonRuntimeDeps, + }); +} diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 9801ce9835..8cbfaac63d 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -31,7 +31,7 @@ import { LEASE_ALLOCATION_BUDGET_MS } from '@agent-device/command-registry/timeo import { getRequestSignal, isRequestCanceled } from '@agent-device/host-kit/request'; import { listDownloadableArtifacts } from '../artifact-tracking.ts'; import { providerSessionIdFromData } from '../provider-session-ownership.ts'; -import type { DaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; +import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; type LeaseHandlerArgs = { diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index d8cf91a734..2f01cce66d 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -26,7 +26,7 @@ import type { RequestPlatformProviderScope } from '@agent-device/contracts/platf import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; -import type { DaemonProviderCredentials } from '../provider-credential-fingerprint.ts'; +import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; type RequestHandlerChainParams = { req: DaemonRequest; diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 9b295856a4..9df1bab385 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -93,7 +93,7 @@ import type { AndroidObservationAdapter } from '@agent-device/contracts/android- import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import type { DaemonPlatformServices } from './platform-services.ts'; -import type { DaemonProviderCredentials } from '../provider-credential-fingerprint.ts'; +import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; import { restrictDeviceInventoryToDaemonPolicy } from './daemon-policy.ts'; import type { DaemonPolicy } from '@agent-device/daemon-contracts/daemon-policy-file'; diff --git a/src/daemon/server/daemon-runtime-device-claims.test.ts b/src/daemon/server/daemon-runtime-device-claims.test.ts index b08042b233..9c5ba1de69 100644 --- a/src/daemon/server/daemon-runtime-device-claims.test.ts +++ b/src/daemon/server/daemon-runtime-device-claims.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; vi.mock('../session-teardown.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -50,6 +51,7 @@ test('does not clear a claim after shutdown teardown rejects', async () => { sessionStore, stderr: { write: () => {} }, afterSuccessfulTeardown, + platformCleanup: platformResourceCleanup, }); expect(afterSuccessfulTeardown).not.toHaveBeenCalled(); @@ -67,6 +69,7 @@ test('does not clear a claim after shutdown teardown times out', async () => { sessionStore, stderr: { write: () => {} }, afterSuccessfulTeardown, + platformCleanup: platformResourceCleanup, }); await vi.advanceTimersByTimeAsync(5_000); await teardown; diff --git a/src/daemon/server/daemon-runtime-idle-reap.test.ts b/src/daemon/server/daemon-runtime-idle-reap.test.ts index e361cdd698..2b0a852fb1 100644 --- a/src/daemon/server/daemon-runtime-idle-reap.test.ts +++ b/src/daemon/server/daemon-runtime-idle-reap.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { afterEach, test, vi } from 'vitest'; -import { testCreateCommandSurface } from '../__tests__/command-surface-fixture.ts'; const leaseProbe = vi.hoisted(() => ({ registries: [] as import('../lease-registry.ts').LeaseRegistry[], @@ -18,7 +17,7 @@ vi.mock('../lease-registry.ts', async (importOriginal) => { }); import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { startDaemonRuntime } from './daemon-runtime.ts'; +import { startDaemonRuntimeFixture } from '../__tests__/daemon-runtime-fixture.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; afterEach(() => { @@ -37,8 +36,7 @@ test('daemon runtime self-reaps after the idle window when nothing ever uses it' }); try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -77,8 +75,7 @@ test('daemon runtime never self-reaps when AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS i let exitCode: number | undefined; try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -115,8 +112,7 @@ test('only a retained lease keeps the daemon runtime from self-reaping', async ( }); try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/daemon/server/daemon-runtime-interactor-composition.test.ts b/src/daemon/server/daemon-runtime-interactor-composition.test.ts index 6a1cf2180d..37a79e40cc 100644 --- a/src/daemon/server/daemon-runtime-interactor-composition.test.ts +++ b/src/daemon/server/daemon-runtime-interactor-composition.test.ts @@ -13,7 +13,6 @@ import { } from '@agent-device/daemon-contracts/daemon-registration-owner'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { interactorResolution } from '../interactor-resolution.ts'; -import { testCreateCommandSurface } from '../__tests__/command-surface-fixture.ts'; vi.mock('../../platform-runtime.ts', () => ({ androidObservation: {}, @@ -45,7 +44,7 @@ vi.mock('../../provider-device-runtimes.ts', () => ({ })), })); -import { startDaemonRuntime } from './daemon-runtime.ts'; +import { startDaemonRuntimeFixture } from '../__tests__/daemon-runtime-fixture.ts'; afterEach(() => { setActiveProviderDeviceRuntimes([]); @@ -60,8 +59,7 @@ test('daemon startup composes the interactor resolution the daemon resolves thro const stateDir = mkdtempForTestSync('agent-device-daemon-interactor-composition-'); const providerInteractor = { snapshot: async () => ({ nodes: [] }) } as unknown as Interactor; try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -95,8 +93,7 @@ test('daemon startup composes the interactor resolution the daemon resolves thro test('daemon startup compares lease credentials with its own startup environment', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-credential-composition-'); const daemonEnv = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, ...daemonEnv, @@ -162,8 +159,7 @@ test('a daemon attempt losing the lock shuts down every constructed provider', a platformModules: [], }); const exit = vi.fn(); - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { AGENT_DEVICE_STATE_DIR: stateDir }, exit, registerProcessHandlers: false, diff --git a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts index 111af1a5b9..c8dc4e05ea 100644 --- a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts +++ b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -import { testCreateCommandSurface } from '../__tests__/command-surface-fixture.ts'; +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; const lifecycleEvents = vi.hoisted(() => [] as string[]); @@ -135,7 +135,8 @@ import { } from '../../__tests__/test-utils/device-claim-store.ts'; import { sendRequest } from '../../daemon-client/daemon-client-transport.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { startDaemonRuntime, teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; +import { teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; +import { startDaemonRuntimeFixture } from '../__tests__/daemon-runtime-fixture.ts'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -154,8 +155,7 @@ test('daemon shutdown detaches before session teardown and force-finalizes only const exits: number[] = []; const startupErrors: string[] = []; try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -183,8 +183,7 @@ test('a SIGTERM shutdown gives the handoff a diagnostics scope to write its reas // including "why did this runner get killed instead of handed off" — disappears with the daemon. const stateDir = mkdtempForTestSync('agent-device-daemon-detach-diagnostics-'); try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -212,8 +211,7 @@ const claimStores = isolatedDeviceClaimStores('daemon-drain-admission-'); test('shutdown includes drain publications, releases their claims and refuses post-snapshot publication', async () => { const { stateDir } = claimStores(); - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -267,8 +265,7 @@ test('shutdown includes drain publications, releases their claims and refuses po test('shutdown joins dispatch completion after force-closing the client before taking its snapshot', async () => { const { stateDir } = claimStores(); - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -381,6 +378,7 @@ test.each([false, true])( sessionStore, stderr: { write: () => {} }, finalizeApplicationLifecycle, + platformCleanup: platformResourceCleanup, }); expect(finalizeApplicationLifecycle).toHaveBeenCalledWith( @@ -406,8 +404,7 @@ test.each([false, true])( test('daemon shutdown releases a session lease whose session teardown rejects', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-session-lease-shutdown-'); - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -441,8 +438,7 @@ test('daemon shutdown releases a session lease whose session teardown rejects', test('daemon shutdown releases a retainOnClose lease that no session holds', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-retained-lease-shutdown-'); try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/daemon/server/daemon-runtime-metadata-ownership.test.ts b/src/daemon/server/daemon-runtime-metadata-ownership.test.ts index a35a90e9aa..bdecd8e6ce 100644 --- a/src/daemon/server/daemon-runtime-metadata-ownership.test.ts +++ b/src/daemon/server/daemon-runtime-metadata-ownership.test.ts @@ -3,7 +3,6 @@ import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { publishDaemonRegistration } from '@agent-device/daemon-contracts/daemon-registration-fixtures'; -import { testCreateCommandSurface } from '../__tests__/command-surface-fixture.ts'; const lifecycleEvents = vi.hoisted(() => [] as string[]); const startupFailure = vi.hoisted(() => ({ active: false })); @@ -58,7 +57,7 @@ vi.mock('../../platform-runtime-daemon-lifecycle.ts', () => ({ }, })); -import { startDaemonRuntime } from './daemon-runtime.ts'; +import { startDaemonRuntimeFixture } from '../__tests__/daemon-runtime-fixture.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; type DaemonPaths = ReturnType; @@ -68,8 +67,7 @@ type DaemonPaths = ReturnType; const SUCCESSOR_PID = 999_999_999; function startRuntime(stateDir: string, exit: (code: number) => void) { - return startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + return startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/daemon/server/daemon-runtime-recording-reaper.test.ts b/src/daemon/server/daemon-runtime-recording-reaper.test.ts index c64fde7db4..a7fad8753d 100644 --- a/src/daemon/server/daemon-runtime-recording-reaper.test.ts +++ b/src/daemon/server/daemon-runtime-recording-reaper.test.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -import { testCreateCommandSurface } from '../__tests__/command-surface-fixture.ts'; const reapCalls = vi.hoisted(() => [] as Array>); @@ -43,7 +42,7 @@ vi.mock('../../provider-device-runtimes.ts', () => ({ createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), })); -import { startDaemonRuntime } from './daemon-runtime.ts'; +import { startDaemonRuntimeFixture } from '../__tests__/daemon-runtime-fixture.ts'; afterEach(() => { reapCalls.length = 0; @@ -52,8 +51,7 @@ afterEach(() => { test('daemon startup reaps orphaned simctl recorders with a graceful finalize window', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-recording-reaper-'); try { - const runtime = await startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + const runtime = await startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/daemon/server/daemon-runtime-recording-teardown.test.ts b/src/daemon/server/daemon-runtime-recording-teardown.test.ts index 0e53a2f235..3a3ca87e2d 100644 --- a/src/daemon/server/daemon-runtime-recording-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-recording-teardown.test.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import type { ScreenRecordingCompletion } from '@agent-device/contracts/screen-recording-runtime'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; import { SessionStore } from '../session-store.ts'; import { makeRecordingSession } from '../__tests__/session-teardown.fixtures.ts'; import { teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; @@ -38,6 +39,7 @@ test('daemon shutdown awaits durable recording finalization inside its extended ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, + platformCleanup: platformResourceCleanup, }); await vi.advanceTimersByTimeAsync(10_000); await teardown; @@ -75,6 +77,7 @@ test('daemon shutdown resolves durable recording resources through the effective ref: sessionStore.lookup(effectiveSessionName)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, + platformCleanup: platformResourceCleanup, }); expect(stderrChunks.join('')).not.toMatch(/resource record is missing/); diff --git a/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts b/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts index dab0647cb8..a14e6579a8 100644 --- a/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts +++ b/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts @@ -94,6 +94,7 @@ for (const initialSession of ['published', 'draft'] as const) { ref: shutdownRef, sessionStore, stderr: { write: (chunk) => stderr.push(chunk) }, + platformCleanup: platformResourceCleanup, }); await cleanupEntered.promise; await vi.advanceTimersByTimeAsync(DAEMON_SESSION_TEARDOWN_TIMEOUT_MS); diff --git a/src/daemon/server/daemon-runtime-web-close-teardown.test.ts b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts index 8b4a391b7d..6313b2ae78 100644 --- a/src/daemon/server/daemon-runtime-web-close-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts @@ -8,6 +8,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { installFakeManagedAgentBrowser } from '../../__tests__/test-utils/web-managed-agent-browser.ts'; +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; import { runCmd } from '@agent-device/host-kit/command'; import { SessionStore } from '../session-store.ts'; import { makeWebSession } from '../__tests__/session-teardown.fixtures.ts'; @@ -52,6 +53,7 @@ test('daemon shutdown awaits a slow web close inside its extended budget', async ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, + platformCleanup: platformResourceCleanup, }); await vi.dynamicImportSettled(); await vi.advanceTimersByTimeAsync(20_000); @@ -85,6 +87,7 @@ test('daemon shutdown closes an open web session immediately, without waiting fo ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, + platformCleanup: platformResourceCleanup, }); expect(stderrChunks.join('')).toBe(''); @@ -117,6 +120,7 @@ test('daemon shutdown reports a web close failure on stderr instead of losing it ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, + platformCleanup: platformResourceCleanup, }); // Best-effort: the failure is surfaced, but it never blocks the session from being torn down. diff --git a/src/daemon/server/daemon-runtime-xctest-device-set.test.ts b/src/daemon/server/daemon-runtime-xctest-device-set.test.ts index 22cbc09d45..6bb93652b6 100644 --- a/src/daemon/server/daemon-runtime-xctest-device-set.test.ts +++ b/src/daemon/server/daemon-runtime-xctest-device-set.test.ts @@ -3,7 +3,6 @@ import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { withMockedPlatform } from '../../__tests__/test-utils/host-execution.ts'; -import { testCreateCommandSurface } from '../__tests__/command-surface-fixture.ts'; const legacyRedirect = vi.hoisted(() => ({ xctestDeviceSetPath: '', @@ -54,7 +53,7 @@ vi.mock('../../provider-device-runtimes.ts', () => ({ createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), })); -import { startDaemonRuntime } from './daemon-runtime.ts'; +import { startDaemonRuntimeFixture } from '../__tests__/daemon-runtime-fixture.ts'; const roots: string[] = []; @@ -74,8 +73,7 @@ function loggedEvents(logPath: string): Array<{ phase: string; data?: Record - startDaemonRuntime({ - createCommandSurface: testCreateCommandSurface, + startDaemonRuntimeFixture({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 6bde5986a1..0eb9d84549 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -8,34 +8,17 @@ import { } from '@agent-device/daemon-contracts/daemon-resolution'; import { createDaemonHttpServer } from './http-server.ts'; import { trackDownloadableArtifact } from '../artifact-tracking.ts'; -import { - createProviderDeviceRuntimeRequestProviders, - isActiveProviderDevice, -} from '../../provider-device-runtime.ts'; import { installProviderDeviceAdmission } from '../provider-device-admission.ts'; -import { assertDaemonPolicyAllowsCapability } from '../daemon-policy.ts'; import { loadDaemonPolicy, type DaemonPolicy, } from '@agent-device/daemon-contracts/daemon-policy-file'; -import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; -import { getInteractor } from '../../core/interactors.ts'; +import { assertDaemonPolicyAllowsCapability } from '../daemon-policy.ts'; import { installInteractorResolution } from '../interactor-resolution.ts'; -import { - androidObservation, - createPlatformRuntimeGateway, - createPlatformDeviceInventoryGateways, - createRequestPlatformProviders, -} from '../../platform-runtime.ts'; -import { createHostDiagnostics } from '../../platform-runtime-host-diagnostics.ts'; -import { - createDaemonProviderRuntimeComposition, - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, -} from '../../provider-device-runtimes.ts'; -import { readDaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; import { createRequestHandler } from '../request-router.ts'; +import type { RequestRouterDeps } from '../request-router.ts'; import { getLeaseRegistryExecutionLocks } from '../request-execution-scope.ts'; import { stopSessionAppLog, teardownSessionResources } from '../session-teardown.ts'; import { resolveDaemonSessionTeardownTimeoutMs } from '../session-teardown-budget.ts'; @@ -62,14 +45,12 @@ import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; import { emitDiagnostic, type ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import { - createOwnedProcessRecordStore, - type OwnedProcessRecordStore, readCurrentOwnerIdentity, reapOwnedProcessRecordsAtStartup, + type OwnedProcessRecordStore, type OwnerIdentity, } from '@agent-device/host-kit/process'; import { isEnvTruthy, sleep } from '@agent-device/host-kit/retry'; - import { parseIntegerEnv, readVersion, @@ -81,7 +62,7 @@ import { DAEMON_STARTUP_EXIT_CODES, type DaemonRegistrationOwner, } from '@agent-device/daemon-contracts/daemon-registration-owner'; -import { watchDaemonMetadataLoss, type DaemonMetadataLoss } from './daemon-metadata-loss.ts'; +import { watchDaemonMetadataLoss } from './daemon-metadata-loss.ts'; import { createSocketServer, listenHttpServer, @@ -89,13 +70,22 @@ import { type DaemonServer, } from './transport.ts'; import { prewarmPngWorker, terminatePngWorker } from '@agent-device/capture-kit/png-worker-client'; - -import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; -import { platformDaemonLifecycleOwners } from '../../platform-runtime-daemon-lifecycle.ts'; import { openWebSessionNames } from '../web-session-names.ts'; import { recoverAppLogResourcesAfterDaemonLock } from '../app-log-resource-recovery.ts'; import { createDaemonRecoveryPlatformScope } from '../platform-request-scope.ts'; import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; +import type { AppLogSessionArtifacts } from '@agent-device/contracts/app-log-runtime'; +import type { InteractorResolution } from '../interactor-resolution.ts'; +import type { ProviderDeviceAdmission } from '../provider-device-admission.ts'; +import type { PlatformOwnerLifecycle } from '../platform-owner-lifecycle.ts'; +import type { PlatformResourceCleanup } from '../platform-resource-cleanup.ts'; +import type { ClaimRecoveryGatewayFactory } from '../device/claim-recovery-gateway.ts'; +import type { + LeaseLifecycleProvider, + ProviderDeviceRuntime, + ProviderExpiredLeaseRecovery, +} from '@agent-device/contracts/device'; +import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; const DAEMON_LEASE_RELEASE_TIMEOUT_MS = 1_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; @@ -109,6 +99,182 @@ type WritableOutput = { write: (chunk: string) => unknown; }; +/** + * Every capability the daemon runtime cannot construct for itself: the platform and provider + * runtimes, host diagnostics, credential fingerprinting, resource cleanup, the platform lifecycle + * owners, and the request-router inputs that name them. The server owns the paths, the policy, and + * the session store, and asks the root for the rest through a `composeDeps` factory — the process + * root's implementation is `src/daemon-composition.ts` (ADR 0019). All members are required: a + * daemon process either receives the root's composition or cannot start, and a test passes the + * same factory, so the production assembly is the one every runtime test boots. + */ +export type DaemonRuntimeDeps = Readonly<{ + ownedProcessRecords: OwnedProcessRecordStore; + hostDiagnostics: HostDiagnostics; + ownerLifecycle: PlatformOwnerLifecycle; + providerDeviceRuntimes: readonly ProviderDeviceRuntime[]; + /** + * Provider runtimes the composition skipped. Data only: this server records each one as a + * `provider_runtime_skipped` diagnostic after publishing its registration, so log emission + * stays in this module. + */ + skippedProviderRuntimes: readonly Readonly<{ provider: string; error: AppError }>[]; + interactorResolution: InteractorResolution; + providerDeviceAdmission: ProviderDeviceAdmission; + cleanupManagedWebOrphans: ( + params: Readonly<{ + stateDir: string; + openWebSessionNames: readonly string[]; + ownedProcessRecords?: OwnedProcessRecordStore; + }>, + ) => Promise; + leaseLifecycleProvider?: LeaseLifecycleProvider; + providerRuntimeIds: readonly string[]; + recoverExpiredLease?: ProviderExpiredLeaseRecovery; + recoverableProviderIds: readonly string[]; + /** The runtime gateway every lifecycle and recovery step in this server runs through. */ + deviceRuntimeGateway: RequestRouterDeps['deviceRuntimeGateway']; + /** The resource-cleanup surface the session teardowns run through. */ + platformResourceCleanup: NonNullable; + /** The owner-scoped gateway factory the startup claim reconciliation composes. */ + claimRecoveryGateway: ClaimRecoveryGatewayFactory; + /** + * The root-composed half of `createRequestHandler`'s inputs; the server adds the rest. + * Optionality mirrors `RequestRouterDeps`. Facts the server also reads outside any request — + * the gateway, the cleanup surface, the claim-recovery factory, and the lease-provider facts — + * are declared once at the top level of the deps, not here. + */ + requestRouter: Pick< + RequestRouterDeps, + | 'requestPlatformProviders' + | 'deviceInventoryGateways' + | 'platformServices' + | 'androidObservation' + | 'providerCredentials' + | 'createCommandSurface' + | 'cloudArtifactProvider' + | 'providerAppCatalog' + | 'providerRuntimeRequiredIds' + | 'providerDeviceRuntimeScope' + >; +}>; + +/** Everything the composition factory needs that the server owns: paths, policy, and the store. */ +export type DaemonRuntimeDepsInput = Readonly<{ + env: NodeJS.ProcessEnv; + /** The resolved state-dir root; the server resolves daemon paths and passes it down. */ + baseDir: string; + sessionsDir: string; + /** Refuses the shutdown capability the operator denied; undefined when no policy names it. */ + assertShutdownAllowed: (() => void) | undefined; + resolveSessionDir(sessionId: string): string; + resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; + openWebSessionNames(): readonly string[]; +}>; + +/** + * The factory that names this daemon's runtimes. The server calls it once, after resolving its + * paths and loading the operator's policy, and before it acquires the registration; the process + * root's implementation is `createDaemonRuntimeDeps`. + */ +export type ComposeDaemonRuntimeDeps = ( + input: DaemonRuntimeDepsInput, +) => Promise; + +export type DaemonRuntimeOptions = { + env?: NodeJS.ProcessEnv; + stdout?: WritableOutput; + stderr?: WritableOutput; + exit?: (code: number) => void; + registerProcessHandlers?: boolean; + /** The root's runtime composition, as a factory so the server owns paths, policy, and store. */ + composeDeps: ComposeDaemonRuntimeDeps; +}; + +export type DaemonRuntimeController = { + httpPort?: number; + socketPort?: number; + shutdown: (options?: { exitCode?: number; cause?: unknown }) => Promise; + token: string; +}; + +export async function flushDaemonStartupDiagnostics( + logPath: string, + diagnostics: readonly ResourceDiagnostic[], +): Promise { + if (diagnostics.length === 0) return; + await withDaemonDiagnosticsScope({ logPath, command: 'daemon-startup', debug: false }, () => { + for (const diagnostic of diagnostics) { + emitDiagnostic({ + level: 'warn', + phase: diagnostic.phase, + data: { resourcePath: diagnostic.resourcePath, ...diagnostic.data }, + }); + } + }); +} + +/** + * Records one daemon-level event. These run outside any request, so there is no request scope and no + * resolved debug level to inherit; debug is forced on for the same reason the #2681 handoff forces it — + * the event is the point of the record and must not be dropped by a level that was never set for it. + */ +async function emitDaemonDiagnostic( + logPath: string, + phase: string, + data: Record, +): Promise { + await withDaemonDiagnosticsScope({ logPath }, () => { + emitDiagnostic({ level: 'warn', phase, data }); + }); +} + +async function finishDaemonRegistration(params: { + registration: DaemonRegistrationOwner; + infoPath: string; + logPath: string; + outcome?: Parameters[0]; +}): Promise { + try { + const removal = await params.registration.finish(params.outcome); + if (removal.state !== 'removed' && removal.state !== 'absent') { + await emitDaemonDiagnostic(params.logPath, 'daemon_info_removal_declined', { + infoPath: params.infoPath, + removed: false, + reason: removal.state, + ...(removal.state === 'replaced' ? { registeredPid: removal.identity.pid } : {}), + }); + } + } catch (error) { + await emitDaemonDiagnostic(params.logPath, 'daemon_registration_finish_failed', { + error: normalizeError(error), + }); + } +} + +/** + * Starts the watch that reports this daemon's registration being taken over, and returns the handle + * that stops it. It is armed only once this process has published its own record: before publication + * the file legitimately describes a predecessor, and losing that is not this daemon's event. + */ +function armDaemonMetadataLossWatch( + stateDir: string, + infoPath: string, + logPath: string, + owner: OwnerIdentity, +): () => void { + return watchDaemonMetadataLoss({ + infoPath, + stateDir, + owner, + onLoss: (loss) => + void emitDaemonDiagnostic(logPath, 'daemon_metadata_lost', { + infoPath, + ...loss, + }).catch(() => {}), + }); +} + async function settleDaemonTeardownStep(params: { session: SessionState; stderr: WritableOutput; @@ -141,14 +307,21 @@ export async function teardownDaemonSessionForShutdown(params: { ref: SessionRef; sessionStore: SessionStore; stderr: WritableOutput; + platformCleanup: PlatformResourceCleanup; finalizeApplicationLifecycle?: ( session: SessionState, runtimeHints: RuntimeHintValues, ) => Promise; afterSuccessfulTeardown?: (session: SessionState) => Promise; }): Promise { - const { ref, sessionStore, stderr, finalizeApplicationLifecycle, afterSuccessfulTeardown } = - params; + const { + ref, + sessionStore, + stderr, + platformCleanup, + finalizeApplicationLifecycle, + afterSuccessfulTeardown, + } = params; const current = sessionStore.resolveCurrent(ref); const session = current ?? ref.session; const runtimeHints = runtimeHintValues( @@ -176,7 +349,7 @@ export async function teardownDaemonSessionForShutdown(params: { appLog: 'already-settled', ref, sessionStore, - platformCleanup: platformResourceCleanup, + platformCleanup, }), }); const lifecycleTeardownSucceeded = finalizeApplicationLifecycle @@ -206,121 +379,6 @@ export async function teardownDaemonSessionForShutdown(params: { sessionStore.retire(ref); } -export type DaemonRuntimeOptions = { - env?: NodeJS.ProcessEnv; - stdout?: WritableOutput; - stderr?: WritableOutput; - exit?: (code: number) => void; - registerProcessHandlers?: boolean; - /** The command-surface factory the request handler builds per-request runtimes from. */ - createCommandSurface: CreateDaemonCommandSurface; -}; - -export type DaemonRuntimeController = { - httpPort?: number; - socketPort?: number; - shutdown: (options?: { exitCode?: number; cause?: unknown }) => Promise; - token: string; -}; - -export async function flushDaemonStartupDiagnostics( - logPath: string, - diagnostics: readonly ResourceDiagnostic[], -): Promise { - if (diagnostics.length === 0) return; - await withDaemonDiagnosticsScope({ logPath, command: 'daemon-startup', debug: false }, () => { - for (const diagnostic of diagnostics) { - emitDiagnostic({ - level: 'warn', - phase: diagnostic.phase, - data: { resourcePath: diagnostic.resourcePath, ...diagnostic.data }, - }); - } - }); -} - -async function noteSkippedProviderRuntimes( - logPath: string, - skipped: readonly Readonly<{ provider: string; error: AppError }>[] | undefined, -): Promise { - for (const { provider, error } of skipped ?? []) { - await emitDaemonDiagnostic(logPath, 'provider_runtime_skipped', { - provider, - code: error.code, - message: error.message, - hint: error.details?.hint, - }); - } -} - -/** - * Records one daemon-level event. These run outside any request, so there is no request scope and no - * resolved debug level to inherit; debug is forced on for the same reason the #2681 handoff forces it — - * the event is the point of the record and must not be dropped by a level that was never set for it. - */ -async function emitDaemonDiagnostic( - logPath: string, - phase: string, - data: Record, -): Promise { - await withDaemonDiagnosticsScope({ logPath }, () => { - emitDiagnostic({ level: 'warn', phase, data }); - }); -} - -async function finishDaemonRegistration(params: { - registration: DaemonRegistrationOwner; - infoPath: string; - logPath: string; - outcome?: Parameters[0]; -}): Promise { - try { - const removal = await params.registration.finish(params.outcome); - if (removal.state !== 'removed' && removal.state !== 'absent') { - await emitDaemonDiagnostic(params.logPath, 'daemon_info_removal_declined', { - infoPath: params.infoPath, - removed: false, - reason: removal.state, - ...(removal.state === 'replaced' ? { registeredPid: removal.identity.pid } : {}), - }); - } - } catch (error) { - await emitDaemonDiagnostic(params.logPath, 'daemon_registration_finish_failed', { - error: normalizeError(error), - }); - } -} - -async function noteDaemonMetadataLoss(params: { - infoPath: string; - logPath: string; - loss: DaemonMetadataLoss; -}): Promise { - await emitDaemonDiagnostic(params.logPath, 'daemon_metadata_lost', { - infoPath: params.infoPath, - ...params.loss, - }); -} - -/** - * Starts the watch that reports this daemon's registration being taken over, and returns the handle - * that stops it. It is armed only once this process has published its own record: before publication - * the file legitimately describes a predecessor, and losing that is not this daemon's event. - */ -function armDaemonMetadataLossWatch( - stateDir: string, - infoPath: string, - logPath: string, - owner: OwnerIdentity, -): () => void { - return watchDaemonMetadataLoss({ - infoPath, - stateDir, - owner, - onLoss: (loss) => void noteDaemonMetadataLoss({ infoPath, logPath, loss }).catch(() => {}), - }); -} - export async function startDaemonRuntime( options: DaemonRuntimeOptions, ): Promise { @@ -333,7 +391,8 @@ export async function startDaemonRuntime( const daemonServerMode = resolveDaemonServerMode(env.AGENT_DEVICE_DAEMON_SERVER_MODE); const retainArtifacts = isEnvTruthy(env.AGENT_DEVICE_RETAIN_ARTIFACTS); // ADR 0029: a policy that cannot be read or validated stops startup; the daemon never runs - // with a weaker policy than its operator named. + // with a weaker policy than its operator named. The server loads it before composing any + // runtime, so no capability is ever built under a policy that failed validation. let daemonPolicy: DaemonPolicy | undefined; try { daemonPolicy = loadDaemonPolicy(env); @@ -342,68 +401,50 @@ export async function startDaemonRuntime( exit(1); return null; } + // Derived here, beside the policy it reads: the server hands the same refusal to the root's + // composition, so a runtime can never be built without the operator's shutdown gate. + const assertShutdownAllowed = daemonPolicy + ? () => assertDaemonPolicyAllowsCapability(daemonPolicy, 'device-shutdown') + : undefined; const sessionStore = new SessionStore(sessionsDir); - const ownedProcessRecords = createOwnedProcessRecordStore({ - stateDir: baseDir, + const deps = await options.composeDeps({ + env, + baseDir, sessionsDir, + assertShutdownAllowed, resolveSessionDir: (sessionId) => sessionStore.resolveSessionDir(sessionId), + resolveSessionArtifacts: (sessionId) => ({ + outputPath: sessionStore.resolveAppLogPath(sessionId), + pidPath: sessionStore.resolveAppLogPidPath(sessionId), + }), + openWebSessionNames: () => openWebSessionNames(sessionStore), }); const appLogAdmissionLedger = createAppLogAdmissionLedger(); const audioProbeAdmissionLedger = createAudioProbeAdmissionLedger(); const perfCaptureAdmissionLedger = createPerfCaptureAdmissionLedger(); - const hostDiagnostics = createHostDiagnostics(); const screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger(); const version = readVersion(); const token = crypto.randomBytes(24).toString('hex'); const daemonIdentity = readCurrentOwnerIdentity(); const daemonCodeOrigin = resolveDaemonCodeOrigin(); const daemonCodeSignature = resolveDaemonCodeSignature(); - const providerComposition = await createDaemonProviderRuntimeComposition(env); - const providerDeviceRuntimes = [...providerComposition.runtimes]; - const deviceRuntimeGateway = createPlatformRuntimeGateway({ - assertShutdownAllowed: daemonPolicy - ? () => assertDaemonPolicyAllowsCapability(daemonPolicy, 'device-shutdown') - : undefined, - providerRuntimes: providerDeviceRuntimes, - providerModules: providerComposition.platformModules, - sessionsDir, - ownedProcesses: ownedProcessRecords, - resolveSessionArtifacts: (sessionId) => ({ - outputPath: sessionStore.resolveAppLogPath(sessionId), - pidPath: sessionStore.resolveAppLogPidPath(sessionId), - }), - }); + const providerDeviceRuntimes = deps.providerDeviceRuntimes; + const deviceRuntimeGateway = deps.deviceRuntimeGateway; const applicationLifecycle = deviceRuntimeGateway.applicationLifecycle; if (!applicationLifecycle) { throw new AppError('COMMAND_FAILED', 'Platform lifecycle gateway is not configured.', { reason: 'runtime-gateway-missing', }); } - const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders( - providerDeviceRuntimes, - { providerRuntimeRequiredIds: DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS }, - ); - installProviderDeviceAdmission({ isActive: (device) => isActiveProviderDevice(device) }); - installInteractorResolution({ resolve: getInteractor }); - const requestPlatformProviders = createRequestPlatformProviders({ - providers: { - appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider, - appleRunnerScreenRecordingTransport: - providerRuntimeProviders.appleRunnerScreenRecordingTransport, - }, - defaultWebProvider: { - stateDir: baseDir, - openWebSessionNames: () => openWebSessionNames(sessionStore), - ownedProcessRecords, - }, - }); + installProviderDeviceAdmission(deps.providerDeviceAdmission); + installInteractorResolution(deps.interactorResolution); const expiredProviderLeaseReleaser = createExpiredProviderLeaseReleaser({ - leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, - providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, - recoverExpiredLease: providerRuntimeProviders.recoverExpiredLease, + leaseLifecycleProvider: deps.leaseLifecycleProvider, + providerRuntimeIds: deps.providerRuntimeIds, + recoverExpiredLease: deps.recoverExpiredLease, stateDir: baseDir, - recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, + recoverableProviderIds: deps.recoverableProviderIds, }); void expiredProviderLeaseReleaser.retryPending(); const leaseRegistry = new LeaseRegistry({ @@ -415,46 +456,25 @@ export async function startDaemonRuntime( void expiredProviderLeaseReleaser.release(lease); }, }); - const cloudArtifactProvider = providerRuntimeProviders.cloudArtifactProvider; - const providerAppCatalog = providerRuntimeProviders.providerAppCatalog; - const deviceInventoryGateways = createPlatformDeviceInventoryGateways( - providerRuntimeProviders.deviceInventorySource, - ); - // Root composition of the platform-services port and the owner-scoped claim-recovery gateway. - // platform-runtime.ts is already static in this file, so the deferred imports keep these thin - // composition modules (and the readiness/open-target adapters only the services module pulls) - // off `src/daemon.ts`'s eager closure; what each adapter itself loads stays as lazy as it was. - const { createDaemonPlatformServices } = - await import('../../platform-runtime-daemon-services.ts'); - const { createClaimRecoveryGateway } = await import('../../platform-runtime-claim-recovery.ts'); - const dispatchRequest = createRequestHandler({ logPath, token, sessionStore, leaseRegistry, - leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, - cloudArtifactProvider, - providerAppCatalog, - deviceInventoryGateways, - deviceRuntimeGateway, - platformServices: createDaemonPlatformServices(), - claimRecoveryGateway: createClaimRecoveryGateway, appLogAdmissionLedger, audioProbeAdmissionLedger, perfCaptureAdmissionLedger, - hostDiagnostics, + hostDiagnostics: deps.hostDiagnostics, screenRecordingAdmissionLedger, - requestPlatformProviders, - androidObservation, - platformResourceCleanup, - providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, - providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, - providerCredentials: readDaemonProviderCredentials(env, baseDir), - providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, - trackDownloadableArtifact, daemonPolicy, - createCommandSurface: options.createCommandSurface, + trackDownloadableArtifact, + // The facts the deps declare at the top level, offered to the router from that one place. + deviceRuntimeGateway, + claimRecoveryGateway: deps.claimRecoveryGateway, + platformResourceCleanup: deps.platformResourceCleanup, + leaseLifecycleProvider: deps.leaseLifecycleProvider, + providerRuntimeIds: deps.providerRuntimeIds, + ...deps.requestRouter, }); let stopMetadataLossWatch: () => void = () => {}; @@ -480,6 +500,7 @@ export async function startDaemonRuntime( ref, sessionStore, stderr, + platformCleanup: deps.platformResourceCleanup, finalizeApplicationLifecycle: async (sessionToFinalize, runtimeHints) => await finalizeDaemonSessionApplicationLifecycle({ gateway: deviceRuntimeGateway, @@ -517,7 +538,7 @@ export async function startDaemonRuntime( appLog: 'run', ref, sessionStore, - platformCleanup: platformResourceCleanup, + platformCleanup: deps.platformResourceCleanup, }); await finalizeDaemonSessionApplicationLifecycle({ gateway: deviceRuntimeGateway, @@ -673,13 +694,12 @@ export async function startDaemonRuntime( let httpPort: number | undefined; const startupDiagnostics: ResourceDiagnostic[] = []; try { - await platformDaemonLifecycleOwners.configureForDaemonLock({ + await deps.ownerLifecycle.configureForDaemonLock({ stateDir: baseDir, hasDeviceClaimAuthority: processOwnsActiveDeviceClaim, onDiagnostic: (diagnostic) => startupDiagnostics.push(diagnostic), }); - const legacyMarkerRecovery = - await platformDaemonLifecycleOwners.recoverLegacyAppLogMarkers(sessionsDir); + const legacyMarkerRecovery = await deps.ownerLifecycle.recoverLegacyAppLogMarkers(sessionsDir); appLogAdmissionLedger.retainLegacyMarkers(legacyMarkerRecovery.retained); for (const markerPath of legacyMarkerRecovery.recovered) { startupDiagnostics.push({ @@ -704,15 +724,15 @@ export async function startDaemonRuntime( scope: createDaemonRecoveryPlatformScope(), onDiagnostic: (diagnostic) => startupDiagnostics.push(diagnostic), }); - await reapOwnedProcessRecordsAtStartup(ownedProcessRecords, { + await reapOwnedProcessRecordsAtStartup(deps.ownedProcessRecords, { openWebSessionNames: openWebSessionNames(sessionStore), purposes: ['simctl-screen-recording'], termTimeoutMs: DAEMON_RECORDING_REAP_TERM_TIMEOUT_MS, }); - await cleanupWebBrowserOrphansForDaemonStartup({ + await deps.cleanupManagedWebOrphans({ stateDir: baseDir, - sessionStore, - ownedProcessRecords, + openWebSessionNames: openWebSessionNames(sessionStore), + ownedProcessRecords: deps.ownedProcessRecords, }); // Marker-gated lifecycle recovery owns test-IME orphan repair. Its implementation remains // lazy until the marker exists, so a normal daemon startup does not load or probe adb. @@ -730,14 +750,21 @@ export async function startDaemonRuntime( publishDaemonInfo(socketPort, httpPort); stopMetadataLossWatch = armDaemonMetadataLossWatch(baseDir, infoPath, logPath, daemonIdentity); await flushDaemonStartupDiagnostics(logPath, startupDiagnostics); - await noteSkippedProviderRuntimes(logPath, providerComposition.skipped); + for (const skipped of deps.skippedProviderRuntimes) { + await emitDaemonDiagnostic(logPath, 'provider_runtime_skipped', { + provider: skipped.provider, + code: skipped.error.code, + message: skipped.error.message, + hint: skipped.error.details?.hint, + }); + } // After publication: publishDaemonInfo truncates daemon.log, so anything // written before it is lost — including reconciliation diagnostics. await reconcileDeviceClaimsForDaemonStartup( logPath, createOwnerScopedDeviceClaimReconciler({ scope: createDaemonRecoveryPlatformScope(), - composeGateway: createClaimRecoveryGateway, + composeGateway: deps.claimRecoveryGateway, }), baseDir, ); @@ -755,7 +782,7 @@ export async function startDaemonRuntime( providerDeviceRuntimes.map(async (runtime) => await runtime.shutdown()), ); await finishDaemonRegistration({ registration, infoPath, logPath }); - await platformDaemonLifecycleOwners.clearDaemonLockConfiguration(); + await deps.ownerLifecycle.clearDaemonLockConfiguration(); exit(1); return null; } @@ -798,7 +825,7 @@ export async function startDaemonRuntime( }); }); try { - await platformDaemonLifecycleOwners.resetAndroidSnapshotHelper(); + await deps.ownerLifecycle.resetAndroidSnapshotHelper(); } catch (error) { emitDiagnostic({ level: 'warn', @@ -836,7 +863,7 @@ export async function startDaemonRuntime( logPath, outcome: { providerReleases: providerReleaseDrain, claims: shutdownClaimLedger.claims }, }); - await platformDaemonLifecycleOwners.clearDaemonLockConfiguration(); + await deps.ownerLifecycle.clearDaemonLockConfiguration(); exit(shutdownOptions.exitCode ?? 0); }; @@ -891,25 +918,3 @@ async function reconcileDeviceClaimsForDaemonStartup( } }); } - -export async function cleanupWebBrowserOrphansForDaemonStartup(params: { - stateDir: string; - sessionStore: SessionStore; - ownedProcessRecords?: OwnedProcessRecordStore; -}): Promise { - try { - await platformDaemonLifecycleOwners.cleanupManagedWebOrphans({ - stateDir: params.stateDir, - openWebSessionNames: openWebSessionNames(params.sessionStore), - ...(params.ownedProcessRecords === undefined - ? {} - : { ownedProcessRecords: params.ownedProcessRecords }), - }); - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'web_agent_browser_orphan_cleanup_failed', - data: { error: error instanceof Error ? error.message : String(error) }, - }); - } -} diff --git a/src/provider-credential-fingerprint.ts b/src/provider-credential-fingerprint.ts index 3f96ecc17b..4559c52b36 100644 --- a/src/provider-credential-fingerprint.ts +++ b/src/provider-credential-fingerprint.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; import { cloudWebDriverProviderDeclaration } from '@agent-device/provider-webdriver/providers'; import type { LIMRUN_PROVIDER } from '@agent-device/provider-limrun'; import type { EnvMap } from '@agent-device/kernel/source-value'; @@ -46,13 +47,6 @@ function declaredCredentialVariables(provider: string, env: EnvMap): readonly st ?.agentDevicePlugin.credentialVariables; } -/** The provider credentials a daemon started with, and the state dir that names that daemon. */ -export type DaemonProviderCredentials = Readonly<{ - /** Undefined when the daemon's environment holds none of the credentials such a lease reads. */ - fingerprint(provider: string, leaseBackend?: string): string | undefined; - stateDir: string; -}>; - export function readDaemonProviderCredentials( env: EnvMap, stateDir: string, diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 0ec6bbaaf7..7cfec96903 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -54,7 +54,7 @@ import { unavailableDeviceRuntimeGateway } from '../../../src/daemon/__tests__/t import { openWebSessionNames } from '../../../src/daemon/web-session-names.ts'; import { testCreateCommandSurface } from '../../../src/daemon/__tests__/command-surface-fixture.ts'; -// Match daemon composition (src/daemon/server/daemon-runtime.ts): the daemon decides on provider +// Match daemon composition (src/daemon-composition.ts): the daemon decides on provider // ownership through its own admission seam, which root composition installs. installProviderDeviceAdmission({ isActive: (device) => isActiveProviderDevice(device) }); installInteractorResolution({ resolve: getInteractor }); @@ -178,7 +178,7 @@ export async function createProviderScenarioHarness( : createTestDeviceInventoryGatewaysFromProvider(deviceInventoryProvider), deviceRuntimeGateway, trackDownloadableArtifact, - // Match daemon composition (src/daemon/server/daemon-runtime.ts): doctor's host-scoped + // Match daemon composition (src/daemon-composition.ts): doctor's host-scoped // diagnostics are injected at the root, so the harness composes them the same way. hostDiagnostics: createHostDiagnostics(), androidObservation,