mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
51ed6217cc
* refactor(daemon): extract the repair-tombstone reader below store and client `findUnrecoveredRepairCommitFailure` reads session artifacts off disk and is reached from the daemon client, which had to import `session-store.ts` — the daemon's largest server module — for it. Move the tombstone shape, its file reader and the unrecovered-commit scan into `session-repair-tombstone.ts`, a leaf below both, and give the tombstone file name a single owner. No behavior change; both consumers keep their existing tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZkJjeEhLmyGpGtcwY8pqc * refactor(daemon): relocate the daemon client out of src/daemon `src/daemon/client/` is the daemon's client, not the daemon: no daemon file imports it, and its consumers are the CLI, the Node client, the proxy command and the injected dispatch type. Move it to `src/daemon-client/` as renames so `src/daemon` is server code plus the shared kernel the client still needs — `config.ts`, `daemon-process.ts`, `request-progress-protocol.ts`, `daemon-request.ts` and the extracted `session-repair-tombstone.ts`. Zone name and rank are unchanged (`daemon-client`, 5); the zone now falls out of the folder instead of a `src/daemon/client/` prefix. Tests move unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZkJjeEhLmyGpGtcwY8pqc * refactor(daemon): move the session artifact path helpers out of session-store `src/cli.ts` and `src/remote/remote-request-diagnostics.ts` reach into `session-store.ts` for one pure path function, `resolveRemoteRequestDiagnosticsPath`, which made every CLI process eagerly evaluate the daemon's session store and its whole subtree — the script writer, the event log, the action recorder and the replay transaction vocabulary. The four artifact path helpers name files; they hold no store state. Move them to `src/daemon/session-artifact-paths.ts`, a leaf over `session-paths.ts`, and point all ten consumers at it. `src/cli.ts`'s eager closure drops from 379 modules to 365 and no longer contains `session-store.ts`; the store itself is 464 -> 341 lines. AGENTS.md's declaration-site pointer follows. No behavior change: the helpers are unmodified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZkJjeEhLmyGpGtcwY8pqc * chore(gates): re-key the daemon-client gate paths onto src/daemon-client Path-keyed enforcement follows the relocated files: the fallow health baseline entries, the oxlint per-file override, the wire-compat surface/ledger/mutation paths, and the layering zone derivation (the `src/daemon/client/` prefix is dead now that the folder itself names the zone). R10's external daemon request/session-state importer list gains the five client modules. The edges are unchanged by this PR — the client has always built `DaemonRequest` and read `DaemonResponse`; it sat inside `src/daemon/` and so fell under the prefix skip. Naming the files keeps the dependency enumerated and shrink-only, so a new `src/daemon-client/` module reaching `session-state` still fails. Its size assertion now reads the recorded list instead of a literal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZkJjeEhLmyGpGtcwY8pqc --------- Co-authored-by: Claude <noreply@anthropic.com>
338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
import path from 'node:path';
|
|
import {
|
|
INTERACTION_RETIRED_HANDLER_PATHS,
|
|
LOGICAL_MODULE_POLICIES,
|
|
matchesDeclaredRoot,
|
|
SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS,
|
|
SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS,
|
|
SNAPSHOT_EXECUTION_RETIRED_HANDLER_PATHS,
|
|
type LogicalModulePolicy,
|
|
} from './architecture-ownership.ts';
|
|
import { targetDagZone, type LayeringViolation, type ResolvedImportEdge } from './model.ts';
|
|
import type { LayeringRatchets } from './ratchet-reference.ts';
|
|
|
|
// R7 ownership pressure and the largest type cycle (whole and per zone) are ratcheted against the
|
|
// merge-base with origin/main (`ratchet-reference.ts`); the importer membership below stays a
|
|
// recorded list, because it names files rather than counting them.
|
|
export const DAEMON_MODULARITY_BASELINE = {
|
|
externalDaemonTypesImporters: [
|
|
'src/client/client-normalizers.ts',
|
|
// #2342 relocated the daemon client to `src/daemon-client/`. These five edges are unchanged
|
|
// by that move — the client has always built `DaemonRequest` and read `DaemonResponse`, it
|
|
// simply sat inside `src/daemon/` and so fell under the prefix skip below. Naming the files
|
|
// is stronger than letting a folder prefix hide them: the set can only shrink, so a new
|
|
// `src/daemon-client/` module reaching `session-state.ts` or the daemon-private request half
|
|
// still fails this gate. Reducing these five means giving the client a neutral request
|
|
// contract, which is a type change, not a relocation.
|
|
'src/daemon-client/daemon-client-lifecycle.ts',
|
|
'src/daemon-client/daemon-client-progress.ts',
|
|
'src/daemon-client/daemon-client-rpc.ts',
|
|
'src/daemon-client/daemon-client-transport.ts',
|
|
'src/daemon-client/daemon-client.ts',
|
|
'src/remote/daemon-artifacts.ts',
|
|
],
|
|
} as const;
|
|
|
|
// The modules that own the daemon's dispatch vocabulary since #2338 split `daemon/types.ts`:
|
|
// the request shape, its wire-only half, and the live session record. All three are ratcheted
|
|
// together, so moving a symbol between them cannot reopen the boundary to a new outside zone.
|
|
const DAEMON_TYPE_MODULES: readonly string[] = [
|
|
'src/daemon/daemon-request.ts',
|
|
'src/daemon/daemon-request-wire.ts',
|
|
'src/daemon/session-state.ts',
|
|
];
|
|
|
|
const ENGINE_FILE_PREFIXES = [
|
|
'packages/ad-replay/src/',
|
|
'packages/maestro/src/',
|
|
'src/daemon/replay/internal/',
|
|
'packages/replay-test/src/',
|
|
] as const;
|
|
|
|
/**
|
|
* Catches: the daemon modularity migration regressing quietly — a SessionState field losing
|
|
* its owner, a logical module gaining a forbidden or internal import, or an external
|
|
* external daemon request/session-state importer count creeping up — any of which erodes the wave-by-wave
|
|
* extraction #1478/#1478-P5 already paid for, and nothing enforces the wave order itself.
|
|
* Evidence: 2316fd32c5 (#1487) pinned the migration contracts this ratchet grew from;
|
|
* 6984a1e095 (#1852) fixed the R10 zone-listing message when the type-cycle ceiling trips.
|
|
* Cost: 937 LOC total for the file (323 rule + 614 test; shared with R9's checkTypeCycleBaseline
|
|
* below, not attributed separately).
|
|
* Kill criterion: none enforced today; retire only by maintainer decision that the daemon
|
|
* modularity measurements (SessionState field-owner counts, logical-module import policies and
|
|
* facades, the external daemon request/session-state importer list, per-zone cycle membership) no longer
|
|
* matter. Every one is a count or an import edge the compiler accepts either way.
|
|
*/
|
|
export function checkDaemonModularityRatchets(
|
|
edges: readonly ResolvedImportEdge[],
|
|
measured: LayeringRatchets,
|
|
reference: LayeringRatchets,
|
|
): LayeringViolation[] {
|
|
return [
|
|
...checkSessionStateBaseline(measured.sessionState, reference.sessionState),
|
|
...checkTypeCycleBaseline(measured.largestTypeCycle, reference.largestTypeCycle),
|
|
...checkDaemonTypesImporters(edges),
|
|
...checkLogicalModuleImports(edges),
|
|
];
|
|
}
|
|
|
|
export function checkRetiredSessionLifecyclePaths(
|
|
sourceFiles: readonly string[],
|
|
): LayeringViolation[] {
|
|
return checkRetiredHandlerPaths(
|
|
sourceFiles,
|
|
SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS,
|
|
/^src\/daemon\/handlers\/session-(?:open|close)(?:-[^/]+)?\.ts$/,
|
|
'session lifecycle',
|
|
);
|
|
}
|
|
|
|
export function checkRetiredSessionObservabilityPaths(
|
|
sourceFiles: readonly string[],
|
|
): LayeringViolation[] {
|
|
return checkRetiredHandlerPaths(
|
|
sourceFiles,
|
|
SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS,
|
|
/^src\/daemon\/handlers\/session-(?:observability|perf|logs|events|network|audio)(?:-[^/]+)?\.ts$/,
|
|
'session observability',
|
|
);
|
|
}
|
|
|
|
export function checkRetiredSnapshotExecutionPaths(
|
|
sourceFiles: readonly string[],
|
|
): LayeringViolation[] {
|
|
return checkRetiredHandlerPaths(
|
|
sourceFiles,
|
|
SNAPSHOT_EXECUTION_RETIRED_HANDLER_PATHS,
|
|
/$^/,
|
|
'snapshot execution handler',
|
|
'Reuse the daemon-owned snapshot execution module instead of restoring shared mechanics beneath a route adapter.',
|
|
);
|
|
}
|
|
|
|
function checkRetiredHandlerPaths(
|
|
sourceFiles: readonly string[],
|
|
retiredPaths: readonly string[],
|
|
pattern: RegExp,
|
|
capability: string,
|
|
guidance = 'Keep the neutral seam at its daemon owner instead of rebuilding a handler grab-bag.',
|
|
): LayeringViolation[] {
|
|
return sourceFiles
|
|
.filter((file) => retiredPaths.includes(file) || pattern.test(file))
|
|
.map((file) => ({
|
|
rule: 'R10 daemon-modularity',
|
|
file,
|
|
line: 1,
|
|
message: `retired ${capability} path was restored: ${file}. ` + guidance,
|
|
}));
|
|
}
|
|
|
|
export function checkRetiredInteractionPaths(sourceFiles: readonly string[]): LayeringViolation[] {
|
|
return checkRetiredHandlerPaths(
|
|
sourceFiles,
|
|
INTERACTION_RETIRED_HANDLER_PATHS,
|
|
/^src\/daemon\/handlers\/(?:find|interaction)(?:-[^/]+)?\.ts$/,
|
|
'interaction handler',
|
|
'Keep route implementations behind src/daemon/interaction/index.ts instead of rebuilding a handler-owned interaction surface.',
|
|
);
|
|
}
|
|
|
|
function checkSessionStateBaseline(
|
|
measured: LayeringRatchets['sessionState'],
|
|
reference: LayeringRatchets['sessionState'],
|
|
): LayeringViolation[] {
|
|
const violations: LayeringViolation[] = [];
|
|
for (const metric of ['writerOwnedFields', 'ownerFileClaims'] as const) {
|
|
if (measured[metric] <= reference[metric]) continue;
|
|
violations.push({
|
|
rule: 'R10 daemon-modularity',
|
|
file: 'scripts/layering/daemon-modularity.ts',
|
|
line: 1,
|
|
message:
|
|
`R7 ${metric} grew to ${measured[metric]} (baseline ${reference[metric]} at the ` +
|
|
`merge-base). Route the new write through an existing owner instead.`,
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/**
|
|
* Catches: the largest type-only import cycle growing past what the merge-base holds, whole or
|
|
* in any one zone — R4 keeps the value graph acyclic, so these cycles cost nothing at runtime,
|
|
* but an ungoverned type cycle can grow without bound while every individual edge still looks
|
|
* locally reasonable.
|
|
* Evidence: 6984a1e095 (#1852) fixed R10's zone listing when this ceiling trips, evidence the
|
|
* check fires in practice; ef6ec2995b (#1825, #1781 A6) made a banked shrink mandatory rather
|
|
* than advisory, which measuring the merge-base now does without an edit.
|
|
* Cost: 937 LOC total for the file (323 rule + 614 test; shared with R10's ratchets above, not
|
|
* attributed separately).
|
|
* Kill criterion: none enforced today; retire only by maintainer decision that a bounded
|
|
* type-only cycle size no longer matters. tsc never rejects a type-only cycle, and a merge-base
|
|
* with no cycle pins the size at zero rather than retiring the check.
|
|
*/
|
|
function checkTypeCycleBaseline(
|
|
members: readonly string[],
|
|
referenceMembers: readonly string[],
|
|
): LayeringViolation[] {
|
|
const violations: LayeringViolation[] = [];
|
|
if (members.length > referenceMembers.length) {
|
|
violations.push({
|
|
rule: 'R9 type-cycle-size',
|
|
file: 'scripts/layering/daemon-modularity.ts',
|
|
line: 1,
|
|
message:
|
|
`the largest type-level import cycle grew to ${members.length} files (baseline ` +
|
|
`${referenceMembers.length} at the merge-base). A type-only import that closes a loop makes ` +
|
|
`every file in the loop unreadable in isolation. Declare the shared type below both modules.`,
|
|
});
|
|
}
|
|
|
|
const referenceByZone = groupBy(referenceMembers, targetDagZone);
|
|
for (const [zone, zoneMembers] of groupBy(members, targetDagZone)) {
|
|
const referenceZoneMembers = new Set(referenceByZone.get(zone) ?? []);
|
|
const allowed = referenceZoneMembers.size;
|
|
if (zoneMembers.length <= allowed) continue;
|
|
// A ceiling recorded a count, so the gate could only list the whole zone and #1837's
|
|
// diagnosis landed on a file that had been in the cycle all along. The merge-base carries
|
|
// membership, so the files that joined are named exactly.
|
|
const joined = zoneMembers.filter((member) => !referenceZoneMembers.has(member));
|
|
violations.push({
|
|
rule: 'R10 daemon-modularity',
|
|
file: 'scripts/layering/daemon-modularity.ts',
|
|
line: 1,
|
|
message:
|
|
`the largest type cycle now contains ${zoneMembers.length} ${zone} file(s) (baseline ` +
|
|
`${allowed} at the merge-base); extraction must not trade one zone's locality for ` +
|
|
`another's. ${zoneMembers.length - allowed} over the merge-base — the ${zone} file(s) ` +
|
|
`that joined: ${joined.join(', ')}. Cut the edge that pulled them in.`,
|
|
});
|
|
}
|
|
|
|
for (const member of members) {
|
|
if (!ENGINE_FILE_PREFIXES.some((prefix) => member.startsWith(prefix))) continue;
|
|
violations.push({
|
|
rule: 'R10 daemon-modularity',
|
|
file: member,
|
|
line: 1,
|
|
message:
|
|
'an engine file entered the largest type cycle. Keep engine contracts neutral and adapters outside the engine so extraction does not worsen R9.',
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function checkDaemonTypesImporters(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
|
|
const allowed = new Set<string>(DAEMON_MODULARITY_BASELINE.externalDaemonTypesImporters);
|
|
const importers = new Map<string, ResolvedImportEdge>();
|
|
for (const edge of edges) {
|
|
if (!DAEMON_TYPE_MODULES.includes(edge.target) || edge.file.startsWith('src/daemon/')) continue;
|
|
importers.set(edge.file, edge);
|
|
}
|
|
const violations = [...importers]
|
|
.filter(([file]) => !allowed.has(file))
|
|
.map(([file, edge]) => ({
|
|
rule: 'R10 daemon-modularity',
|
|
file,
|
|
line: edge.line,
|
|
message:
|
|
`external production imports of the daemon request/session-state modules may only shrink ` +
|
|
`from the recorded ${allowed.size}. ` +
|
|
'Use an existing neutral contract; do not move DaemonRequest into contracts to satisfy this gate.',
|
|
}));
|
|
for (const file of allowed) {
|
|
if (importers.has(file)) continue;
|
|
violations.push({
|
|
rule: 'R10 daemon-modularity',
|
|
file: 'scripts/layering/daemon-modularity.ts',
|
|
line: 1,
|
|
message: `${file} no longer imports a daemon request/session-state module — delete it from externalDaemonTypesImporters in the same change so the dependency cannot return.`,
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function checkLogicalModuleImports(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
|
|
const violations: LayeringViolation[] = [];
|
|
for (const edge of edges) {
|
|
const sourceModule = moduleForFile(edge.file);
|
|
const targetModule = moduleForFile(edge.target);
|
|
if (
|
|
sourceModule &&
|
|
isInsideInternalTree(edge.file, sourceModule.roots) &&
|
|
sourceModule.internalForbiddenTargetRoots?.some((root) =>
|
|
matchesDeclaredRoot(edge.target, root),
|
|
)
|
|
) {
|
|
violations.push({
|
|
rule: 'R10 daemon-modularity',
|
|
file: edge.file,
|
|
line: edge.line,
|
|
message:
|
|
`${edge.file} must not import ${edge.target} from ${sourceModule.name}'s internal tree; ` +
|
|
'keep handler adapters above the interaction façade.',
|
|
});
|
|
continue;
|
|
}
|
|
if (
|
|
targetModule &&
|
|
sourceModule !== targetModule &&
|
|
isInsideInternalTree(edge.target, targetModule.roots)
|
|
) {
|
|
violations.push({
|
|
rule: 'R10 daemon-modularity',
|
|
file: edge.file,
|
|
line: edge.line,
|
|
message: `${edge.file} must not import ${targetModule.name}'s internal tree (${edge.target}); use that module's façade.`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!sourceModule) continue;
|
|
// A module's own files are never a forbidden target.
|
|
if (sourceModule.roots.some((root) => matchesDeclaredRoot(edge.target, root))) continue;
|
|
if (!sourceModule.forbiddenTargetRoots.some((root) => matchesDeclaredRoot(edge.target, root)))
|
|
continue;
|
|
violations.push({
|
|
rule: 'R10 daemon-modularity',
|
|
file: edge.file,
|
|
line: edge.line,
|
|
message: `${sourceModule.name} must not import ${edge.target}; communicate through its façade and a narrow port with two real adapters.`,
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function moduleForFile(file: string): LogicalModulePolicy | undefined {
|
|
return LOGICAL_MODULE_POLICIES.find((module) =>
|
|
module.roots.some((root) => matchesDeclaredRoot(file, root)),
|
|
);
|
|
}
|
|
|
|
function isInsideInternalTree(file: string, roots: readonly string[]): boolean {
|
|
return roots.some((root) => matchesDeclaredRoot(file, path.posix.join(root, 'internal/')));
|
|
}
|
|
|
|
function groupBy(
|
|
values: readonly string[],
|
|
keyOf: (value: string) => string,
|
|
): Map<string, string[]> {
|
|
const groups = new Map<string, string[]>();
|
|
for (const value of values) {
|
|
const key = keyOf(value);
|
|
const group = groups.get(key) ?? [];
|
|
group.push(value);
|
|
groups.set(key, group);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
export function daemonModularitySummary(reference: LayeringRatchets): string {
|
|
const session = reference.sessionState;
|
|
return (
|
|
`R10 holds R7 at the merge-base's ${session.writerOwnedFields} writer-owned fields / ` +
|
|
`${session.ownerFileClaims} owner claims, R9 at its ${reference.largestTypeCycle.length} files per zone, ` +
|
|
`${DAEMON_MODULARITY_BASELINE.externalDaemonTypesImporters.length} external daemon request/session-state importers, ` +
|
|
'and zero forbidden logical-module imports'
|
|
);
|
|
}
|