Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -827,14 +827,18 @@ matching the layering scanner's scope) to `src/platforms/**` and concrete
`@agent-device/platform-*` packages. R13 governs concrete package imports across the whole tree,
while `retired-platforms-zone` prevents the old root seam from being recreated; platform freedom is
therefore structurally enforced rather than periodically measured.

### 11. The platform-services port carries host asks that bind no device

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

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,
Expand Down
42 changes: 21 additions & 21 deletions scripts/layering/daemon-platform-runtime-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import {
isRootPlatformRuntimeTarget,
} from './daemon-platform-runtime-inventory.ts';

// The classified pair these fixtures plant is the platform-services composition factory: the
// 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-daemon-services.ts';
const CLASSIFIED_STUB = 'export function createDaemonPlatformServices() { return {}; }\n';
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';

function violations(sources: Record<string, string>): LayeringViolation[] {
Expand All @@ -26,8 +26,8 @@ test('R76 accepts a classified edge with the exact recorded symbols', () => {
const sources = {
[CLASSIFIED_TARGET]: CLASSIFIED_STUB,
[CLASSIFIED_IMPORTER]:
"import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" +
'void createDaemonPlatformServices;\n',
"import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" +
'void createClaimRecoveryGateway;\n',
};
assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []);
});
Expand All @@ -54,8 +54,8 @@ test('R76 reports every classified edge missing from the tree as stale, not the
const sources = {
[CLASSIFIED_TARGET]: CLASSIFIED_STUB,
[CLASSIFIED_IMPORTER]:
"import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" +
'void createDaemonPlatformServices;\n',
"import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" +
'void createClaimRecoveryGateway;\n',
};
const stale = violations(sources).filter(
(violation) => violation.file === 'scripts/layering/daemon-platform-runtime-inventory.ts',
Expand Down Expand Up @@ -86,32 +86,32 @@ test('R76 rejects an unclassified edge with the pair and its line', () => {

test('R76 rejects new symbols on a classified edge', () => {
const sources = {
[CLASSIFIED_TARGET]: CLASSIFIED_STUB + 'export function extraServicesKnob() {}\n',
[CLASSIFIED_TARGET]: CLASSIFIED_STUB + 'export function extraGatewayKnob() {}\n',
[CLASSIFIED_IMPORTER]:
"import { createDaemonPlatformServices, extraServicesKnob } from '../../platform-runtime-daemon-services.ts';\n" +
'void [createDaemonPlatformServices, extraServicesKnob];\n',
"import { createClaimRecoveryGateway, extraGatewayKnob } from '../../platform-runtime-claim-recovery.ts';\n" +
'void [createClaimRecoveryGateway, 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, /createDaemonPlatformServices, extraServicesKnob/);
assert.match(found[0]!.message, /createClaimRecoveryGateway, extraGatewayKnob/);
});

test('R76 rejects a services object widened through an expanded composition edge', () => {
test('R76 rejects a gateway assembled from an expanded recovery edge', () => {
const sources = {
'src/platform-runtime-daemon-services.ts':
'export function createDaemonPlatformServices() { return {}; }\n' +
'export function createProviderPlatformServices() { return {}; }\n',
'src/platform-runtime-claim-recovery.ts':
'export const createClaimRecoveryGateway = () => ({});\n' +
'export const createProviderRecoveryGateway = () => ({});\n',
'src/daemon/server/daemon-runtime.ts':
"import {\n createDaemonPlatformServices,\n createProviderPlatformServices,\n} from '../../platform-runtime-daemon-services.ts';\n" +
'void [createDaemonPlatformServices, createProviderPlatformServices];\n',
"import {\n createClaimRecoveryGateway,\n createProviderRecoveryGateway,\n} from '../../platform-runtime-claim-recovery.ts';\n" +
'void [createClaimRecoveryGateway, createProviderRecoveryGateway];\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/);
assert.match(found[0]!.message, /createDaemonPlatformServices, createProviderPlatformServices/);
assert.match(found[0]!.message, /createClaimRecoveryGateway, createProviderRecoveryGateway/);
});

test('R76 matches a destructured dynamic import by target with the recorded bindings', () => {
Expand Down Expand Up @@ -210,9 +210,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 { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" +
"export { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" +
'void createDaemonPlatformServices;\n',
"import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" +
"export { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';\n" +
'void createClaimRecoveryGateway;\n',
};
assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []);
});
Expand Down
20 changes: 10 additions & 10 deletions scripts/layering/daemon-platform-runtime-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,6 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[]
'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/device/device-claim-owner-recovery.ts',
target: 'src/platform-runtime.ts',
symbols: ['createPlatformRuntimeGateway'],
classification: 'composition-essential',
rationale:
"per-transaction neutral gateway assembly scoped to the dead owner's state dir " +
'(#2168); the process root cannot carry a per-claim sessionsDir, so the scoped ' +
"composition belongs to the recovery policy's own module.",
},
{
file: 'src/daemon/direct-ios-selector.ts',
target: 'src/platform-runtime-apple-resources.ts',
Expand Down Expand Up @@ -188,6 +178,16 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[]
'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 {
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/root-module-zones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const ROOT_MODULE_ZONES: Readonly<Record<string, readonly string[]>> = {
'src/platform-runtime-android-observation-host.ts',
'src/platform-runtime-apple-resources.ts',
'src/platform-runtime-apple-runner-owner.ts',
'src/platform-runtime-claim-recovery.ts',
'src/platform-runtime-daemon-lifecycle.ts',
'src/platform-runtime-daemon-owner-cleanup.ts',
'src/platform-runtime-daemon-services.ts',
Expand Down
8 changes: 5 additions & 3 deletions src/cli/commands/device-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createOwnerScopedDeviceClaimReconciler,
type OwnerScopedClaimRecoveryComposer,
} from '../../daemon/device/device-claim-owner-recovery.ts';
import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';
import { createDaemonRecoveryPlatformScope } from '../../daemon/platform-request-scope.ts';
import {
releaseProvenStaleDeviceClaims,
Expand All @@ -22,9 +23,10 @@ export async function runStaleDeviceClaimRelease(
): Promise<DeviceClaimStaleReleaseOutcome[]> {
return await releaseProvenStaleDeviceClaims({
selectors,
reconcile: createOwnerScopedDeviceClaimReconciler(
createDaemonRecoveryPlatformScope(),
reconcile: createOwnerScopedDeviceClaimReconciler({
scope: createDaemonRecoveryPlatformScope(),
composeGateway: createClaimRecoveryGateway,
composeRecovery,
),
}),
});
}
6 changes: 5 additions & 1 deletion src/daemon/__tests__/human-control-router-fixture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts';
import { daemonPlatformServicesFixture } from './platform-services-fixture.ts';
import {
daemonPlatformServicesFixture,
daemonClaimRecoveryGatewayFixture,
} from './platform-services-fixture.ts';
import { makeIosAppSession } from '../../__tests__/test-utils/session-factories.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { buildSessionLeaseFromRequest } from '../lease-context.ts';
Expand All @@ -24,6 +27,7 @@ export function createHumanControlHarness() {
const handleRequest = createRequestHandler({
logPath: '/tmp/agent-device-human-control.log',
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
token: 'test-token',
sessionStore,
leaseRegistry: registry,
Expand Down
11 changes: 11 additions & 0 deletions src/daemon/__tests__/platform-services-fixture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';
import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts';
import type { DaemonPlatformServices } from '../platform-services.ts';
import type { ClaimRecoveryGatewayFactory } from '../device/claim-recovery-gateway.ts';

/**
* The port the production composition root builds, handed to a test the same way the shared router
Expand All @@ -13,3 +15,12 @@ export function daemonPlatformServicesFixture(
): DaemonPlatformServices {
return Object.freeze({ ...createDaemonPlatformServices(), ...overrides });
}

/**
* The recovery-gateway half of root composition, same rule as the services above: tests that
* route a stale claim see the real composed gateway, and a test that cares about composition
* passes its own factory.
*/
export function daemonClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory {
return createClaimRecoveryGateway;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { expect, test, vi } from 'vitest';
import { daemonPlatformServicesFixture } from './platform-services-fixture.ts';
import {
daemonPlatformServicesFixture,
daemonClaimRecoveryGatewayFixture,
} from './platform-services-fixture.ts';
import { makeSession } from '../../__tests__/test-utils/session-factories.ts';
import { LINUX_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
Expand Down Expand Up @@ -47,6 +50,7 @@ test('expired leases remove owned sessions before the next command and free capa

const scope = await createRequestExecutionScope({
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
req: makeRequest({ command: 'snapshot' }),
sessionStore,
leaseRegistry,
Expand Down Expand Up @@ -101,6 +105,7 @@ test.each(['rebuild', 'retire'] as const)(
now = 1_011;
const scope = await createRequestExecutionScope({
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
req: makeRequest({ session: address, flags: { session: address } }),
sessionStore: store,
leaseRegistry: leases,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { expect, test } from 'vitest';
import { daemonPlatformServicesFixture } from './platform-services-fixture.ts';
import {
daemonPlatformServicesFixture,
daemonClaimRecoveryGatewayFixture,
} from './platform-services-fixture.ts';
import { makeMacOsSession } from '../../__tests__/test-utils/session-factories.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { parseDaemonPolicy } from '../../daemon-policy-file.ts';
Expand Down Expand Up @@ -39,6 +42,7 @@ async function runScoped(params: {
}): Promise<{ ran: boolean; error?: unknown }> {
const scope = await createRequestExecutionScope({
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
req: { token: 't', session: 'default', positionals: [], ...params.req },
sessionStore: params.sessionStore,
leaseRegistry: params.leaseRegistry,
Expand Down Expand Up @@ -121,6 +125,7 @@ test('a request under a pid-pinned macos-app lease does not run once that proces
);
const scope = await createRequestExecutionScope({
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
req: { token: 't', session: 'default', command: 'snapshot', positionals: [], flags: {} },
sessionStore,
leaseRegistry,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { afterAll, test, expect, vi } from 'vitest';
import { daemonPlatformServicesFixture } from './platform-services-fixture.ts';
import {
daemonPlatformServicesFixture,
daemonClaimRecoveryGatewayFixture,
} from './platform-services-fixture.ts';
import fs from 'node:fs';
import { getFlagDefinitionsForKey } from '@agent-device/command-registry/flag-registry';
import type { CommandFlags } from '@agent-device/contracts/command';
Expand Down Expand Up @@ -117,12 +120,14 @@ test('an open that lost the race to a free device re-waits and opens rather than

const first = await createRequestExecutionScope({
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
req: openRequest('first-opener'),
sessionStore,
leaseRegistry,
});
const second = await createRequestExecutionScope({
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
req: openRequest('second-opener'),
sessionStore,
leaseRegistry,
Expand Down Expand Up @@ -156,6 +161,7 @@ test('a close that frees the device mid-wait gets through while the open is wait

const opened = createRequestExecutionScope({
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
req: openRequest('waiter'),
sessionStore,
leaseRegistry,
Expand All @@ -169,6 +175,7 @@ test('a close that frees the device mid-wait gets through while the open is wait
await sleep(50);
const closer = await createRequestExecutionScope({
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
req: closeRequest('holder'),
sessionStore,
leaseRegistry,
Expand Down Expand Up @@ -201,6 +208,7 @@ test('an out-of-range wait budget is refused before resolving the target device'
await expect(
createRequestExecutionScope({
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
req: openRequest('unbounded-opener', max + 1, 'request-unbounded'),
sessionStore: makeSessionStore('agent-device-open-wait-bounds-'),
leaseRegistry: new LeaseRegistry(),
Expand Down
11 changes: 9 additions & 2 deletions src/daemon/__tests__/request-execution-scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { daemonPlatformServicesFixture } from './platform-services-fixture.ts';
import {
daemonPlatformServicesFixture,
daemonClaimRecoveryGatewayFixture,
} from './platform-services-fixture.ts';
import { afterAll, test, expect, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
Expand Down Expand Up @@ -889,11 +892,15 @@ test('router: an existing lease-less session under tenant isolation still refuse
});

function createTestRequestScope(
params: Omit<Parameters<typeof createRequestExecutionScope>[0], 'platformServices'>,
params: Omit<
Parameters<typeof createRequestExecutionScope>[0],
'platformServices' | 'claimRecoveryGateway'
>,
) {
return createRequestExecutionScope({
...params,
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
});
}

Expand Down
8 changes: 6 additions & 2 deletions src/daemon/__tests__/request-recording-health.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { daemonPlatformServicesFixture } from './platform-services-fixture.ts';
import {
daemonPlatformServicesFixture,
daemonClaimRecoveryGatewayFixture,
} from './platform-services-fixture.ts';
import { test, expect, vi, beforeEach } from 'vitest';
import type { SessionState } from '../session-state.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
Expand Down Expand Up @@ -201,7 +204,8 @@ test.each(['rebuild', 'retire'] as const)(
leaseRegistry: new LeaseRegistry(),
// Locked preparation refreshes recording health through the port, so the scope carries
// the fixture whose observation member is the test's spy.
platformServices: platformServices,
platformServices,
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
});
const prepared = scope.runLocked(() =>
prepareLockedRequestScope({
Expand Down
6 changes: 5 additions & 1 deletion src/daemon/__tests__/request-router-open.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts';
import { daemonPlatformServicesFixture } from './platform-services-fixture.ts';
import {
daemonPlatformServicesFixture,
daemonClaimRecoveryGatewayFixture,
} from './platform-services-fixture.ts';
import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts';
import { test, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
Expand Down Expand Up @@ -491,6 +494,7 @@ test('close fails synchronously when root composition omits platform resource cl
const handler = createProductionRequestHandler({
logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'),
platformServices: daemonPlatformServicesFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
token: 'test-token',
sessionStore,
leaseRegistry: new LeaseRegistry(),
Expand Down
Loading
Loading