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
2 changes: 1 addition & 1 deletion docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ 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` (`packages/daemon/src/core/platform-services.ts`), assembled by the daemon runtime's
`DaemonPlatformServices` (`packages/daemon/src/core/platform-services.ts`, exported from the package entry `@agent-device/daemon`), 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
Expand Down
33 changes: 19 additions & 14 deletions docs/adr/0033-collocation-decision-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,20 +192,25 @@ files have no seam (published facade, public-type collision, root dependency fac

## The daemon package's export surface

`@agent-device/daemon` publishes a façade, not importer reach: one named subpath per
production/script consumer — a subpath that only tests import is not production surface.
Everything else a root test reaches lives under the `./testing/` namespace: named
`./testing/<module>` entries for production modules tests drive, named `./testing/<fixture>`
entries, and, per fixture folder, a `./testing/<dir>/*` pattern whose target template
reconstructs the source path. Never a wildcard that also exposes production modules. R11
refuses any `./testing/` import from a non-test file, so root production code cannot import
a module the package publishes for tests (a package test double reached through the
boundary is production code depending on a fake), and root production imports are limited
to the named production surface. The exports keys stay exact except the per-folder fixture
patterns; `resolveWorkspaceSpecifier` (layering model) owns Node's exports semantics — the
layering edges, the depgraph, mutation ownership, and R11's named-surface check call it, and
the Stryker alias table orders its Vite first-match list to resolve in exactly its order —
so every lane gives a specifier the module id Node's exports resolution gives it.
`@agent-device/daemon` has one entry, `.`, which is `packages/daemon/src/index.ts`: explicit named
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
re-exports of what the daemon process root uses (`startDaemonRuntime` and the types its
composition and platform-runtime modules name), no `export *`. It is the package boundary, the
one place a barrel is allowed; the layer folders hold no barrel. A value import of the package

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: session-lifecycle/index.ts and session-observability/index.ts remain internal re-export barrels; qualify this as the only public package barrel rather than the only allowed barrel.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/adr/0033-collocation-decision-table.md, line 198:

<comment>`session-lifecycle/index.ts` and `session-observability/index.ts` remain internal re-export barrels; qualify this as the only public package barrel rather than the only allowed barrel.</comment>

<file context>
@@ -192,20 +192,25 @@ files have no seam (published facade, public-type collision, root dependency fac
+`@agent-device/daemon` has one entry, `.`, which is `packages/daemon/src/index.ts`: explicit named
+re-exports of what the daemon process root uses (`startDaemonRuntime` and the types its
+composition and platform-runtime modules name), no `export *`. It is the package boundary, the
+one place a barrel is allowed; the layer folders hold no barrel. A value import of the package
+evaluates the daemon server graph, so root code that needs only command metadata reads it from
+its owner: the route owner-file claims sit beside the per-command claims in
</file context>
Suggested change
one place a barrel is allowed; the layer folders hold no barrel. A value import of the package
only public package barrel; legacy internal barrels remain in subfolders, not at the layer roots. A value import of the package

evaluates the daemon server graph, so root code that needs only command metadata reads it from
its owner: the route owner-file claims sit beside the per-command claims in
`@agent-device/command-registry/owner-files`, and the wire request and response types come from
`@agent-device/contracts/command` and `@agent-device/kernel/contracts`.

Root tests that still drive daemon internals reach them through transitional `./testing/`
entries: named `./testing/<module>` entries for production modules, named `./testing/<fixture>`
entries, and per fixture folder a `./testing/<dir>/*` pattern. Each batch of tests that moves into
`packages/daemon/src`, where they import their subject relatively, deletes the entries it frees.
R11 refuses any `./testing/` import from a non-test file, so root production code cannot import
a module the package publishes for tests (a package test double reached through the boundary is
production code depending on a fake). `resolveWorkspaceSpecifier` (layering model) owns Node's
exports semantics — the layering edges, the depgraph, mutation ownership, and R11's named-surface
check call it, and the Stryker alias table orders its Vite first-match list to resolve in exactly
its order — so every lane gives a specifier the module id Node's exports resolution gives it.

## Refuted alternatives

Expand Down
26 changes: 26 additions & 0 deletions packages/command-registry/src/owner-files.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { DaemonCommandRoute } from './daemon-command-descriptor.ts';
import { RAW_COMMAND_DESCRIPTORS, type Command } from './registry.ts';

/**
Expand Down Expand Up @@ -46,3 +47,28 @@ export const COMMAND_OWNER_FILES = buildOwnerFiles(RAW_COMMAND_DESCRIPTORS) sati
export function ownerFilesForCommand(command: Command): readonly [string, ...string[]] {
return COMMAND_OWNER_FILES[command];
}

/**
* Development-only owner-file navigation claims for each daemon route. The route's runtime
* binding is its lazy loader in the daemon's handler table; this map is tooling metadata only

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The generic route is not lazy: the handler chain statically imports its module. Narrow this statement to specialized routes so the metadata comment does not misdescribe the generic route.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/command-registry/src/owner-files.ts, line 53:

<comment>The `generic` route is not lazy: the handler chain statically imports its module. Narrow this statement to specialized routes so the metadata comment does not misdescribe the generic route.</comment>

<file context>
@@ -46,3 +47,28 @@ export const COMMAND_OWNER_FILES = buildOwnerFiles(RAW_COMMAND_DESCRIPTORS) sati
+
+/**
+ * Development-only owner-file navigation claims for each daemon route. The route's runtime
+ * binding is its lazy loader in the daemon's handler table; this map is tooling metadata only
+ * `explain:command` reads, so it lives with the command claims above and the bundler drops it.
+ *
</file context>
Suggested change
* binding is its lazy loader in the daemon's handler table; this map is tooling metadata only
* Specialized routes bind through lazy loaders in the daemon's handler table; this map is tooling metadata only

* `explain:command` reads, so it lives with the command claims above and the bundler drops it.
*
* `satisfies Record<DaemonCommandRoute, string>` keeps the map complete: adding or renaming a
* route is a compile error until this map matches. The daemon's `request-handler-chain` parity
* test asserts each path still points at the module that route's loader imports.
*/
const DAEMON_ROUTE_OWNER_FILES = {
humanControl: 'packages/daemon/src/sessions/handlers/human-control.ts',
lease: 'packages/daemon/src/sessions/handlers/lease.ts',
session: 'packages/daemon/src/sessions/handlers/session.ts',
snapshot: 'packages/daemon/src/sessions/handlers/snapshot.ts',
reactNative: 'packages/daemon/src/execution/handlers/react-native.ts',
recordTrace: 'packages/daemon/src/sessions/handlers/record-trace.ts',
find: 'packages/daemon/src/execution/interaction/index.ts',
interaction: 'packages/daemon/src/execution/interaction/index.ts',
generic: 'packages/daemon/src/execution/request-generic-dispatch.ts',
} as const satisfies Record<DaemonCommandRoute, string>;

export function getDaemonRouteOwnerFiles(): Record<DaemonCommandRoute, string> {
return { ...DAEMON_ROUTE_OWNER_FILES };
}
38 changes: 11 additions & 27 deletions packages/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,33 +31,9 @@
"yaml": "^2.9.0"
},
"exports": {
"./daemon-command-registry": {
"types": "./src/core/daemon-command-registry.ts",
"default": "./src/core/daemon-command-registry.ts"
},
"./daemon-request": {
"types": "./src/core/daemon-request.ts",
"default": "./src/core/daemon-request.ts"
},
"./platform-owner-lifecycle": {
"types": "./src/core/platform-owner-lifecycle.ts",
"default": "./src/core/platform-owner-lifecycle.ts"
},
"./platform-resource-cleanup": {
"types": "./src/core/platform-resource-cleanup.ts",
"default": "./src/core/platform-resource-cleanup.ts"
},
"./platform-services": {
"types": "./src/core/platform-services.ts",
"default": "./src/core/platform-services.ts"
},
"./route-owner-files": {
"types": "./src/sessions/route-owner-files.ts",
"default": "./src/sessions/route-owner-files.ts"
},
"./server/daemon-runtime": {
"types": "./src/server/http/daemon-runtime.ts",
"default": "./src/server/http/daemon-runtime.ts"
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./testing/__tests__/android-observation-fixture": {
"types": "./src/__tests__/support/android-observation-fixture.ts",
Expand Down Expand Up @@ -187,6 +163,10 @@
"types": "./src/core/context.ts",
"default": "./src/core/context.ts"
},
"./testing/daemon-command-registry": {
"types": "./src/core/daemon-command-registry.ts",
"default": "./src/core/daemon-command-registry.ts"
},
"./testing/deferred-interaction-outcome": {
"types": "./src/core/deferred-interaction-outcome.ts",
"default": "./src/core/deferred-interaction-outcome.ts"
Expand Down Expand Up @@ -363,6 +343,10 @@
"types": "./src/execution/selector-runtime-backend.ts",
"default": "./src/execution/selector-runtime-backend.ts"
},
"./testing/server/daemon-runtime": {
"types": "./src/server/http/daemon-runtime.ts",
"default": "./src/server/http/daemon-runtime.ts"
},
"./testing/server/http-server": {
"types": "./src/server/http/http-server.ts",
"default": "./src/server/http/http-server.ts"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import type { RefFrameEffect } from '@agent-device/contracts/replay';
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog';
import {
DAEMON_COMMAND_DESCRIPTORS,
resolveRefFrameEffect,
} from '@agent-device/daemon/daemon-command-registry';
import { DAEMON_COMMAND_DESCRIPTORS, resolveRefFrameEffect } from '../daemon-command-registry.ts';

// ADR 0014 migration step 2: the ref-frame-effect classification is an
// honesty/completeness guard. Every command that reaches a session-owning daemon
Expand Down
11 changes: 11 additions & 0 deletions packages/daemon/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { startDaemonRuntime } from './server/http/daemon-runtime.ts';
export type {
ComposeDaemonRuntimeDeps,
DaemonRuntimeController,
DaemonRuntimeDeps,
DaemonRuntimeDepsInput,
} from './server/http/daemon-runtime.ts';
export type { DaemonInvokeFn, DaemonRequest } from './core/daemon-request.ts';
export type { DaemonPlatformServices } from './core/platform-services.ts';
export type { PlatformOwnerLifecycle } from './core/platform-owner-lifecycle.ts';
export type { PlatformResourceCleanup } from './core/platform-resource-cleanup.ts';
33 changes: 0 additions & 33 deletions packages/daemon/src/sessions/route-owner-files.ts

This file was deleted.

9 changes: 8 additions & 1 deletion scripts/__tests__/eager-closure-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,14 @@ export const NEW_ENTRY_CEILINGS: Readonly<Record<EntryCategory, number>> = Objec
*/
export const APPROVED_OVER_CEILING: Readonly<
Record<string, { issue: string; reason: string; owner: string }>
> = Object.freeze({});
> = Object.freeze({
'packages/daemon/src/index.ts': {
issue: '#3352',
reason:
"The daemon package's one public entry. Its only value export is `startDaemonRuntime`, so its closure is the server graph that `src/daemon.ts` loads on every daemon start; the other exports are types. `src/daemon.ts` stays under the no-growth rule, and no CLI-process entry reaches the package (R78).",
owner: 'thymikee',
},
});

/** The category is a function of the path, never a hand-written column. */
export function entryCategoryOf(entryFile: string): EntryCategory {
Expand Down
6 changes: 4 additions & 2 deletions scripts/check-bundle-owner-files.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';
import { COMMAND_OWNER_FILES } from '@agent-device/command-registry/owner-files';
import { getDaemonRouteOwnerFiles } from '@agent-device/daemon/route-owner-files';
import {
COMMAND_OWNER_FILES,
getDaemonRouteOwnerFiles,
} from '@agent-device/command-registry/owner-files';
import { walkFiles } from './lib/walk-files.ts';

const repoRoot = path.resolve(import.meta.dirname, '..');
Expand Down
29 changes: 15 additions & 14 deletions scripts/depgraph/affected-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog';
import { deriveDaemonCommandDescriptors } from '@agent-device/command-registry/derive';
import { commandDescriptors } from '@agent-device/command-registry/registry';
import {
INTERACTION_DISPATCH_PATHS,
type InteractionPathId,
} from '@agent-device/contracts/interaction-guarantees';
import { DAEMON_COMMAND_DESCRIPTORS } from '@agent-device/daemon/daemon-command-registry';
import { parseArgs } from 'node:util';
import { selectChecks, type CheckPlan } from '../check-affected/model.ts';
import {
Expand Down Expand Up @@ -141,19 +142,19 @@ function commandChains(repoRoot: string): CommandChain[] {
const source = fs.readFileSync(path.join(repoRoot, HANDLER_CHAIN_FILE), 'utf8');
const routeEntries = parseRouteEntries(source, HANDLER_CHAIN_FILE);
const publicCommands = new Set<string>(Object.values(PUBLIC_COMMANDS));
return DAEMON_COMMAND_DESCRIPTORS.filter((descriptor) =>
publicCommands.has(descriptor.command),
).map((descriptor) => {
const entry = routeEntries.get(descriptor.route);
if (!entry) {
throw new Error(
`no handler entry for daemon route "${descriptor.route}" — the route table in ` +
`${HANDLER_CHAIN_FILE} changed shape; update parseRouteEntries in ` +
`scripts/depgraph/affected-run.ts.`,
);
}
return { command: descriptor.command, route: descriptor.route, entry };
});
return deriveDaemonCommandDescriptors(commandDescriptors)
.filter((descriptor) => publicCommands.has(descriptor.command))
.map((descriptor) => {
const entry = routeEntries.get(descriptor.route);
if (!entry) {
throw new Error(
`no handler entry for daemon route "${descriptor.route}" — the route table in ` +
`${HANDLER_CHAIN_FILE} changed shape; update parseRouteEntries in ` +
`scripts/depgraph/affected-run.ts.`,
);
}
return { command: descriptor.command, route: descriptor.route, entry };
});
}

/** Flatten the ADR 0011 matrix into one row per (path, guarantee) cell that names a module. */
Expand Down
2 changes: 1 addition & 1 deletion scripts/explain-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { explainCommand, formatCommandExplanation } from '../src/cli/command-explain.ts';
import { getDaemonRouteOwnerFiles } from '@agent-device/daemon/route-owner-files';
import { getDaemonRouteOwnerFiles } from '@agent-device/command-registry/owner-files';

const repoRoot = path.resolve(import.meta.dirname, '..');
export type ExplainCommandCliResult = {
Expand Down
11 changes: 10 additions & 1 deletion scripts/layering/daemon-layers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { resolveImportEdges, type ResolvedImportEdge } from './model.ts';
import {
checkDaemonLayers,
DAEMON_LAYER_ORDER,
DAEMON_PACKAGE_ENTRY,
DAEMON_ROOT,
daemonLayerOf,
} from './daemon-layers.ts';
Expand All @@ -34,7 +35,7 @@ test('every tracked daemon production file resolves to a layer folder, and no st
const daemonFiles = listSourceFiles().filter((file) => file.startsWith(`${DAEMON_ROOT}/`));
assert.ok(daemonFiles.length > 0, 'expected daemon production files to scan');
assert.deepEqual(
daemonFiles.filter((file) => daemonLayerOf(file) === null),
daemonFiles.filter((file) => file !== DAEMON_PACKAGE_ENTRY && daemonLayerOf(file) === null),
[],
);

Expand Down Expand Up @@ -144,6 +145,14 @@ test('a production file at the daemon root fails: the folder is the declaration'
assert.match(violations[1]!.message, /does not start with one/);
});

test('the package entry is the one root file that needs no layer folder', () => {
assert.deepEqual(
checkDaemonLayers([...FIXTURE_FILES, DAEMON_PACKAGE_ENTRY], [], () => {}),
[],
);
assert.equal(DAEMON_PACKAGE_ENTRY, `${DAEMON_ROOT}/index.ts`);
});

test('tests and test support carry no layer and never trip the folder claim', () => {
const testSide = [
`${DAEMON_ROOT}/core/__tests__/thing.test.ts`,
Expand Down
12 changes: 9 additions & 3 deletions scripts/layering/daemon-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
// only direction claim over the daemon's interior. Dynamic imports crossing layers are reported
// explicitly, never rejected: the route loaders in `request-handler-chain.ts` defer exactly the
// handlers the eager closure must not carry, and R5 made the same dynamic-vs-static decision
// (#3280). An unknown first segment (or a production file sitting at the daemon root) fails,
// so a new file cannot enter the zone without picking a layer.
// (#3280). An unknown first segment (or a production file sitting at the daemon root, bar the
// package entry `index.ts`) fails, so a new file cannot enter the zone without picking a layer.
// Evidence: #3391 landed the partition; PR 10 moved each layer into its folder and re-measured
// the zero upward static value-or-type edge claim against the moved graph.
// Cost: attributed to the R81 rule registration in check.ts; not a standalone CI job.
Expand All @@ -27,6 +27,12 @@ export const DAEMON_ROOT = DAEMON_SOURCE_ROOT.slice(0, -1);
/** The daemon-local order, lowest first. The global spine rank stays 4 for all five layers. */
export const DAEMON_LAYER_ORDER = ['core', 'resources', 'execution', 'sessions', 'server'] as const;

/**
* The package boundary: the one production file allowed at the daemon root, because it declares
* the public API rather than belonging to a layer.
*/
export const DAEMON_PACKAGE_ENTRY = `${DAEMON_SOURCE_ROOT}index.ts`;

export type DaemonLayer = (typeof DAEMON_LAYER_ORDER)[number];

function isDaemonLayer(segment: string): segment is DaemonLayer {
Expand Down Expand Up @@ -99,7 +105,7 @@ export function checkDaemonLayers(
for (const file of trackedDaemonProductionFiles) {
// The context's scan names every tracked TS file in the zone; tests and test support mirror
// their source module by convention and carry no folder claim.
if (!isProductionSourceFile(file)) continue;
if (!isProductionSourceFile(file) || file === DAEMON_PACKAGE_ENTRY) continue;
if (daemonLayerOf(file) === null) {
const rest = path.relative(DAEMON_ROOT, file);
violations.push({
Expand Down
6 changes: 4 additions & 2 deletions src/__tests__/command-descriptor-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
listCliCommandNames,
PUBLIC_COMMANDS,
} from '@agent-device/command-registry/catalog';
import { canRunReplayScopedAction } from '@agent-device/daemon/daemon-command-registry';
import {
deriveDaemonCommandDescriptors,
deriveStructuredBatchCommandNames,
Expand Down Expand Up @@ -134,6 +133,9 @@ test('structured-batch allowlist is built from descriptors', () => {
// #1310: every raw descriptor explicitly decides recording; the daemon
// replayScopedAction trait and MCP schema projection are both derived from it.
test('recordsSessionAction is explicit on every raw descriptor and drives daemon replay policy', () => {
const derivedDaemonCommands = new Map(
deriveDaemonCommandDescriptors(commandDescriptors).map((daemon) => [daemon.command, daemon]),
);
for (const descriptor of RAW_COMMAND_DESCRIPTORS) {
assert.equal(
typeof descriptor.recordsSessionAction,
Expand Down Expand Up @@ -167,7 +169,7 @@ test('recordsSessionAction is explicit on every raw descriptor and drives daemon
if (!('daemon' in descriptor)) continue;
assert.equal(
resolveCommandRecordsSessionAction(descriptor.name),
canRunReplayScopedAction(descriptor.name),
derivedDaemonCommands.get(descriptor.name)?.replayScopedAction === true,
`${descriptor.name} recordsSessionAction matches daemon replayScopedAction`,
);
}
Expand Down
Loading
Loading