diff --git a/.changeset/bright-graphs-navigate.md b/.changeset/bright-graphs-navigate.md new file mode 100644 index 000000000..b671d4dcb --- /dev/null +++ b/.changeset/bright-graphs-navigate.md @@ -0,0 +1,5 @@ +--- +"@sapiom/agent": minor +--- + +Add `PACKAGE_INVENTORY_PROTOCOL`, `packageInventorySchema`, and the public package-inventory types for validating versioned working-tree and immutable-bundle agent inventories. diff --git a/.changeset/calm-graphs-navigate.md b/.changeset/calm-graphs-navigate.md new file mode 100644 index 000000000..66b464515 --- /dev/null +++ b/.changeset/calm-graphs-navigate.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Project dependency graphs now open immediately and stay on their fast path. Agents appear as soon as a project is selected and fill in their real names in the background, and a project whose graph is otherwise fine no longer shows "Graph may be incomplete" because one agent's source could not be read. That agent is still called out on its own; the rest of the graph is cached. Graph navigation targets are now served with the revision they belong to, so opening an agent from the map lands on the right one. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 6b6c80fc7..8a39fac44 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -112,6 +112,17 @@ export type { StepInputContract, AgentInputContract } from './introspection.js'; export { MANIFEST_PROTOCOL, agentManifestSchema } from './manifest.js'; export type { AgentManifest, AgentStepManifest, ManifestTransition } from './manifest.js'; +// Multi-agent package inventory — separate from the single-agent build manifest. +export { PACKAGE_INVENTORY_PROTOCOL, packageInventorySchema } from './package-inventory.js'; +export type { + PackageInventory, + PackageInventoryAgent, + PackageInventoryIdentityIssue, + PackageInventoryJsonValue, + PackageInventoryStaticSignals, + PackageInventoryVersion, +} from './package-inventory.js'; + // Manifest generator + graph validation — called by the build phase. export { buildManifest, validateGraph, assertValidGraph } from './build-manifest.js'; export type { GraphValidation } from './build-manifest.js'; diff --git a/packages/agent/src/package-inventory.spec.ts b/packages/agent/src/package-inventory.spec.ts new file mode 100644 index 000000000..0dab20dca --- /dev/null +++ b/packages/agent/src/package-inventory.spec.ts @@ -0,0 +1,366 @@ +import { + PACKAGE_INVENTORY_PROTOCOL, + packageInventorySchema, + type PackageInventory, + type PackageInventoryIdentityIssue, + type PackageInventoryJsonValue, + type PackageInventoryStaticSignals, +} from "./index.js"; + +const SHA_A = `sha256:${"a".repeat(64)}` as const; +const SHA_B = `sha256:${"b".repeat(64)}` as const; + +function workingTree( + overrides: Partial = {}, +): PackageInventory { + return { + protocol: PACKAGE_INVENTORY_PROTOCOL, + version: { + kind: "working-tree", + workspaceKey: "workspace-acme", + revision: SHA_A, + }, + status: "complete", + agents: [ + { + agentKey: "Research", + identityStatus: "canonical", + path: "agents/research", + entrypoint: "index.ts", + }, + ], + ...overrides, + }; +} + +describe("packageInventorySchema", () => { + it("parses working-tree and bundle inventories and sorts agents deterministically", () => { + const agents = [ + { + agentKey: "zeta", + identityStatus: "canonical" as const, + path: "zeta", + entrypoint: "index.ts", + }, + { + agentKey: "Alpha", + identityStatus: "canonical" as const, + path: ".", + entrypoint: "src/index.ts", + staticSignals: { + protocol: 1, + payload: { nested: [true, null, 3] }, + }, + }, + ]; + const working = packageInventorySchema.parse( + workingTree({ agents: [...agents].reverse() }), + ); + const equivalent = packageInventorySchema.parse(workingTree({ agents })); + + expect(working.agents.map((agent) => agent.agentKey)).toEqual([ + "Alpha", + "zeta", + ]); + expect(working.agents).toEqual(equivalent.agents); + expect( + packageInventorySchema.parse({ + protocol: 1, + version: { kind: "bundle", bundleDigest: SHA_B }, + status: "complete", + agents, + }).version, + ).toEqual({ kind: "bundle", bundleDigest: SHA_B }); + }); + + it("accepts a degraded working tree with a safe provisional identity", () => { + expect( + packageInventorySchema.parse( + workingTree({ + status: "degraded", + agents: [ + { + agentKey: "local:agents/research", + identityStatus: "provisional", + identityIssue: "identity-pending", + path: "agents/research", + entrypoint: "index.ts", + }, + ], + }), + ).status, + ).toBe("degraded"); + }); + + it.each(["local:C:", "local:C:/agent"])( + "rejects Windows drive-shaped provisional key %j", + (agentKey) => { + expect(() => + packageInventorySchema.parse( + workingTree({ + status: "degraded", + agents: [ + { + agentKey, + identityStatus: "provisional", + identityIssue: "identity-unavailable", + path: "agent", + entrypoint: "index.ts", + }, + ], + }), + ), + ).toThrow(); + }, + ); + + it.each([ + "", + " agent", + "agent ", + ".", + "..", + "agents/research", + "agents\\research", + "/absolute", + "local:reserved", + "control\nname", + "control\u0085name", + "control\u009fname", + ])("rejects unsafe canonical name %j", (agentKey) => { + expect(() => + packageInventorySchema.parse( + workingTree({ + agents: [ + { + agentKey, + identityStatus: "canonical", + path: ".", + entrypoint: "index.ts", + }, + ], + }), + ), + ).toThrow(); + }); + + it.each([ + "sha256:ABCDEF", + `sha256:${"A".repeat(64)}`, + `sha256:${"a".repeat(63)}`, + "a".repeat(64), + ])("rejects invalid digest %j", (revision) => { + expect(() => + packageInventorySchema.parse( + workingTree({ + version: { + kind: "working-tree", + workspaceKey: "workspace-acme", + revision: revision as `sha256:${string}`, + }, + }), + ), + ).toThrow(); + }); + + it.each([ + { path: "", entrypoint: "index.ts" }, + { path: "/absolute", entrypoint: "index.ts" }, + { path: "C:/absolute", entrypoint: "index.ts" }, + { path: "agents\\research", entrypoint: "index.ts" }, + { path: "agents/../research", entrypoint: "index.ts" }, + { path: ".", entrypoint: "../index.ts" }, + { path: ".", entrypoint: "/index.ts" }, + { path: ".", entrypoint: "C:/index.ts" }, + { path: ".", entrypoint: "." }, + ])( + "rejects unsafe relative location $path/$entrypoint", + ({ path, entrypoint }) => { + expect(() => + packageInventorySchema.parse( + workingTree({ + agents: [ + { + agentKey: "research", + identityStatus: "canonical", + path, + entrypoint, + }, + ], + }), + ), + ).toThrow(); + }, + ); + + it("rejects duplicate keys and duplicate path/entrypoint pairs", () => { + const base = workingTree().agents[0]!; + expect(() => + packageInventorySchema.parse( + workingTree({ agents: [base, { ...base, path: "copy" }] }), + ), + ).toThrow(/Duplicate agentKey/); + expect(() => + packageInventorySchema.parse( + workingTree({ agents: [base, { ...base, agentKey: "growth" }] }), + ), + ).toThrow(/Duplicate agent path/); + }); + + it("rejects complete provisional inventories and every degraded bundle", () => { + const provisional = workingTree({ + status: "degraded", + agents: [ + { + agentKey: "local:research", + identityStatus: "provisional", + identityIssue: "identity-unavailable", + path: "research", + entrypoint: "index.ts", + }, + ], + }); + expect(() => + packageInventorySchema.parse({ ...provisional, status: "complete" }), + ).toThrow(/provisional identities must be degraded/); + expect(() => + packageInventorySchema.parse({ + ...provisional, + version: { kind: "bundle", bundleDigest: SHA_B }, + }), + ).toThrow(/bundle inventory/); + expect(() => + packageInventorySchema.parse({ + ...workingTree({ status: "degraded" }), + version: { kind: "bundle", bundleDigest: SHA_B }, + }), + ).toThrow(/bundle inventory/); + }); + + it("accepts degraded working trees with canonical-only or empty rows", () => { + expect( + packageInventorySchema.parse(workingTree({ status: "degraded" })).status, + ).toBe("degraded"); + expect( + packageInventorySchema.parse( + workingTree({ status: "degraded", agents: [] }), + ).agents, + ).toEqual([]); + }); + + it("requires duplicate provisional records to retain their safe candidate", () => { + const duplicate = { + agentKey: "local:research-a", + identityStatus: "provisional" as const, + identityIssue: "duplicate-agent-key" as const, + path: "research-a", + entrypoint: "index.ts", + }; + expect(() => + packageInventorySchema.parse( + workingTree({ status: "degraded", agents: [duplicate] as never }), + ), + ).toThrow(/ambiguous candidate/); + expect( + packageInventorySchema.parse( + workingTree({ + status: "degraded", + agents: [{ ...duplicate, candidateAgentKey: "research" }], + }), + ).agents[0]?.candidateAgentKey, + ).toBe("research"); + }); + + it.each([ + { + agentKey: "research", + identityStatus: "canonical", + identityIssue: "identity-pending", + path: "research", + entrypoint: "index.ts", + }, + { + agentKey: "research", + identityStatus: "canonical", + candidateAgentKey: "candidate", + path: "research", + entrypoint: "index.ts", + }, + { + agentKey: "local:research", + identityStatus: "canonical", + path: "research", + entrypoint: "index.ts", + }, + { + agentKey: "local:research", + identityStatus: "provisional", + path: "research", + entrypoint: "index.ts", + }, + { + agentKey: "local:research", + identityStatus: "provisional", + identityIssue: "identity-pending", + candidateAgentKey: "research", + path: "research", + entrypoint: "index.ts", + }, + ])("rejects inconsistent identity state %#", (agent) => { + expect(() => + packageInventorySchema.parse( + workingTree({ status: "degraded", agents: [agent] as never }), + ), + ).toThrow(); + }); + + it.each([ + { protocol: 0, payload: null }, + { protocol: 1.5, payload: null }, + { protocol: 1, payload: Number.NaN }, + { protocol: 1, payload: Number.POSITIVE_INFINITY }, + { protocol: 1, payload: undefined }, + { protocol: 1, payload: () => undefined }, + { protocol: 1, payload: BigInt(1) }, + ])("rejects invalid static signals %#", (staticSignals) => { + expect(() => + packageInventorySchema.parse( + workingTree({ + agents: [ + { + agentKey: "research", + identityStatus: "canonical", + path: "research", + entrypoint: "index.ts", + staticSignals, + }, + ] as never, + }), + ), + ).toThrow(); + }); +}); + +describe("package inventory public type surface", () => { + // Both of these are reachable from exported types — `PackageInventoryAgent` + // is defined in terms of the issue union, and `staticSignals.payload` is the + // JSON type — so a consumer that cannot name them has to re-declare them by + // hand, and that copy diverges the moment a later protocol adds a reason. + it("names every type a consumer needs to handle an inventory", () => { + const reasons: PackageInventoryIdentityIssue[] = [ + "identity-pending", + "identity-unavailable", + "identity-invalid", + "duplicate-agent-key", + ]; + const payload: PackageInventoryJsonValue = { + calls: ["billing", { name: "payments", async: true }], + depth: 2, + unknown: null, + }; + const signals: PackageInventoryStaticSignals = { protocol: 1, payload }; + + expect(reasons).toHaveLength(4); + expect(signals.payload).toBe(payload); + }); +}); diff --git a/packages/agent/src/package-inventory.ts b/packages/agent/src/package-inventory.ts new file mode 100644 index 000000000..2bb12e48b --- /dev/null +++ b/packages/agent/src/package-inventory.ts @@ -0,0 +1,357 @@ +// `zod/v4` is available from both supported peer ranges (Zod 3.25 and 4.x). +import { z } from "zod/v4"; + +/** Protocol version for the package inventory exchanged by build and Studio tooling. */ +export const PACKAGE_INVENTORY_PROTOCOL = 1 as const; + +/** The JSON a producer may attach as static-analysis evidence. */ +export type PackageInventoryJsonValue = + | null + | boolean + | number + | string + | readonly PackageInventoryJsonValue[] + | { readonly [key: string]: PackageInventoryJsonValue }; + +export type PackageInventoryVersion = + | { + /** Mutable checkout identity plus its normalized public-content revision. */ + readonly kind: "working-tree"; + readonly workspaceKey: string; + readonly revision: `sha256:${string}`; + } + | { + /** + * Immutable uploaded-package identity. `bundleDigest` names the exact + * bundle whose inventory was derived; protocol 1 bundle inventories are + * complete snapshots and are rejected when marked degraded. + */ + readonly kind: "bundle"; + readonly bundleDigest: `sha256:${string}`; + }; + +/** + * Optional, producer-owned static-analysis evidence attached to one agent. + * + * `protocol` versions the producer's JSON-only `payload`; consumers must + * interpret only protocols they explicitly support and otherwise preserve or + * ignore the envelope. Protocol 1 intentionally does not prescribe a signal + * payload yet, so future extractors can add evidence without changing the + * package-inventory identity and location fields. + */ +export interface PackageInventoryStaticSignals { + readonly protocol: number; + readonly payload: PackageInventoryJsonValue; +} + +/** + * Why an identity is provisional. Exported so a consumer can exhaustively + * switch over the reasons rather than re-declaring the union, which would + * silently diverge when a later protocol adds one. + */ +export type PackageInventoryIdentityIssue = + | "identity-pending" + | "identity-unavailable" + | "identity-invalid" + | "duplicate-agent-key"; + +interface PackageInventoryAgentBase { + readonly agentKey: string; + /** POSIX, package-root-relative directory (`.` denotes the package root). */ + readonly path: string; + /** POSIX path relative to the agent directory. */ + readonly entrypoint: string; + /** Advisory versioned evidence; never required to identify the agent. */ + readonly staticSignals?: PackageInventoryStaticSignals; +} + +export type PackageInventoryAgent = PackageInventoryAgentBase & + ( + | { + readonly identityStatus: "canonical"; + readonly identityIssue?: never; + readonly candidateAgentKey?: never; + } + | { + readonly identityStatus: "provisional"; + readonly identityIssue: Exclude< + PackageInventoryIdentityIssue, + "duplicate-agent-key" + >; + readonly candidateAgentKey?: never; + } + | { + readonly identityStatus: "provisional"; + readonly identityIssue: "duplicate-agent-key"; + readonly candidateAgentKey: string; + } + ); + +export interface PackageInventory { + readonly protocol: typeof PACKAGE_INVENTORY_PROTOCOL; + readonly version: PackageInventoryVersion; + readonly status: "complete" | "degraded"; + readonly agents: readonly PackageInventoryAgent[]; +} + +const SHA256 = /^sha256:[0-9a-f]{64}$/; +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.codePointAt(0)!; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + }); +} + +function canonicalAgentKey(value: string): boolean { + return ( + value !== "" && + value === value.trim() && + value !== "." && + value !== ".." && + !value.startsWith("local:") && + !hasControlCharacter(value) && + !value.includes("/") && + !value.includes("\\") + ); +} + +function provisionalAgentKey(value: string): boolean { + if (canonicalAgentKey(value)) return true; + if ( + !value.startsWith("local:") || + value !== value.trim() || + hasControlCharacter(value) + ) { + return false; + } + const relative = value.slice("local:".length); + return ( + relative !== "" && + !/^[A-Za-z]:(?:$|\/)/.test(relative) && + !relative.includes("\\") && + relative + .split("/") + .every((segment) => segment !== "" && segment !== "." && segment !== "..") + ); +} + +function relativePosixPath(value: string, allowRoot: boolean): boolean { + if (allowRoot && value === ".") return true; + if ( + value === "" || + value !== value.trim() || + value.startsWith("/") || + /^[A-Za-z]:\//.test(value) || + value.includes("\\") || + hasControlCharacter(value) + ) { + return false; + } + return value + .split("/") + .every((segment) => segment !== "" && segment !== "." && segment !== ".."); +} + +const digestSchema = z + .string() + .regex(SHA256, "Expected lowercase sha256:<64 hex characters>") + .transform((value) => value as `sha256:${string}`); +const canonicalAgentKeySchema = z + .string() + .refine(canonicalAgentKey, "Expected a safe canonical agent key"); +const provisionalAgentKeySchema = z + .string() + .refine(provisionalAgentKey, "Expected a safe package inventory agent key"); +const packagePathSchema = z + .string() + .refine( + (value) => relativePosixPath(value, true), + "Expected a package-root-relative POSIX path", + ); +const entrypointSchema = z + .string() + .refine( + (value) => relativePosixPath(value, false), + "Expected an agent-root-relative POSIX path", + ); + +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.null(), + z.boolean(), + z.number().finite(), + z.string(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]), +); + +const packageInventoryStaticSignalsSchema: z.ZodType = + z + .object({ + protocol: z.number().int().positive(), + payload: jsonValueSchema, + }) + .strict(); + +const packageInventoryAgentSchema = z + .object({ + agentKey: provisionalAgentKeySchema, + identityStatus: z.enum(["canonical", "provisional"]), + identityIssue: z + .enum([ + "identity-pending", + "identity-unavailable", + "identity-invalid", + "duplicate-agent-key", + ]) + .optional(), + candidateAgentKey: canonicalAgentKeySchema.optional(), + path: packagePathSchema, + entrypoint: entrypointSchema, + staticSignals: packageInventoryStaticSignalsSchema.optional(), + }) + .strict() + .superRefine((agent, context) => { + if (agent.identityStatus === "canonical") { + if (!canonicalAgentKey(agent.agentKey)) { + context.addIssue({ + code: "custom", + path: ["agentKey"], + message: "A canonical inventory agent requires a canonical agent key", + }); + } + if ( + agent.identityIssue !== undefined || + agent.candidateAgentKey !== undefined + ) { + context.addIssue({ + code: "custom", + message: + "A canonical inventory agent cannot carry provisional identity metadata", + }); + } + return; + } + if (agent.identityIssue === undefined) { + context.addIssue({ + code: "custom", + path: ["identityIssue"], + message: "A provisional inventory agent requires an identity issue", + }); + } + if ( + agent.identityIssue === "duplicate-agent-key" && + agent.candidateAgentKey === undefined + ) { + context.addIssue({ + code: "custom", + path: ["candidateAgentKey"], + message: "A duplicate identity requires its ambiguous candidate key", + }); + } + if ( + agent.identityIssue !== "duplicate-agent-key" && + agent.candidateAgentKey !== undefined + ) { + context.addIssue({ + code: "custom", + path: ["candidateAgentKey"], + message: "Only a duplicate identity can carry a candidate key", + }); + } + }); + +const packageInventoryVersionSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("working-tree"), + workspaceKey: z.string().trim().min(1), + revision: digestSchema, + }) + .strict(), + z + .object({ + kind: z.literal("bundle"), + bundleDigest: digestSchema, + }) + .strict(), +]); + +function compareText(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +/** + * Strict parser and normalizer for package inventory protocol 1. + * + * Parsing sorts agents by identity and location so every consumer sees one + * deterministic representation. It intentionally does not alter paths or + * identities: non-canonical spellings are rejected instead of silently + * changing package identity. + */ +export const packageInventorySchema: z.ZodType = z + .object({ + protocol: z.literal(PACKAGE_INVENTORY_PROTOCOL), + version: packageInventoryVersionSchema, + status: z.enum(["complete", "degraded"]), + agents: z.array(packageInventoryAgentSchema), + }) + .strict() + .superRefine((inventory, context) => { + const agentKeys = new Set(); + const entrypoints = new Set(); + for (const [index, agent] of inventory.agents.entries()) { + if (agentKeys.has(agent.agentKey)) { + context.addIssue({ + code: "custom", + path: ["agents", index, "agentKey"], + message: `Duplicate agentKey: ${agent.agentKey}`, + }); + } + agentKeys.add(agent.agentKey); + const entrypointKey = `${agent.path}\u0000${agent.entrypoint}`; + if (entrypoints.has(entrypointKey)) { + context.addIssue({ + code: "custom", + path: ["agents", index, "entrypoint"], + message: "Duplicate agent path and entrypoint", + }); + } + entrypoints.add(entrypointKey); + } + + const hasProvisional = inventory.agents.some( + (agent) => agent.identityStatus === "provisional", + ); + if (hasProvisional && inventory.status !== "degraded") { + context.addIssue({ + code: "custom", + path: ["status"], + message: "An inventory with provisional identities must be degraded", + }); + } + if ( + inventory.version.kind === "bundle" && + (hasProvisional || inventory.status !== "complete") + ) { + context.addIssue({ + code: "custom", + path: ["agents"], + message: + "A bundle inventory must be complete and contain only canonical identities", + }); + } + }) + .transform( + (inventory) => + ({ + ...inventory, + agents: [...inventory.agents].sort( + (left, right) => + compareText(left.agentKey, right.agentKey) || + compareText(left.path, right.path) || + compareText(left.entrypoint, right.entrypoint), + ), + }) as PackageInventory, + ); diff --git a/packages/harness/docs/workspace-system-graph.md b/packages/harness/docs/workspace-system-graph.md index edab3ce0e..f79e25770 100644 --- a/packages/harness/docs/workspace-system-graph.md +++ b/packages/harness/docs/workspace-system-graph.md @@ -71,6 +71,34 @@ X-Sapiom-System-Graph-Cache: complete | degraded `degraded` all report `degraded`; the header is a health/cacheability signal, not an HTTP cache directive. +## Resolving agent navigation + +Filesystem navigation is isolated from the public graph payload behind a +separate boot-token-protected route: + +```http +GET /api/workspaces/:workspaceKey/system-graph/navigation +``` + +It returns the resolver sidecar committed atomically with the graph snapshot: + +```ts +interface SystemGraphNavigationResponse { + workspaceKey: string; + revision: number; + targets: Array<{ agentKey: string; workflowPath: string }>; +} +``` + +The response has `Cache-Control: no-store`. Agent paths appear only in this +protected sidecar; they never enter `SystemGraph` JSON, lifecycle events, or +browser-derived identity logic. The browser accepts a sidecar only when both +its `workspaceKey` and `revision` exactly match the displayed snapshot. A +malformed or mismatched response, a newer invalidation, or a snapshot in a +loading, error, `building`, or `stale` state leaves graph cards inert. If the +resolver is newer, the browser reloads the graph through the normal lifecycle +and retries the join within a bounded loop. + ## Graph payload `SystemGraph` is path-free and has `kind: "system"`. Its scope repeats only the @@ -90,6 +118,27 @@ Projection can remain useful while reporting warnings: | `duplicate-agent-key` | More than one contained agent proposed the same key; local fallback identities disambiguate them. | | `inventory-extraction-failed` | One agent could not be enriched, so the remaining inventory was returned. | +Registry-known agents enter a working-tree package inventory and render +immediately; source inspection does not block the first graph. An unresolved +agent uses a safe provisional marker or `local:` identity. After the snapshot +and its navigation sidecar commit, source-name inspection runs in the +background. A valid current source definition name becomes canonical and +publishes a newer graph revision, while the older marker remains only a +compatibility alias. Inspection failure or an invalid name preserves the +provisional node and any unambiguous direct edges. + +Cacheability follows whether identity work has *finished*, not how it +finished. While any agent is still awaiting inspection the snapshot is +`degraded` and is not cached, because caching mid-enrichment would freeze +provisional names in place. Once every identity has resolved — including the +ones that resolved to an unavailable, invalid, or duplicate key — the snapshot +is `ready` and cached, and the agents that could not be named carry +`inventory-extraction-failed` instead. A source edit invalidates that agent's +fingerprint and re-projects, so an agent that is later fixed recovers its +canonical identity without an explicit retry. One permanently unidentifiable +agent therefore costs its own node's name, not the whole workspace's fast +path. + ## Freshness event After an accepted transition, the Harness event WebSocket publishes: diff --git a/packages/harness/src/core/canvas-graph.ts b/packages/harness/src/core/canvas-graph.ts index b8153361e..bb97e3c0f 100644 --- a/packages/harness/src/core/canvas-graph.ts +++ b/packages/harness/src/core/canvas-graph.ts @@ -66,6 +66,8 @@ export interface ExtractionFailure { ok: false; /** Human-readable, terminal-safe reason — shown verbatim in the degraded panel. */ reason: string; + /** Stable agent-core failure code when extraction reached check(). */ + code?: string; } export type ExtractionResult = ExtractionSuccess | ExtractionFailure; @@ -244,7 +246,13 @@ export function mergeCapabilitiesIntoGraph( */ export async function extractWorkflowGraph(sourceDir: string): Promise { const result = await runManifestCheck(sourceDir); - if (!result.ok) return { ok: false, reason: result.reason }; + if (!result.ok) { + return { + ok: false, + reason: result.reason, + ...(result.code ? { code: result.code } : {}), + }; + } const graph = graphFromManifest(result.manifest as AgentManifest, result.warnings); const stepIds = new Set(graph.nodes.map((n) => n.id)); // One walk over the sources yields both — halves the I/O on the auto-render diff --git a/packages/harness/src/core/canvas-manifest-check.test.ts b/packages/harness/src/core/canvas-manifest-check.test.ts index 2c0a38b7b..d3b19adf7 100644 --- a/packages/harness/src/core/canvas-manifest-check.test.ts +++ b/packages/harness/src/core/canvas-manifest-check.test.ts @@ -51,4 +51,14 @@ describe("runManifestCheck", () => { // The deps-not-installed hint already names it; one mention, not two. expect(result.reason.split(dir).length - 1).toBe(1); }); + + it("preserves the NO_DEFINITION code for a valid unnamed module", async () => { + writeFileSync(path.join(dir, "index.ts"), "export const value = 1;\n"); + + const result = await runManifestCheck(dir); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("NO_DEFINITION"); + }); }); diff --git a/packages/harness/src/core/canvas-manifest-check.ts b/packages/harness/src/core/canvas-manifest-check.ts index dc48b0275..df747acc9 100644 --- a/packages/harness/src/core/canvas-manifest-check.ts +++ b/packages/harness/src/core/canvas-manifest-check.ts @@ -62,7 +62,11 @@ try { const result = await check({ sourceDir, typecheck: false }); process.stdout.write(JSON.stringify({ ok: true, manifest: result.manifest, warnings: result.warnings })); } catch (err) { - process.stdout.write(JSON.stringify({ ok: false, reason: reasonFor(err) })); + process.stdout.write(JSON.stringify({ + ok: false, + reason: reasonFor(err), + ...(err instanceof AgentOperationError ? { code: err.code } : {}), + })); } `; @@ -75,6 +79,8 @@ export interface ManifestCheckSuccess { export interface ManifestCheckFailure { ok: false; reason: string; + /** Stable agent-core failure code when check() supplied one. */ + code?: string; } export type ManifestCheckResult = ManifestCheckSuccess | ManifestCheckFailure; @@ -149,6 +155,7 @@ export function runManifestCheck( : { ok: false, reason: withProjectContext(sourceDir, parsed.reason), + ...(parsed.code ? { code: parsed.code } : {}), }, ); } catch { diff --git a/packages/harness/src/core/definition-name.test.ts b/packages/harness/src/core/definition-name.test.ts index 9d0512409..a46886737 100644 --- a/packages/harness/src/core/definition-name.test.ts +++ b/packages/harness/src/core/definition-name.test.ts @@ -75,6 +75,22 @@ describe("inspectManifestName", () => { }); }); + it("treats agent-core NO_DEFINITION as normal identity absence", async () => { + const extract = vi.fn().mockResolvedValue({ + result: { + ok: false as const, + code: "NO_DEFINITION", + reason: "No defineAgent() call was found", + }, + cached: false, + fingerprint: "1:1", + }); + + await expect( + inspectManifestName("/proj/unnamed", extract), + ).resolves.toEqual({ status: "absent" }); + }); + it("reports a thrown extraction as failed", async () => { const extract = vi.fn().mockRejectedValue(new Error("boom")); diff --git a/packages/harness/src/core/definition-name.ts b/packages/harness/src/core/definition-name.ts index 4a9cdfd91..bbb409222 100644 --- a/packages/harness/src/core/definition-name.ts +++ b/packages/harness/src/core/definition-name.ts @@ -29,7 +29,11 @@ export async function inspectManifestName( ): Promise { try { const { result } = await extract(projectDir); - if (!result.ok) return { status: "failed" }; + if (!result.ok) { + return { + status: result.code === "NO_DEFINITION" ? "absent" : "failed", + }; + } const name = result.graph.manifestName.trim(); return name === "" ? { status: "absent" } : { status: "found", name }; } catch { diff --git a/packages/harness/src/core/system-graph-inventory.test.ts b/packages/harness/src/core/system-graph-inventory.test.ts index 044476adf..0b56f5204 100644 --- a/packages/harness/src/core/system-graph-inventory.test.ts +++ b/packages/harness/src/core/system-graph-inventory.test.ts @@ -8,8 +8,12 @@ import { dirtyGraphSourceRoots, graphSourceRootsWithinScope, HarnessRegistryInventoryProvider, + inventorySourceRoot, + type AgentInventoryResult, + type HarnessRegistryInventoryProviderOptions, type WorkspaceScope, } from "./system-graph-inventory.js"; +import { workspaceRelativeLocalKey } from "../shared/system-graph.js"; import type { ManifestNameInspection } from "./definition-name.js"; const WORKSPACE = "/private/workspaces/acme"; @@ -26,7 +30,7 @@ function workflow( ): WorkflowInfo { return { name, - path: `${WORKSPACE}/${relativePath}`, + path: relativePath ? `${WORKSPACE}/${relativePath}` : WORKSPACE, definitionId: definitionSlug ? 1 : null, definitionSlug, source: "scan", @@ -36,369 +40,926 @@ function workflow( function provider( workflows: readonly WorkflowInfo[], - options: { - inspectManifestName?: ( - sourceRoot: string, - ) => Promise; - manifestInspectionBudgetMs?: number; - } = {}, + options: Partial = {}, ): HarnessRegistryInventoryProvider { return new HarnessRegistryInventoryProvider({ listWorkflows: () => workflows, - ...(options.inspectManifestName - ? { inspectManifestName: options.inspectManifestName } - : {}), - ...(options.manifestInspectionBudgetMs !== undefined - ? { manifestInspectionBudgetMs: options.manifestInspectionBudgetMs } - : {}), + fingerprintSource: async (sourceRoot) => `fingerprint:${sourceRoot}`, + ...options, }); } +async function enrich( + inventory: HarnessRegistryInventoryProvider, + initial: AgentInventoryResult, + changed: ReturnType, +): Promise { + initial.startEnrichment?.(); + await vi.waitFor(() => expect(changed).toHaveBeenCalled()); + return inventory.listAgents(SCOPE); +} + describe("HarnessRegistryInventoryProvider", () => { - it("matches canonical workflow roots beneath a symlinked workspace", async () => { - if (process.platform === "win32") return; - const tempRoot = await fs.mkdtemp( - path.join(os.tmpdir(), "system-graph-symlink-"), + it("derives inventory roots using POSIX, drive, and UNC workspace flavor", () => { + expect(inventorySourceRoot("/workspace", "nested/agent")).toBe( + "/workspace/nested/agent", ); - try { - const workspaceRoot = path.join(tempRoot, "real-workspace"); - const agentRoot = path.join(workspaceRoot, "agent"); - const nestedRoot = path.join(agentRoot, "nested-agent"); - const outsideRoot = path.join(tempRoot, "outside", "agent"); - const linkedRoot = path.join(tempRoot, "linked-workspace"); - await Promise.all([ - fs.mkdir(nestedRoot, { recursive: true }), - fs.mkdir(outsideRoot, { recursive: true }), - ]); - await fs.symlink(workspaceRoot, linkedRoot, "dir"); + expect(inventorySourceRoot("C:\\workspace", "nested/agent")).toBe( + "C:\\workspace\\nested\\agent", + ); + expect( + inventorySourceRoot("\\\\server\\share\\workspace", "nested/agent"), + ).toBe("\\\\server\\share\\workspace\\nested\\agent"); + }); - const canonicalAgentRoot = await fs.realpath(agentRoot); - const canonicalNestedRoot = await fs.realpath(nestedRoot); - expect( - graphSourceRootsWithinScope(linkedRoot, [ - agentRoot, - nestedRoot, - outsideRoot, - ]), - ).toEqual([canonicalAgentRoot, canonicalNestedRoot]); - expect( - dirtyGraphSourceRoots( - linkedRoot, - [agentRoot, nestedRoot, outsideRoot], - [path.join(linkedRoot, "agent", "nested-agent", "index.ts")], - ), - ).toEqual([canonicalNestedRoot]); - expect( - dirtyGraphSourceRoots( - linkedRoot, - [agentRoot, nestedRoot], - [path.join(linkedRoot, "unregistered", "index.ts")], - ), - ).toEqual([]); - } finally { - await fs.rm(tempRoot, { recursive: true, force: true }); - } + it("uses a checkout-invariant shared local key for a scope-root agent", () => { + expect(workspaceRelativeLocalKey("/checkouts/one", "/checkouts/one")).toBe( + "local:root", + ); + expect( + workspaceRelativeLocalKey("/different/name", "/different/name"), + ).toBe("local:root"); }); - it("returns every registry-known agent regardless of deployment, source, or relationships", async () => { - const inspectManifestName = vi.fn(async (sourceRoot: string) => { - if (sourceRoot.endsWith("/growth")) { - return { status: "found" as const, name: "growth-manifest" }; - } - return { status: "absent" as const }; + it("returns linked agents provisionally before source inspection starts", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const inspectManifestName = vi.fn(async () => { + await gate; + return { status: "found" as const, name: "SourceName" }; }); + const changed = vi.fn(); const inventory = provider( - [ - workflow("Research package", "research", "research", { - definitionId: 101, - }), - workflow("Growth package", "growth", null, { source: "connect" }), - workflow("Reporting package", "reporting", null), + [workflow("Research package", "research", "old-marker")], + { inspectManifestName, onIdentityChange: changed }, + ); + + const initial = await inventory.listAgents(SCOPE); + + expect(inspectManifestName).not.toHaveBeenCalled(); + expect(initial.inventory).toMatchObject({ + status: "degraded", + agents: [ { - ...workflow("Outside", "outside", "outside"), - path: `${WORKSPACE}-archive/outside`, + agentKey: "old-marker", + identityStatus: "provisional", + identityIssue: "identity-pending", + path: "research", + entrypoint: "index.ts", }, ], - { inspectManifestName }, + }); + expect(initial.context[0]).toMatchObject({ + agentKey: "old-marker", + workflowPath: `${WORKSPACE}/research`, + resolutionAliases: ["old-marker"], + }); + + initial.startEnrichment?.(); + await vi.waitFor(() => + expect(inspectManifestName).toHaveBeenCalledTimes(1), ); + release(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + const enriched = await inventory.listAgents(SCOPE); - await expect(inventory.listAgents(SCOPE)).resolves.toEqual({ + expect(enriched.inventory).toMatchObject({ + status: "complete", agents: [ { - agentKey: "growth-manifest", - definitionId: null, - definitionSlug: null, - label: "Growth package", - resolutionAliases: ["growth-manifest"], - sourceRoot: `${WORKSPACE}/growth`, - }, - { - agentKey: "local:reporting", - definitionId: null, - definitionSlug: null, - label: "Reporting package", - resolutionAliases: ["local:reporting"], - sourceRoot: `${WORKSPACE}/reporting`, - }, - { - agentKey: "research", - definitionId: 101, - definitionSlug: "research", - label: "Research package", - resolutionAliases: ["research"], - sourceRoot: `${WORKSPACE}/research`, + agentKey: "SourceName", + identityStatus: "canonical", }, ], - cacheable: true, - warnings: [], }); - expect(inspectManifestName).toHaveBeenCalledTimes(2); - expect(inspectManifestName).not.toHaveBeenCalledWith( - `${WORKSPACE}/research`, + expect(enriched.context[0]?.resolutionAliases).toEqual(["old-marker"]); + }); + + it("uses a renamed source identity while retaining the marker only as an alias", async () => { + const inspectManifestName = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ status: "found", name: "Before" }) + .mockResolvedValueOnce({ status: "found", name: "After" }); + const changed = vi.fn(); + const inventory = provider([workflow("Package", "agent", "marker-name")], { + inspectManifestName, + onIdentityChange: changed, + }); + + const before = await enrich( + inventory, + await inventory.listAgents(SCOPE), + changed, ); + expect(before.inventory.agents[0]?.agentKey).toBe("Before"); + + changed.mockClear(); + inventory.invalidateSource(`${WORKSPACE}/agent`); + const pending = await inventory.listAgents(SCOPE); + expect(pending.inventory.agents[0]).toMatchObject({ + agentKey: "marker-name", + identityIssue: "identity-pending", + }); + const after = await enrich(inventory, pending, changed); + + expect(after.inventory.agents[0]?.agentKey).toBe("After"); + expect(after.context[0]?.resolutionAliases).toEqual(["marker-name"]); }); - it("bounds manifest inspection concurrency and keeps partial results", async () => { + it("does not call two pending agents a collision before their sources are read", async () => { + // Two agents sharing a registry marker look identical only until their + // sources are read. Counting them as a duplicate at that point reports + // `duplicate-agent-key` — an issue the contract treats as settled — so the + // projection above declares the identities finished and caches a graph + // whose names are still guesses. Enrichment then retracts the warning. let release!: () => void; - const gate = new Promise((resolve) => { + const inspectionGate = new Promise((resolve) => { release = resolve; }); - const started: string[] = []; const inspectManifestName = vi.fn(async (sourceRoot: string) => { - started.push(sourceRoot); - await gate; - if (sourceRoot.endsWith("/broken")) { - return { status: "failed" as const }; - } - if (sourceRoot.endsWith("/thrown")) { - throw new Error(`unreadable ${sourceRoot}`); - } - return sourceRoot.endsWith("/named") - ? { status: "found" as const, name: "declared-name" } - : { status: "absent" as const }; - }); - const inventoryPromise = provider( + await inspectionGate; + return { + status: "found" as const, + name: sourceRoot.endsWith("/first") ? "payments" : "billing", + }; + }); + const changed = vi.fn(); + const inventory = provider( [ - workflow("Named", "named", null), - workflow("Broken", "broken", null), - workflow("Thrown", "thrown", null), - workflow("Fallback", "fallback", null), - workflow("Extra A", "extra-a", null), - workflow("Extra B", "extra-b", null), + workflow("First", "first", "shared-marker"), + workflow("Second", "second", "shared-marker"), ], - { inspectManifestName }, - ).listAgents(SCOPE); + { inspectManifestName, onIdentityChange: changed }, + ); - await vi.waitFor(() => expect(started).toHaveLength(4)); - await Promise.resolve(); - expect(started).toHaveLength(4); + const pending = await inventory.listAgents(SCOPE); + expect(pending.warnings).toEqual([]); + expect( + pending.inventory.agents.map((agent) => agent.identityIssue), + ).toEqual(["identity-pending", "identity-pending"]); + + pending.startEnrichment?.(); release(); - const result = await inventoryPromise; - - expect(result.agents.map((agent) => agent.agentKey)).toEqual([ - "declared-name", - "local:broken", - "local:extra-a", - "local:extra-b", - "local:fallback", - "local:thrown", + await vi.waitFor(() => expect(changed).toHaveBeenCalled()); + const settled = await inventory.listAgents(SCOPE); + + // They were never the same agent; reading the sources proves it. + expect(settled.inventory.agents.map((agent) => agent.agentKey)).toEqual([ + "billing", + "payments", + ]); + expect(settled.warnings).toEqual([]); + }); + + it("keeps duplicate source names as separate deterministic local identities", async () => { + const inspectManifestName = vi.fn(async () => ({ + status: "found" as const, + name: "shared", + })); + const changed = vi.fn(); + const inventory = provider( + [ + workflow("First", "first", "marker-first"), + workflow("Second", "second", "marker-second"), + ], + { inspectManifestName, onIdentityChange: changed }, + ); + const initial = await inventory.listAgents(SCOPE); + initial.startEnrichment?.(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + const result = await inventory.listAgents(SCOPE); + + expect(result.inventory.agents).toEqual([ + { + agentKey: "local:first", + identityStatus: "provisional", + identityIssue: "duplicate-agent-key", + candidateAgentKey: "shared", + path: "first", + entrypoint: "index.ts", + }, + { + agentKey: "local:second", + identityStatus: "provisional", + identityIssue: "duplicate-agent-key", + candidateAgentKey: "shared", + path: "second", + entrypoint: "index.ts", + }, + ]); + expect(result.context.map((item) => item.resolutionAliases)).toEqual([ + ["marker-first", "shared"], + ["marker-second", "shared"], ]); expect(result.warnings).toEqual([ { - code: "inventory-extraction-failed", - agentKey: "local:broken", - message: "Could not inspect Broken; using its local identity.", + code: "duplicate-agent-key", + agentKey: "shared", + message: "Multiple agents use shared; kept each with a local identity.", + }, + ]); + }); + + it("keeps a source-canonical identity above a colliding provisional marker", async () => { + const inspectManifestName = vi.fn(async (sourceRoot: string) => + sourceRoot.endsWith("/canonical") + ? ({ status: "found", name: "payments" } as const) + : ({ status: "absent" } as const), + ); + const changed = vi.fn(); + const inventory = provider( + [ + workflow("Canonical", "canonical", "old-payments"), + workflow("Pending", "pending", "payments"), + ], + { inspectManifestName, onIdentityChange: changed }, + ); + const initial = await inventory.listAgents(SCOPE); + initial.startEnrichment?.(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + const result = await inventory.listAgents(SCOPE); + + expect(result.inventory.agents).toEqual([ + { + agentKey: "local:pending", + identityStatus: "provisional", + identityIssue: "identity-unavailable", + path: "pending", + entrypoint: "index.ts", }, { - code: "inventory-extraction-failed", - agentKey: "local:thrown", - message: "Could not inspect Thrown; using its local identity.", + agentKey: "payments", + identityStatus: "canonical", + path: "canonical", + entrypoint: "index.ts", }, ]); - expect(inspectManifestName).toHaveBeenCalledTimes(6); - expect(result.cacheable).toBe(false); - expect(JSON.stringify(result.warnings)).not.toContain(WORKSPACE); + expect( + result.context.map((item) => [item.agentKey, item.resolutionAliases]), + ).toEqual([ + ["local:pending", ["payments"]], + ["payments", ["old-payments"]], + ]); + expect(result.warnings).toEqual([]); + }); + + it("preserves a provisional marker identity after inspection failure and retries only explicitly", async () => { + const inspectManifestName = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ status: "failed" }) + .mockResolvedValueOnce({ status: "found", name: "recovered" }); + const changed = vi.fn(); + const inventory = provider([workflow("Research", "research", "marker")], { + inspectManifestName, + onIdentityChange: changed, + }); + const failed = await enrich( + inventory, + await inventory.listAgents(SCOPE), + changed, + ); + + expect(failed.inventory.agents[0]).toMatchObject({ + agentKey: "marker", + identityStatus: "provisional", + identityIssue: "identity-unavailable", + }); + expect(failed.warnings[0]?.code).toBe("inventory-extraction-failed"); + failed.startEnrichment?.(); + await Promise.resolve(); + expect(inspectManifestName).toHaveBeenCalledTimes(1); + + inventory.retryFailedInspections(SCOPE); + changed.mockClear(); + const retrying = await inventory.listAgents(SCOPE); + const recovered = await enrich(inventory, retrying, changed); + expect(inspectManifestName).toHaveBeenCalledTimes(2); + expect(recovered.inventory.agents[0]).toMatchObject({ + agentKey: "recovered", + identityStatus: "canonical", + }); + }); + + it("treats an absent source definition as normal unavailable identity", async () => { + const changed = vi.fn(); + const inspectManifestName = vi.fn(async () => ({ + status: "absent" as const, + })); + const inventory = provider([workflow("Research", "research", "marker")], { + inspectManifestName, + onIdentityChange: changed, + }); + const result = await enrich( + inventory, + await inventory.listAgents(SCOPE), + changed, + ); + + expect(result.inventory.agents[0]).toMatchObject({ + agentKey: "marker", + identityStatus: "provisional", + identityIssue: "identity-unavailable", + }); + expect(result.warnings).toEqual([]); + + inventory.retryFailedInspections(SCOPE); + const afterExplicitRetry = await inventory.listAgents(SCOPE); + expect(afterExplicitRetry.inventory.agents[0]).toMatchObject({ + agentKey: "marker", + identityIssue: "identity-unavailable", + }); + expect(inspectManifestName).toHaveBeenCalledTimes(1); + }); + + it("keeps invalid source names provisional without leaking the candidate", async () => { + const changed = vi.fn(); + const inventory = provider([workflow("Agent", "agent", "safe-marker")], { + inspectManifestName: async () => ({ + status: "found", + name: "../unsafe", + }), + onIdentityChange: changed, + }); + const result = await enrich( + inventory, + await inventory.listAgents(SCOPE), + changed, + ); + + expect(result.inventory.agents[0]).toEqual({ + agentKey: "safe-marker", + identityStatus: "provisional", + identityIssue: "identity-invalid", + path: "agent", + entrypoint: "index.ts", + }); + expect(result.warnings).toEqual([ + expect.objectContaining({ code: "inventory-extraction-failed" }), + ]); + expect(JSON.stringify(result)).not.toContain("../unsafe"); }); - it("returns partial inventory when the enrichment budget expires", async () => { + it("caps background source inspection concurrency at four", async () => { let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); - const started: string[] = []; + let active = 0; + let maximum = 0; const inspectManifestName = vi.fn(async (sourceRoot: string) => { - started.push(sourceRoot); - if (sourceRoot.endsWith("/fast")) { - return { status: "found" as const, name: "fast-manifest" }; - } + active += 1; + maximum = Math.max(maximum, active); await gate; - return { status: "absent" as const }; + active -= 1; + return { status: "found" as const, name: path.basename(sourceRoot) }; }); + const changed = vi.fn(); + const inventory = provider( + Array.from({ length: 7 }, (_, index) => + workflow(`Agent ${index}`, `agent-${index}`, null), + ), + { inspectManifestName, onIdentityChange: changed }, + ); - const result = await provider( - [ - workflow("Fast", "fast", null), - workflow("Slow A", "slow-a", null), - workflow("Slow B", "slow-b", null), - workflow("Slow C", "slow-c", null), - workflow("Slow D", "slow-d", null), - workflow("Slow E", "slow-e", null), - ], - { inspectManifestName, manifestInspectionBudgetMs: 20 }, - ).listAgents(SCOPE); - - expect(result.agents.map((agent) => agent.agentKey)).toEqual([ - "fast-manifest", - "local:slow-a", - "local:slow-b", - "local:slow-c", - "local:slow-d", - "local:slow-e", - ]); - expect( - result.warnings.map(({ code, agentKey }) => [code, agentKey]), - ).toEqual([ - ["inventory-extraction-failed", "local:slow-a"], - ["inventory-extraction-failed", "local:slow-b"], - ["inventory-extraction-failed", "local:slow-c"], - ["inventory-extraction-failed", "local:slow-d"], - ["inventory-extraction-failed", "local:slow-e"], - ]); - expect(started).toHaveLength(5); - expect(result.cacheable).toBe(false); - + (await inventory.listAgents(SCOPE)).startEnrichment?.(); + await vi.waitFor(() => + expect(inspectManifestName).toHaveBeenCalledTimes(4), + ); + expect(maximum).toBe(4); release(); - await Promise.resolve(); - await Promise.resolve(); - expect(started).toHaveLength(5); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + expect(changed).toHaveBeenCalledWith( + Array.from({ length: 7 }, (_, index) => `${WORKSPACE}/agent-${index}`), + ); + expect(maximum).toBe(4); }); - it("includes nested agents in every containing selected project", async () => { - const nestedRoot = `${WORKSPACE}/experiments`; - const workflows = [ - workflow("Root agent", "", "root-agent"), - workflow("Parent agent", "research", "research"), - workflow("Nested agent", "experiments/evaluator", "evaluator"), + it("surfaces settled identities within a bounded window while slower work continues", async () => { + let releaseSlow!: () => void; + const slow = new Promise((resolve) => { + releaseSlow = resolve; + }); + const inspectManifestName = vi.fn(async (sourceRoot: string) => { + if (sourceRoot.endsWith("/slow")) await slow; + return { status: "found" as const, name: path.basename(sourceRoot) }; + }); + const changed = vi.fn(); + const inventory = provider( + [workflow("Fast", "fast", null), workflow("Slow", "slow", null)], { - ...workflow("Prefix sibling", "sibling", "sibling"), - path: `${WORKSPACE}-archive/sibling`, + inspectManifestName, + onIdentityChange: changed, }, - ]; + ); - const parent = await provider(workflows).listAgents(SCOPE); - const nested = await provider(workflows).listAgents({ - workspaceKey: "workspace-experiments", - root: nestedRoot, + (await inventory.listAgents(SCOPE)).startEnrichment?.(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1), { + timeout: 5_000, }); + expect(changed).toHaveBeenNthCalledWith(1, [`${WORKSPACE}/fast`]); - expect(parent.agents.map((agent) => agent.agentKey)).toEqual([ - "evaluator", - "research", - "root-agent", - ]); - expect(nested.agents.map((agent) => agent.agentKey)).toEqual(["evaluator"]); + const partial = await inventory.listAgents(SCOPE); + expect( + partial.inventory.agents.find((agent) => agent.path === "fast"), + ).toMatchObject({ agentKey: "fast", identityStatus: "canonical" }); + expect( + partial.inventory.agents.find((agent) => agent.path === "slow"), + ).toMatchObject({ identityIssue: "identity-pending" }); + + releaseSlow(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(2), { + timeout: 5_000, + }); + expect(changed).toHaveBeenNthCalledWith(2, [`${WORKSPACE}/slow`]); }); - it("does not confuse same-basename roots or mixed Windows separators", async () => { - const windowsScope: WorkspaceScope = { - workspaceKey: "workspace-windows", - root: "C:\\Users\\Demo\\project", - }; - const workflows: WorkflowInfo[] = [ - { - ...workflow("Windows parent", "unused", "windows-parent"), - path: "c:/users/demo/project/main-agent", - }, - { - ...workflow("Windows nested", "unused", "windows-nested"), - path: "C:\\Users\\Demo\\project\\experiments\\evaluator", - }, - { - ...workflow("Prefix sibling", "unused", "prefix-sibling"), - path: "C:\\Users\\Demo\\project-old\\agent", - }, + it("drops a settled batch notification when its source is invalidated before the batch drains", async () => { + let releaseSlow!: () => void; + const slow = new Promise((resolve) => { + releaseSlow = resolve; + }); + const inspectManifestName = vi.fn(async (sourceRoot: string) => { + if (sourceRoot.endsWith("/slow")) await slow; + return { status: "found" as const, name: path.basename(sourceRoot) }; + }); + const changed = vi.fn(); + const inventory = provider( + [workflow("Fast", "fast", null), workflow("Slow", "slow", null)], { - ...workflow("Other basename", "unused", "other"), - path: "D:\\Other\\project\\agent", + inspectManifestName, + onIdentityChange: changed, + identityChangeCoalesceMs: 10_000, }, - ]; - const inventory = new HarnessRegistryInventoryProvider({ - listWorkflows: () => workflows, + ); + + (await inventory.listAgents(SCOPE)).startEnrichment?.(); + await vi.waitFor(async () => { + const snapshot = await inventory.listAgents(SCOPE); + expect( + snapshot.inventory.agents.find((agent) => agent.path === "fast") + ?.agentKey, + ).toBe("fast"); + expect( + snapshot.inventory.agents.find((agent) => agent.path === "slow") + ?.identityIssue, + ).toBe("identity-pending"); + }); + inventory.invalidateSource(`${WORKSPACE}/fast`); + releaseSlow(); + + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + expect(changed).toHaveBeenCalledWith([`${WORKSPACE}/slow`]); + }); + + it("deduplicates in-flight inspection and prevents an invalidated result from winning", async () => { + let resolveBefore!: (value: ManifestNameInspection) => void; + let resolveAfter!: (value: ManifestNameInspection) => void; + const before = new Promise((resolve) => { + resolveBefore = resolve; + }); + const after = new Promise((resolve) => { + resolveAfter = resolve; + }); + const inspectManifestName = vi + .fn<() => Promise>() + .mockReturnValueOnce(before) + .mockReturnValueOnce(after); + const changed = vi.fn(); + const sourceRoot = `${WORKSPACE}/agent`; + const inventory = provider([workflow("Agent", "agent", "marker")], { + inspectManifestName, + onIdentityChange: changed, + }); + + const initial = await inventory.listAgents(SCOPE); + initial.startEnrichment?.(); + initial.startEnrichment?.(); + await vi.waitFor(() => + expect(inspectManifestName).toHaveBeenCalledTimes(1), + ); + + inventory.invalidateSource(sourceRoot); + (await inventory.listAgents(SCOPE)).startEnrichment?.(); + resolveBefore({ status: "found", name: "Before" }); + await vi.waitFor(() => + expect(inspectManifestName).toHaveBeenCalledTimes(2), + ); + expect(changed).not.toHaveBeenCalled(); + resolveAfter({ status: "found", name: "After" }); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + + expect( + (await inventory.listAgents(SCOPE)).inventory.agents[0]?.agentKey, + ).toBe("After"); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it("coalesces burst invalidations so one root cannot occupy every inspection slot", async () => { + let releaseOld!: (value: ManifestNameInspection) => void; + let releaseLatest!: (value: ManifestNameInspection) => void; + const oldInspection = new Promise((resolve) => { + releaseOld = resolve; + }); + const latestInspection = new Promise((resolve) => { + releaseLatest = resolve; + }); + const inspectManifestName = vi + .fn<() => Promise>() + .mockReturnValueOnce(oldInspection) + .mockReturnValueOnce(latestInspection); + const changed = vi.fn(); + const sourceRoot = `${WORKSPACE}/agent`; + const inventory = provider([workflow("Agent", "agent", "marker")], { + inspectManifestName, + onIdentityChange: changed, + }); + + (await inventory.listAgents(SCOPE)).startEnrichment?.(); + await vi.waitFor(() => + expect(inspectManifestName).toHaveBeenCalledTimes(1), + ); + for (let index = 0; index < 6; index += 1) { + inventory.invalidateSource(sourceRoot); + (await inventory.listAgents(SCOPE)).startEnrichment?.(); + } + expect(inspectManifestName).toHaveBeenCalledTimes(1); + + releaseOld({ status: "found", name: "Old" }); + await vi.waitFor(() => + expect(inspectManifestName).toHaveBeenCalledTimes(2), + ); + expect(changed).not.toHaveBeenCalled(); + releaseLatest({ status: "found", name: "Latest" }); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + expect( + (await inventory.listAgents(SCOPE)).inventory.agents[0]?.agentKey, + ).toBe("Latest"); + }); + + it("does not let an active inspection repopulate identity after clear", async () => { + let release!: (value: ManifestNameInspection) => void; + const inspection = new Promise((resolve) => { + release = resolve; + }); + const inspectManifestName = vi.fn(() => inspection); + const changed = vi.fn(); + const inventory = provider([workflow("Agent", "agent", "marker")], { + inspectManifestName, + onIdentityChange: changed, + }); + + (await inventory.listAgents(SCOPE)).startEnrichment?.(); + await vi.waitFor(() => + expect(inspectManifestName).toHaveBeenCalledTimes(1), + ); + inventory.clear(); + release({ status: "found", name: "Retired" }); + await vi.waitFor(() => expect(changed).not.toHaveBeenCalled()); + + expect( + (await inventory.listAgents(SCOPE)).inventory.agents[0], + ).toMatchObject({ + agentKey: "marker", + identityIssue: "identity-pending", + }); + }); + + it("prunes settled identity state for a removed registry root", async () => { + const inspectManifestName = vi.fn(async () => ({ + status: "found" as const, + name: "Settled", + })); + const changed = vi.fn(); + const sourceRoot = `${WORKSPACE}/agent`; + const inventory = provider([workflow("Agent", "agent", "marker")], { + inspectManifestName, + onIdentityChange: changed, + }); + await enrich(inventory, await inventory.listAgents(SCOPE), changed); + + inventory.retainSources(new Set()); + const readded = await inventory.listAgents(SCOPE); + + expect(readded.inventory.agents[0]).toMatchObject({ + agentKey: "marker", + identityIssue: "identity-pending", + }); + expect(sourceRoot).toBe(readded.context[0]?.sourceRoot); + }); + + it("invalidates an in-flight inspection when its source root retires", async () => { + let release!: (value: ManifestNameInspection) => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const inspectManifestName = vi.fn(() => pending); + const changed = vi.fn(); + const inventory = provider([workflow("Agent", "agent", "marker")], { + inspectManifestName, + onIdentityChange: changed, + }); + + (await inventory.listAgents(SCOPE)).startEnrichment?.(); + await vi.waitFor(() => + expect(inspectManifestName).toHaveBeenCalledTimes(1), + ); + inventory.retainSources(new Set()); + release({ status: "found", name: "Retired" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(changed).not.toHaveBeenCalled(); + expect( + (await inventory.listAgents(SCOPE)).inventory.agents[0], + ).toMatchObject({ + agentKey: "marker", + identityIssue: "identity-pending", + }); + }); + + it("serves a cached identity immediately and refreshes it after a missed edit", async () => { + let fingerprint = "v1"; + const inspectManifestName = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ status: "found", name: "Before" }) + .mockResolvedValueOnce({ status: "found", name: "After" }); + const changed = vi.fn(); + const inventory = provider([workflow("Agent", "agent", "marker")], { + inspectManifestName, + fingerprintSource: async () => fingerprint, + onIdentityChange: changed, + }); + const before = await enrich( + inventory, + await inventory.listAgents(SCOPE), + changed, + ); + expect(before.inventory.agents[0]?.agentKey).toBe("Before"); + + changed.mockClear(); + fingerprint = "v2"; + const immediate = await inventory.listAgents(SCOPE); + expect(immediate.inventory.agents[0]?.agentKey).toBe("Before"); + immediate.startEnrichment?.(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + expect( + (await inventory.listAgents(SCOPE)).inventory.agents[0]?.agentKey, + ).toBe("After"); + }); + + it("preserves a settled identity when background fingerprinting fails", async () => { + let fingerprintFails = false; + const fingerprintSource = vi.fn(async () => { + if (fingerprintFails) throw new Error("stat failed"); + return "v1"; + }); + const inspectManifestName = vi.fn(async () => ({ + status: "found" as const, + name: "Before", + })); + const changed = vi.fn(); + const inventory = provider([workflow("Agent", "agent", "marker")], { + inspectManifestName, + fingerprintSource, + onIdentityChange: changed, + }); + await enrich(inventory, await inventory.listAgents(SCOPE), changed); + + changed.mockClear(); + fingerprintFails = true; + const cached = await inventory.listAgents(SCOPE); + expect(cached.inventory.agents[0]?.agentKey).toBe("Before"); + cached.startEnrichment?.(); + await vi.waitFor(() => expect(fingerprintSource).toHaveBeenCalledTimes(2)); + + expect( + (await inventory.listAgents(SCOPE)).inventory.agents[0]?.agentKey, + ).toBe("Before"); + expect(changed).not.toHaveBeenCalled(); + }); + + it("degrades when a changed fingerprint is observed but inspection fails", async () => { + let fingerprint = "v1"; + const inspectManifestName = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ status: "found", name: "Before" }) + .mockRejectedValueOnce(new Error("bundle failed")); + const changed = vi.fn(); + const inventory = provider([workflow("Agent", "agent", "marker")], { + inspectManifestName, + fingerprintSource: async () => fingerprint, + onIdentityChange: changed, }); + await enrich(inventory, await inventory.listAgents(SCOPE), changed); - const result = await inventory.listAgents(windowsScope); + fingerprint = "v2"; + changed.mockClear(); + const cached = await inventory.listAgents(SCOPE); + cached.startEnrichment?.(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + const degraded = await inventory.listAgents(SCOPE); - expect(result.agents.map((agent) => agent.agentKey)).toEqual([ - "windows-nested", - "windows-parent", + expect(degraded.inventory.agents[0]).toMatchObject({ + agentKey: "marker", + identityIssue: "identity-unavailable", + }); + expect(degraded.warnings).toEqual([ + expect.objectContaining({ code: "inventory-extraction-failed" }), ]); }); - it("preserves duplicate slugs with deterministic local identities and a warning", async () => { - const result = await provider([ - workflow("First copy", "first", "shared"), - workflow("Second copy", "second", "shared", { source: "connect" }), - ]).listAgents(SCOPE); + it("reinspects a retired root when it is later re-added", async () => { + const registered = workflow("Agent", "agent", "marker"); + let workflows: WorkflowInfo[] = [registered]; + let fingerprint = "v1"; + const inspectManifestName = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ status: "found", name: "Before" }) + .mockResolvedValueOnce({ status: "found", name: "After" }); + const changed = vi.fn(); + const inventory = new HarnessRegistryInventoryProvider({ + listWorkflows: () => workflows, + inspectManifestName, + fingerprintSource: async () => fingerprint, + onIdentityChange: changed, + }); + await enrich(inventory, await inventory.listAgents(SCOPE), changed); - expect(result.agents).toMatchObject([ + workflows = []; + expect((await inventory.listAgents(SCOPE)).inventory.agents).toEqual([]); + fingerprint = "v2"; + workflows = [registered]; + changed.mockClear(); + const readded = await inventory.listAgents(SCOPE); + expect(readded.inventory.agents[0]?.agentKey).toBe("marker"); + readded.startEnrichment?.(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + expect( + (await inventory.listAgents(SCOPE)).inventory.agents[0]?.agentKey, + ).toBe("After"); + }); + + it("computes an order- and checkout-invariant revision without private metadata", async () => { + const left = provider([ + workflow("Zeta label", "zeta", "zeta", { definitionId: 1 }), + workflow("Alpha label", "alpha", "alpha", { definitionId: 2 }), + ]); + const otherRoot = "/different/checkout"; + const right = provider([ { - agentKey: "local:first", - definitionSlug: "shared", - resolutionAliases: ["shared"], + ...workflow("Private label changed", "alpha", "alpha", { + definitionId: 999, + }), + path: `${otherRoot}/alpha`, }, { - agentKey: "local:second", - definitionSlug: "shared", - resolutionAliases: ["shared"], + ...workflow("Other private label", "zeta", "zeta", { + definitionId: null, + }), + path: `${otherRoot}/zeta`, }, ]); - expect(result.warnings).toEqual([ + + const leftResult = await left.listAgents(SCOPE); + const rightResult = await right.listAgents({ + workspaceKey: SCOPE.workspaceKey, + root: otherRoot, + }); + expect(leftResult.inventory.agents.map((agent) => agent.agentKey)).toEqual([ + "alpha", + "zeta", + ]); + expect(leftResult.inventory.version).toEqual(rightResult.inventory.version); + }); + + it("uses a checkout-invariant local identity for a markerless root agent", async () => { + const otherRoot = "/different/checkout"; + const left = await provider([ + workflow("Left checkout", "", null), + ]).listAgents(SCOPE); + const right = await provider([ { - code: "duplicate-agent-key", - agentKey: "shared", - message: "Multiple agents use shared; kept each with a local identity.", + ...workflow("Right checkout", "", null), + path: otherRoot, }, + ]).listAgents({ workspaceKey: SCOPE.workspaceKey, root: otherRoot }); + + expect(left.inventory.agents).toEqual([ + expect.objectContaining({ agentKey: "local:root", path: "." }), ]); - expect(JSON.stringify(result.warnings)).not.toContain(WORKSPACE); + expect(left.inventory.version).toEqual(right.inventory.version); }); - it("keeps a duplicated local candidate ambiguous after suffixing its node ids", async () => { - const duplicate = workflow("Connected copy", "connected", null, { - source: "connect", - }); - - const result = await provider([duplicate, { ...duplicate }]).listAgents( - SCOPE, - ); + it("resolves colliding local fallbacks without canonical duplicate metadata", async () => { + const workflows = [ + workflow("Package root", "", null), + workflow("Root child", "root", null), + workflow("Suffix child", "root~2", null), + ]; + const forward = await provider(workflows).listAgents(SCOPE); + const reversed = await provider([...workflows].reverse()).listAgents(SCOPE); - expect(result.agents.map((agent) => agent.agentKey)).toEqual([ - "local:connected", - "local:connected~2", + expect(forward.inventory).toEqual(reversed.inventory); + expect(forward.inventory.agents).toEqual([ + expect.objectContaining({ + agentKey: "local:root", + identityIssue: "identity-unavailable", + path: ".", + }), + expect.objectContaining({ + agentKey: "local:root~2", + identityIssue: "identity-unavailable", + path: "root", + }), + expect.objectContaining({ + agentKey: "local:root~2~2", + identityIssue: "identity-unavailable", + path: "root~2", + }), ]); - expect(result.agents.map((agent) => agent.resolutionAliases)).toEqual([ - ["local:connected"], - ["local:connected"], + expect(forward.warnings).toEqual([]); + expect( + forward.inventory.agents.every( + (agent) => agent.identityIssue !== "duplicate-agent-key", + ), + ).toBe(true); + }); + + it("deduplicates exact registry roots independently of registry order", async () => { + const alpha = workflow("Alpha", "agent", "alpha", { definitionId: 1 }); + const zeta = workflow("Zeta", "agent", "zeta", { definitionId: 2 }); + const forward = await provider([zeta, alpha]).listAgents(SCOPE); + const reversed = await provider([alpha, zeta]).listAgents(SCOPE); + + expect(forward.inventory).toEqual(reversed.inventory); + expect(forward.inventory.agents).toEqual([ + expect.objectContaining({ agentKey: "alpha", path: "agent" }), ]); - expect(result.warnings).toEqual([ + expect(forward.inventory.version).toEqual(reversed.inventory.version); + }); + + it("includes nested agents in every containing selected project", async () => { + const workflows = [ + workflow("Root", "", "root"), + workflow("Parent", "research", "research"), + workflow("Nested", "experiments/evaluator", "evaluator"), { - code: "duplicate-agent-key", - agentKey: "local:connected", - message: - "Multiple agents use local:connected; kept each with a local identity.", + ...workflow("Outside", "outside", "outside"), + path: `${WORKSPACE}-old/outside`, }, + ]; + const parent = await provider(workflows).listAgents(SCOPE); + const nested = await provider(workflows).listAgents({ + workspaceKey: "workspace-experiments", + root: `${WORKSPACE}/experiments`, + }); + + expect(parent.inventory.agents.map((agent) => agent.path)).toEqual([ + "experiments/evaluator", + "research", + ".", + ]); + expect(nested.inventory.agents.map((agent) => agent.path)).toEqual([ + "evaluator", ]); }); - it("needs only the selected scope to build a cacheable inventory", async () => { - const inventory = new HarnessRegistryInventoryProvider({ - listWorkflows: () => [workflow("Research", "research", "research")], - }); + it("matches canonical workflow roots beneath a symlinked workspace", async () => { + if (process.platform === "win32") return; + const tempRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "inventory-symlink-"), + ); + try { + const workspaceRoot = path.join(tempRoot, "real-workspace"); + const agentRoot = path.join(workspaceRoot, "agent"); + const nestedRoot = path.join(agentRoot, "nested-agent"); + const outsideRoot = path.join(tempRoot, "outside", "agent"); + const linkedRoot = path.join(tempRoot, "linked-workspace"); + await Promise.all([ + fs.mkdir(nestedRoot, { recursive: true }), + fs.mkdir(outsideRoot, { recursive: true }), + ]); + await fs.symlink(workspaceRoot, linkedRoot, "dir"); - await expect(inventory.listAgents(SCOPE)).resolves.toMatchObject({ - agents: [{ agentKey: "research" }], - cacheable: true, - warnings: [], - }); + expect( + graphSourceRootsWithinScope(linkedRoot, [ + agentRoot, + nestedRoot, + outsideRoot, + ]), + ).toEqual([await fs.realpath(agentRoot), await fs.realpath(nestedRoot)]); + expect( + dirtyGraphSourceRoots( + linkedRoot, + [agentRoot, nestedRoot], + [path.join(linkedRoot, "agent", "nested-agent", "index.ts")], + ), + ).toEqual([await fs.realpath(nestedRoot)]); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } }); it("fails the provider call when the registry snapshot cannot be read", async () => { @@ -407,7 +968,6 @@ describe("HarnessRegistryInventoryProvider", () => { throw new Error("registry unavailable"); }, }); - await expect(inventory.listAgents(SCOPE)).rejects.toThrow( "registry unavailable", ); diff --git a/packages/harness/src/core/system-graph-inventory.ts b/packages/harness/src/core/system-graph-inventory.ts index cbaf2041a..4d029317c 100644 --- a/packages/harness/src/core/system-graph-inventory.ts +++ b/packages/harness/src/core/system-graph-inventory.ts @@ -1,31 +1,50 @@ +import { createHash } from "node:crypto"; import { realpathSync } from "node:fs"; import * as path from "node:path"; import { - workspaceRelativeLocalKey, + PACKAGE_INVENTORY_PROTOCOL, + packageInventorySchema, + type PackageInventory, + type PackageInventoryAgent, +} from "@sapiom/agent"; + +import { type AgentKey, type GraphWarning, type WorkspaceKey, } from "../shared/system-graph.js"; import type { WorkflowInfo } from "../shared/types.js"; +import { fingerprintWorkflowSources } from "./canvas-cache.js"; import type { ManifestNameInspection } from "./definition-name.js"; -export { workspaceRelativeLocalKey } from "../shared/system-graph.js"; - export interface WorkspaceScope { workspaceKey: WorkspaceKey; root: string; } -export interface AgentInventoryItem { +/** Harness-only evidence paired with one public inventory record. */ +export interface AgentInventoryContextItem { agentKey: AgentKey; /** Internal deployment provenance. Never serialize this into SystemGraph. */ definitionId: number | null; definitionSlug: string | null; label: string; + /** Marker/source compatibility aliases. Never cross the graph HTTP boundary. */ resolutionAliases: string[]; - /** Internal filesystem evidence. Never serialize this into SystemGraph. */ + /** Canonical filesystem evidence. Never serialize this into SystemGraph. */ sourceRoot: string; + /** Registry-owned navigation target, served only by the protected resolver. */ + workflowPath: string; + /** Joins this context to the public record without relying on array order. */ + path: string; + entrypoint: string; +} + +/** Builder-facing item after the public contract and private context are joined. */ +export interface AgentInventoryItem extends AgentInventoryContextItem { + /** Parsed public evidence used to keep authoritative keys above aliases. */ + identityStatus: PackageInventoryAgent["identityStatus"]; } export interface AgentInventoryWarning { @@ -38,15 +57,18 @@ export interface AgentInventoryWarning { } export interface AgentInventoryResult { - agents: AgentInventoryItem[]; - /** False when a later graph open should retry degraded enrichment. */ - cacheable: boolean; + inventory: PackageInventory; + context: AgentInventoryContextItem[]; warnings: AgentInventoryWarning[]; + /** Starts source identity work only after the provisional graph is committed. */ + startEnrichment?: () => void; } -/** Read-only boundary between Studio's current registry and graph projection. */ +/** Read-only boundary between Studio's registry and graph projection. */ export interface AgentInventoryProvider { listAgents(scope: WorkspaceScope): Promise; + /** Prunes private identity state for roots no active graph can reference. */ + retainSources?(sourceRoots: ReadonlySet): void; } type ManifestNameInspector = ( @@ -58,56 +80,37 @@ export interface HarnessRegistryInventoryProviderOptions { | readonly WorkflowInfo[] | Promise; inspectManifestName?: ManifestNameInspector; - /** Test seam; production keeps the default first-open latency budget. */ - manifestInspectionBudgetMs?: number; + /** + * Called with coalesced identity changes. Settled roots are surfaced within + * a short bounded window, while a fully drained queue flushes immediately. + */ + onIdentityChange?: (sourceRoots: readonly string[]) => void | Promise; + /** Test seam. Production uses the same fingerprint as Canvas extraction. */ + fingerprintSource?: (sourceRoot: string) => Promise; + /** Test seam for the bounded identity-change coalescing window. */ + identityChangeCoalesceMs?: number; } const MANIFEST_INSPECTION_CONCURRENCY = 4; -// Individual extraction can wait 15 s; inventory must return well before that. -const MANIFEST_INSPECTION_BUDGET_MS = 5_000; - -async function mapWithDeadline( - values: readonly Input[], - concurrency: number, - budgetMs: number, - map: (value: Input) => Promise, -): Promise> { - const results = new Array(values.length); - let nextIndex = 0; - let deadlineReached = false; - const worker = async (): Promise => { - while (!deadlineReached && nextIndex < values.length) { - const index = nextIndex; - nextIndex += 1; - try { - results[index] = await map(values[index]!); - } catch { - // The caller turns every unfinished/failed item into partial inventory. - } - } - }; - const workers = Promise.all( - Array.from({ length: Math.min(concurrency, values.length) }, async () => - worker(), - ), - ); - let timer: ReturnType | undefined; - const deadline = new Promise((resolve) => { - timer = setTimeout(() => { - deadlineReached = true; - resolve(); - }, budgetMs); +const IDENTITY_CHANGE_COALESCE_MS = 250; +const ENTRYPOINT = "index.ts"; +const ZERO_REVISION = `sha256:${"0".repeat(64)}` as const; +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.codePointAt(0)!; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); }); - await Promise.race([workers, deadline]); - if (!deadlineReached && timer) clearTimeout(timer); - // Already-started production inspections have their own 15 s timeout. Do - // not await them, and prevent workers from starting any more after the cap. - void workers; - return results.slice(); } +type InventoryIdentityIssue = Exclude< + PackageInventoryAgent["identityIssue"], + undefined +>; + function isWindowsAbsolute(input: string): boolean { - return /^[A-Za-z]:[\\/]/.test(input); + return ( + /^[A-Za-z]:[\\/]/.test(input) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(input) + ); } function pathApi(input: string): typeof path.posix { @@ -129,9 +132,7 @@ export function canonicalGraphPath(input: string): string { return realpathSync.native(resolved); } catch { // Watchers can report a path after an atomic rename or deletion, so the - // leaf itself may no longer exist. Resolve the nearest existing ancestor - // and append the missing tail; otherwise a symlinked workspace would stop - // matching exactly when targeted invalidation matters most. + // leaf itself may no longer exist. Resolve the nearest existing ancestor. const missingSegments: string[] = []; let ancestor = resolved; let parent = api.dirname(ancestor); @@ -149,6 +150,24 @@ export function canonicalGraphPath(input: string): string { } } +/** + * Resolve a public package-relative inventory path against its workspace. + * The inventory path is always POSIX, while the workspace path keeps the + * host's native drive/UNC/POSIX flavor. Canonicalization also resolves a + * symlinked workspace before this value is compared with private context. + */ +export function inventorySourceRoot( + scopeRoot: string, + inventoryPath: string, +): string { + const api = pathApi(scopeRoot); + const joined = + inventoryPath === "." + ? scopeRoot + : api.join(scopeRoot, ...inventoryPath.split("/")); + return canonicalGraphPath(joined); +} + export function isWithinGraphPath(root: string, candidate: string): boolean { if (isWindowsAbsolute(root) !== isWindowsAbsolute(candidate)) return false; const api = pathApi(root); @@ -180,8 +199,7 @@ export function graphSourceRootsWithinScope( /** * Attribute exact source edits to the deepest registered project roots. - * A null path list is the polling/ambiguous-event fallback and dirties every - * caller in the scope. + * A null path list is the polling/ambiguous-event fallback. */ export function dirtyGraphSourceRoots( scopeRoot: string, @@ -211,26 +229,29 @@ export function dirtyGraphSourceRoots( return [...dirty].sort(); } -function normalizedAlias(value: string | null): string | null { - const alias = value?.trim() ?? ""; +function canonicalIdentity(value: string | null): string | null { + const identity = value?.trim() ?? ""; if ( - alias === "" || - /[\0\r\n]/.test(alias) || - alias.includes("/") || - alias.includes("\\") || - path.posix.isAbsolute(alias) || - path.win32.isAbsolute(alias) + identity === "" || + identity === "." || + identity === ".." || + identity.startsWith("local:") || + hasControlCharacter(identity) || + identity.includes("/") || + identity.includes("\\") || + path.posix.isAbsolute(identity) || + path.win32.isAbsolute(identity) ) { return null; } - return alias; + return identity; } function safeLabel(value: string, fallback: string): string { const label = value.trim(); if ( label === "" || - /[\0\r\n]/.test(label) || + hasControlCharacter(label) || path.posix.isAbsolute(label) || path.win32.isAbsolute(label) ) { @@ -242,25 +263,116 @@ function safeLabel(value: string, fallback: string): string { function uniqueAliases(values: Array): string[] { return [ ...new Set(values.filter((value): value is string => value !== null)), - ]; + ].sort(compareText); +} + +function compareText(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +function packageRelativePath(scopeRoot: string, workflowPath: string): string { + const api = pathApi(scopeRoot); + const relative = api.relative(scopeRoot, workflowPath); + if ( + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${api.sep}`) && + !api.isAbsolute(relative) + ) { + return relative.split(api.sep).join("/"); + } + if (relative === "") return "."; + + // A symlinked registry path can have a different lexical spelling. Its + // canonical source was already proven inside the canonical scope. + const canonicalScope = canonicalGraphPath(scopeRoot); + const canonicalSource = canonicalGraphPath(workflowPath); + const canonicalApi = pathApi(canonicalScope); + const canonicalRelative = canonicalApi.relative( + canonicalScope, + canonicalSource, + ); + return canonicalRelative === "" + ? "." + : canonicalRelative.split(canonicalApi.sep).join("/"); +} + +function stableJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((item) => stableJson(item)).join(",")}]`; + } + const entries = Object.entries(value as Record) + .filter(([, child]) => child !== undefined) + .sort(([left], [right]) => compareText(left, right)); + return `{${entries + .map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`) + .join(",")}}`; +} + +function buildWorkingTreeInventory( + workspaceKey: WorkspaceKey, + agents: PackageInventoryAgent[], +): PackageInventory { + const status = agents.some((agent) => agent.identityStatus === "provisional") + ? "degraded" + : "complete"; + const normalized = packageInventorySchema.parse({ + protocol: PACKAGE_INVENTORY_PROTOCOL, + version: { + kind: "working-tree", + workspaceKey, + revision: ZERO_REVISION, + }, + status, + agents, + }); + const revision = `sha256:${createHash("sha256") + .update( + stableJson({ + protocol: normalized.protocol, + status: normalized.status, + agents: normalized.agents, + }), + ) + .digest("hex")}` as const; + return { + ...normalized, + version: { kind: "working-tree", workspaceKey, revision }, + }; } interface PreparedAgent { + canonicalName: string | null; candidateKey: AgentKey; fallbackKey: AgentKey; + identityIssue: InventoryIdentityIssue | null; definitionId: number | null; definitionSlug: string | null; - extractionFailed: boolean; label: string; - resolutionAliases: string[]; + markerAlias: string | null; + path: string; + sourceRoot: string; + workflowPath: string; + warnOnIdentityFailure: boolean; +} + +interface IdentityCacheEntry { + fingerprint: string | null; + inspection: ManifestNameInspection; +} + +interface IdentityTask { sourceRoot: string; + generation: number; + epoch: number; } function preparedOrder(left: PreparedAgent, right: PreparedAgent): number { return ( - left.candidateKey.localeCompare(right.candidateKey) || - left.sourceRoot.localeCompare(right.sourceRoot) || - left.label.localeCompare(right.label) + compareText(left.candidateKey, right.candidateKey) || + compareText(left.path, right.path) || + compareText(left.sourceRoot, right.sourceRoot) ); } @@ -269,205 +381,485 @@ function warningOrder( right: AgentInventoryWarning, ): number { return ( - left.code.localeCompare(right.code) || - left.agentKey.localeCompare(right.agentKey) || - left.message.localeCompare(right.message) + compareText(left.code, right.code) || + compareText(left.agentKey, right.agentKey) || + compareText(left.message, right.message) + ); +} + +function workflowRegistryOrder( + scopeRoot: string, + left: { workflow: WorkflowInfo; sourceRoot: string }, + right: { workflow: WorkflowInfo; sourceRoot: string }, +): number { + const leftWorkflow = left.workflow; + const rightWorkflow = right.workflow; + return ( + compareText(left.sourceRoot, right.sourceRoot) || + compareText( + packageRelativePath(scopeRoot, leftWorkflow.path), + packageRelativePath(scopeRoot, rightWorkflow.path), + ) || + compareText( + leftWorkflow.definitionSlug ?? "", + rightWorkflow.definitionSlug ?? "", + ) || + compareText(leftWorkflow.name, rightWorkflow.name) || + (leftWorkflow.definitionId ?? -1) - (rightWorkflow.definitionId ?? -1) || + compareText(leftWorkflow.source, rightWorkflow.source) || + compareText(leftWorkflow.path, rightWorkflow.path) ); } /** - * V0 inventory adapter. WorkflowRegistry's scan/connect flows remain the only - * writers; this provider receives snapshots and performs no discovery. The - * injected manifest-name inspection may run cached extraction, so enrichment - * has both a concurrency cap and a wall-clock budget on a cold graph open. + * Local transition adapter from WorkflowRegistry to the public package + * inventory contract. Registry reads are immediate. Source definition names + * enrich provisional identities only after that first graph revision commits. */ export class HarnessRegistryInventoryProvider implements AgentInventoryProvider { + private readonly identityCache = new Map(); + private readonly generations = new Map(); + private readonly queuedTasks: IdentityTask[] = []; + private readonly activeTasks = new Map(); + private readonly pendingIdentityChanges = new Set(); + private activeInspections = 0; + private identityChangeFlushInFlight = false; + private identityChangeTimer: ReturnType | null = null; + private epoch = 0; + private nextGeneration = 1; + constructor( private readonly options: HarnessRegistryInventoryProviderOptions, ) {} async listAgents(scope: WorkspaceScope): Promise { - // A registry read is the one operation whose failure makes inventory - // unavailable. Per-agent enrichment failures degrade to local identities. const workflows = await this.options.listWorkflows(); - const navigationRoot = scope.root; - const selectedScope: WorkspaceScope = { - workspaceKey: scope.workspaceKey, - root: canonicalGraphPath(scope.root), - }; - // Project-axis membership is containment in the SELECTED root. An agent can - // therefore appear in both a parent project's graph and a separately-opened - // nested project's graph, exactly as it appears under both project rows in - // the rail. Deepest-scope ownership made the parent row visibly list agents - // that disappeared when its graph opened. + this.retainSources( + new Set(workflows.map((workflow) => canonicalGraphPath(workflow.path))), + ); + const canonicalScopeRoot = canonicalGraphPath(scope.root); + const bySourceRoot = new Map< + string, + { workflow: WorkflowInfo; sourceRoot: string } + >(); const contained = workflows .map((workflow) => ({ workflow, sourceRoot: canonicalGraphPath(workflow.path), })) .filter(({ sourceRoot }) => - isWithinGraphPath(selectedScope.root, sourceRoot), + isWithinGraphPath(canonicalScopeRoot, sourceRoot), ) - .map(({ workflow, sourceRoot }) => { - const fallbackKey = - workspaceRelativeLocalKey(navigationRoot, workflow.path) ?? - workspaceRelativeLocalKey(selectedScope.root, sourceRoot); - if (!fallbackKey) { - throw new Error("Contained system graph path had no local identity"); - } - return { workflow, sourceRoot, fallbackKey }; - }); - - const inspections = await mapWithDeadline( - contained, - MANIFEST_INSPECTION_CONCURRENCY, - this.options.manifestInspectionBudgetMs ?? MANIFEST_INSPECTION_BUDGET_MS, - ({ workflow, sourceRoot, fallbackKey }) => - this.prepareAgent(workflow, sourceRoot, fallbackKey), - ); - const prepared = Array.from( - { length: contained.length }, - (_, index) => - inspections[index] ?? - this.prepareFallbackAgent( - contained[index]!.workflow, - contained[index]!.sourceRoot, - contained[index]!.fallbackKey, - ), - ).sort(preparedOrder); + .sort((left, right) => workflowRegistryOrder(scope.root, left, right)); + for (const { workflow, sourceRoot } of contained) { + // Registry persistence is expected to be unique by path. Keep the first + // deterministic row if a corrupt/legacy file contains an exact duplicate. + if (!bySourceRoot.has(sourceRoot)) { + bySourceRoot.set(sourceRoot, { workflow, sourceRoot }); + } + } - const candidateCounts = new Map(); + const inspectionRoots: string[] = []; + const prepared = [...bySourceRoot.values()] + .map(({ workflow, sourceRoot }): PreparedAgent => { + const inventoryPath = packageRelativePath(scope.root, workflow.path); + const fallbackKey = `local:${ + inventoryPath === "." ? "root" : inventoryPath + }` as AgentKey; + const markerAlias = canonicalIdentity(workflow.definitionSlug); + const cached = this.identityCache.get(sourceRoot); + let canonicalName: string | null = null; + let identityIssue: InventoryIdentityIssue | null = null; + let warnOnIdentityFailure = false; + if (!this.options.inspectManifestName) { + identityIssue = "identity-unavailable"; + } else { + inspectionRoots.push(sourceRoot); + this.ensureGeneration(sourceRoot); + if (!cached) { + identityIssue = "identity-pending"; + } else if (cached.inspection.status === "found") { + canonicalName = canonicalIdentity(cached.inspection.name); + if (!canonicalName) { + identityIssue = "identity-invalid"; + warnOnIdentityFailure = true; + } + } else { + identityIssue = "identity-unavailable"; + warnOnIdentityFailure = cached.inspection.status === "failed"; + } + } + const candidateKey = canonicalName ?? markerAlias ?? fallbackKey; + return { + canonicalName, + candidateKey, + fallbackKey, + identityIssue, + definitionId: workflow.definitionId, + definitionSlug: markerAlias, + label: safeLabel( + workflow.name, + canonicalName ?? markerAlias ?? fallbackKey.slice("local:".length), + ), + markerAlias, + path: inventoryPath, + sourceRoot, + workflowPath: workflow.path, + warnOnIdentityFailure, + }; + }) + .sort(preparedOrder); + + const canonicalCounts = new Map(); + const provisionalCounts = new Map(); for (const agent of prepared) { - candidateCounts.set( - agent.candidateKey, - (candidateCounts.get(agent.candidateKey) ?? 0) + 1, - ); + // A pending agent's candidate key is a guess, not a claim: two agents + // that merely share a marker while their sources are still being read + // are not yet a collision, and calling them one flashes a warning that + // enrichment retracts a moment later. + if (agent.identityIssue === "identity-pending") continue; + const counts = agent.canonicalName ? canonicalCounts : provisionalCounts; + counts.set(agent.candidateKey, (counts.get(agent.candidateKey) ?? 0) + 1); + } + + const warnings: AgentInventoryWarning[] = []; + const candidates = new Set([ + ...canonicalCounts.keys(), + ...provisionalCounts.keys(), + ]); + for (const candidateKey of [...candidates].sort(compareText)) { + const canonicalCount = canonicalCounts.get(candidateKey) ?? 0; + const provisionalCount = provisionalCounts.get(candidateKey) ?? 0; + const ambiguous = + canonicalCount > 1 || (canonicalCount === 0 && provisionalCount > 1); + if (!ambiguous || canonicalIdentity(candidateKey) === null) continue; + warnings.push({ + code: "duplicate-agent-key", + agentKey: candidateKey, + message: `Multiple agents use ${candidateKey}; kept each with a local identity.`, + }); } const used = new Set(); - const assigned = prepared.map((agent) => { - const duplicate = (candidateCounts.get(agent.candidateKey) ?? 0) > 1; - let agentKey = duplicate ? agent.fallbackKey : agent.candidateKey; - if (used.has(agentKey)) agentKey = agent.fallbackKey; - let suffix = 2; + const publicAgents: PackageInventoryAgent[] = []; + const context: AgentInventoryContextItem[] = []; + for (const agent of prepared) { + const canonicalCount = canonicalCounts.get(agent.candidateKey) ?? 0; + const provisionalCount = provisionalCounts.get(agent.candidateKey) ?? 0; + const safeCandidate = canonicalIdentity(agent.candidateKey) !== null; + const duplicate = + safeCandidate && + (agent.canonicalName + ? canonicalCount > 1 + : canonicalCount > 1 || + (canonicalCount === 0 && provisionalCount > 1)); + const shadowedByCanonical = + agent.canonicalName === null && canonicalCount === 1; + let agentKey = + duplicate || shadowedByCanonical + ? agent.fallbackKey + : agent.candidateKey; const base = agentKey; + let suffix = 2; while (used.has(agentKey)) { agentKey = `${base}~${suffix}`; suffix += 1; } used.add(agentKey); - return { agent, agentKey }; - }); - const warnings: AgentInventoryWarning[] = []; - for (const candidateKey of [...candidateCounts.keys()].sort()) { - if ((candidateCounts.get(candidateKey) ?? 0) < 2) continue; - warnings.push({ - code: "duplicate-agent-key", - agentKey: candidateKey, - message: `Multiple agents use ${candidateKey}; kept each with a local identity.`, - }); - } - for (const { agent, agentKey } of assigned) { - if (!agent.extractionFailed) continue; - warnings.push({ - code: "inventory-extraction-failed", + const canonical = agent.canonicalName !== null && !duplicate; + const identityIssue = duplicate + ? "duplicate-agent-key" + : agent.identityIssue; + let publicAgent: PackageInventoryAgent; + if (canonical) { + publicAgent = { + agentKey, + identityStatus: "canonical", + path: agent.path, + entrypoint: ENTRYPOINT, + }; + } else if (duplicate) { + const candidateAgentKey = canonicalIdentity(agent.candidateKey); + if (!candidateAgentKey) { + throw new Error("Duplicate inventory identity had no safe candidate"); + } + publicAgent = { + agentKey, + identityStatus: "provisional", + identityIssue: "duplicate-agent-key", + candidateAgentKey, + path: agent.path, + entrypoint: ENTRYPOINT, + }; + } else { + const provisionalIssue = + identityIssue === "duplicate-agent-key" || identityIssue === null + ? "identity-unavailable" + : identityIssue; + publicAgent = { + agentKey, + identityStatus: "provisional", + identityIssue: provisionalIssue, + path: agent.path, + entrypoint: ENTRYPOINT, + }; + } + publicAgents.push(publicAgent); + + const resolutionAliases = uniqueAliases([ + agent.markerAlias, + duplicate ? canonicalIdentity(agent.candidateKey) : null, + ]); + context.push({ agentKey, - message: `Could not inspect ${agent.label}; using its local identity.`, + definitionId: agent.definitionId, + definitionSlug: agent.definitionSlug, + label: agent.label, + resolutionAliases, + sourceRoot: agent.sourceRoot, + workflowPath: agent.workflowPath, + path: agent.path, + entrypoint: ENTRYPOINT, }); - } - - const agents = assigned - .map( - ({ agent, agentKey }): AgentInventoryItem => ({ + if (!canonical && agent.warnOnIdentityFailure && !duplicate) { + warnings.push({ + code: "inventory-extraction-failed", agentKey, - definitionId: agent.definitionId, - definitionSlug: agent.definitionSlug, - label: agent.label, - resolutionAliases: agent.resolutionAliases, - sourceRoot: agent.sourceRoot, - }), - ) - .sort( - (left, right) => - left.agentKey.localeCompare(right.agentKey) || - left.sourceRoot.localeCompare(right.sourceRoot), - ); + message: `Could not resolve ${agent.label}'s source identity; using its provisional identity.`, + }); + } + } + const inventory = buildWorkingTreeInventory( + scope.workspaceKey, + publicAgents, + ); + const contextByAgent = new Map( + context.map((item) => [item.agentKey, item]), + ); + const normalizedContext = inventory.agents.map((agent) => { + const item = contextByAgent.get(agent.agentKey); + if (!item) throw new Error("Package inventory context was incomplete"); + return item; + }); warnings.sort(warningOrder); + const roots = [...new Set(inspectionRoots)].sort(compareText); + const enrichmentEpoch = this.epoch; + const tasks = roots.map((sourceRoot) => ({ + sourceRoot, + generation: this.generations.get(sourceRoot)!, + epoch: enrichmentEpoch, + })); return { - agents, - cacheable: prepared.every((agent) => !agent.extractionFailed), + inventory, + context: normalizedContext, warnings, + ...(roots.length > 0 + ? { + startEnrichment: () => + this.enqueueInspections(tasks, enrichmentEpoch), + } + : {}), }; } - private async prepareAgent( - workflow: WorkflowInfo, - sourceRoot: string, - fallbackKey: AgentKey, - ): Promise { - const definitionSlug = normalizedAlias(workflow.definitionSlug); - let manifestName: string | null = null; - let extractionFailed = false; - if (!definitionSlug && this.options.inspectManifestName) { - try { - const inspected = await this.options.inspectManifestName(sourceRoot); - if (inspected.status === "found") { - manifestName = normalizedAlias(inspected.name); - } else { - extractionFailed = inspected.status === "failed"; - } - } catch { - extractionFailed = true; + /** Drops settled/pending identity state after a relevant source edit. */ + invalidateSource(sourceRoot: string): void { + const key = canonicalGraphPath(sourceRoot); + this.identityCache.delete(key); + this.generations.set(key, this.nextGeneration++); + this.dropQueuedTasks(key); + this.pendingIdentityChanges.delete(key); + if (this.pendingIdentityChanges.size === 0) { + this.clearIdentityChangeTimer(); + } + } + + /** Explicit Retry may retry failures even when no source fingerprint changed. */ + retryFailedInspections(scope: WorkspaceScope): void { + const root = canonicalGraphPath(scope.root); + for (const [sourceRoot, cached] of this.identityCache) { + if ( + isWithinGraphPath(root, sourceRoot) && + (cached.inspection.status === "failed" || + (cached.inspection.status === "found" && + canonicalIdentity(cached.inspection.name) === null)) + ) { + this.invalidateSource(sourceRoot); } } + } - const candidateKey = definitionSlug ?? manifestName ?? fallbackKey; - // Keep the pre-disambiguation candidate as an alias for every copy. When - // duplicate local fallbacks exist, a caller targeting that candidate must - // see ambiguity rather than silently resolving to the unsuffixed copy. - const resolutionAliases = uniqueAliases([ - definitionSlug, - manifestName, - candidateKey, + clear(): void { + this.epoch += 1; + this.identityCache.clear(); + this.generations.clear(); + this.queuedTasks.length = 0; + this.pendingIdentityChanges.clear(); + this.clearIdentityChangeTimer(); + } + + /** Drops cache, queue, and generation state for retired source roots. */ + retainSources(sourceRoots: ReadonlySet): void { + const retained = new Set([...sourceRoots].map(canonicalGraphPath)); + const known = new Set([ + ...this.identityCache.keys(), + ...this.generations.keys(), + ...this.queuedTasks.map((task) => task.sourceRoot), ]); - return { - candidateKey, - fallbackKey, - definitionId: workflow.definitionId, - definitionSlug, - extractionFailed, - label: safeLabel( - workflow.name, - definitionSlug ?? - manifestName ?? - (fallbackKey.slice("local:".length) || "Local agent"), - ), - resolutionAliases, - sourceRoot, - }; + for (const sourceRoot of known) { + if (retained.has(sourceRoot)) continue; + this.identityCache.delete(sourceRoot); + this.generations.delete(sourceRoot); + this.dropQueuedTasks(sourceRoot); + this.pendingIdentityChanges.delete(sourceRoot); + } + if (this.pendingIdentityChanges.size === 0) { + this.clearIdentityChangeTimer(); + } } - private prepareFallbackAgent( - workflow: WorkflowInfo, - sourceRoot: string, - fallbackKey: AgentKey, - ): PreparedAgent { - const definitionSlug = normalizedAlias(workflow.definitionSlug); - const candidateKey = definitionSlug ?? fallbackKey; - return { - candidateKey, - fallbackKey, - definitionId: workflow.definitionId, - definitionSlug, - extractionFailed: !definitionSlug, - label: safeLabel( - workflow.name, - definitionSlug ?? (fallbackKey.slice("local:".length) || "Local agent"), - ), - resolutionAliases: [candidateKey], - sourceRoot, - }; + private enqueueInspections( + tasks: readonly IdentityTask[], + requestedEpoch: number, + ): void { + if (!this.options.inspectManifestName || requestedEpoch !== this.epoch) { + return; + } + for (const task of tasks) { + if (!this.isCurrentTask(task)) continue; + this.dropQueuedTasks(task.sourceRoot); + const active = this.activeTasks.get(task.sourceRoot); + if ( + active?.generation === task.generation && + active.epoch === task.epoch + ) { + continue; + } + this.queuedTasks.push(task); + } + this.drainInspectionQueue(); + } + + private drainInspectionQueue(): void { + for (let index = this.queuedTasks.length - 1; index >= 0; index -= 1) { + if (!this.isCurrentTask(this.queuedTasks[index]!)) { + this.queuedTasks.splice(index, 1); + } + } + while (this.activeInspections < MANIFEST_INSPECTION_CONCURRENCY) { + const index = this.queuedTasks.findIndex( + (task) => !this.activeTasks.has(task.sourceRoot), + ); + if (index === -1) break; + const [task] = this.queuedTasks.splice(index, 1); + if (!task || !this.isCurrentTask(task)) continue; + this.activeTasks.set(task.sourceRoot, task); + this.activeInspections += 1; + void this.inspectSource(task).finally(() => { + if (this.activeTasks.get(task.sourceRoot) === task) { + this.activeTasks.delete(task.sourceRoot); + this.activeInspections -= 1; + } + this.drainInspectionQueue(); + }); + } + this.scheduleIdentityChangeFlush(); + } + + private async inspectSource(task: IdentityTask): Promise { + const inspect = this.options.inspectManifestName; + if (!inspect || !this.isCurrentTask(task)) return; + let fingerprint: string | null = null; + let inspection: ManifestNameInspection; + try { + fingerprint = await ( + this.options.fingerprintSource ?? fingerprintWorkflowSources + )(task.sourceRoot); + if (!this.isCurrentTask(task)) return; + const hit = this.identityCache.get(task.sourceRoot); + if (hit?.fingerprint === fingerprint) return; + inspection = await inspect(task.sourceRoot); + } catch { + if (!this.isCurrentTask(task)) return; + const hit = this.identityCache.get(task.sourceRoot); + if (fingerprint === null && hit) return; + inspection = { status: "failed" }; + } + if (!this.isCurrentTask(task)) return; + this.identityCache.set(task.sourceRoot, { fingerprint, inspection }); + this.pendingIdentityChanges.add(task.sourceRoot); + this.scheduleIdentityChangeFlush(); + } + + private scheduleIdentityChangeFlush(): void { + if (this.pendingIdentityChanges.size === 0) return; + if (this.activeInspections === 0 && this.queuedTasks.length === 0) { + this.clearIdentityChangeTimer(); + this.flushIdentityChanges(); + return; + } + if (this.identityChangeFlushInFlight || this.identityChangeTimer !== null) { + return; + } + this.identityChangeTimer = setTimeout(() => { + this.identityChangeTimer = null; + this.flushIdentityChanges(); + }, this.options.identityChangeCoalesceMs ?? IDENTITY_CHANGE_COALESCE_MS); + } + + private flushIdentityChanges(): void { + if ( + this.identityChangeFlushInFlight || + this.pendingIdentityChanges.size === 0 + ) { + return; + } + this.clearIdentityChangeTimer(); + const sourceRoots = [...this.pendingIdentityChanges].sort(compareText); + this.pendingIdentityChanges.clear(); + const notify = this.options.onIdentityChange; + if (!notify) return; + + this.identityChangeFlushInFlight = true; + void Promise.resolve() + .then(() => notify(sourceRoots)) + .catch(() => { + // A refresh hint cannot make settled identity results disappear. + }) + .finally(() => { + this.identityChangeFlushInFlight = false; + this.scheduleIdentityChangeFlush(); + }); + } + + private clearIdentityChangeTimer(): void { + if (this.identityChangeTimer === null) return; + clearTimeout(this.identityChangeTimer); + this.identityChangeTimer = null; + } + + private dropQueuedTasks(sourceRoot: string): void { + for (let index = this.queuedTasks.length - 1; index >= 0; index -= 1) { + if (this.queuedTasks[index]!.sourceRoot === sourceRoot) { + this.queuedTasks.splice(index, 1); + } + } + } + + private isCurrentTask(task: IdentityTask): boolean { + return ( + task.epoch === this.epoch && + task.generation === this.generations.get(task.sourceRoot) + ); + } + + private ensureGeneration(sourceRoot: string): number { + const existing = this.generations.get(sourceRoot); + if (existing !== undefined) return existing; + const generation = this.nextGeneration++; + this.generations.set(sourceRoot, generation); + return generation; } } diff --git a/packages/harness/src/core/system-graph-relationships.test.ts b/packages/harness/src/core/system-graph-relationships.test.ts index 4c2f9652a..b9bdf1df5 100644 --- a/packages/harness/src/core/system-graph-relationships.test.ts +++ b/packages/harness/src/core/system-graph-relationships.test.ts @@ -20,11 +20,15 @@ async function callerWithSource(source: string): Promise { await fs.writeFile(path.join(sourceRoot, "index.ts"), source); return { agentKey: "research", + identityStatus: "canonical", definitionId: 1, definitionSlug: "research", label: "Research", resolutionAliases: ["research"], sourceRoot, + workflowPath: sourceRoot, + path: ".", + entrypoint: "index.ts", }; } diff --git a/packages/harness/src/core/system-graph-store.test.ts b/packages/harness/src/core/system-graph-store.test.ts index 3040c67a2..fe6f34ab6 100644 --- a/packages/harness/src/core/system-graph-store.test.ts +++ b/packages/harness/src/core/system-graph-store.test.ts @@ -26,11 +26,12 @@ function graphFor(label: string): SystemGraph { }; } -function buildResult( - label: string, - cacheable = true, -): SystemGraphBuildResult { - return { cacheable, graph: graphFor(label) }; +function buildResult(label: string, cacheable = true): SystemGraphBuildResult { + return { + cacheable, + graph: graphFor(label), + navigation: [{ agentKey: label, workflowPath: `/private/${label}` }], + }; } function deferred(): { @@ -61,6 +62,11 @@ describe("SystemGraphStore", () => { const ready = await first; expect(ready).toMatchObject({ state: "ready", graph: graphFor("first") }); + expect(store.peekNavigation(scope.workspaceKey)).toEqual({ + workspaceKey: scope.workspaceKey, + revision: ready.revision, + targets: [{ agentKey: "first", workflowPath: "/private/first" }], + }); await expect(store.get(scope)).resolves.toBe(ready); expect(builder.build).toHaveBeenCalledTimes(1); }); @@ -81,6 +87,10 @@ describe("SystemGraphStore", () => { const stale = store.requestRefresh(scope); expect(stale).toMatchObject({ state: "stale", graph: graphFor("first") }); await expect(store.get(scope)).resolves.toBe(stale); + expect(store.peekNavigation(scope.workspaceKey)).toMatchObject({ + revision: stale.revision, + targets: [{ agentKey: "first", workflowPath: "/private/first" }], + }); refresh.resolve(buildResult("second")); await vi.waitFor(() => { @@ -92,6 +102,28 @@ describe("SystemGraphStore", () => { expect(changes.map((change) => change.state)).toContain("stale"); }); + it("does not let a builder mutate last-good navigation after commit", async () => { + const refresh = deferred(); + const navigation = [{ agentKey: "first", workflowPath: "/private/first" }]; + const build = vi + .fn() + .mockResolvedValueOnce({ + cacheable: true, + graph: graphFor("first"), + navigation, + }) + .mockReturnValueOnce(refresh.promise); + const store = new SystemGraphStore({ build }); + await store.get(scope); + + navigation[0]!.workflowPath = "/private/mutated"; + store.requestRefresh(scope); + + expect(store.peekNavigation(scope.workspaceKey)?.targets).toEqual([ + { agentKey: "first", workflowPath: "/private/first" }, + ]); + }); + it("discards an older refresh and commits the newest edit", async () => { const oldRefresh = deferred(); const newestRefresh = deferred(); @@ -140,6 +172,10 @@ describe("SystemGraphStore", () => { refreshing.revision, ); }); + expect(store.peekNavigation(scope.workspaceKey)).toMatchObject({ + revision: store.peek(scope.workspaceKey)?.revision, + targets: [{ agentKey: "initial", workflowPath: "/private/initial" }], + }); await store.get(scope); await vi.waitFor(() => { @@ -183,6 +219,10 @@ describe("SystemGraphStore", () => { state: "stale", graph: graphFor("initial"), }); + expect(store.peekNavigation(scope.workspaceKey)).toMatchObject({ + revision: stale.revision, + targets: [{ agentKey: "initial", workflowPath: "/private/initial" }], + }); pending.resolve(buildResult("obsolete")); await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(2)); @@ -206,6 +246,30 @@ describe("SystemGraphStore", () => { expect(onChange).toHaveBeenCalledTimes(1); }); + it("arms background enrichment even when the build that carried it is superseded", async () => { + // `reportRefreshFailure` bumps the generation with `refreshPending` already + // false and `automaticRetryUsed` true, so an in-flight build loses its + // commit and nothing schedules a follow-up — not the superseded-build path, + // not the next read. If the enrichment callback goes down with it, no + // identity ever leaves `identity-pending`, the projection can never become + // cacheable, and the workspace is stuck degraded until an unrelated watcher + // event arrives. Enrichment is idempotent, so it is armed before the commit + // is decided. + const pending = deferred(); + const startEnrichment = vi.fn(); + const store = new SystemGraphStore({ build: vi.fn(() => pending.promise) }); + + const cold = store.get(scope); + store.reportRefreshFailure(scope); + pending.resolve({ + ...buildResult("initial", false), + afterCommit: startEnrichment, + }); + await cold; + + expect(startEnrichment).toHaveBeenCalledTimes(1); + }); + it("allows an explicit refresh after automatic recovery was exhausted", async () => { const build = vi .fn() @@ -233,6 +297,29 @@ describe("SystemGraphStore", () => { }); }); + it("starts enrichment only after its provisional graph and resolver commit", async () => { + const afterCommit = vi.fn(() => { + const snapshot = store.peek(scope.workspaceKey); + const navigation = store.peekNavigation(scope.workspaceKey); + expect(snapshot).toMatchObject({ state: "degraded" }); + expect(navigation?.revision).toBe(snapshot?.revision); + expect(navigation?.targets).toEqual([ + { agentKey: "pending", workflowPath: "/private/pending" }, + ]); + }); + const store = new SystemGraphStore({ + build: vi.fn(async () => ({ + ...buildResult("pending", false), + afterCommit, + })), + }); + + const snapshot = await store.get(scope); + + expect(snapshot.state).toBe("degraded"); + expect(afterCommit).toHaveBeenCalledTimes(1); + }); + it("allows one later-open retry for a partial projection", async () => { const retry = deferred(); const build = vi @@ -261,6 +348,38 @@ describe("SystemGraphStore", () => { }); }); + it("atomically publishes changed navigation from a same-graph degraded recovery", async () => { + const graph = graphFor("partial"); + const build = vi + .fn() + .mockResolvedValueOnce({ + cacheable: false, + graph, + navigation: [{ agentKey: "partial", workflowPath: "/private/before" }], + }) + .mockResolvedValueOnce({ + cacheable: false, + graph, + navigation: [{ agentKey: "partial", workflowPath: "/private/after" }], + }); + const onChange = vi.fn(); + const store = new SystemGraphStore({ build }, { onChange }); + const initial = await store.get(scope); + onChange.mockClear(); + + await store.get(scope); + await vi.waitFor(() => + expect(store.peekNavigation(scope.workspaceKey)?.targets).toEqual([ + { agentKey: "partial", workflowPath: "/private/after" }, + ]), + ); + + expect(store.peek(scope.workspaceKey)?.revision).toBeGreaterThan( + initial.revision, + ); + expect(onChange).toHaveBeenCalledTimes(1); + }); + it("does not publish or retain an in-flight build after scope retirement", async () => { const pending = deferred(); const onChange = vi.fn(); diff --git a/packages/harness/src/core/system-graph-store.ts b/packages/harness/src/core/system-graph-store.ts index effca816a..4f928915e 100644 --- a/packages/harness/src/core/system-graph-store.ts +++ b/packages/harness/src/core/system-graph-store.ts @@ -1,6 +1,8 @@ import type { SystemGraph, SystemGraphLifecycleState, + SystemGraphNavigationResponse, + SystemGraphNavigationTarget, SystemGraphSnapshot, WorkspaceKey, } from "../shared/system-graph.js"; @@ -14,7 +16,11 @@ export interface SystemGraphStoreOptions { interface SystemGraphEntry { scope: WorkspaceScope; snapshot: SystemGraphSnapshot; - lastGood: SystemGraph | null; + navigation: SystemGraphNavigationResponse; + lastGood: { + graph: SystemGraph; + navigation: SystemGraphNavigationTarget[]; + } | null; activeBuild: Promise | null; generation: number; refreshPending: boolean; @@ -22,6 +28,32 @@ interface SystemGraphEntry { retired: boolean; } +function visibleProjection(entry: SystemGraphEntry): { + graph: SystemGraph | null; + navigation: readonly SystemGraphNavigationTarget[]; +} { + return ( + entry.lastGood ?? { + graph: entry.snapshot.graph, + navigation: entry.navigation.targets, + } + ); +} + +function sameNavigation( + left: readonly SystemGraphNavigationTarget[], + right: readonly SystemGraphNavigationTarget[], +): boolean { + return ( + left.length === right.length && + left.every( + (target, index) => + target.agentKey === right[index]?.agentKey && + target.workflowPath === right[index]?.workflowPath, + ) + ); +} + /** * Process-lifetime, workspace-scoped projection store. * @@ -63,6 +95,20 @@ export class SystemGraphStore { return Promise.resolve(entry.snapshot); } + /** + * Cold-initialize resolver data without treating an existing degraded/stale + * projection as a later graph open. Navigation reads must be lifecycle + * side-effect-free for the exact revision the browser already displays. + */ + ensureInitialized(scope: WorkspaceScope): Promise { + const entry = this.entries.get(scope.workspaceKey); + if (!entry) return this.get(scope); + if (entry.activeBuild && entry.snapshot.graph === null) { + return entry.activeBuild; + } + return Promise.resolve(entry.snapshot); + } + /** Marks a workspace dirty and starts (or queues) a background refresh. */ requestRefresh(scope: WorkspaceScope): SystemGraphSnapshot { const entry = this.ensureEntry(scope); @@ -82,6 +128,13 @@ export class SystemGraphStore { return this.entries.get(workspaceKey)?.snapshot ?? null; } + /** Resolver data stamped with the exact graph revision it accompanies. */ + peekNavigation( + workspaceKey: WorkspaceKey, + ): SystemGraphNavigationResponse | null { + return this.entries.get(workspaceKey)?.navigation ?? null; + } + /** Records a refresh prerequisite failure while preserving visible data. */ reportRefreshFailure(scope: WorkspaceScope): SystemGraphSnapshot { const entry = this.ensureEntry(scope); @@ -91,10 +144,16 @@ export class SystemGraphStore { // graph read rebuild from the stale inventory and relabel it ready; the // watcher retries the failed inventory callback instead. entry.automaticRetryUsed = true; - const visibleGraph = entry.lastGood ?? entry.snapshot.graph; - return visibleGraph === null + const visible = visibleProjection(entry); + return visible.graph === null ? this.transition(entry, "degraded", null) - : this.transition(entry, "stale", visibleGraph); + : this.transition( + entry, + "stale", + visible.graph, + false, + visible.navigation, + ); } /** Retires projections for workspace scopes Studio no longer exposes. */ @@ -141,6 +200,11 @@ export class SystemGraphStore { state: "building", graph: null, }, + navigation: { + workspaceKey: scope.workspaceKey, + revision: this.revisionFloors.get(scope.workspaceKey) ?? 0, + targets: [], + }, lastGood: null, activeBuild: null, generation: 0, @@ -159,12 +223,14 @@ export class SystemGraphStore { if (entry.retired) return null; entry.generation += 1; entry.refreshPending = true; - const visibleGraph = entry.lastGood ?? entry.snapshot.graph; + const visible = visibleProjection(entry); if (!preserveLifecycle) { this.transition( entry, - visibleGraph === null ? "building" : "stale", - visibleGraph, + visible.graph === null ? "building" : "stale", + visible.graph, + false, + visible.navigation, ); } if (entry.activeBuild) return entry.activeBuild; @@ -176,12 +242,14 @@ export class SystemGraphStore { if (entry.activeBuild) return entry.activeBuild; const generation = entry.generation; entry.refreshPending = false; - const visibleGraph = entry.lastGood ?? entry.snapshot.graph; + const visible = visibleProjection(entry); if (entry.snapshot.state !== "degraded") { this.transition( entry, - visibleGraph === null ? "building" : "stale", - visibleGraph, + visible.graph === null ? "building" : "stale", + visible.graph, + false, + visible.navigation, ); } @@ -205,16 +273,47 @@ export class SystemGraphStore { generation: number, result: Awaited>, ): SystemGraphSnapshot | Promise { + // Enrichment is armed on both exits, never only on the commit. A build + // that loses its generation still carries the only callback that starts + // identity work, and `reportRefreshFailure` supersedes an in-flight build + // with `refreshPending` already false and `automaticRetryUsed` already + // true — so nothing schedules a follow-up and no later read queues one. + // Dropping the callback there leaves every identity `identity-pending`, + // which can never be cacheable: the stuck-`degraded` state through another + // door. It has to be armed *after* the commit decision, though, because a + // callback that refreshes synchronously would otherwise bump the + // generation out from under the very result being committed. if (!this.canCommit(entry, generation)) { - return this.continueAfterSupersededBuild(entry); + const superseded = this.continueAfterSupersededBuild(entry); + this.afterCommit(result.afterCommit); + return superseded; } entry.activeBuild = null; + const navigation = (result.navigation ?? []).map((target) => ({ + ...target, + })); if (result.cacheable) { - entry.lastGood = result.graph; + entry.lastGood = { graph: result.graph, navigation }; entry.automaticRetryUsed = false; - return this.transition(entry, "ready", result.graph); + const snapshot = this.transition( + entry, + "ready", + result.graph, + false, + navigation, + ); + this.afterCommit(result.afterCommit); + return snapshot; } - return this.transition(entry, "degraded", result.graph); + const snapshot = this.transition( + entry, + "degraded", + result.graph, + false, + navigation, + ); + this.afterCommit(result.afterCommit); + return snapshot; } private finishFailure( @@ -225,10 +324,16 @@ export class SystemGraphStore { return this.continueAfterSupersededBuild(entry); } entry.activeBuild = null; - const visibleGraph = entry.lastGood ?? entry.snapshot.graph; - return visibleGraph === null + const visible = visibleProjection(entry); + return visible.graph === null ? this.transition(entry, "degraded", null) - : this.transition(entry, "stale", visibleGraph, true); + : this.transition( + entry, + "stale", + visible.graph, + true, + visible.navigation, + ); } private canCommit(entry: SystemGraphEntry, generation: number): boolean { @@ -243,10 +348,7 @@ export class SystemGraphStore { entry: SystemGraphEntry, ): SystemGraphSnapshot | Promise { entry.activeBuild = null; - if ( - entry.retired || - this.entries.get(entry.scope.workspaceKey) !== entry - ) { + if (entry.retired || this.entries.get(entry.scope.workspaceKey) !== entry) { this.retainBuilderWorkspaces(); return entry.snapshot; } @@ -270,11 +372,15 @@ export class SystemGraphStore { state: SystemGraphLifecycleState, graph: SystemGraph | null, forceRevision = false, + navigation: readonly SystemGraphNavigationTarget[] = graph === null + ? [] + : entry.navigation.targets, ): SystemGraphSnapshot { if ( !forceRevision && entry.snapshot.state === state && - entry.snapshot.graph === graph + entry.snapshot.graph === graph && + sameNavigation(entry.navigation.targets, navigation) ) { return entry.snapshot; } @@ -284,10 +390,12 @@ export class SystemGraphStore { state, graph, }; - this.revisionFloors.set( - entry.scope.workspaceKey, - entry.snapshot.revision, - ); + entry.navigation = { + workspaceKey: entry.scope.workspaceKey, + revision: entry.snapshot.revision, + targets: navigation.map((target) => ({ ...target })), + }; + this.revisionFloors.set(entry.scope.workspaceKey, entry.snapshot.revision); try { this.options.onChange?.(entry.snapshot); } catch { @@ -295,4 +403,13 @@ export class SystemGraphStore { } return entry.snapshot; } + + private afterCommit(callback: (() => void) | undefined): void { + if (!callback) return; + try { + callback(); + } catch { + // Background enrichment is a refresh hint, not part of the committed read. + } + } } diff --git a/packages/harness/src/core/system-graph.test.ts b/packages/harness/src/core/system-graph.test.ts index 18aaece21..93d4a56c5 100644 --- a/packages/harness/src/core/system-graph.test.ts +++ b/packages/harness/src/core/system-graph.test.ts @@ -2,12 +2,14 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; +import type { PackageInventoryAgent } from "@sapiom/agent"; import type { WorkflowInfo } from "../shared/types.js"; import { HarnessRegistryInventoryProvider, LocalWorkspaceScopeCatalog, StaticSystemGraphBuilder, type AgentInventoryProvider, + type AgentInventoryResult, type AgentRelationshipProvider, type AgentRelationshipProviderResult, type WorkspaceScope, @@ -56,6 +58,102 @@ const EMPTY_RELATIONSHIPS: AgentRelationshipProviderResult = { }; const EVIDENCE = [{ file: "index.ts", line: 1, column: 1 }]; +const REVISION = `sha256:${"a".repeat(64)}` as const; + +function inventoryResult( + scope: WorkspaceScope, + agents: Array<{ + agentKey: string; + label: string; + sourceRoot?: string; + workflowPath?: string; + definitionId?: number | null; + definitionSlug?: string | null; + resolutionAliases?: string[]; + provisional?: boolean; + identityIssue?: + | "identity-pending" + | "identity-unavailable" + | "identity-invalid" + | "duplicate-agent-key"; + candidateAgentKey?: string; + }>, + options: { + warnings?: AgentInventoryResult["warnings"]; + degraded?: boolean; + } = {}, +): AgentInventoryResult { + const records = agents.map((agent) => { + const sourceRoot = + agent.sourceRoot ?? path.join(scope.root, agent.agentKey); + const relative = + path.relative(scope.root, sourceRoot).split(path.sep).join("/") || "."; + const provisional = + agent.provisional ?? agent.agentKey.startsWith("local:"); + let publicAgent: PackageInventoryAgent; + if (!provisional) { + publicAgent = { + agentKey: agent.agentKey, + identityStatus: "canonical", + path: relative, + entrypoint: "index.ts", + }; + } else if (agent.identityIssue === "duplicate-agent-key") { + if (!agent.candidateAgentKey) { + throw new Error( + "Duplicate inventory fixtures require a candidate agent key", + ); + } + publicAgent = { + agentKey: agent.agentKey, + identityStatus: "provisional", + identityIssue: "duplicate-agent-key", + candidateAgentKey: agent.candidateAgentKey, + path: relative, + entrypoint: "index.ts", + }; + } else { + publicAgent = { + agentKey: agent.agentKey, + identityStatus: "provisional", + identityIssue: agent.identityIssue ?? "identity-unavailable", + path: relative, + entrypoint: "index.ts", + }; + } + return { + public: publicAgent, + context: { + agentKey: agent.agentKey, + definitionId: agent.definitionId ?? null, + definitionSlug: agent.definitionSlug ?? null, + label: agent.label, + resolutionAliases: agent.resolutionAliases ?? [], + sourceRoot, + workflowPath: agent.workflowPath ?? sourceRoot, + path: relative, + entrypoint: "index.ts", + }, + }; + }); + const degraded = + options.degraded ?? + records.some((record) => record.public.identityStatus === "provisional"); + return { + inventory: { + protocol: 1, + version: { + kind: "working-tree", + workspaceKey: scope.workspaceKey, + revision: REVISION, + }, + status: degraded ? "degraded" : "complete", + agents: records.map((record) => record.public), + }, + context: records.map((record) => record.context), + warnings: options.warnings ?? [], + }; +} describe("LocalWorkspaceScopeCatalog", () => { it("gives a canonical root a stable opaque key and rejects unknown keys", async () => { @@ -103,12 +201,24 @@ describe("StaticSystemGraphBuilder", () => { }; it("projects literal Research -> Growth blocking and async calls into the public contract", async () => { - const inventory = new HarnessRegistryInventoryProvider({ - listWorkflows: () => [ - workflow("Research", "research", "research"), - workflow("Growth", "growth", "growth"), - ], - }); + const inventory: AgentInventoryProvider = { + listAgents: vi.fn(async () => + inventoryResult(scope, [ + { + agentKey: "growth", + label: "Growth", + sourceRoot: path.join(FIXTURE, "growth"), + resolutionAliases: ["growth"], + }, + { + agentKey: "research", + label: "Research", + sourceRoot: path.join(FIXTURE, "research"), + resolutionAliases: ["research"], + }, + ]), + ), + }; const graph = await buildGraph( new StaticSystemGraphBuilder(inventory), @@ -145,15 +255,14 @@ describe("StaticSystemGraphBuilder", () => { it("deduplicates by mode, retains dual-mode edges, and reports duplicate and unresolved targets", async () => { const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => ({ - agents: [ + listAgents: vi.fn(async () => + inventoryResult(scope, [ { agentKey: "research", definitionId: 1, definitionSlug: "research", label: "Research", resolutionAliases: ["research"], - sourceRoot: "/private/research", }, { agentKey: "growth", @@ -161,12 +270,9 @@ describe("StaticSystemGraphBuilder", () => { definitionSlug: "growth", label: "Growth", resolutionAliases: ["growth"], - sourceRoot: "/private/growth", }, - ], - cacheable: true, - warnings: [], - })), + ]), + ), }; const relationships = relationshipProvider(async (root) => root.endsWith("research") @@ -223,20 +329,17 @@ describe("StaticSystemGraphBuilder", () => { it("projects dynamic extraction warnings without degrading cacheability or leaking evidence", async () => { const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => ({ - agents: [ + listAgents: vi.fn(async () => + inventoryResult(scope, [ { agentKey: "research", definitionId: 1, definitionSlug: "research", label: "Research", resolutionAliases: ["research"], - sourceRoot: "/private/research", }, - ], - cacheable: true, - warnings: [], - })), + ]), + ), }; const relationships = relationshipProvider(async () => ({ relationships: [], @@ -267,13 +370,49 @@ describe("StaticSystemGraphBuilder", () => { }); it("keeps duplicate definition slugs as unique nodes and reports ambiguous launches", async () => { - const inventory = new HarnessRegistryInventoryProvider({ - listWorkflows: () => [ - workflow("Caller", "caller", "caller"), - workflow("First copy", "growth", "shared"), - workflow("Second copy", "research", "shared"), - ], - }); + const inventory: AgentInventoryProvider = { + listAgents: vi.fn(async () => + inventoryResult( + scope, + [ + { + agentKey: "caller", + label: "Caller", + resolutionAliases: ["caller"], + }, + { + agentKey: "local:growth", + label: "First copy", + sourceRoot: path.join(FIXTURE, "growth"), + resolutionAliases: ["shared"], + provisional: true, + identityIssue: "duplicate-agent-key", + candidateAgentKey: "shared", + }, + { + agentKey: "local:research", + label: "Second copy", + sourceRoot: path.join(FIXTURE, "research"), + resolutionAliases: ["shared"], + provisional: true, + identityIssue: "duplicate-agent-key", + candidateAgentKey: "shared", + }, + ], + { + degraded: true, + warnings: [ + { + code: "duplicate-agent-key", + agentKey: "shared", + message: + "Multiple agents use shared; kept each with a local identity.", + }, + ], + }, + ), + ), + }; const relationships = relationshipProvider(async (root) => root.endsWith("caller") ? { @@ -312,114 +451,343 @@ describe("StaticSystemGraphBuilder", () => { expect(JSON.stringify(graph)).not.toContain(FIXTURE); }); - it("reasserts safe unique node identities for an arbitrary inventory provider", async () => { - const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => ({ - agents: [ - { - agentKey: "alpha", - definitionId: null, - definitionSlug: "alpha", - label: "/private/first", - resolutionAliases: ["alpha"], - sourceRoot: path.join(FIXTURE, "growth"), - }, - { - agentKey: "alpha", - definitionId: null, - definitionSlug: "alpha", - label: "C:\\Users\\Demo\\second", - resolutionAliases: ["alpha"], - sourceRoot: path.join(FIXTURE, "research"), - }, - { - agentKey: "local:growth", - definitionId: null, - definitionSlug: null, - label: "Caller", - resolutionAliases: ["local:growth"], - sourceRoot: path.join(FIXTURE, "caller"), - }, + it("synthesizes a duplicate warning from public identity evidence", async () => { + const result = inventoryResult( + scope, + [ + { + agentKey: "local:only", + label: "Only", + sourceRoot: path.join(FIXTURE, "only"), + provisional: true, + identityIssue: "duplicate-agent-key", + candidateAgentKey: "shared", + resolutionAliases: ["shared"], + }, + ], + { degraded: true, warnings: [] }, + ); + const graph = await buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ); + + expect(graph.warnings).toEqual([ + { + code: "duplicate-agent-key", + agentKey: "shared", + message: "Multiple agents use shared; kept each with a local identity.", + }, + ]); + }); + + it("deduplicates redundant provider duplicate warnings", async () => { + const duplicateWarning = { + code: "duplicate-agent-key" as const, + agentKey: "shared", + message: "provider-owned private wording", + }; + const result = inventoryResult( + scope, + [ + { + agentKey: "local:only", + label: "Only", + sourceRoot: path.join(FIXTURE, "only"), + provisional: true, + identityIssue: "duplicate-agent-key", + candidateAgentKey: "shared", + resolutionAliases: ["shared"], + }, + ], + { + degraded: true, + warnings: [duplicateWarning, duplicateWarning], + }, + ); + const graph = await buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ); + + expect(graph.warnings).toEqual([ + { + code: "duplicate-agent-key", + agentKey: "shared", + message: "Multiple agents use shared; kept each with a local identity.", + }, + ]); + expect(JSON.stringify(graph)).not.toContain("provider-owned"); + }); + + it("rejects a provider duplicate warning without matching public evidence", async () => { + const result = inventoryResult( + scope, + [{ agentKey: "reporting", label: "Reporting" }], + { + warnings: [ { - agentKey: "/private/leaked-key", - definitionId: null, - definitionSlug: null, - label: "/private/leaked-label", - resolutionAliases: ["/private/leaked-alias"], - sourceRoot: "/outside/private-agent", + code: "duplicate-agent-key", + agentKey: "unsupported", + message: "private", }, ], - cacheable: true, - warnings: [], - })), - }; + }, + ); + + await expect( + buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ), + ).rejects.toThrow("inventory warning was invalid"); + }); + it("resolves an exact canonical key before a stale compatibility alias", async () => { + const inventory = inventoryResult( + scope, + [ + { + agentKey: "caller", + label: "Caller", + sourceRoot: path.join(FIXTURE, "caller"), + }, + { + agentKey: "payments", + label: "Payments", + sourceRoot: path.join(FIXTURE, "payments"), + }, + { + agentKey: "local:pending", + label: "Pending", + sourceRoot: path.join(FIXTURE, "pending"), + provisional: true, + identityIssue: "identity-unavailable", + resolutionAliases: ["payments"], + }, + ], + { degraded: true }, + ); const graph = await buildGraph( new StaticSystemGraphBuilder( - inventory, - relationshipProvider(async () => EMPTY_RELATIONSHIPS), + { listAgents: async () => inventory }, + relationshipProvider(async (sourceRoot) => + sourceRoot.endsWith("caller") + ? { + relationships: [ + { target: "payments", mode: "async", evidence: EVIDENCE }, + ], + warnings: [], + } + : EMPTY_RELATIONSHIPS, + ), ), scope, ); - expect(graph.nodes).toHaveLength(4); - expect(new Set(graph.nodes.map((node) => node.id)).size).toBe(4); - const byKey = new Map(graph.nodes.map((node) => [node.agentKey, node])); - expect([...byKey.keys()]).toEqual( - expect.arrayContaining([ - "local:caller", - "local:growth", - "local:research", - ]), + expect(graph.edges).toEqual([ + { + from: "agent:caller", + to: "agent:payments", + kind: "invokes", + basis: "static", + mode: "async", + }, + ]); + }); + + it("keeps a relationship ambiguous when only multiple aliases match", async () => { + const inventory = inventoryResult( + scope, + [ + { + agentKey: "caller", + label: "Caller", + sourceRoot: path.join(FIXTURE, "caller"), + }, + { + agentKey: "local:first", + label: "First", + sourceRoot: path.join(FIXTURE, "first"), + provisional: true, + resolutionAliases: ["legacy"], + }, + { + agentKey: "local:second", + label: "Second", + sourceRoot: path.join(FIXTURE, "second"), + provisional: true, + resolutionAliases: ["legacy"], + }, + ], + { degraded: true }, + ); + const graph = await buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => inventory }, + relationshipProvider(async (sourceRoot) => + sourceRoot.endsWith("caller") + ? { + relationships: [ + { target: "legacy", mode: "async", evidence: EVIDENCE }, + ], + warnings: [], + } + : EMPTY_RELATIONSHIPS, + ), + ), + scope, ); - const hashedKey = [...byKey.keys()].find((key) => - /^local:[a-f0-9]{16}$/.test(key), + + expect(graph.edges).toEqual([]); + expect(graph.warnings).toContainEqual({ + code: "unresolved-target", + agentKey: "caller", + message: "Caller invokes ambiguous agent legacy.", + }); + }); + + it("keeps a provisional public key ambiguous with another agent's alias", async () => { + const inventory = inventoryResult( + scope, + [ + { + agentKey: "caller", + label: "Caller", + sourceRoot: path.join(FIXTURE, "caller"), + }, + { + agentKey: "legacy", + label: "Provisional", + sourceRoot: path.join(FIXTURE, "provisional"), + provisional: true, + identityIssue: "identity-unavailable", + }, + { + agentKey: "current", + label: "Current", + sourceRoot: path.join(FIXTURE, "current"), + resolutionAliases: ["legacy"], + }, + ], + { degraded: true }, ); - expect(hashedKey).toBeDefined(); - expect(byKey.get(hashedKey!)?.label).toBe( - hashedKey!.slice("local:".length), + const graph = await buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => inventory }, + relationshipProvider(async (sourceRoot) => + sourceRoot.endsWith("caller") + ? { + relationships: [ + { target: "legacy", mode: "async", evidence: EVIDENCE }, + ], + warnings: [], + } + : EMPTY_RELATIONSHIPS, + ), + ), + scope, ); - expect(byKey.get("local:caller")?.label).toBe("Caller"); - expect(byKey.get("local:growth")?.label).toBe("growth"); - expect(byKey.get("local:research")?.label).toBe("research"); - expect(graph.warnings).toEqual([ + + expect(graph.edges).toEqual([]); + expect(graph.warnings).toContainEqual({ + code: "unresolved-target", + agentKey: "caller", + message: "Caller invokes ambiguous agent legacy.", + }); + }); + + it("rejects an arbitrary provider that violates the public or private boundary", async () => { + const duplicate = inventoryResult(scope, [ + { agentKey: "alpha", label: "Alpha" }, + { agentKey: "beta", label: "Beta" }, + ]); + duplicate.inventory = { + ...duplicate.inventory, + agents: duplicate.inventory.agents.map((agent) => ({ + ...agent, + agentKey: "alpha", + })), + }; + const inventory: AgentInventoryProvider = { + listAgents: vi.fn(async () => duplicate), + }; + + await expect( + buildGraph( + new StaticSystemGraphBuilder( + inventory, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ), + ).rejects.toThrow(); + + const outside = inventoryResult(scope, [ { - code: "duplicate-agent-key", agentKey: "alpha", - message: "Multiple agents use alpha; kept each with a local identity.", + label: "Alpha", + sourceRoot: path.join(FIXTURE, "growth"), }, + ]); + outside.context[0]!.sourceRoot = "/outside/private-agent"; + await expect( + buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => outside }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ), + ).rejects.toThrow("inventory context was invalid"); + + const mismatchedLocation = inventoryResult(scope, [ { - code: "duplicate-agent-key", - agentKey: "local:growth", - message: - "Multiple agents use local:growth; kept each with a local identity.", + agentKey: "alpha", + label: "Alpha", + sourceRoot: path.join(FIXTURE, "alpha"), }, ]); - expect(JSON.stringify(graph)).not.toContain("/private/"); - expect(JSON.stringify(graph)).not.toContain("C:\\Users"); + mismatchedLocation.context[0]!.sourceRoot = path.join(FIXTURE, "growth"); + mismatchedLocation.context[0]!.workflowPath = path.join(FIXTURE, "growth"); + await expect( + buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => mismatchedLocation }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ), + ).rejects.toThrow("inventory context was invalid"); }); it("degrades a scanner failure into a path-free warning", async () => { const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => ({ - agents: [ + listAgents: vi.fn(async () => + inventoryResult(scope, [ { agentKey: "research", definitionId: 1, definitionSlug: "research", label: "Research", resolutionAliases: ["research"], - sourceRoot: "/private/research", }, - ], - cacheable: true, - warnings: [], - })), + ]), + ), }; const built = await new StaticSystemGraphBuilder( inventory, relationshipProvider(async () => { - throw new Error("boom at /private/research"); + throw new Error("boom at private source"); }), ).build(scope); const graph = built.graph; @@ -468,36 +836,316 @@ describe("StaticSystemGraphBuilder", () => { expect(JSON.stringify(graph)).not.toContain(FIXTURE); }); - it("projects disconnected inventory nodes and merges inventory warnings", async () => { - const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => ({ - agents: [ - { - agentKey: "research", - definitionId: 1, - definitionSlug: "research", - label: "Research", - resolutionAliases: ["research"], - sourceRoot: "/private/research", - }, + it("keeps marker-resolved edges when source identity inspection fails", async () => { + const changed = vi.fn(); + const inventory = new HarnessRegistryInventoryProvider({ + listWorkflows: () => [ + workflow("Caller", "caller", "caller-marker"), + workflow("Target", "target", "target-marker"), + ], + inspectManifestName: vi.fn(async () => ({ status: "failed" as const })), + fingerprintSource: async (sourceRoot) => `fingerprint:${sourceRoot}`, + onIdentityChange: changed, + }); + const builder = new StaticSystemGraphBuilder( + inventory, + relationshipProvider(async (sourceRoot) => + sourceRoot.endsWith("caller") + ? { + relationships: [ + { + target: "target-marker", + mode: "async", + evidence: EVIDENCE, + }, + ], + warnings: [], + } + : EMPTY_RELATIONSHIPS, + ), + ); + const initial = await builder.build(scope); + initial.afterCommit?.(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + + const failed = await builder.build(scope); + + expect(failed.graph.nodes.map((node) => node.agentKey)).toEqual([ + "caller-marker", + "target-marker", + ]); + expect(failed.graph.edges).toEqual([ + { + from: "agent:caller-marker", + to: "agent:target-marker", + kind: "invokes", + basis: "static", + mode: "async", + }, + ]); + expect(failed.graph.warnings.map((warning) => warning.code)).toEqual([ + "inventory-extraction-failed", + "inventory-extraction-failed", + ]); + expect(JSON.stringify(failed.graph)).not.toContain(FIXTURE); + }); + + it("sanitizes private labels and warning messages at an arbitrary provider boundary", async () => { + const leakedPath = "/private/provider-secret"; + const result = inventoryResult( + scope, + [ + { + agentKey: "local:reporting", + label: `${leakedPath}\u0085`, + sourceRoot: path.join(FIXTURE, "reporting"), + provisional: true, + }, + ], + { + degraded: true, + warnings: [ { + code: "inventory-extraction-failed", agentKey: "local:reporting", - definitionId: null, - definitionSlug: null, - label: "Reporting", - resolutionAliases: [], - sourceRoot: "/private/reporting", + message: `inspection failed at ${leakedPath}`, }, ], - cacheable: true, + }, + ); + + const graph = await buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ); + + expect(graph.nodes).toEqual([ + { + id: "agent:local:reporting", + agentKey: "local:reporting", + label: "reporting", + }, + ]); + expect(graph.warnings).toEqual([ + { + code: "inventory-extraction-failed", + agentKey: "local:reporting", + message: + "Could not resolve reporting's source identity; using its provisional identity.", + }, + ]); + expect(JSON.stringify(graph)).not.toContain(leakedPath); + expect(JSON.stringify(graph)).not.toContain("\u0085"); + }); + + it("preserves a scoped package display label without admitting path-shaped labels", async () => { + const result = inventoryResult(scope, [ + { + agentKey: "reporting", + label: "@acme/proj-a", + sourceRoot: path.join(FIXTURE, "reporting"), + }, + ]); + + const graph = await buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ); + + expect(graph.nodes).toEqual([ + { + id: "agent:reporting", + agentKey: "reporting", + label: "@acme/proj-a", + }, + ]); + }); + + it("rejects a provider warning whose identity is not inventory-owned", async () => { + const result = inventoryResult( + scope, + [ + { + agentKey: "local:reporting", + label: "Reporting", + sourceRoot: path.join(FIXTURE, "reporting"), + provisional: true, + }, + ], + { + degraded: true, warnings: [ { - code: "inventory-extraction-failed" as const, - agentKey: "local:reporting", - message: "Could not inspect Reporting; using its local identity.", + code: "inventory-extraction-failed", + agentKey: "/private/provider-secret", + message: "private", }, ], - })), + }, + ); + + await expect( + buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ), + ).rejects.toThrow("inventory warning was invalid"); + }); + + it("rejects extraction warnings unsupported by parsed identity evidence", async () => { + const unsupported = [ + { + agentKey: "canonical", + result: inventoryResult(scope, [ + { agentKey: "canonical", label: "Canonical" }, + ]), + }, + { + agentKey: "local:pending", + result: inventoryResult( + scope, + [ + { + agentKey: "local:pending", + label: "Pending", + provisional: true, + identityIssue: "identity-pending", + }, + ], + { degraded: true }, + ), + }, + { + agentKey: "local:duplicate", + result: inventoryResult( + scope, + [ + { + agentKey: "local:duplicate", + label: "Duplicate", + provisional: true, + identityIssue: "duplicate-agent-key", + candidateAgentKey: "shared", + }, + ], + { degraded: true }, + ), + }, + ]; + + for (const { agentKey, result } of unsupported) { + result.warnings = [ + { + code: "inventory-extraction-failed", + agentKey, + message: "private", + }, + ]; + await expect( + buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ), + ).rejects.toThrow("inventory warning was invalid"); + } + }); + + it.each([ + ["path alias", ["private/agent"]], + ["control alias", ["agent\u0085name"]], + ["reserved local alias", ["local:agent"]], + ])("rejects an arbitrary provider %s", async (_case, resolutionAliases) => { + const result = inventoryResult(scope, [ + { + agentKey: "reporting", + label: "Reporting", + sourceRoot: path.join(FIXTURE, "reporting"), + resolutionAliases, + }, + ]); + + await expect( + buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ), + scope, + ), + ).rejects.toThrow("inventory context was invalid"); + }); + + it("normalizes arbitrary provider aliases before relationship resolution", async () => { + const result = inventoryResult(scope, [ + { + agentKey: "reporting", + label: "Reporting", + sourceRoot: path.join(FIXTURE, "reporting"), + resolutionAliases: ["zeta", "alpha", "zeta"], + }, + ]); + const listRelationships = vi.fn< + AgentRelationshipProvider["listRelationships"] + >(async () => EMPTY_RELATIONSHIPS); + + await buildGraph( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + { listRelationships }, + ), + scope, + ); + + expect(listRelationships.mock.calls[0]?.[0].resolutionAliases).toEqual([ + "alpha", + "zeta", + ]); + }); + + it("projects disconnected inventory nodes and merges inventory warnings", async () => { + const inventory: AgentInventoryProvider = { + listAgents: vi.fn(async () => + inventoryResult( + scope, + [ + { + agentKey: "research", + definitionId: 1, + definitionSlug: "research", + label: "Research", + resolutionAliases: ["research"], + }, + { + agentKey: "local:reporting", + label: "Reporting", + provisional: true, + }, + ], + { + degraded: true, + warnings: [ + { + code: "inventory-extraction-failed", + agentKey: "local:reporting", + message: + "Could not inspect Reporting; using its local identity.", + }, + ], + }, + ), + ), }; const graph = await buildGraph( @@ -517,19 +1165,123 @@ describe("StaticSystemGraphBuilder", () => { { code: "inventory-extraction-failed", agentKey: "local:reporting", - message: "Could not inspect Reporting; using its local identity.", + message: + "Could not resolve Reporting's source identity; using its provisional identity.", }, ]); expect(JSON.stringify(graph)).not.toContain("/private/"); }); + it("caches a project whose one unidentifiable agent has finished resolving", async () => { + // The failure this prevents: a single agent that can never be named — a + // dashboard with no `defineAgent`, a package with no `node_modules` — used + // to veto the whole project's cache, so the graph re-projected on every + // open and never left `degraded`. Its identity has settled; only + // `identity-pending` is a reason to refuse the cache. + const inventory: AgentInventoryProvider = { + listAgents: vi.fn(async () => + inventoryResult( + scope, + [ + { agentKey: "research", label: "Research" }, + { agentKey: "growth", label: "Growth" }, + { + agentKey: "local:dashboard", + label: "Dashboard", + provisional: true, + identityIssue: "identity-unavailable", + }, + ], + { + degraded: true, + warnings: [ + { + code: "inventory-extraction-failed", + agentKey: "local:dashboard", + message: + "Could not resolve Dashboard's source identity; using its provisional identity.", + }, + ], + }, + ), + ), + }; + + const built = await new StaticSystemGraphBuilder( + inventory, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ).build(scope); + + expect(built.cacheable).toBe(true); + // Cacheable is not the same as silent: the agent that could not be named + // still says so on the graph. + expect(built.graph.warnings).toEqual([ + { + code: "inventory-extraction-failed", + agentKey: "local:dashboard", + message: + "Could not resolve Dashboard's source identity; using its provisional identity.", + }, + ]); + expect(built.graph.nodes.map((node) => node.agentKey)).toEqual([ + "growth", + "local:dashboard", + "research", + ]); + }); + + it("refuses the cache while any one identity is still being resolved", async () => { + // The other half of the rule. Caching a projection mid-enrichment would + // freeze the provisional names on screen for as long as nothing else + // invalidated the workspace. + const inventory: AgentInventoryProvider = { + listAgents: vi.fn(async () => + inventoryResult( + scope, + [ + { agentKey: "research", label: "Research" }, + { + agentKey: "local:dashboard", + label: "Dashboard", + provisional: true, + identityIssue: "identity-unavailable", + }, + { + agentKey: "local:growth", + label: "Growth", + provisional: true, + identityIssue: "identity-pending", + }, + ], + { degraded: true }, + ), + ), + }; + + const built = await new StaticSystemGraphBuilder( + inventory, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ).build(scope); + + expect(built.cacheable).toBe(false); + }); + it("keeps degraded cache policy outside the public graph contract", async () => { const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => ({ - agents: [], - cacheable: false, - warnings: [], - })), + listAgents: vi.fn(async () => + inventoryResult( + scope, + [ + { + agentKey: "local:pending", + label: "Pending", + provisional: true, + identityIssue: "identity-pending", + }, + ], + { degraded: true }, + ), + ), }; const built = await new StaticSystemGraphBuilder( @@ -541,37 +1293,74 @@ describe("StaticSystemGraphBuilder", () => { expect(built.graph).toEqual({ kind: "system", scope: { kind: "working-tree", workspaceKey: scope.workspaceKey }, - nodes: [], + nodes: [ + { + id: "agent:local:pending", + agentKey: "local:pending", + label: "Pending", + }, + ], edges: [], warnings: [], }); expect(built.graph).not.toHaveProperty("cacheable"); }); + it("rejects an inventory whose status contradicts its own identities", async () => { + // The re-parse in consumeInventory is the last boundary before + // projection, and this is what proves it still runs: the contract forbids + // `complete` alongside a provisional identity, so a provider that claims + // the fast path it has not earned is refused rather than believed. + // + // Mutating the result object after `listAgents` resolves is deliberately + // NOT the test here. `build` consumes the inventory in the same tick it + // receives it, so no external mutation can land in between — an assertion + // written that way passes whether or not the boundary exists. + const result = inventoryResult( + scope, + [ + { + agentKey: "local:pending", + label: "Pending", + sourceRoot: path.join(FIXTURE, "pending"), + provisional: true, + identityIssue: "identity-pending", + }, + ], + { degraded: true }, + ); + (result.inventory as { status: "complete" | "degraded" }).status = + "complete"; + + await expect( + new StaticSystemGraphBuilder( + { listAgents: async () => result }, + relationshipProvider(async () => EMPTY_RELATIONSHIPS), + ).build(scope), + ).rejects.toThrow(); + }); + it("retains caller caches across active workspaces and prunes retired ones", async () => { const secondScope: WorkspaceScope = { workspaceKey: "workspace-second", root: "/private/second", }; + const retainSources = vi.fn<(sources: ReadonlySet) => void>(); const inventory: AgentInventoryProvider = { listAgents: vi.fn(async (activeScope) => { const second = activeScope.workspaceKey === secondScope.workspaceKey; const agentKey = second ? "second" : "first"; - return { - agents: [ - { - agentKey, - definitionId: null, - definitionSlug: agentKey, - label: agentKey, - resolutionAliases: [agentKey], - sourceRoot: activeScope.root, - }, - ], - cacheable: true, - warnings: [], - }; + return inventoryResult(activeScope, [ + { + agentKey, + definitionSlug: agentKey, + label: agentKey, + resolutionAliases: [agentKey], + sourceRoot: path.join(activeScope.root, agentKey), + }, + ]); }), + retainSources, }; const retainCallers = vi.fn(); const relationships: AgentRelationshipProvider = { @@ -594,5 +1383,8 @@ describe("StaticSystemGraphBuilder", () => { .at(-1)?.[0] .map((caller: { agentKey: string }) => caller.agentKey), ).toEqual(["second"]); + expect([...(retainSources.mock.calls.at(-1)?.[0] ?? [])]).toEqual([ + "/private/second/second", + ]); }); }); diff --git a/packages/harness/src/core/system-graph.ts b/packages/harness/src/core/system-graph.ts index e97fe8d20..37a2fed18 100644 --- a/packages/harness/src/core/system-graph.ts +++ b/packages/harness/src/core/system-graph.ts @@ -1,20 +1,22 @@ import { createHash } from "node:crypto"; -import * as path from "node:path"; + +import { packageInventorySchema } from "@sapiom/agent"; import type { - AgentKey, GraphWarning, SystemGraph, SystemGraphEdge, + SystemGraphNavigationTarget, WorkspaceKey, WorkspaceScopeSummary, } from "../shared/system-graph.js"; -import { workspaceRelativeLocalKey } from "../shared/system-graph.js"; import { canonicalGraphPath, + inventorySourceRoot, isWithinGraphPath, type AgentInventoryItem, type AgentInventoryProvider, + type AgentInventoryWarning, type WorkspaceScope, } from "./system-graph-inventory.js"; import { @@ -61,6 +63,10 @@ export interface SystemGraphBuildResult { /** Internal cache policy; only graph crosses the HTTP boundary. */ cacheable: boolean; graph: SystemGraph; + /** Private resolver data committed atomically with the graph revision. */ + navigation?: SystemGraphNavigationTarget[]; + /** Starts non-blocking enrichment only after this result is visible. */ + afterCommit?: () => void; } function workspaceKeyForRoot(root: string): WorkspaceKey { @@ -111,151 +117,211 @@ function warningOrder(left: GraphWarning, right: GraphWarning): number { ); } -function fallbackAgentKey(scope: WorkspaceScope, sourceRoot: string): AgentKey { - const canonicalScope = canonicalGraphPath(scope.root); - const canonicalSource = canonicalGraphPath(sourceRoot); - if (isWithinGraphPath(canonicalScope, canonicalSource)) { - const localKey = workspaceRelativeLocalKey(canonicalScope, canonicalSource); - if (localKey) return localKey; - } - return `local:${createHash("sha256") - .update(canonicalSource) - .digest("hex") - .slice(0, 16)}`; +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.codePointAt(0)!; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + }); } -function safeAgentKey(value: string): AgentKey | null { - const key = value.trim(); - if ( - key === "" || - /[\0\r\n]/.test(key) || - path.posix.isAbsolute(key) || - path.win32.isAbsolute(key) || - key.includes("\\") - ) { - return null; - } - if (!key.startsWith("local:")) return key.includes("/") ? null : key; +function fallbackLabel(agentKey: string): string { + if (!agentKey.startsWith("local:")) return agentKey; + return agentKey.slice("local:".length).split("/").at(-1) ?? "Agent"; +} - const relative = key.slice("local:".length); - if ( - relative === "" || - path.posix.isAbsolute(relative) || - path.win32.isAbsolute(relative) || - relative - .split("/") - .some((segment) => segment === "" || segment === "." || segment === "..") - ) { - return null; +function isScopedPackageLabel(value: string): boolean { + return /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/.test(value); +} + +function safeContextLabel(label: unknown, agentKey: string): string { + if (typeof label !== "string") return fallbackLabel(agentKey); + const trimmed = label.trim(); + return trimmed === "" || + hasControlCharacter(trimmed) || + trimmed.includes("\\") || + (trimmed.includes("/") && !isScopedPackageLabel(trimmed)) + ? fallbackLabel(agentKey) + : trimmed; +} + +function normalizeResolutionAliases(value: unknown): string[] { + if (!Array.isArray(value)) { + throw new Error("System graph inventory context was invalid"); } - return key; + const aliases = value.map((alias) => { + if ( + typeof alias !== "string" || + alias === "" || + alias !== alias.trim() || + alias === "." || + alias === ".." || + alias.startsWith("local:") || + hasControlCharacter(alias) || + alias.includes("/") || + alias.includes("\\") + ) { + throw new Error("System graph inventory context was invalid"); + } + return alias; + }); + return [...new Set(aliases)].sort((left, right) => + left === right ? 0 : left < right ? -1 : 1, + ); } -function safeLabel(value: string, agentKey: AgentKey): string { - const label = value.trim(); - if ( - label !== "" && - !/[\0\r\n]/.test(label) && - !path.posix.isAbsolute(label) && - !path.win32.isAbsolute(label) - ) { - return label; +function sanitizeInventoryWarnings( + warnings: readonly AgentInventoryWarning[], + publicAgents: ReturnType["agents"], + agents: readonly AgentInventoryItem[], +): GraphWarning[] { + const agentsByKey = new Map(agents.map((agent) => [agent.agentKey, agent])); + const publicAgentsByKey = new Map( + publicAgents.map((agent) => [agent.agentKey, agent]), + ); + const duplicateCandidates = new Set(); + for (const agent of publicAgents) { + if ( + agent.identityStatus === "provisional" && + agent.identityIssue === "duplicate-agent-key" + ) { + duplicateCandidates.add(agent.candidateAgentKey); + } } - if (agentKey.startsWith("local:")) { - return agentKey.slice("local:".length) || "Local agent"; + + const sanitized: GraphWarning[] = [...duplicateCandidates].map( + (candidateAgentKey) => ({ + code: "duplicate-agent-key", + agentKey: candidateAgentKey, + message: `Multiple agents use ${candidateAgentKey}; kept each with a local identity.`, + }), + ); + const seenExtractionFailures = new Set(); + for (const warning of warnings) { + if ( + !warning || + typeof warning !== "object" || + typeof warning.agentKey !== "string" || + typeof warning.message !== "string" + ) { + throw new Error("System graph inventory warning was invalid"); + } + if (warning.code === "duplicate-agent-key") { + if (!duplicateCandidates.has(warning.agentKey)) { + throw new Error("System graph inventory warning was invalid"); + } + continue; + } + if (warning.code === "inventory-extraction-failed") { + const agent = agentsByKey.get(warning.agentKey); + const publicAgent = publicAgentsByKey.get(warning.agentKey); + if ( + !agent || + !publicAgent || + publicAgent.identityStatus !== "provisional" || + (publicAgent.identityIssue !== "identity-unavailable" && + publicAgent.identityIssue !== "identity-invalid") + ) { + throw new Error("System graph inventory warning was invalid"); + } + if (seenExtractionFailures.has(warning.agentKey)) continue; + seenExtractionFailures.add(warning.agentKey); + sanitized.push({ + code: warning.code, + agentKey: warning.agentKey, + message: `Could not resolve ${agent.label}'s source identity; using its provisional identity.`, + }); + continue; + } + throw new Error("System graph inventory warning was invalid"); } - return agentKey; + return sanitized.sort(warningOrder); } -interface PreparedInventoryItem { - agent: AgentInventoryItem; - candidateKey: AgentKey; - fallbackKey: AgentKey; +interface ConsumedInventory { + agents: AgentInventoryItem[]; + warnings: GraphWarning[]; + /** Every identity has finished resolving, however it resolved. */ + settled: boolean; + startEnrichment?: () => void; } /** - * The provider contract promises safe unique keys, but projection is the last - * server-side boundary before serialization. Re-assert that invariant here so - * a future provider cannot make the browser reject the whole graph payload. + * Re-parse the public contract and join private context at the last boundary + * before graph projection. A future adapter cannot bypass inventory safety or + * smuggle an outside navigation target into the resolver. */ -function normalizeInventory( +function consumeInventory( scope: WorkspaceScope, - inventory: readonly AgentInventoryItem[], -): { agents: AgentInventoryItem[]; warnings: GraphWarning[] } { - const prepared: PreparedInventoryItem[] = inventory - .map((agent) => { - const fallbackKey = fallbackAgentKey(scope, agent.sourceRoot); - return { - agent, - candidateKey: safeAgentKey(agent.agentKey) ?? fallbackKey, - fallbackKey, - }; - }) - .sort( - (left, right) => - left.candidateKey.localeCompare(right.candidateKey) || - left.agent.sourceRoot.localeCompare(right.agent.sourceRoot) || - left.agent.label.localeCompare(right.agent.label), - ); - const counts = new Map(); - for (const item of prepared) { - counts.set(item.candidateKey, (counts.get(item.candidateKey) ?? 0) + 1); + result: Awaited>, +): ConsumedInventory { + const inventory = packageInventorySchema.parse(result.inventory); + if ( + inventory.version.kind !== "working-tree" || + inventory.version.workspaceKey !== scope.workspaceKey + ) { + throw new Error("System graph received an inventory for another scope"); } - - const used = new Set(); - const duplicateKeys = new Set( - [...counts.entries()] - .filter(([, count]) => count > 1) - .map(([candidateKey]) => candidateKey), + const canonicalScope = canonicalGraphPath(scope.root); + const context = new Map( + result.context.map((item) => [`${item.path}\0${item.entrypoint}`, item]), ); - const agents = prepared.map(({ agent, candidateKey, fallbackKey }) => { - const duplicate = (counts.get(candidateKey) ?? 0) > 1; - let agentKey = duplicate ? fallbackKey : candidateKey; - if (used.has(agentKey)) { - duplicateKeys.add(agentKey); - agentKey = fallbackKey; - } - const base = agentKey; - let suffix = 2; - while (used.has(agentKey)) { - duplicateKeys.add(base); - agentKey = `${base}~${suffix}`; - suffix += 1; + if ( + context.size !== result.context.length || + result.context.length !== inventory.agents.length + ) { + throw new Error( + "System graph inventory context did not match public locations", + ); + } + const agents = inventory.agents.map((agent) => { + const key = `${agent.path}\0${agent.entrypoint}`; + const item = context.get(key); + const sourceRoot = item ? canonicalGraphPath(item.sourceRoot) : ""; + const workflowPath = item ? canonicalGraphPath(item.workflowPath) : ""; + const expectedSourceRoot = inventorySourceRoot(scope.root, agent.path); + if ( + !item || + item.agentKey !== agent.agentKey || + !isWithinGraphPath(canonicalScope, sourceRoot) || + !isWithinGraphPath(expectedSourceRoot, sourceRoot) || + !isWithinGraphPath(sourceRoot, expectedSourceRoot) || + !isWithinGraphPath(sourceRoot, workflowPath) || + !isWithinGraphPath(workflowPath, sourceRoot) + ) { + throw new Error("System graph inventory context was invalid"); } - used.add(agentKey); - - const resolutionAliases = [ - ...new Set( - [ - ...(agentKey !== candidateKey ? [candidateKey] : []), - ...agent.resolutionAliases, - ] - .map(safeAgentKey) - .filter((alias): alias is AgentKey => alias !== null), - ), - ]; return { - ...agent, - agentKey, - label: safeLabel(agent.label, agentKey), - resolutionAliases, + ...item, + identityStatus: agent.identityStatus, + label: safeContextLabel(item.label, item.agentKey), + resolutionAliases: normalizeResolutionAliases(item.resolutionAliases), }; }); - agents.sort( - (left, right) => - left.agentKey.localeCompare(right.agentKey) || - left.sourceRoot.localeCompare(right.sourceRoot), - ); - - const warnings: GraphWarning[] = []; - for (const candidateKey of [...duplicateKeys].sort()) { - warnings.push({ - code: "duplicate-agent-key", - agentKey: candidateKey, - message: `Multiple agents use ${candidateKey}; kept each with a local identity.`, - }); - } - return { agents, warnings }; + return { + agents, + warnings: sanitizeInventoryWarnings( + result.warnings, + inventory.agents, + agents, + ), + // A degraded inventory is not the same as an unfinished one, and only the + // second is a reason to refuse the cache: an identity that resolved to + // unavailable, invalid, or a duplicate key cannot be improved by + // re-projecting, and a source edit re-projects through the watcher anyway. + // Only `identity-pending` still has enrichment in flight, and caching that + // would freeze provisional names on screen. Gating on `status` instead — + // which the contract forces to `degraded` whenever any identity is + // provisional — gave one permanently unidentifiable agent a veto over the + // whole project's fast path. + settled: !inventory.agents.some( + (agent) => + agent.identityStatus === "provisional" && + agent.identityIssue === "identity-pending", + ), + ...(typeof result.startEnrichment === "function" + ? { startEnrichment: result.startEnrichment } + : {}), + }; } export class StaticSystemGraphBuilder implements SystemGraphBuilder { @@ -273,8 +339,8 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { async build(scope: WorkspaceScope): Promise { const inventory = await this.inventory.listAgents(scope); - const normalized = normalizeInventory(scope, inventory.agents); - const agents = normalized.agents; + const consumed = consumeInventory(scope, inventory); + const agents = consumed.agents; this.callersByWorkspace.set(scope.workspaceKey, agents); this.retainRelationshipCallers(); const nodes = agents.map((agent) => ({ @@ -282,28 +348,33 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { agentKey: agent.agentKey, label: agent.label, })); - const byTarget = new Map(); - const registerTarget = (key: string, agent: AgentInventoryItem): void => { - const candidates = byTarget.get(key) ?? []; + const canonicalTargets = new Map(); + const candidateTargets = new Map(); + const registerCandidate = ( + key: string, + agent: AgentInventoryItem, + ): void => { + const candidates = candidateTargets.get(key) ?? []; if ( !candidates.some((candidate) => candidate.agentKey === agent.agentKey) ) { candidates.push(agent); - byTarget.set(key, candidates); + candidateTargets.set(key, candidates); } }; for (const agent of agents) { - registerTarget(agent.agentKey, agent); + if (agent.identityStatus === "canonical") { + canonicalTargets.set(agent.agentKey, agent); + } else { + registerCandidate(agent.agentKey, agent); + } for (const alias of agent.resolutionAliases) { - registerTarget(alias, agent); + registerCandidate(alias, agent); } } const edges: SystemGraphEdge[] = []; - const warnings: GraphWarning[] = [ - ...inventory.warnings, - ...normalized.warnings, - ]; + const warnings: GraphWarning[] = [...consumed.warnings]; const seenEdges = new Set(); let relationshipsComplete = true; @@ -352,7 +423,10 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { } for (const relationship of result.relationships) { - const candidates = byTarget.get(relationship.target) ?? []; + const exact = canonicalTargets.get(relationship.target); + const candidates = exact + ? [exact] + : (candidateTargets.get(relationship.target) ?? []); if (candidates.length !== 1) { const target = /^[A-Za-z0-9@_.:-]+$/.test(relationship.target) ? relationship.target @@ -410,7 +484,7 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { ].sort(warningOrder); return { - cacheable: inventory.cacheable && relationshipsComplete, + cacheable: consumed.settled && relationshipsComplete, graph: { kind: "system", scope: { kind: "working-tree", workspaceKey: scope.workspaceKey }, @@ -418,6 +492,13 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { edges, warnings: uniqueWarnings, }, + navigation: agents.map(({ agentKey, workflowPath }) => ({ + agentKey, + workflowPath, + })), + ...(consumed.startEnrichment + ? { afterCommit: consumed.startEnrichment } + : {}), }; } @@ -427,14 +508,24 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { this.callersByWorkspace.delete(workspaceKey); } } + try { + this.inventory.retainSources?.( + new Set( + [...this.callersByWorkspace.values()] + .flat() + .map((caller) => caller.sourceRoot), + ), + ); + } catch { + // Private cache pruning cannot make graph projection fail. + } this.retainRelationshipCallers(); } private retainRelationshipCallers(): void { + const callers = [...this.callersByWorkspace.values()].flat(); try { - this.relationships.retainCallers?.( - [...this.callersByWorkspace.values()].flat(), - ); + this.relationships.retainCallers?.(callers); } catch { // Cache pruning is an optimization and cannot make projection fail. } diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index d21fa404e..db1561f66 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -850,12 +850,31 @@ export const startServer = async ( const systemGraphRelationships = new CachedAgentRelationshipProvider( new SourceAgentRelationshipProvider(), ); + const activeSystemGraphScopes = new Map(); + const systemGraphInventory = new HarnessRegistryInventoryProvider({ + listWorkflows: () => workflowsCache, + inspectManifestName, + onIdentityChange: (sourceRoots) => { + const canonicalSourceRoots = sourceRoots.map(canonicalGraphPath); + for (const scope of activeSystemGraphScopes.values()) { + const canonicalScope = { + workspaceKey: scope.workspaceKey, + root: canonicalGraphPath(scope.root), + }; + if ( + systemGraphStore.peek(canonicalScope.workspaceKey) && + canonicalSourceRoots.some((sourceRoot) => + isWithinGraphPath(canonicalScope.root, sourceRoot), + ) + ) { + systemGraphStore.requestRefresh(canonicalScope); + } + } + }, + }); const systemGraphStore = new SystemGraphStore( new StaticSystemGraphBuilder( - new HarnessRegistryInventoryProvider({ - listWorkflows: () => workflowsCache, - inspectManifestName, - }), + systemGraphInventory, systemGraphRelationships, ), { @@ -869,7 +888,6 @@ export const startServer = async ( }, }, ); - const activeSystemGraphScopes = new Map(); const refreshSystemGraphScopesForRoot = ( changedRoot: string, @@ -1276,6 +1294,7 @@ export const startServer = async ( ); if (dirtyRoots.length === 0) return; for (const workflowRoot of dirtyRoots) { + systemGraphInventory.invalidateSource(workflowRoot); systemGraphRelationships.invalidateSource(workflowRoot); } systemGraphStore.requestRefresh(canonicalScope); @@ -1558,6 +1577,7 @@ export const startServer = async ( }, onScopeRefresh: async (scope) => { try { + systemGraphInventory.retryFailedInspections(scope); return await refreshSystemGraphInventory(scope); } catch { console.error("[harness] workspace graph manual refresh failed"); @@ -2050,6 +2070,7 @@ export const startServer = async ( systemGraphWatcher.stopAll(); activeSystemGraphScopes.clear(); systemGraphRelationships.clear(); + systemGraphInventory.clear(); systemGraphStore.clear(); installWatcher.stopAll(); for (const tailer of codexTailers.values()) tailer.stop(); diff --git a/packages/harness/src/server/system-graph-freshness.test.ts b/packages/harness/src/server/system-graph-freshness.test.ts index 9549464e9..f8b05efba 100644 --- a/packages/harness/src/server/system-graph-freshness.test.ts +++ b/packages/harness/src/server/system-graph-freshness.test.ts @@ -110,9 +110,33 @@ describe("workspace graph freshness wiring", () => { expect(raw).not.toContain(workspaceRoot); return JSON.parse(raw) as SystemGraphSnapshot; }; + // The first read is served from provisional identities, before any + // source has been inspected: usable immediately, and honestly labelled + // as not yet settled. const initial = await readGraph(); - expect(initial).toMatchObject({ state: "ready" }); + expect(initial).toMatchObject({ state: "degraded" }); expect(initial.graph?.edges).toEqual([]); + + // These fixtures have no `defineAgent` export, so enrichment can never + // name them — and the projection still has to reach `ready`. An agent + // whose identity has finished resolving badly is settled, not pending, + // and a settled projection is the fast path. Asserting `ready` here is + // what keeps a permanently unidentifiable agent from re-acquiring its + // veto over the whole workspace's cache. + let settled!: SystemGraphSnapshot; + await vi.waitFor( + async () => { + settled = await readGraph(); + expect(settled.revision).toBeGreaterThan(initial.revision); + expect(settled.state).toBe("ready"); + expect( + settled.graph?.warnings.some( + (warning) => warning.code === "inventory-extraction-failed", + ), + ).toBe(false); + }, + { timeout: 8_000, interval: 150 }, + ); graphEvents.length = 0; await fs.writeFile( @@ -123,7 +147,7 @@ describe("workspace graph freshness wiring", () => { await vi.waitFor( async () => { sourceRefresh = await readGraph(); - expect(sourceRefresh.revision).toBeGreaterThan(initial.revision); + expect(sourceRefresh.revision).toBeGreaterThan(settled.revision); expect(sourceRefresh.state).toBe("ready"); expect(sourceRefresh.graph?.edges).toEqual([ expect.objectContaining({ @@ -136,7 +160,21 @@ describe("workspace graph freshness wiring", () => { { timeout: 8_000, interval: 150 }, ); expect(graphEvents.some((event) => event.state === "stale")).toBe(true); - expect(graphEvents.some((event) => event.state === "ready")).toBe(true); + // Bound to the revision the read observed, not just to the state: a bare + // `some(state === "ready")` is satisfied by events that were already in + // flight when the edit landed, so it survives the bus going quiet. + await vi.waitFor( + () => { + expect( + graphEvents.some( + (event) => + event.state === "ready" && + event.revision === sourceRefresh.revision, + ), + ).toBe(true); + }, + { timeout: 4_000, interval: 50 }, + ); await fs.writeFile(path.join(researchRoot, "index.ts"), "export {};\n"); let sourceRemoved!: SystemGraphSnapshot; diff --git a/packages/harness/src/server/system-graph.test.ts b/packages/harness/src/server/system-graph.test.ts index 9e65596d8..dbca7ba93 100644 --- a/packages/harness/src/server/system-graph.test.ts +++ b/packages/harness/src/server/system-graph.test.ts @@ -53,15 +53,23 @@ describe("createSystemGraphRouter", () => { ), }; const builder: SystemGraphBuilder = { - build: vi.fn(async () => ({ cacheable, graph })), + build: vi.fn(async () => ({ + cacheable, + graph, + navigation: [ + { agentKey: "research", workflowPath: "/private/workspace/research" }, + { agentKey: "growth", workflowPath: "/private/workspace/growth" }, + ], + })), }; + const store = new SystemGraphStore(builder); const app = express(); app.use("/api", createBootTokenMiddleware("test-token")); app.use( "/api", createSystemGraphRouter({ scopeResolver, - store: new SystemGraphStore(builder), + store, onScopeAccess, }), ); @@ -71,6 +79,7 @@ describe("createSystemGraphRouter", () => { baseUrl: `http://127.0.0.1:${address.port}`, scopeResolver, builder, + store, onScopeAccess, }; } @@ -144,6 +153,140 @@ describe("createSystemGraphRouter", () => { expect(builder.build).toHaveBeenCalledTimes(2); }); + it("serves a separately protected resolver stamped with the graph revision", async () => { + const { baseUrl, builder } = start(); + const graphRoute = `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`; + const navigationRoute = `${graphRoute}/navigation`; + const headers = { "X-Harness-Token": "test-token" }; + const snapshot = (await ( + await fetch(graphRoute, { headers }) + ).json()) as SystemGraphSnapshot; + + expect((await fetch(navigationRoute)).status).toBe(401); + const response = await fetch(navigationRoute, { headers }); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(await response.json()).toEqual({ + workspaceKey, + revision: snapshot.revision, + targets: [ + { + agentKey: "research", + workflowPath: "/private/workspace/research", + }, + { + agentKey: "growth", + workflowPath: "/private/workspace/growth", + }, + ], + }); + expect(builder.build).toHaveBeenCalledTimes(1); + }); + + it("serves degraded navigation without consuming a graph recovery retry", async () => { + const { baseUrl, builder } = start(false); + const graphRoute = `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`; + const headers = { "X-Harness-Token": "test-token" }; + const snapshot = (await ( + await fetch(graphRoute, { headers }) + ).json()) as SystemGraphSnapshot; + + const navigation = await fetch(`${graphRoute}/navigation`, { headers }); + + expect(navigation.status).toBe(200); + expect(await navigation.json()).toMatchObject({ + workspaceKey, + revision: snapshot.revision, + }); + expect(builder.build).toHaveBeenCalledTimes(1); + }); + + it("does not send a projection retired while its HTTP build is in flight", async () => { + let resolveBuild!: ( + value: Awaited>, + ) => void; + const pending = new Promise< + Awaited> + >((resolve) => { + resolveBuild = resolve; + }); + const builder: SystemGraphBuilder = { + build: vi.fn(() => pending), + }; + const store = new SystemGraphStore(builder); + const scopeResolver: WorkspaceScopeResolver = { + resolve: vi.fn(async () => ({ + workspaceKey, + root: "/private/workspace", + })), + }; + const app = express(); + app.use("/api", createBootTokenMiddleware("test-token")); + app.use("/api", createSystemGraphRouter({ scopeResolver, store })); + server = app.listen(0); + const address = server.address() as AddressInfo; + const response = fetch( + `http://127.0.0.1:${address.port}/api/workspaces/${workspaceKey}/system-graph`, + { headers: { "X-Harness-Token": "test-token" } }, + ); + await vi.waitFor(() => expect(builder.build).toHaveBeenCalledTimes(1)); + + store.retire(workspaceKey); + resolveBuild({ + cacheable: true, + graph, + navigation: [], + }); + + expect((await response).status).toBe(404); + }); + + it("serves the current accepted revision when afterCommit immediately refreshes", async () => { + const builder: SystemGraphBuilder = { + build: vi + .fn() + .mockResolvedValueOnce({ + cacheable: false, + graph, + navigation: [], + afterCommit: () => + store.requestRefresh({ + workspaceKey, + root: "/private/workspace", + }), + }) + .mockResolvedValueOnce({ cacheable: true, graph, navigation: [] }), + }; + const store = new SystemGraphStore(builder); + const app = express(); + app.use("/api", createBootTokenMiddleware("test-token")); + app.use( + "/api", + createSystemGraphRouter({ + scopeResolver: { + resolve: async () => ({ + workspaceKey, + root: "/private/workspace", + }), + }, + store, + }), + ); + server = app.listen(0); + const address = server.address() as AddressInfo; + + const response = await fetch( + `http://127.0.0.1:${address.port}/api/workspaces/${workspaceKey}/system-graph`, + { headers: { "X-Harness-Token": "test-token" } }, + ); + const body = (await response.json()) as SystemGraphSnapshot; + + expect(response.status).toBe(200); + expect(body.revision).toBe(store.peek(workspaceKey)?.revision); + expect(body.revision).toBeGreaterThan(1); + }); + it("rejects an unknown opaque workspace key without scanning", async () => { const { baseUrl, builder } = start(); const response = await fetch( @@ -156,5 +299,13 @@ describe("createSystemGraphRouter", () => { expect(response.status).toBe(404); expect(await response.json()).toEqual({ error: "Workspace not found" }); expect(builder.build).not.toHaveBeenCalled(); + expect( + ( + await fetch( + `${baseUrl}/api/workspaces/unknown/system-graph/navigation`, + { headers: { "X-Harness-Token": "test-token" } }, + ) + ).status, + ).toBe(404); }); }); diff --git a/packages/harness/src/server/system-graph.ts b/packages/harness/src/server/system-graph.ts index aee630e9c..cfbb31292 100644 --- a/packages/harness/src/server/system-graph.ts +++ b/packages/harness/src/server/system-graph.ts @@ -13,6 +13,7 @@ import type { SystemGraphStore } from "../core/system-graph-store.js"; import { SYSTEM_GRAPH_CACHE_HEADER, type SystemGraphCacheStatus, + type SystemGraphNavigationResponse, type SystemGraphSnapshot, } from "../shared/system-graph.js"; @@ -53,10 +54,14 @@ export function createSystemGraphRouter( // Watcher setup is best-effort. A graph read must remain available // even when automatic freshness cannot be armed. } - const snapshot = refresh - ? await (options.onScopeRefresh?.(scope) ?? - options.store.refresh(scope)) - : await options.store.get(scope); + await (refresh + ? (options.onScopeRefresh?.(scope) ?? options.store.refresh(scope)) + : options.store.get(scope)); + const snapshot = options.store.peek(scope.workspaceKey); + if (!snapshot) { + res.status(404).json({ error: "Workspace not found" }); + return; + } const cacheStatus: SystemGraphCacheStatus = snapshot.state === "ready" ? "complete" : "degraded"; res.set(SYSTEM_GRAPH_CACHE_HEADER, cacheStatus).json(snapshot); @@ -72,5 +77,36 @@ export function createSystemGraphRouter( void serve(req, res, next, true); }); + router.get(`${route}/navigation`, async (req, res, next) => { + try { + const scope = await options.scopeResolver.resolve( + req.params.workspaceKey, + ); + if (!scope) { + res.status(404).json({ error: "Workspace not found" }); + return; + } + try { + await options.onScopeAccess?.(scope); + } catch { + // Resolver reads remain available when freshness watching cannot arm. + } + await options.store.ensureInitialized(scope); + if (!options.store.peek(scope.workspaceKey)) { + res.status(404).json({ error: "Workspace not found" }); + return; + } + const navigation = options.store.peekNavigation(scope.workspaceKey); + if (!navigation) { + res.status(404).json({ error: "System graph not found" }); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.json(navigation satisfies SystemGraphNavigationResponse); + } catch (err) { + next(err); + } + }); + return router; } diff --git a/packages/harness/src/shared/system-graph.ts b/packages/harness/src/shared/system-graph.ts index 682335508..1fd10396a 100644 --- a/packages/harness/src/shared/system-graph.ts +++ b/packages/harness/src/shared/system-graph.ts @@ -90,7 +90,7 @@ export function workspaceRelativeLocalKey( return null; } const relative = source.segments.slice(scope.segments.length); - const local = relative.join("/") || source.segments.at(-1) || "root"; + const local = relative.join("/") || "root"; return `local:${local}`; } @@ -157,3 +157,16 @@ export interface SystemGraphSnapshot { /** Null only before a usable projection exists. */ graph: SystemGraph | null; } + +/** Protected local resolver payload. Paths remain separate from SystemGraph. */ +export interface SystemGraphNavigationTarget { + agentKey: AgentKey; + workflowPath: string; +} + +export interface SystemGraphNavigationResponse { + workspaceKey: WorkspaceKey; + /** Must equal the displayed SystemGraphSnapshot revision before use. */ + revision: number; + targets: SystemGraphNavigationTarget[]; +} diff --git a/packages/harness/web/e2e/smoke.spec.ts b/packages/harness/web/e2e/smoke.spec.ts index fe4161cf6..99c53d754 100644 --- a/packages/harness/web/e2e/smoke.spec.ts +++ b/packages/harness/web/e2e/smoke.spec.ts @@ -918,11 +918,15 @@ test.describe("three-zone IA (rail explorer, tab strip, right pane)", () => { revision: 2, state: "stale", }); + win.__HARNESS_TEST__?.publish?.({ type: "workflows.changed" }); }, workspaceKey); await page.getByTestId("project-select-acme-app").click(); await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); await expect(page.getByTestId("system-graph-refreshing")).toBeVisible(); + await expect(page.getByTestId("system-graph-node-leasing")).not.toHaveClass( + /is-navigable/, + ); await page.evaluate(() => { delete (window as unknown as { __MOCK_SYSTEM_GRAPH_DELAY_MS__?: number }) .__MOCK_SYSTEM_GRAPH_DELAY_MS__; @@ -997,6 +1001,38 @@ test.describe("three-zone IA (rail explorer, tab strip, right pane)", () => { }); await page.waitForTimeout(250); expect(await requestCount()).toBe(5); + + // React may batch adjacent event frames. A following unrelated frame must + // not overwrite the graph invalidation or leave old navigation clickable. + const leasingNode = page.getByTestId("system-graph-node-leasing"); + await expect(leasingNode).toHaveClass(/is-navigable/); + await page.evaluate((key) => { + const win = window as unknown as { + __MOCK_SYSTEM_GRAPH_REVISION__?: number; + __MOCK_SYSTEM_GRAPH_STATE__?: string; + __MOCK_SYSTEM_GRAPH_DELAY_MS__?: number; + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + }; + win.__MOCK_SYSTEM_GRAPH_REVISION__ = 7; + win.__MOCK_SYSTEM_GRAPH_STATE__ = "ready"; + win.__MOCK_SYSTEM_GRAPH_DELAY_MS__ = 3_000; + win.__HARNESS_TEST__?.publish?.({ + type: "system-graph.changed", + workspaceKey: key, + revision: 7, + state: "stale", + }); + win.__HARNESS_TEST__?.publish?.({ type: "workflows.changed" }); + }, workspaceKey); + await expect(leasingNode).not.toHaveClass(/is-navigable/); + await expect(page.getByTestId("system-graph-refreshing")).toBeVisible(); + await page.evaluate(() => { + delete (window as unknown as { __MOCK_SYSTEM_GRAPH_DELAY_MS__?: number }) + .__MOCK_SYSTEM_GRAPH_DELAY_MS__; + }); + await expect.poll(requestCount).toBe(6); + await expect(page.getByTestId("system-graph-refreshing")).toHaveCount(0); + await expect(leasingNode).toHaveClass(/is-navigable/); }); test("switching sessions makes the canvas follow the new session's content", async ({ diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index ac69b71ba..6d783de64 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -1964,9 +1964,10 @@ export const App = (): JSX.Element => { workspaceKey={selectedWorkspaceKey} workspaceName={selectedWorkspaceName} api={harness.api} - workflows={state.workflows} - workspaceScopes={workspaceScopes} - lastMessage={harness.lastMessage} + latestAnnouncement={ + harness.systemGraphAnnouncements.get(selectedWorkspaceKey) ?? + null + } onOpenAgent={handleFocusAgent} onExpandRail={ railCollapsed ? () => setRailCollapsed(false) : undefined diff --git a/packages/harness/web/src/components/WorkspaceGraphView.test.ts b/packages/harness/web/src/components/WorkspaceGraphView.test.ts new file mode 100644 index 000000000..f28d0e272 --- /dev/null +++ b/packages/harness/web/src/components/WorkspaceGraphView.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import type { + SystemGraphNavigationResponse, + SystemGraphSnapshot, +} from "@shared/system-graph"; + +import { workspaceGraphNavigationIsCurrent } from "./WorkspaceGraphView"; +import { systemGraphNavigationForSnapshot } from "../lib/system-graph-navigation"; +import { + retainSystemGraphAnnouncements, + systemGraphAnnouncementsAfterMessage, +} from "../lib/system-graph-announcements"; + +describe("WorkspaceGraphView navigation lifecycle", () => { + it("retains a graph announcement across a batched unrelated frame", () => { + let announcements = new Map(); + announcements = systemGraphAnnouncementsAfterMessage(announcements, { + type: "system-graph.changed", + workspaceKey: "workspace-test", + revision: 8, + state: "stale", + }); + announcements = systemGraphAnnouncementsAfterMessage(announcements, { + type: "workflows.changed", + }); + const incoming = announcements.get("workspace-test"); + + expect(incoming).toMatchObject({ revision: 8, state: "stale" }); + expect( + workspaceGraphNavigationIsCurrent({ + snapshotRevision: 7, + snapshotState: "ready", + announcementRevision: null, + incomingRevision: incoming?.revision, + loading: false, + error: false, + }), + ).toBe(false); + }); + + it("retains the highest revision per active workspace", () => { + let announcements = new Map(); + for (const message of [ + { + type: "system-graph.changed" as const, + workspaceKey: "workspace-one", + revision: 3, + state: "ready" as const, + }, + { + type: "system-graph.changed" as const, + workspaceKey: "workspace-two", + revision: 7, + state: "degraded" as const, + }, + { + type: "system-graph.changed" as const, + workspaceKey: "workspace-one", + revision: 2, + state: "stale" as const, + }, + ]) { + announcements = systemGraphAnnouncementsAfterMessage( + announcements, + message, + ); + } + + expect(announcements.get("workspace-one")?.revision).toBe(3); + expect(announcements.get("workspace-two")?.revision).toBe(7); + expect( + retainSystemGraphAnnouncements( + announcements, + new Set(["workspace-one", "workspace-two", "workspace-three"]), + ), + ).toBe(announcements); + expect([ + ...retainSystemGraphAnnouncements( + announcements, + new Set(["workspace-two"]), + ).keys(), + ]).toEqual(["workspace-two"]); + }); + + it("fails closed before a newer deferred graph arrives and stays closed when it rejects", () => { + const displayed = { + snapshotRevision: 7, + snapshotState: "ready" as const, + announcementRevision: null, + loading: false, + error: false, + }; + expect(workspaceGraphNavigationIsCurrent(displayed)).toBe(true); + + const announced = { ...displayed, announcementRevision: 8 }; + expect(workspaceGraphNavigationIsCurrent(announced)).toBe(false); + expect( + workspaceGraphNavigationIsCurrent({ ...announced, loading: true }), + ).toBe(false); + expect( + workspaceGraphNavigationIsCurrent({ + ...announced, + loading: false, + error: true, + }), + ).toBe(false); + }); + + it("keeps a resolver-newer graph inert through catch-up failure", () => { + expect( + workspaceGraphNavigationIsCurrent({ + snapshotRevision: 7, + snapshotState: "ready", + announcementRevision: 8, + loading: false, + error: true, + }), + ).toBe(false); + }); + + it("fails closed when no recognized committed lifecycle state is present", () => { + expect( + workspaceGraphNavigationIsCurrent({ + snapshotRevision: 7, + snapshotState: null, + announcementRevision: null, + loading: false, + error: false, + }), + ).toBe(false); + }); + + it("is inert on the first render carrying a newer bus announcement", () => { + expect( + workspaceGraphNavigationIsCurrent({ + snapshotRevision: 7, + snapshotState: "ready", + announcementRevision: null, + incomingRevision: 8, + loading: false, + error: false, + }), + ).toBe(false); + }); + + it("keeps a matching stale sidecar inert until projection settles", () => { + const snapshot = (revision: number, state: "stale" | "degraded") => + ({ + workspaceKey: "workspace-test", + revision, + state, + graph: { + kind: "system", + scope: { + kind: "working-tree", + workspaceKey: "workspace-test", + }, + nodes: [{ id: "agent:a", agentKey: "a", label: "A" }], + edges: [], + warnings: [], + }, + }) satisfies SystemGraphSnapshot; + const response = (revision: number) => + ({ + workspaceKey: "workspace-test", + revision, + targets: [{ agentKey: "a", workflowPath: "/private/a" }], + }) satisfies SystemGraphNavigationResponse; + const stale = snapshot(8, "stale"); + const staleCurrent = workspaceGraphNavigationIsCurrent({ + snapshotRevision: stale.revision, + snapshotState: stale.state, + announcementRevision: 8, + loading: false, + error: false, + }); + const staleNavigation = staleCurrent + ? systemGraphNavigationForSnapshot(response(8), stale) + : new Map(); + expect(staleNavigation.size).toBe(0); + + const degraded = snapshot(9, "degraded"); + const degradedCurrent = workspaceGraphNavigationIsCurrent({ + snapshotRevision: degraded.revision, + snapshotState: degraded.state, + announcementRevision: 8, + loading: false, + error: false, + }); + const degradedNavigation = degradedCurrent + ? systemGraphNavigationForSnapshot(response(9), degraded) + : new Map(); + expect([...degradedNavigation]).toEqual([["a", "/private/a"]]); + }); +}); diff --git a/packages/harness/web/src/components/WorkspaceGraphView.tsx b/packages/harness/web/src/components/WorkspaceGraphView.tsx index 829d1b3ad..9749e8632 100644 --- a/packages/harness/web/src/components/WorkspaceGraphView.tsx +++ b/packages/harness/web/src/components/WorkspaceGraphView.tsx @@ -1,17 +1,19 @@ import { useEffect, useMemo, useState } from "react"; import type { JSX } from "react"; import type { - AgentKey, SystemGraphLifecycleState, + SystemGraphNavigationResponse, SystemGraphSnapshot, WorkspaceKey, - WorkspaceScopeSummary, } from "@shared/system-graph"; -import type { BusMessage, WorkflowInfo } from "@shared/types"; import type { HarnessApi } from "../lib/api"; import { systemGraphLoader } from "../lib/system-graph-loader"; -import { mapSystemGraphNavigation } from "../lib/system-graph-navigation"; +import type { SystemGraphAnnouncement } from "../lib/system-graph-announcements"; +import { + resolveSystemGraphNavigationForRevision, + systemGraphNavigationForSnapshot, +} from "../lib/system-graph-navigation"; import { trackingAttrs } from "../lib/analytics/tracking-attrs"; import { EmptyState } from "./EmptyState"; import { Icon } from "./Icon"; @@ -21,20 +23,37 @@ interface WorkspaceGraphViewProps { workspaceKey: WorkspaceKey; workspaceName: string; api: HarnessApi; - workflows: readonly WorkflowInfo[]; - workspaceScopes: readonly WorkspaceScopeSummary[]; - lastMessage: BusMessage | null; + latestAnnouncement: SystemGraphAnnouncement | null; onOpenAgent: (path: string) => void; onExpandRail?: () => void; } +export function workspaceGraphNavigationIsCurrent(input: { + snapshotRevision: number | null; + snapshotState: SystemGraphLifecycleState | null; + announcementRevision: number | null; + incomingRevision?: number | null; + loading: boolean; + error: boolean; +}): boolean { + const newestAnnouncement = Math.max( + input.announcementRevision ?? -1, + input.incomingRevision ?? -1, + ); + return ( + !input.loading && + !input.error && + input.snapshotRevision !== null && + (input.snapshotState === "ready" || input.snapshotState === "degraded") && + newestAnnouncement <= input.snapshotRevision + ); +} + export function WorkspaceGraphView({ workspaceKey, workspaceName, api, - workflows, - workspaceScopes, - lastMessage, + latestAnnouncement, onOpenAgent, onExpandRail, }: WorkspaceGraphViewProps): JSX.Element { @@ -45,14 +64,34 @@ export function WorkspaceGraphView({ revision: number; state: SystemGraphLifecycleState; } | null>(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(false); const [refreshSeq, setRefreshSeq] = useState(0); + const [navigationResponse, setNavigationResponse] = + useState(null); + const incomingAnnouncement = + latestAnnouncement?.workspaceKey === workspaceKey + ? latestAnnouncement + : null; + const effectiveAnnouncement = + incomingAnnouncement && + incomingAnnouncement.revision > (announcement?.revision ?? -1) + ? incomingAnnouncement + : announcement; + const navigationIsCurrent = workspaceGraphNavigationIsCurrent({ + snapshotRevision: snapshot?.revision ?? null, + snapshotState: snapshot?.state ?? null, + announcementRevision: announcement?.revision ?? null, + incomingRevision: incomingAnnouncement?.revision ?? null, + loading, + error, + }); useEffect(() => { let active = true; setError(false); setLoading(true); + setNavigationResponse(null); void systemGraphLoader.load(api, workspaceKey).then( (next) => { if (!active) return; @@ -66,7 +105,6 @@ export function WorkspaceGraphView({ }, () => { if (!active) return; - setAnnouncement(null); setError(true); setLoading(false); }, @@ -76,10 +114,51 @@ export function WorkspaceGraphView({ }; }, [api, workspaceKey, refreshSeq]); + useEffect(() => { + if (!snapshot?.graph || !navigationIsCurrent) { + setNavigationResponse(null); + return; + } + const revision = snapshot.revision; + let active = true; + const controller = new AbortController(); + void (async () => { + const resolution = await resolveSystemGraphNavigationForRevision( + api, + workspaceKey, + revision, + controller.signal, + ); + if (!active) return; + if (resolution.kind === "matched") { + setNavigationResponse(resolution.response); + return; + } + if (resolution.kind === "graph-behind") { + setNavigationResponse(null); + systemGraphLoader.invalidate(workspaceKey, resolution.revision); + setAnnouncement({ + revision: resolution.revision, + state: "stale", + }); + setError(false); + setRefreshSeq((value) => value + 1); + return; + } + setNavigationResponse(null); + })().catch(() => { + if (active) setNavigationResponse(null); + }); + return () => { + active = false; + controller.abort(); + }; + }, [api, navigationIsCurrent, snapshot?.revision, workspaceKey]); + useEffect(() => { if ( - lastMessage?.type !== "system-graph.changed" || - lastMessage.workspaceKey !== workspaceKey + !latestAnnouncement || + latestAnnouncement.workspaceKey !== workspaceKey ) { return; } @@ -87,26 +166,33 @@ export function WorkspaceGraphView({ snapshot?.revision ?? -1, announcement?.revision ?? -1, ); - if (lastMessage.revision <= knownRevision) return; + if (latestAnnouncement.revision <= knownRevision) return; // The global event subscriber already invalidates the shared cache while // this destination is closed. Repeating it here keeps the view correct in // isolation and is a no-op for an already-observed revision. - systemGraphLoader.invalidate(workspaceKey, lastMessage.revision); + systemGraphLoader.invalidate(workspaceKey, latestAnnouncement.revision); setAnnouncement({ - revision: lastMessage.revision, - state: lastMessage.state, + revision: latestAnnouncement.revision, + state: latestAnnouncement.state, }); + setNavigationResponse(null); setError(false); setRefreshSeq((value) => value + 1); - }, [announcement?.revision, lastMessage, snapshot?.revision, workspaceKey]); + }, [ + announcement?.revision, + latestAnnouncement, + snapshot?.revision, + workspaceKey, + ]); const graph = snapshot?.graph ?? null; const announcementIsNewer = - announcement !== null && announcement.revision > (snapshot?.revision ?? -1); + effectiveAnnouncement !== null && + effectiveAnnouncement.revision > (snapshot?.revision ?? -1); let lifecycle: SystemGraphLifecycleState = snapshot?.state ?? "building"; if (announcementIsNewer) { lifecycle = - announcement.state === "degraded" + effectiveAnnouncement.state === "degraded" ? "degraded" : graph ? "stale" @@ -117,26 +203,22 @@ export function WorkspaceGraphView({ !error && graph !== null && (loading || - (announcementIsNewer && announcement?.state !== "degraded")); + (announcementIsNewer && effectiveAnnouncement?.state !== "degraded")); const retry = (): void => { systemGraphLoader.invalidate(workspaceKey); setAnnouncement(null); + setNavigationResponse(null); setError(false); setRefreshSeq((value) => value + 1); }; const navigation = useMemo( () => - graph - ? mapSystemGraphNavigation( - graph.nodes, - workspaceKey, - workflows, - workspaceScopes, - ) - : new Map(), - [graph, workspaceKey, workflows, workspaceScopes], + navigationIsCurrent + ? systemGraphNavigationForSnapshot(navigationResponse, snapshot) + : new Map(), + [navigationIsCurrent, navigationResponse, snapshot], ); return ( @@ -277,8 +359,8 @@ export function WorkspaceGraphView({ workspaceKey={workspaceKey} navigableAgentKeys={new Set(navigation.keys())} onOpenAgent={(agentKey) => { - const workflow = navigation.get(agentKey); - if (workflow) onOpenAgent(workflow.path); + const workflowPath = navigation.get(agentKey); + if (workflowPath) onOpenAgent(workflowPath); }} /> )} diff --git a/packages/harness/web/src/lib/api.test.ts b/packages/harness/web/src/lib/api.test.ts index 5139063dc..d84b8c12e 100644 --- a/packages/harness/web/src/lib/api.test.ts +++ b/packages/harness/web/src/lib/api.test.ts @@ -1,15 +1,225 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WorkflowInfo } from "@shared/types"; import { createApi, isMockMode, + MockApi, parseNdjsonLine, + projectMockSystemGraphInventory, progressiveLeasingRun, PROGRESSIVE_STEP_MS, terminalDeployEvent, type DeployStreamEvent, } from "./api"; +describe("MockApi deterministic system graph identity and navigation", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("assigns duplicate definition slugs distinct deterministic local identities", () => { + const workflows = [ + { + name: "Second", + path: "/workspace/second", + definitionId: 2, + definitionSlug: "shared", + source: "scan" as const, + }, + { + name: "First", + path: "/workspace/first", + definitionId: 1, + definitionSlug: "shared", + source: "scan" as const, + }, + ]; + + const forward = projectMockSystemGraphInventory("/workspace", workflows); + const reversed = projectMockSystemGraphInventory( + "/workspace", + [...workflows].reverse(), + ); + + expect(forward).toEqual(reversed); + expect(forward.nodes.map((node) => node.agentKey)).toEqual([ + "local:first", + "local:second", + ]); + expect(new Set(forward.nodes.map((node) => node.id)).size).toBe(2); + expect(forward.warnings).toEqual([ + { + code: "duplicate-agent-key", + agentKey: "shared", + message: "Multiple agents use shared; kept each with a local identity.", + }, + ]); + expect(forward.degraded).toBe(true); + }); + + it("suffixes colliding local fallbacks without duplicate warnings", () => { + const workflows = [ + { + name: "Root", + path: "/workspace", + definitionId: null, + definitionSlug: null, + source: "scan" as const, + }, + { + name: "Nested root", + path: "/workspace/root", + definitionId: null, + definitionSlug: null, + source: "scan" as const, + }, + { + name: "Suffixed root", + path: "/workspace/root~2", + definitionId: null, + definitionSlug: null, + source: "scan" as const, + }, + ]; + const projection = projectMockSystemGraphInventory("/workspace", workflows); + + expect(projection.nodes.map((node) => node.agentKey)).toEqual([ + "local:root", + "local:root~2", + "local:root~2~2", + ]); + expect(projection.warnings).toEqual([]); + expect(projection.degraded).toBe(false); + expect(projection).toEqual( + projectMockSystemGraphInventory("/workspace", [...workflows].reverse()), + ); + }); + + it("uses projection warnings and lifecycle for non-special mock graphs", async () => { + const api = new MockApi(); + const scope = (await api.getState()).workspaceScopes?.find( + (candidate) => candidate.cwd !== "/Users/demo/acme-app", + ); + expect(scope).toBeDefined(); + const setWorkflows = (workflows: WorkflowInfo[]) => { + (api as unknown as { workflows: WorkflowInfo[] }).workflows = workflows; + }; + setWorkflows([ + { + name: "First", + path: `${scope!.cwd}/first`, + definitionId: 1, + definitionSlug: "shared", + source: "scan", + }, + { + name: "Second", + path: `${scope!.cwd}/second`, + definitionId: 2, + definitionSlug: "shared", + source: "scan", + }, + ]); + + const duplicate = await api.getSystemGraph(scope!.workspaceKey); + expect(duplicate.state).toBe("degraded"); + expect(duplicate.graph?.nodes.map((node) => node.agentKey)).toEqual([ + "local:first", + "local:second", + ]); + expect(duplicate.graph?.warnings).toEqual([ + { + code: "duplicate-agent-key", + agentKey: "shared", + message: "Multiple agents use shared; kept each with a local identity.", + }, + ]); + + setWorkflows([ + { + name: "Unique", + path: `${scope!.cwd}/unique`, + definitionId: null, + definitionSlug: null, + source: "scan", + }, + ]); + const unique = await api.getSystemGraph(scope!.workspaceKey); + expect(unique.state).toBe("ready"); + expect(unique.graph?.warnings).toEqual([]); + }); + + it("caches ordinary graph reads and advances an explicit refresh", async () => { + const api = new MockApi(); + const scope = (await api.getState()).workspaceScopes?.[0]; + expect(scope).toBeDefined(); + + const first = await api.getSystemGraph(scope!.workspaceKey); + const cached = await api.getSystemGraph(scope!.workspaceKey); + const refreshed = await api.getSystemGraph(scope!.workspaceKey, { + refresh: true, + }); + + expect(cached).toBe(first); + expect(refreshed.revision).toBeGreaterThan(first.revision); + }); + + it("bypasses a cached graph for a directly announced mock revision", async () => { + const api = new MockApi(); + const scope = (await api.getState()).workspaceScopes?.[0]; + expect(scope).toBeDefined(); + const first = await api.getSystemGraph(scope!.workspaceKey); + vi.stubGlobal("window", { + location: { search: "" }, + __MOCK_SYSTEM_GRAPH_REVISION__: first.revision + 1, + __MOCK_SYSTEM_GRAPH_STATE__: "stale", + }); + + const announced = await api.getSystemGraph(scope!.workspaceKey); + + expect(announced).toMatchObject({ + revision: first.revision + 1, + state: "stale", + }); + expect(announced).not.toBe(first); + }); + + it("rebuilds graph navigation atomically after a workflow move", async () => { + const api = new MockApi(); + const state = await api.getState(); + const scope = state.workspaceScopes?.find( + (candidate) => candidate.cwd === "/Users/demo/acme-app", + ); + expect(scope).toBeDefined(); + + const before = await api.getSystemGraph(scope!.workspaceKey); + const oldNavigation = await api.getSystemGraphNavigation( + scope!.workspaceKey, + ); + expect( + oldNavigation.targets.find((target) => target.agentKey === "leasing") + ?.workflowPath, + ).toBe("/Users/demo/acme-app/leasing"); + + await api.moveAgent( + "/Users/demo/acme-app/leasing", + "/Users/demo/acme-app/leasing-moved", + ); + const navigation = await api.getSystemGraphNavigation(scope!.workspaceKey); + + expect(navigation.revision).toBeGreaterThan(before.revision); + expect( + navigation.targets.find((target) => target.agentKey === "leasing") + ?.workflowPath, + ).toBe("/Users/demo/acme-app/leasing-moved"); + expect( + oldNavigation.targets.find((target) => target.agentKey === "leasing") + ?.workflowPath, + ).toBe("/Users/demo/acme-app/leasing"); + }); +}); + describe("RealApi.getSystemGraph", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -104,10 +314,42 @@ describe("RealApi.getSystemGraph", () => { }), ); }); + + it("fetches and strictly parses the protected navigation sidecar", async () => { + if (isMockMode()) return; + vi.stubGlobal("window", { + __HARNESS__: { token: "test-token" }, + location: { search: "" }, + }); + const navigation = { + workspaceKey: "workspace-test", + revision: 8, + targets: [{ agentKey: "research", workflowPath: "/private/research" }], + }; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify(navigation), { status: 200 }), + ), + ); + + await expect( + createApi().getSystemGraphNavigation("workspace-test"), + ).resolves.toEqual(navigation); + expect(fetch).toHaveBeenCalledWith( + "/api/workspaces/workspace-test/system-graph/navigation", + expect.objectContaining({ + headers: expect.objectContaining({ "X-Harness-Token": "test-token" }), + }), + ); + }); }); describe("progressiveLeasingRun", () => { - const at = (elapsed: number) => progressiveLeasingRun("exec-mock-prod-1", elapsed); + const at = (elapsed: number) => + progressiveLeasingRun("exec-mock-prod-1", elapsed); it("starts with the first step running, the rest pending, and no latencies", () => { const run = at(0); @@ -155,7 +397,12 @@ describe("terminalDeployEvent", () => { it("returns the terminal ready event", () => { const events: DeployStreamEvent[] = [ { phase: "building", definitionId: "42" }, - { phase: "ready", definitionId: "42", buildRunId: "b1", status: "succeeded" }, + { + phase: "ready", + definitionId: "42", + buildRunId: "b1", + status: "succeeded", + }, ]; expect(terminalDeployEvent(events)).toEqual({ phase: "ready", @@ -170,14 +417,23 @@ describe("terminalDeployEvent", () => { { phase: "building", definitionId: "42" }, { phase: "error", code: "BUILD_FAILED", message: "boom" }, ]; - expect(terminalDeployEvent(events)).toEqual({ phase: "error", code: "BUILD_FAILED", message: "boom" }); + expect(terminalDeployEvent(events)).toEqual({ + phase: "error", + code: "BUILD_FAILED", + message: "boom", + }); }); it("returns the LAST terminal event when more than one is present", () => { // Defensive: pick the final terminal line, not the first. const events: DeployStreamEvent[] = [ { phase: "error", code: "A", message: "first" }, - { phase: "ready", definitionId: "42", buildRunId: "b1", status: "succeeded" }, + { + phase: "ready", + definitionId: "42", + buildRunId: "b1", + status: "succeeded", + }, ]; expect(terminalDeployEvent(events)).toMatchObject({ phase: "ready" }); }); @@ -185,7 +441,9 @@ describe("terminalDeployEvent", () => { it("synthesizes an error when the stream carried no terminal line", () => { // A stream that only ever said "building" (server died mid-build) still // yields a definite failure outcome, never a building line. - const events: DeployStreamEvent[] = [{ phase: "building", definitionId: "42" }]; + const events: DeployStreamEvent[] = [ + { phase: "building", definitionId: "42" }, + ]; expect(terminalDeployEvent(events)).toEqual({ phase: "error", code: "NO_OUTPUT", @@ -194,7 +452,10 @@ describe("terminalDeployEvent", () => { }); it("synthesizes an error for an empty stream", () => { - expect(terminalDeployEvent([])).toMatchObject({ phase: "error", code: "NO_OUTPUT" }); + expect(terminalDeployEvent([])).toMatchObject({ + phase: "error", + code: "NO_OUTPUT", + }); }); it("treats a linking line as non-terminal", async () => { @@ -204,7 +465,10 @@ describe("terminalDeployEvent", () => { { phase: "linking", name: "order-triage" }, { phase: "building", definitionId: "42" }, ]; - expect(terminalDeployEvent(events)).toMatchObject({ phase: "error", code: "NO_OUTPUT" }); + expect(terminalDeployEvent(events)).toMatchObject({ + phase: "error", + code: "NO_OUTPUT", + }); }); it("treats a warning line as non-terminal", async () => { @@ -212,16 +476,26 @@ describe("terminalDeployEvent", () => { // written to sapiom.json) — it never closes the stream on its own. const events: DeployStreamEvent[] = [ { phase: "linking", name: "order-triage" }, - { phase: "warning", message: "Couldn't save the agent id to sapiom.json." }, + { + phase: "warning", + message: "Couldn't save the agent id to sapiom.json.", + }, { phase: "building", definitionId: "42" }, ]; - expect(terminalDeployEvent(events)).toMatchObject({ phase: "error", code: "NO_OUTPUT" }); + expect(terminalDeployEvent(events)).toMatchObject({ + phase: "error", + code: "NO_OUTPUT", + }); }); }); describe("parseNdjsonLine (deploy stream)", () => { it("parses a well-formed deploy event line", () => { - expect(parseNdjsonLine('{"phase":"building","definitionId":"42"}')).toEqual({ + expect( + parseNdjsonLine( + '{"phase":"building","definitionId":"42"}', + ), + ).toEqual({ phase: "building", definitionId: "42", }); @@ -235,6 +509,8 @@ describe("parseNdjsonLine (deploy stream)", () => { it("drops blank and non-JSON noise lines", () => { expect(parseNdjsonLine(" ")).toBeUndefined(); - expect(parseNdjsonLine("Build succeeded in 12ms")).toBeUndefined(); + expect( + parseNdjsonLine("Build succeeded in 12ms"), + ).toBeUndefined(); }); }); diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 03be8d981..5eddd641c 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -35,6 +35,7 @@ import type { } from "@shared/types"; import { type SystemGraph, + type SystemGraphNavigationResponse, type SystemGraphSnapshot, type WorkspaceKey, type WorkspaceScopeSummary, @@ -43,9 +44,12 @@ import { import type { LocalStepTrace, LocalRunOutcome } from "@sapiom/agent-core"; import { getTheme } from "./theme"; -import { parseSystemGraphSnapshot } from "./system-graph"; +import { + parseSystemGraphNavigation, + parseSystemGraphSnapshot, +} from "./system-graph"; import { refuseMove, remapUnder } from "./agent-move"; -import { basenameOf, isWithinDir, samePath } from "./paths"; +import { isWithinDir, samePath } from "./paths"; import type { CanvasGraph, CanvasGraphNode } from "./canvas-graph"; import { @@ -330,6 +334,10 @@ export interface HarnessApi { workspaceKey: WorkspaceKey, options?: { refresh?: boolean }, ): Promise; + /** Server-owned AgentKey resolver for one exact graph revision. */ + getSystemGraphNavigation( + workspaceKey: WorkspaceKey, + ): Promise; createSession(req: CreateSessionRequest): Promise; attachFile(id: string, req: AttachFileRequest): Promise; listSessions(): Promise; @@ -551,6 +559,15 @@ class RealApi implements HarnessApi { return snapshot; } + async getSystemGraphNavigation( + workspaceKey: WorkspaceKey, + ): Promise { + const value = await this.request( + `/api/workspaces/${encodeURIComponent(workspaceKey)}/system-graph/navigation`, + ); + return parseSystemGraphNavigation(value, { workspaceKey }); + } + createSession(req: CreateSessionRequest): Promise { // Default the launch theme to the app's live theme so the terminal palette // controls Claude's colors (Terminal.tsx). An explicit req.theme still wins. @@ -1317,7 +1334,127 @@ const MOCK_POLSIA_GRAPH_EDGES: SystemGraph["edges"] = [ }, ]; -class MockApi implements HarnessApi { +function codeUnitOrder(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +function hasGraphControl(value: string): boolean { + return [...value].some((character) => { + const code = character.codePointAt(0)!; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + }); +} + +function mockCanonicalIdentity(value: string | null): string | null { + const identity = value?.trim() ?? ""; + return identity !== "" && + identity !== "." && + identity !== ".." && + !identity.startsWith("local:") && + !identity.includes("/") && + !identity.includes("\\") && + !hasGraphControl(identity) + ? identity + : null; +} + +function mockInventoryPath(scopeRoot: string, workflowPath: string): string { + if (samePath(scopeRoot, workflowPath)) return "."; + const normalizedRoot = scopeRoot.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedPath = workflowPath.replace(/\\/g, "/").replace(/\/+$/, ""); + return normalizedPath.slice(normalizedRoot.length + 1); +} + +export interface MockSystemGraphProjection { + nodes: SystemGraph["nodes"]; + targets: SystemGraphNavigationResponse["targets"]; + warnings: SystemGraph["warnings"]; + degraded: boolean; +} + +/** Deterministic identity/navigation projection for the browser mock. */ +export function projectMockSystemGraphInventory( + scopeRoot: string, + workflows: readonly WorkflowInfo[], +): MockSystemGraphProjection { + const rows = workflows + .filter((workflow) => isWithinDir(scopeRoot, workflow.path)) + .map((workflow) => { + const inventoryPath = mockInventoryPath(scopeRoot, workflow.path); + const fallbackKey = `local:${inventoryPath === "." ? "root" : inventoryPath}`; + const marker = mockCanonicalIdentity(workflow.definitionSlug); + return { + workflow, + inventoryPath, + fallbackKey, + candidateKey: marker ?? fallbackKey, + }; + }) + .sort( + (left, right) => + codeUnitOrder(left.inventoryPath, right.inventoryPath) || + codeUnitOrder(left.candidateKey, right.candidateKey) || + codeUnitOrder(left.workflow.name, right.workflow.name) || + codeUnitOrder(left.workflow.path, right.workflow.path), + ) + .filter( + (row, index, all) => + all.findIndex((candidate) => + samePath(candidate.workflow.path, row.workflow.path), + ) === index, + ); + const candidateCounts = new Map(); + for (const row of rows) { + candidateCounts.set( + row.candidateKey, + (candidateCounts.get(row.candidateKey) ?? 0) + 1, + ); + } + const used = new Set(); + const projected = rows.map((row) => { + const duplicated = (candidateCounts.get(row.candidateKey) ?? 0) > 1; + const base = duplicated ? row.fallbackKey : row.candidateKey; + let agentKey = base; + let suffix = 2; + while (used.has(agentKey)) { + agentKey = `${base}~${suffix}`; + suffix += 1; + } + used.add(agentKey); + return { + agentKey, + label: row.workflow.name, + workflowPath: row.workflow.path, + }; + }); + projected.sort((left, right) => codeUnitOrder(left.agentKey, right.agentKey)); + const duplicateCandidates = [...candidateCounts] + .filter( + ([candidateKey, count]) => + count > 1 && mockCanonicalIdentity(candidateKey) !== null, + ) + .map(([candidateKey]) => candidateKey) + .sort(codeUnitOrder); + return { + nodes: projected.map(({ agentKey, label }) => ({ + id: `agent:${agentKey}`, + agentKey, + label, + })), + targets: projected.map(({ agentKey, workflowPath }) => ({ + agentKey, + workflowPath, + })), + warnings: duplicateCandidates.map((candidateKey) => ({ + code: "duplicate-agent-key", + agentKey: candidateKey, + message: `Multiple agents use ${candidateKey}; kept each with a local identity.`, + })), + degraded: duplicateCandidates.length > 0, + }; +} + +export class MockApi implements HarnessApi { // Mock auth state: flipped by startAuth() / disconnect() so D7 e2e tests // can drive the full sign-in flow deterministically without a real browser. private _authenticated = false; @@ -1328,6 +1465,13 @@ class MockApi implements HarnessApi { /** Stable for the lifetime of the mock process, mirroring server-issued * opaque keys without putting filesystem paths into graph payloads. */ private workspaceKeys = new Map(); + private systemGraphSnapshots = new Map(); + private systemGraphNavigation = new Map< + WorkspaceKey, + SystemGraphNavigationResponse + >(); + private systemGraphRevision = new Map(); + private pendingSystemGraphRevision = new Map(); async startAuth(): Promise { // Record the call for Playwright assertions (same pattern as runMacro/deploy). @@ -1423,6 +1567,30 @@ class MockApi implements HarnessApi { private set workflows(next: WorkflowInfo[]) { this.workflowsStore = next; + this.invalidateSystemGraphProjections(); + } + + private allocateSystemGraphRevision(workspaceKey: WorkspaceKey): number { + const revision = (this.systemGraphRevision.get(workspaceKey) ?? 0) + 1; + this.systemGraphRevision.set(workspaceKey, revision); + return revision; + } + + private invalidateSystemGraphProjections(): void { + for (const [workspaceKey, snapshot] of this.systemGraphSnapshots) { + const revision = this.allocateSystemGraphRevision(workspaceKey); + this.pendingSystemGraphRevision.set(workspaceKey, revision); + this.systemGraphSnapshots.delete(workspaceKey); + this.systemGraphNavigation.delete(workspaceKey); + void import("./events").then(({ publishMockBusMessage }) => { + publishMockBusMessage({ + type: "system-graph.changed", + workspaceKey, + revision, + state: snapshot.graph ? "stale" : "building", + }); + }); + } } /** A session whose cwd sat inside a moved directory follows it — on disk it @@ -1555,8 +1723,36 @@ class MockApi implements HarnessApi { async getSystemGraph( workspaceKey: WorkspaceKey, - _options: { refresh?: boolean } = {}, + options: { refresh?: boolean } = {}, ): Promise { + const graphControl = + typeof window === "undefined" + ? null + : (window as unknown as { + __HARNESS_TEST__?: Record; + __MOCK_SYSTEM_GRAPH_FAIL_ONCE__?: boolean; + __MOCK_SYSTEM_GRAPH_DEGRADED_REMAINING__?: number; + __MOCK_SYSTEM_GRAPH_STATE__?: SystemGraphSnapshot["state"]; + __MOCK_SYSTEM_GRAPH_REVISION__?: number; + }); + const cached = this.systemGraphSnapshots.get(workspaceKey); + const fixtureRequestsProjection = + cached !== undefined && + graphControl !== null && + (graphControl.__MOCK_SYSTEM_GRAPH_FAIL_ONCE__ === true || + (graphControl.__MOCK_SYSTEM_GRAPH_DEGRADED_REMAINING__ ?? 0) > 0 || + (graphControl.__MOCK_SYSTEM_GRAPH_STATE__ !== undefined && + graphControl.__MOCK_SYSTEM_GRAPH_STATE__ !== cached.state) || + (graphControl.__MOCK_SYSTEM_GRAPH_REVISION__ !== undefined && + graphControl.__MOCK_SYSTEM_GRAPH_REVISION__ !== cached.revision)); + if ( + !options.refresh && + cached && + !this.pendingSystemGraphRevision.has(workspaceKey) && + !fixtureRequestsProjection + ) { + return cached; + } const graphDelay = typeof window === "undefined" ? 180 @@ -1570,15 +1766,12 @@ class MockApi implements HarnessApi { throw new ApiError(404, "Workspace not found", "Workspace not found"); } let state: SystemGraphSnapshot["state"] = "ready"; - let revision = 1; - if (typeof window !== "undefined") { - const win = window as unknown as { - __HARNESS_TEST__?: Record; - __MOCK_SYSTEM_GRAPH_FAIL_ONCE__?: boolean; - __MOCK_SYSTEM_GRAPH_DEGRADED_REMAINING__?: number; - __MOCK_SYSTEM_GRAPH_STATE__?: SystemGraphSnapshot["state"]; - __MOCK_SYSTEM_GRAPH_REVISION__?: number; - }; + let revision = + this.pendingSystemGraphRevision.get(workspaceKey) ?? + this.allocateSystemGraphRevision(workspaceKey); + this.pendingSystemGraphRevision.delete(workspaceKey); + if (graphControl) { + const win = graphControl; const previous = (win.__HARNESS_TEST__?.systemGraphRequests as | WorkspaceKey[] @@ -1603,6 +1796,10 @@ class MockApi implements HarnessApi { } state = win.__MOCK_SYSTEM_GRAPH_STATE__ ?? state; revision = win.__MOCK_SYSTEM_GRAPH_REVISION__ ?? revision; + this.systemGraphRevision.set( + workspaceKey, + Math.max(this.systemGraphRevision.get(workspaceKey) ?? 0, revision), + ); } const fixtureGraph: SystemGraph = { kind: "system", @@ -1669,38 +1866,55 @@ class MockApi implements HarnessApi { // specs. Every other mock project is an honest inventory projection of the // agents beneath that exact root, which lets Project-axis tests prove parent // and nested projects expose the same membership as the rail. + const projection = projectMockSystemGraphInventory( + selectedScope.cwd, + this.workflows, + ); const graph = samePath(selectedScope.cwd, "/Users/demo/acme-app") ? fixtureGraph : { kind: "system" as const, scope: { kind: "working-tree" as const, workspaceKey }, - nodes: this.workflows - .filter((workflow) => isWithinDir(selectedScope.cwd, workflow.path)) - .map((workflow) => { - const normalizedRoot = selectedScope.cwd - .replace(/\\/g, "/") - .replace(/\/+$/, ""); - const normalizedPath = workflow.path - .replace(/\\/g, "/") - .replace(/\/+$/, ""); - const relative = samePath(selectedScope.cwd, workflow.path) - ? basenameOf(workflow.path) - : normalizedPath.slice(normalizedRoot.length + 1); - const agentKey = - workflow.definitionSlug?.trim() || `local:${relative}`; - return { - id: `agent:${agentKey}`, - agentKey, - label: workflow.name, - }; - }) - .sort((left, right) => left.agentKey.localeCompare(right.agentKey)), + nodes: projection.nodes, edges: samePath(selectedScope.cwd, MOCK_POLSIA_ROOT) ? MOCK_POLSIA_GRAPH_EDGES : [], - warnings: [], + warnings: projection.warnings, }; - return { workspaceKey, revision, state, graph }; + if ( + !samePath(selectedScope.cwd, "/Users/demo/acme-app") && + state === "ready" && + projection.degraded + ) { + state = "degraded"; + } + const snapshot = { workspaceKey, revision, state, graph }; + const graphKeys = new Set(graph.nodes.map((node) => node.agentKey)); + const navigation = { + workspaceKey, + revision, + targets: projection.targets.filter((target) => + graphKeys.has(target.agentKey), + ), + }; + this.systemGraphSnapshots.set(workspaceKey, snapshot); + this.systemGraphNavigation.set(workspaceKey, navigation); + return snapshot; + } + + async getSystemGraphNavigation( + workspaceKey: WorkspaceKey, + ): Promise { + const snapshot = + this.systemGraphSnapshots.get(workspaceKey) ?? + (await this.getSystemGraph(workspaceKey)); + return ( + this.systemGraphNavigation.get(workspaceKey) ?? { + workspaceKey, + revision: snapshot.revision, + targets: [], + } + ); } async createSession(req: CreateSessionRequest): Promise { @@ -2115,6 +2329,7 @@ class MockApi implements HarnessApi { ); if (samePath(from, to)) return; mockMoves.push({ from, to }); + this.invalidateSystemGraphProjections(); void import("./events").then(({ publishMockBusMessage }) => { publishMockBusMessage({ type: "workflows.changed" }); }); diff --git a/packages/harness/web/src/lib/system-graph-announcements.ts b/packages/harness/web/src/lib/system-graph-announcements.ts new file mode 100644 index 000000000..5ffde0b49 --- /dev/null +++ b/packages/harness/web/src/lib/system-graph-announcements.ts @@ -0,0 +1,48 @@ +import type { BusMessage } from "@shared/types"; +import type { + SystemGraphLifecycleState, + WorkspaceKey, +} from "@shared/system-graph"; + +export interface SystemGraphAnnouncement { + workspaceKey: WorkspaceKey; + revision: number; + state: SystemGraphLifecycleState; +} + +/** + * A lossless reducer for the generic event stream. React may batch consecutive + * WebSocket frames, so graph invalidations cannot live in a single last-event + * slot that an unrelated frame can overwrite. + */ +export function systemGraphAnnouncementsAfterMessage( + current: Map, + message: BusMessage, +): Map { + if (message.type !== "system-graph.changed") return current; + const existing = current.get(message.workspaceKey); + if (existing && existing.revision >= message.revision) { + return current; + } + const next = new Map(current); + next.set(message.workspaceKey, { + workspaceKey: message.workspaceKey, + revision: message.revision, + state: message.state, + }); + return next; +} + +export function retainSystemGraphAnnouncements( + current: Map, + workspaceKeys: ReadonlySet, +): Map { + if ( + [...current.keys()].every((workspaceKey) => workspaceKeys.has(workspaceKey)) + ) { + return current; + } + return new Map( + [...current].filter(([workspaceKey]) => workspaceKeys.has(workspaceKey)), + ); +} diff --git a/packages/harness/web/src/lib/system-graph-loader.test.ts b/packages/harness/web/src/lib/system-graph-loader.test.ts index 5bbc2307b..69a503006 100644 --- a/packages/harness/web/src/lib/system-graph-loader.test.ts +++ b/packages/harness/web/src/lib/system-graph-loader.test.ts @@ -166,6 +166,30 @@ describe("createSystemGraphLoader", () => { expect(getSystemGraph).toHaveBeenCalledTimes(2); }); + it("coalesces a Retry POST with the revision event it emits", async () => { + const pending = deferred(); + const getSystemGraph = vi + .fn() + .mockResolvedValueOnce(snapshot(1, "degraded")) + .mockReturnValueOnce(pending.promise); + const loader = createSystemGraphLoader(); + const source: SystemGraphSource = { getSystemGraph }; + await loader.load(source, workspaceKey); + + loader.invalidate(workspaceKey); + const retry = loader.load(source, workspaceKey); + await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(2)); + loader.invalidate(workspaceKey, 2); + const eventLoad = loader.load(source, workspaceKey); + + expect(eventLoad).toBe(retry); + expect(getSystemGraph).toHaveBeenCalledTimes(2); + pending.resolve(snapshot(2)); + await expect(retry).resolves.toEqual(snapshot(2)); + await expect(eventLoad).resolves.toEqual(snapshot(2)); + expect(loader.peek(workspaceKey)).toEqual(snapshot(2)); + }); + it("never lets an older in-flight response overwrite a newer revision", async () => { const oldRequest = deferred(); const newRequest = deferred(); @@ -205,6 +229,85 @@ describe("createSystemGraphLoader", () => { expect(getSystemGraph).toHaveBeenCalledTimes(1); }); + it("does not let an older explicit retry overwrite a newer retry", async () => { + const olderRetry = deferred(); + const newerRetry = deferred(); + const getSystemGraph = vi + .fn() + .mockResolvedValueOnce(snapshot(1)) + .mockReturnValueOnce(olderRetry.promise) + .mockReturnValueOnce(newerRetry.promise); + const loader = createSystemGraphLoader(); + const source: SystemGraphSource = { getSystemGraph }; + await loader.load(source, workspaceKey); + + loader.invalidate(workspaceKey); + const older = loader.load(source, workspaceKey); + await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(2)); + loader.invalidate(workspaceKey); + const newer = loader.load(source, workspaceKey); + await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(3)); + newerRetry.resolve(snapshot(3)); + await expect(newer).resolves.toEqual(snapshot(3)); + olderRetry.resolve(snapshot(2)); + + await expect(older).resolves.toEqual(snapshot(3)); + expect(loader.peek(workspaceKey)).toEqual(snapshot(3)); + }); + + it("keeps a late event reload behind a newer explicit retry", async () => { + const eventReload = deferred(); + const explicitRetry = deferred(); + const getSystemGraph = vi + .fn() + .mockResolvedValueOnce(snapshot(1)) + .mockReturnValueOnce(eventReload.promise) + .mockReturnValueOnce(explicitRetry.promise); + const loader = createSystemGraphLoader(); + const source: SystemGraphSource = { getSystemGraph }; + await loader.load(source, workspaceKey); + + loader.invalidate(workspaceKey, 2); + const announced = loader.load(source, workspaceKey); + await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(2)); + loader.invalidate(workspaceKey); + const retried = loader.load(source, workspaceKey); + await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(3)); + explicitRetry.resolve(snapshot(3)); + await expect(retried).resolves.toEqual(snapshot(3)); + eventReload.resolve(snapshot(2)); + + await expect(announced).resolves.toEqual(snapshot(3)); + expect(loader.peek(workspaceKey)).toEqual(snapshot(3)); + }); + + it("does not let an announced response consume an unclaimed explicit retry", async () => { + const announcedResponse = deferred(); + const explicitResponse = deferred(); + const getSystemGraph = vi + .fn() + .mockResolvedValueOnce(snapshot(1)) + .mockReturnValueOnce(announcedResponse.promise) + .mockReturnValueOnce(explicitResponse.promise); + const loader = createSystemGraphLoader(); + const source: SystemGraphSource = { getSystemGraph }; + await loader.load(source, workspaceKey); + + loader.invalidate(workspaceKey, 2); + const announced = loader.load(source, workspaceKey); + await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(2)); + loader.invalidate(workspaceKey); + announcedResponse.resolve(snapshot(2)); + await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(3)); + expect(getSystemGraph).toHaveBeenNthCalledWith(3, workspaceKey, { + refresh: true, + }); + explicitResponse.resolve(snapshot(3)); + + await expect(announced).resolves.toEqual(snapshot(3)); + expect(loader.peek(workspaceKey)).toEqual(snapshot(3)); + }); + it("ignores old announcements and invalidates only their workspace", async () => { const otherKey = "workspace-other"; let otherRevision = 3; @@ -232,7 +335,7 @@ describe("createSystemGraphLoader", () => { expect(getSystemGraph).toHaveBeenCalledTimes(3); }); - it("retires removed workspace snapshots and rejects their late responses", async () => { + it("retires removed workspace snapshots and does not retain late responses", async () => { const late = deferred(); const ready = snapshot(2); const getSystemGraph = vi diff --git a/packages/harness/web/src/lib/system-graph-loader.ts b/packages/harness/web/src/lib/system-graph-loader.ts index c3c3df50e..d613463ad 100644 --- a/packages/harness/web/src/lib/system-graph-loader.ts +++ b/packages/harness/web/src/lib/system-graph-loader.ts @@ -36,13 +36,15 @@ export function createSystemGraphLoader(): SystemGraphLoader { { lifetime: WorkspaceLifetime; generation: number; + explicitRefresh: boolean; + inFlight: boolean; promise: Promise; } >(); const snapshots = new Map(); const lifetimes = new Map(); const announcedRevisions = new Map(); - const forcedReloads = new Set(); + const forcedReloadGenerations = new Map(); const retryableSeen = new Set(); const retryConsumed = new Set(); @@ -75,15 +77,21 @@ export function createSystemGraphLoader(): SystemGraphLoader { if ( cached && cached.revision >= announcedRevision && - !forcedReloads.has(workspaceKey) && + !forcedReloadGenerations.has(workspaceKey) && !shouldRetry ) { const promise = Promise.resolve(cached); - requests.set(workspaceKey, { lifetime, generation, promise }); + requests.set(workspaceKey, { + lifetime, + generation, + explicitRefresh: false, + inFlight: false, + promise, + }); return promise; } if (shouldRetry) retryConsumed.add(workspaceKey); - const explicitRefresh = forcedReloads.has(workspaceKey); + const explicitRefresh = forcedReloadGenerations.has(workspaceKey); let request!: Promise; request = Promise.resolve() @@ -93,6 +101,10 @@ export function createSystemGraphLoader(): SystemGraphLoader { : source.getSystemGraph(workspaceKey), ) .then((snapshot) => { + const settledRequest = requests.get(workspaceKey); + if (settledRequest?.promise === request) { + settledRequest.inFlight = false; + } if (snapshot.workspaceKey !== workspaceKey) { throw new Error("Invalid system graph response"); } @@ -101,12 +113,27 @@ export function createSystemGraphLoader(): SystemGraphLoader { // may finish, but the response cannot repopulate browser state. return snapshot; } + const current = snapshots.get(workspaceKey); + if (current && snapshot.revision < current.revision) { + return current; + } const newestAnnouncement = announcedRevisions.get(workspaceKey) ?? -1; + const currentRequest = requests.get(workspaceKey); + const superseded = lifetime.generation !== generation; + const latestForcedGeneration = + forcedReloadGenerations.get(workspaceKey); + const coversOutstandingForcedReload = + latestForcedGeneration === undefined || + (explicitRefresh && generation >= latestForcedGeneration); + const satisfiesUnclaimedAnnouncement = + superseded && + currentRequest === undefined && + coversOutstandingForcedReload && + newestAnnouncement >= 0 && + snapshot.revision >= newestAnnouncement; if ( snapshot.revision < newestAnnouncement || - (lifetime.generation !== generation && - forcedReloads.has(workspaceKey) && - !explicitRefresh) + (superseded && !satisfiesUnclaimedAnnouncement) ) { if (requests.get(workspaceKey)?.promise === request) { requests.delete(workspaceKey); @@ -115,11 +142,8 @@ export function createSystemGraphLoader(): SystemGraphLoader { } snapshots.set(workspaceKey, snapshot); - forcedReloads.delete(workspaceKey); - if ( - snapshot.state !== "ready" && - !retryableSeen.has(workspaceKey) - ) { + forcedReloadGenerations.delete(workspaceKey); + if (snapshot.state !== "ready" && !retryableSeen.has(workspaceKey)) { retryableSeen.add(workspaceKey); // A later open gets one recovery attempt. Keep the snapshot itself // so the current view can continue showing loading, partial, or @@ -133,7 +157,13 @@ export function createSystemGraphLoader(): SystemGraphLoader { } return snapshot; }); - requests.set(workspaceKey, { lifetime, generation, promise: request }); + requests.set(workspaceKey, { + lifetime, + generation, + explicitRefresh, + inFlight: true, + promise: request, + }); void request.catch(() => { if (requests.get(workspaceKey)?.promise === request) { requests.delete(workspaceKey); @@ -150,10 +180,29 @@ export function createSystemGraphLoader(): SystemGraphLoader { announcedRevisions.get(workspaceKey) ?? -1, ); if (revision !== undefined && revision <= knownRevision) return false; - if (revision !== undefined) + const lifetime = lifetimeFor(workspaceKey); + const active = requests.get(workspaceKey); + const forcedGeneration = forcedReloadGenerations.get(workspaceKey); + const adoptsActiveExplicitRequest = + revision !== undefined && + forcedGeneration !== undefined && + active?.lifetime === lifetime && + active.generation === lifetime.generation && + active.generation >= forcedGeneration && + active.explicitRefresh && + active.inFlight; + if (adoptsActiveExplicitRequest) { announcedRevisions.set(workspaceKey, revision); - else forcedReloads.add(workspaceKey); - lifetimeFor(workspaceKey).generation += 1; + retryableSeen.delete(workspaceKey); + retryConsumed.delete(workspaceKey); + return true; + } + lifetime.generation += 1; + if (revision !== undefined) { + announcedRevisions.set(workspaceKey, revision); + } else { + forcedReloadGenerations.set(workspaceKey, lifetime.generation); + } requests.delete(workspaceKey); retryableSeen.delete(workspaceKey); retryConsumed.delete(workspaceKey); @@ -165,7 +214,7 @@ export function createSystemGraphLoader(): SystemGraphLoader { ...snapshots.keys(), ...lifetimes.keys(), ...announcedRevisions.keys(), - ...forcedReloads, + ...forcedReloadGenerations.keys(), ...retryableSeen, ...retryConsumed, ]); @@ -177,7 +226,7 @@ export function createSystemGraphLoader(): SystemGraphLoader { requests.delete(workspaceKey); snapshots.delete(workspaceKey); announcedRevisions.delete(workspaceKey); - forcedReloads.delete(workspaceKey); + forcedReloadGenerations.delete(workspaceKey); retryableSeen.delete(workspaceKey); retryConsumed.delete(workspaceKey); } diff --git a/packages/harness/web/src/lib/system-graph-navigation.test.ts b/packages/harness/web/src/lib/system-graph-navigation.test.ts index f9a19b88d..2519a03ea 100644 --- a/packages/harness/web/src/lib/system-graph-navigation.test.ts +++ b/packages/harness/web/src/lib/system-graph-navigation.test.ts @@ -1,156 +1,217 @@ -import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { - SystemGraphNode, - WorkspaceScopeSummary, + SystemGraphNavigationResponse, + SystemGraphSnapshot, } from "@shared/system-graph"; -import { workspaceRelativeLocalKey } from "@shared/system-graph"; -import type { WorkflowInfo } from "@shared/types"; - -import { HarnessRegistryInventoryProvider } from "../../../src/core/system-graph-inventory"; - -import { mapSystemGraphNavigation } from "./system-graph-navigation"; - -const workflow = ( - name: string, - path: string, - definitionSlug: string | null, -): WorkflowInfo => ({ - name, - path, - definitionId: definitionSlug ? 1 : null, - definitionSlug, - source: "scan", -}); - -const graphNode = (agentKey: string, label = agentKey): SystemGraphNode => ({ - id: `agent:${agentKey}`, - agentKey, - label, -}); -const scopes: WorkspaceScopeSummary[] = [ - { workspaceKey: "workspace-root", cwd: "/repo" }, - { workspaceKey: "workspace-nested", cwd: "/repo/nested" }, -]; - -describe("workspaceRelativeLocalKey", () => { - it("handles scope roots, outside paths, Windows paths, and UNC paths", () => { - expect(workspaceRelativeLocalKey("/repo/agent", "/repo/agent")).toBe( - "local:agent", +import { + resolveSystemGraphNavigationForRevision, + systemGraphNavigationForSnapshot, +} from "./system-graph-navigation"; + +const snapshot: SystemGraphSnapshot = { + workspaceKey: "workspace-root", + revision: 7, + state: "ready", + graph: { + kind: "system", + scope: { kind: "working-tree", workspaceKey: "workspace-root" }, + nodes: [ + { id: "agent:canonical", agentKey: "canonical", label: "Canonical" }, + { + id: "agent:local:pending", + agentKey: "local:pending", + label: "Pending", + }, + ], + edges: [], + warnings: [], + }, +}; + +function response( + overrides: Partial = {}, +): SystemGraphNavigationResponse { + return { + workspaceKey: snapshot.workspaceKey, + revision: snapshot.revision, + targets: [ + { agentKey: "canonical", workflowPath: "/repo/canonical" }, + { agentKey: "local:pending", workflowPath: "/repo/pending" }, + ], + ...overrides, + }; +} + +describe("systemGraphNavigationForSnapshot", () => { + it("maps canonical and provisional server-owned targets", () => { + expect([...systemGraphNavigationForSnapshot(response(), snapshot)]).toEqual( + [ + ["canonical", "/repo/canonical"], + ["local:pending", "/repo/pending"], + ], ); - expect(workspaceRelativeLocalKey("/", "/")).toBe("local:root"); - expect(workspaceRelativeLocalKey("/repo", "/other/agent")).toBeNull(); + }); + + it("fails closed for a different workspace or revision", () => { expect( - workspaceRelativeLocalKey("C:\\Repo", "c:\\repo\\Tools\\Reporting"), - ).toBe("local:Tools/Reporting"); + systemGraphNavigationForSnapshot( + response({ workspaceKey: "workspace-other" }), + snapshot, + ).size, + ).toBe(0); expect( - workspaceRelativeLocalKey("C:\\Repo", "D:\\Repo\\Reporting"), - ).toBeNull(); + systemGraphNavigationForSnapshot(response({ revision: 8 }), snapshot) + .size, + ).toBe(0); + }); + + it("does not accept a resolver target absent from graph JSON", () => { expect( - workspaceRelativeLocalKey( - "\\\\Server\\Share\\Repo", - "\\\\server\\share\\repo\\Reporting", + systemGraphNavigationForSnapshot( + response({ + targets: [ + { agentKey: "canonical", workflowPath: "/repo/canonical" }, + { agentKey: "ghost", workflowPath: "/private/ghost" }, + ], + }), + snapshot, ), - ).toBe("local:Reporting"); + ).toEqual(new Map([["canonical", "/repo/canonical"]])); }); }); -describe("mapSystemGraphNavigation", () => { - it("maps authoritative definition slugs and workspace-relative local keys", () => { - const deployed = workflow("Research", "/repo/research", "research-agent"); - const local = workflow("Reporting", "/repo/tools/reporting", null); - const navigation = mapSystemGraphNavigation( - [graphNode("research-agent"), graphNode("local:tools/reporting")], - "workspace-root", - [deployed, local], - scopes, - ); +describe("resolveSystemGraphNavigationForRevision", () => { + it("retries a resolver that lost a commit race and accepts the matching revision", async () => { + const stale = response({ revision: snapshot.revision - 1 }); + const matching = response(); + const getSystemGraphNavigation = vi + .fn() + .mockResolvedValueOnce(stale) + .mockResolvedValueOnce(matching); + + await expect( + resolveSystemGraphNavigationForRevision( + { getSystemGraphNavigation }, + snapshot.workspaceKey, + snapshot.revision, + ), + ).resolves.toEqual({ kind: "matched", response: matching }); + expect(getSystemGraphNavigation).toHaveBeenCalledTimes(2); + }); - expect(navigation.get("research-agent")).toBe(deployed); - expect(navigation.get("local:tools/reporting")).toBe(local); + it("waits between attempts so a behind resolver can catch its commit up", async () => { + // Without the pause the three attempts re-read one pre-commit snapshot as + // fast as the network answers, and the retry never gives the commit it is + // waiting for a chance to land. + const waits: number[] = []; + const stale = response({ revision: snapshot.revision - 1 }); + const matching = response(); + const getSystemGraphNavigation = vi + .fn() + .mockResolvedValueOnce(stale) + .mockResolvedValueOnce(stale) + .mockResolvedValueOnce(matching); + + await expect( + resolveSystemGraphNavigationForRevision( + { getSystemGraphNavigation }, + snapshot.workspaceKey, + snapshot.revision, + undefined, + async (attempt) => { + waits.push(attempt); + }, + ), + ).resolves.toEqual({ kind: "matched", response: matching }); + expect(getSystemGraphNavigation).toHaveBeenCalledTimes(3); + expect(waits).toEqual([0, 1]); }); - it("does not resolve by a display label and includes nested project agents", () => { - const matchingLabel = workflow("Growth", "/repo/growth", null); - const nested = workflow("Nested", "/repo/nested/agent", "nested-agent"); - const navigation = mapSystemGraphNavigation( - [graphNode("manifest-name", "Growth"), graphNode("nested-agent")], - "workspace-root", - [matchingLabel, nested], - scopes, + it("tells the view to advance when the resolver has the newer committed revision", async () => { + const getSystemGraphNavigation = vi.fn(async () => + response({ revision: snapshot.revision + 1 }), ); - expect(navigation.has("manifest-name")).toBe(false); - expect(navigation.get("nested-agent")).toBe(nested); + await expect( + resolveSystemGraphNavigationForRevision( + { getSystemGraphNavigation }, + snapshot.workspaceKey, + snapshot.revision, + ), + ).resolves.toEqual({ + kind: "graph-behind", + revision: snapshot.revision + 1, + }); }); - it("leaves duplicate slugs inert while retaining unambiguous local identities", () => { - const first = workflow("First", "/repo/first", "shared"); - const second = workflow("Second", "/repo/second", "shared"); - const navigation = mapSystemGraphNavigation( - [ - graphNode("shared"), - graphNode("local:first"), - graphNode("local:second"), - ], - "workspace-root", - [first, second], - scopes, + it("fails closed for foreign, repeatedly stale, and rejected responses", async () => { + const foreign = vi.fn(async () => + response({ workspaceKey: "workspace-other" }), ); + await expect( + resolveSystemGraphNavigationForRevision( + { getSystemGraphNavigation: foreign }, + snapshot.workspaceKey, + snapshot.revision, + ), + ).resolves.toEqual({ kind: "unavailable" }); - expect(navigation.has("shared")).toBe(false); - expect(navigation.get("local:first")).toBe(first); - expect(navigation.get("local:second")).toBe(second); - }); - - it("does not make one workflow ambiguous when two exact identities coincide", () => { - const exact = workflow("Exact", "/repo/exact", "local:exact"); - const navigation = mapSystemGraphNavigation( - [graphNode("local:exact")], - "workspace-root", - [exact], - scopes, + const stale = vi.fn(async () => + response({ revision: snapshot.revision - 1 }), ); + await expect( + resolveSystemGraphNavigationForRevision( + { getSystemGraphNavigation: stale }, + snapshot.workspaceKey, + snapshot.revision, + ), + ).resolves.toEqual({ kind: "unavailable" }); + expect(stale).toHaveBeenCalledTimes(3); + + const rejected = vi.fn(async () => { + throw new Error("resolver unavailable"); + }); + await expect( + resolveSystemGraphNavigationForRevision( + { getSystemGraphNavigation: rejected }, + snapshot.workspaceKey, + snapshot.revision, + ), + ).resolves.toEqual({ kind: "unavailable" }); + expect(rejected).toHaveBeenCalledTimes(3); + }); - expect(navigation.get("local:exact")).toBe(exact); + it("recovers from a transient resolver rejection within the bounded loop", async () => { + const matching = response(); + const getSystemGraphNavigation = vi + .fn() + .mockRejectedValueOnce(new Error("temporary")) + .mockResolvedValueOnce(matching); + + await expect( + resolveSystemGraphNavigationForRevision( + { getSystemGraphNavigation }, + snapshot.workspaceKey, + snapshot.revision, + ), + ).resolves.toEqual({ kind: "matched", response: matching }); + expect(getSystemGraphNavigation).toHaveBeenCalledTimes(2); }); - it("uses the same local identity as server inventory for a symlinked project", async () => { - const root = await mkdtemp(path.join(tmpdir(), "system-graph-navigation-")); - try { - const source = path.join(root, "packages", "reporting"); - const linked = path.join(root, "reporting"); - await mkdir(source, { recursive: true }); - await symlink( - source, - linked, - process.platform === "win32" ? "junction" : "dir", - ); - const local = workflow("Reporting", linked, null); - const workspaceKey = "workspace-symlink"; - const workspaceScopes = [{ workspaceKey, cwd: root }]; - const inventory = new HarnessRegistryInventoryProvider({ - listWorkflows: () => [local], - }); - - const result = await inventory.listAgents({ workspaceKey, root }); - expect(result.agents).toHaveLength(1); - const agentKey = result.agents[0]!.agentKey; - const navigation = mapSystemGraphNavigation( - [graphNode(agentKey)], - workspaceKey, - [local], - workspaceScopes, - ); - - expect(agentKey).toBe("local:reporting"); - expect(navigation.get(agentKey)).toBe(local); - } finally { - await rm(root, { recursive: true, force: true }); - } + it("stops resolver retries when aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const getSystemGraphNavigation = vi.fn(async () => response()); + + await expect( + resolveSystemGraphNavigationForRevision( + { getSystemGraphNavigation }, + snapshot.workspaceKey, + snapshot.revision, + controller.signal, + ), + ).resolves.toEqual({ kind: "unavailable" }); + expect(getSystemGraphNavigation).not.toHaveBeenCalled(); }); }); diff --git a/packages/harness/web/src/lib/system-graph-navigation.ts b/packages/harness/web/src/lib/system-graph-navigation.ts index 8e975aabc..8c3983b35 100644 --- a/packages/harness/web/src/lib/system-graph-navigation.ts +++ b/packages/harness/web/src/lib/system-graph-navigation.ts @@ -1,45 +1,88 @@ -import { - workspaceRelativeLocalKey, - type AgentKey, - type SystemGraphNode, - type WorkspaceKey, - type WorkspaceScopeSummary, +import type { + AgentKey, + SystemGraphNavigationResponse, + SystemGraphSnapshot, + WorkspaceKey, } from "@shared/system-graph"; -import type { WorkflowInfo } from "@shared/types"; + +interface SystemGraphNavigationSource { + getSystemGraphNavigation( + workspaceKey: WorkspaceKey, + ): Promise; +} + +export type SystemGraphNavigationResolution = + | { kind: "matched"; response: SystemGraphNavigationResponse } + | { kind: "graph-behind"; revision: number } + | { kind: "unavailable" }; + +/** + * Give the commit the resolver is behind a moment to land. Retrying in the + * same breath just re-reads the value that lost the race, so the bounded loop + * would spend all three attempts on one pre-commit snapshot. + */ +function backOffBeforeRetry(attempt: number): Promise { + return new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt)); +} /** - * Navigation is deliberately narrower than graph projection. The public graph - * omits source paths, so Studio may open a card only when its public AgentKey - * matches registry evidence already in memory. Display labels never resolve. + * Resolve the path-bearing sidecar against the graph revision currently on + * screen. An older response may have straddled a graph commit, so retry it; + * a newer one asks the caller to advance the graph. Foreign, failed, and + * repeatedly stale responses all fail closed. */ -export function mapSystemGraphNavigation( - nodes: readonly SystemGraphNode[], +export async function resolveSystemGraphNavigationForRevision( + source: SystemGraphNavigationSource, workspaceKey: WorkspaceKey, - workflows: readonly WorkflowInfo[], - scopes: readonly WorkspaceScopeSummary[], -): ReadonlyMap { - const selected = scopes.find((scope) => scope.workspaceKey === workspaceKey); - if (!selected) return new Map(); - const graphKeys = new Set(nodes.map((node) => node.agentKey)); - const candidates = new Map>(); - const register = (key: AgentKey | null, workflow: WorkflowInfo): void => { - if (!key || !graphKeys.has(key)) return; - const matches = candidates.get(key) ?? new Set(); - matches.add(workflow); - candidates.set(key, matches); - }; - for (const workflow of workflows) { - // Match the same selected-root containment used by the Project rail and - // backend inventory. A nested agent is navigable from both its own project - // graph and any containing parent project graph. - const localKey = workspaceRelativeLocalKey(selected.cwd, workflow.path); - if (localKey === null) continue; - register(workflow.definitionSlug?.trim() || null, workflow); - register(localKey, workflow); + revision: number, + signal?: AbortSignal, + waitBeforeRetry: (attempt: number) => Promise = backOffBeforeRetry, +): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + if (signal?.aborted) return { kind: "unavailable" }; + if (attempt > 0) { + await waitBeforeRetry(attempt - 1); + if (signal?.aborted) return { kind: "unavailable" }; + } + try { + const response = await source.getSystemGraphNavigation(workspaceKey); + if (signal?.aborted) return { kind: "unavailable" }; + if (response.workspaceKey !== workspaceKey) + return { kind: "unavailable" }; + if (response.revision === revision) return { kind: "matched", response }; + if (response.revision > revision) { + return { kind: "graph-behind", revision: response.revision }; + } + } catch { + if (signal?.aborted) return { kind: "unavailable" }; + // Resolver paths are read-only and cheap. Retry a transient failure + // within the same bounded race loop before failing closed. + } + } + return { kind: "unavailable" }; +} + +/** + * Accept resolver paths only for the exact graph revision on screen. The + * server owns identity resolution; this helper merely joins two revisioned + * responses and refuses stale, foreign, or non-node targets. + */ +export function systemGraphNavigationForSnapshot( + response: SystemGraphNavigationResponse | null, + snapshot: SystemGraphSnapshot | null, +): ReadonlyMap { + if ( + !response || + !snapshot?.graph || + response.workspaceKey !== snapshot.workspaceKey || + response.revision !== snapshot.revision + ) { + return new Map(); } + const graphKeys = new Set(snapshot.graph.nodes.map((node) => node.agentKey)); return new Map( - [...candidates.entries()] - .filter(([, matches]) => matches.size === 1) - .map(([key, matches]) => [key, [...matches][0]!] as const), + response.targets + .filter((target) => graphKeys.has(target.agentKey)) + .map((target) => [target.agentKey, target.workflowPath] as const), ); } diff --git a/packages/harness/web/src/lib/system-graph.test.ts b/packages/harness/web/src/lib/system-graph.test.ts index b4f65f692..c4a14d92e 100644 --- a/packages/harness/web/src/lib/system-graph.test.ts +++ b/packages/harness/web/src/lib/system-graph.test.ts @@ -3,6 +3,7 @@ import type { SystemGraph } from "@shared/system-graph"; import { groupSystemGraphEdges, + parseSystemGraphNavigation, parseSystemGraph, parseSystemGraphSnapshot, } from "./system-graph"; @@ -31,6 +32,22 @@ describe("parseSystemGraph", () => { expect(parseSystemGraph(valid)).toEqual(valid); }); + it("accepts scoped package display labels", () => { + const graph = { + ...valid, + nodes: [ + { + id: "agent:growth", + agentKey: "growth", + label: "@sapiom/example-slack-notifier", + }, + valid.nodes[1], + ], + }; + + expect(parseSystemGraph(graph)).toEqual(graph); + }); + it("accepts blocking edges and dynamic-target warnings", () => { const graph = { ...valid, @@ -61,7 +78,20 @@ describe("parseSystemGraph", () => { }, ]; - expect(parseSystemGraph({ ...valid, warnings }).warnings).toEqual(warnings); + expect( + parseSystemGraph({ + ...valid, + nodes: [ + ...valid.nodes, + { + id: "agent:local:reporting", + agentKey: "local:reporting", + label: "Reporting", + }, + ], + warnings, + }).warnings, + ).toEqual(warnings); }); it("rejects an edge whose endpoint is absent", () => { @@ -97,6 +127,109 @@ describe("parseSystemGraph", () => { }), ).toThrow("Invalid system graph response"); }); + + it("rejects divergent, duplicate, and unsafe node identities", () => { + for (const nodes of [ + [{ id: "agent:other", agentKey: "growth", label: "Growth" }], + [ + { id: "agent:growth", agentKey: "growth", label: "Growth" }, + { id: "agent:other", agentKey: "growth", label: "Other" }, + ], + [ + { + id: "agent:local:../private", + agentKey: "local:../private", + label: "Private", + }, + ], + [ + { + id: "agent:local:C:/private", + agentKey: "local:C:/private", + label: "Private", + }, + ], + ]) { + expect(() => parseSystemGraph({ ...valid, nodes, edges: [] })).toThrow( + "Invalid system graph response", + ); + } + }); + + it("rejects path-bearing/control display data and duplicate edges", () => { + for (const label of [ + "/private/agent", + "private/agent", + "C:/private/agent", + "\\\\server\\share", + "private\\agent", + "agent\u0085name", + ]) { + expect(() => + parseSystemGraph({ + ...valid, + nodes: [{ id: "agent:growth", agentKey: "growth", label }], + edges: [], + }), + ).toThrow("Invalid system graph response"); + } + for (const message of [ + "Failed at /private/agent", + "Failed at C:\\private\\agent", + "failed:/private/agent", + "failed[/private/agent]", + "file:///private/agent", + "Failed\u009f", + ]) { + expect(() => + parseSystemGraph({ + ...valid, + warnings: [ + { code: "projection-failed", agentKey: "growth", message }, + ], + }), + ).toThrow("Invalid system graph response"); + } + expect(() => + parseSystemGraph({ ...valid, edges: [valid.edges[0], valid.edges[0]] }), + ).toThrow("Invalid system graph response"); + + const ratioWarning = { + code: "projection-failed" as const, + agentKey: "growth", + message: "Success/failure ratio was 3/4.", + }; + expect( + parseSystemGraph({ ...valid, warnings: [ratioWarning] }).warnings, + ).toEqual([ratioWarning]); + }); + + it("rejects warning identities without valid provenance", () => { + expect(() => + parseSystemGraph({ + ...valid, + warnings: [ + { + code: "projection-failed", + agentKey: "ghost", + message: "Could not inspect Ghost.", + }, + ], + }), + ).toThrow("Invalid system graph response"); + expect(() => + parseSystemGraph({ + ...valid, + warnings: [ + { + code: "duplicate-agent-key", + agentKey: "local:shared", + message: "Multiple agents use shared.", + }, + ], + }), + ).toThrow("Invalid system graph response"); + }); }); describe("parseSystemGraphSnapshot", () => { @@ -168,6 +301,103 @@ describe("parseSystemGraphSnapshot", () => { graph: valid, }), ).toThrow("Invalid system graph response"); + for (const unsafeWorkspaceKey of [ + "", + " workspace-test", + "workspace\u0085test", + ]) { + expect(() => + parseSystemGraphSnapshot({ + workspaceKey: unsafeWorkspaceKey, + revision: 1, + state: "building", + graph: null, + }), + ).toThrow("Invalid system graph response"); + expect(() => + parseSystemGraph({ + ...valid, + scope: { + kind: "working-tree", + workspaceKey: unsafeWorkspaceKey, + }, + }), + ).toThrow("Invalid system graph response"); + } + }); +}); + +describe("parseSystemGraphNavigation", () => { + const navigation = { + workspaceKey: "workspace-test", + revision: 7, + targets: [ + { agentKey: "research", workflowPath: "/repo/research" }, + { + agentKey: "local:tools/reporting", + workflowPath: "C:\\repo\\tools\\reporting", + }, + ], + }; + + it("accepts a strict resolver response for the expected graph revision", () => { + expect( + parseSystemGraphNavigation(navigation, { + workspaceKey: "workspace-test", + revision: 7, + }), + ).toEqual(navigation); + }); + + it("rejects duplicate keys, malformed targets, and unknown fields", () => { + expect(() => + parseSystemGraphNavigation({ + ...navigation, + targets: [navigation.targets[0], navigation.targets[0]], + }), + ).toThrow("Invalid system graph navigation response"); + for (const target of [ + { agentKey: "", workflowPath: "/repo/research" }, + { agentKey: "research\u0085", workflowPath: "/repo/research" }, + { agentKey: "private/research", workflowPath: "/repo/research" }, + { agentKey: "local:../research", workflowPath: "/repo/research" }, + { agentKey: "local:C:/research", workflowPath: "/repo/research" }, + { agentKey: "research", workflowPath: "relative/research" }, + { agentKey: "research", workflowPath: "/repo/research", alias: "old" }, + ]) { + expect(() => + parseSystemGraphNavigation({ ...navigation, targets: [target] }), + ).toThrow("Invalid system graph navigation response"); + } + expect(() => + parseSystemGraphNavigation({ ...navigation, root: "/repo" }), + ).toThrow("Invalid system graph navigation response"); + expect(() => + parseSystemGraphNavigation({ + ...navigation, + workspaceKey: " workspace-test", + }), + ).toThrow("Invalid system graph navigation response"); + expect(() => + parseSystemGraphNavigation({ + ...navigation, + workspaceKey: "workspace\u009ftest", + }), + ).toThrow("Invalid system graph navigation response"); + }); + + it("rejects a resolver for another workspace or displayed revision", () => { + expect(() => + parseSystemGraphNavigation(navigation, { + workspaceKey: "workspace-other", + }), + ).toThrow("Mismatched system graph navigation response"); + expect(() => + parseSystemGraphNavigation(navigation, { + workspaceKey: "workspace-test", + revision: 8, + }), + ).toThrow("Mismatched system graph navigation response"); }); }); diff --git a/packages/harness/web/src/lib/system-graph.ts b/packages/harness/web/src/lib/system-graph.ts index 8f6ba0018..3b5280a63 100644 --- a/packages/harness/web/src/lib/system-graph.ts +++ b/packages/harness/web/src/lib/system-graph.ts @@ -3,6 +3,8 @@ import type { SystemGraph, SystemGraphEdge, SystemGraphLifecycleState, + SystemGraphNavigationResponse, + SystemGraphNavigationTarget, SystemGraphNode, SystemGraphSnapshot, } from "@shared/system-graph"; @@ -19,13 +21,93 @@ function hasOnlyKeys( return Object.keys(value).every((key) => allowed.has(key)); } +function hasControlCharacter(text: string): boolean { + return [...text].some((character) => { + const code = character.codePointAt(0)!; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + }); +} + +function safeWorkspaceKey(value: unknown): value is string { + return ( + typeof value === "string" && + value.trim() !== "" && + value === value.trim() && + !hasControlCharacter(value) + ); +} + +function safeCanonicalAgentKey(value: string): boolean { + return ( + value !== "" && + value === value.trim() && + value !== "." && + value !== ".." && + !value.startsWith("local:") && + !hasControlCharacter(value) && + !value.includes("/") && + !value.includes("\\") + ); +} + +function safeAgentKey(value: string): boolean { + if (safeCanonicalAgentKey(value)) return true; + if ( + !value.startsWith("local:") || + value !== value.trim() || + hasControlCharacter(value) || + value.includes("\\") + ) { + return false; + } + const relative = value.slice("local:".length); + return ( + relative !== "" && + !/^[A-Za-z]:(?:$|\/)/.test(relative) && + relative + .split("/") + .every((segment) => segment !== "" && segment !== "." && segment !== "..") + ); +} + +function containsPrivatePathShape(value: string): boolean { + return ( + /[A-Za-z]:[\\/]/.test(value) || + /(?:^|[^A-Za-z0-9@._~-])[/\\]{2}[^\s/\\]/.test(value) || + /(?:^|[^A-Za-z0-9@._~-])\/[^\s/]/.test(value) + ); +} + +function isScopedPackageLabel(value: string): boolean { + return /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/.test(value); +} + +function safeNodeLabel(value: string): boolean { + if ( + value.trim() === "" || + value !== value.trim() || + hasControlCharacter(value) + ) { + return false; + } + if (isScopedPackageLabel(value)) return true; + return ( + !value.includes("/") && + !value.includes("\\") && + !containsPrivatePathShape(value) + ); +} + function parseNode(value: unknown): SystemGraphNode | null { if (!isRecord(value) || !hasOnlyKeys(value, ["id", "agentKey", "label"])) return null; if ( typeof value.id !== "string" || typeof value.agentKey !== "string" || - typeof value.label !== "string" + !safeAgentKey(value.agentKey) || + value.id !== `agent:${value.agentKey}` || + typeof value.label !== "string" || + !safeNodeLabel(value.label) ) { return null; } @@ -72,7 +154,12 @@ function parseWarning(value: unknown): GraphWarning | null { typeof value.code !== "string" || !WARNING_CODES.has(value.code as GraphWarning["code"]) || typeof value.message !== "string" || - (value.agentKey !== undefined && typeof value.agentKey !== "string") + value.message.trim() === "" || + value.message !== value.message.trim() || + hasControlCharacter(value.message) || + containsPrivatePathShape(value.message) || + (value.agentKey !== undefined && + (typeof value.agentKey !== "string" || !safeAgentKey(value.agentKey))) ) { return null; } @@ -146,7 +233,7 @@ export function parseSystemGraph(value: unknown): SystemGraph { } if ( value.scope.kind !== "working-tree" || - typeof value.scope.workspaceKey !== "string" + !safeWorkspaceKey(value.scope.workspaceKey) ) { throw new Error("Invalid system graph response"); } @@ -173,9 +260,24 @@ export function parseSystemGraph(value: unknown): SystemGraph { const typedEdges = edges as SystemGraphEdge[]; const typedWarnings = warnings as GraphWarning[]; const nodeIds = new Set(typedNodes.map((node) => node.id)); + const agentKeys = new Set(typedNodes.map((node) => node.agentKey)); + const edgeKeys = new Set( + typedEdges.map((edge) => `${edge.from}\0${edge.to}\0${edge.mode}`), + ); if ( nodeIds.size !== typedNodes.length || - typedEdges.some((edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to)) + agentKeys.size !== typedNodes.length || + edgeKeys.size !== typedEdges.length || + typedEdges.some( + (edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to), + ) || + typedWarnings.some( + (warning) => + warning.agentKey !== undefined && + (warning.code === "duplicate-agent-key" + ? !safeCanonicalAgentKey(warning.agentKey) + : !agentKeys.has(warning.agentKey)), + ) ) { throw new Error("Invalid system graph response"); } @@ -200,7 +302,7 @@ export function parseSystemGraphSnapshot(value: unknown): SystemGraphSnapshot { if ( !isRecord(value) || !hasOnlyKeys(value, ["workspaceKey", "revision", "state", "graph"]) || - typeof value.workspaceKey !== "string" || + !safeWorkspaceKey(value.workspaceKey) || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || typeof value.state !== "string" || @@ -229,3 +331,71 @@ export function parseSystemGraphSnapshot(value: unknown): SystemGraphSnapshot { graph, }; } + +function isAbsoluteWorkflowPath(value: string): boolean { + const normalized = value.replace(/\\/g, "/"); + return ( + normalized.startsWith("/") || + /^[A-Za-z]:\//.test(normalized) || + normalized.startsWith("//") + ); +} + +function parseNavigationTarget( + value: unknown, +): SystemGraphNavigationTarget | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["agentKey", "workflowPath"]) || + typeof value.agentKey !== "string" || + !safeAgentKey(value.agentKey) || + typeof value.workflowPath !== "string" || + value.workflowPath.trim() === "" || + value.workflowPath !== value.workflowPath.trim() || + !isAbsoluteWorkflowPath(value.workflowPath) || + hasControlCharacter(value.workflowPath) + ) { + return null; + } + return { agentKey: value.agentKey, workflowPath: value.workflowPath }; +} + +/** Strict parser for the protected, path-bearing resolver response. */ +export function parseSystemGraphNavigation( + value: unknown, + expected?: { workspaceKey: string; revision?: number }, +): SystemGraphNavigationResponse { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["workspaceKey", "revision", "targets"]) || + !safeWorkspaceKey(value.workspaceKey) || + !Number.isSafeInteger(value.revision) || + (value.revision as number) < 0 || + !Array.isArray(value.targets) + ) { + throw new Error("Invalid system graph navigation response"); + } + const targets = value.targets.map(parseNavigationTarget); + if (targets.some((target) => target === null)) { + throw new Error("Invalid system graph navigation response"); + } + const typedTargets = targets as SystemGraphNavigationTarget[]; + if ( + new Set(typedTargets.map((target) => target.agentKey)).size !== + typedTargets.length + ) { + throw new Error("Invalid system graph navigation response"); + } + if ( + expected && + (value.workspaceKey !== expected.workspaceKey || + (expected.revision !== undefined && value.revision !== expected.revision)) + ) { + throw new Error("Mismatched system graph navigation response"); + } + return { + workspaceKey: value.workspaceKey, + revision: value.revision as number, + targets: typedTargets, + }; +} diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index 1372618b5..68990851e 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -51,6 +51,12 @@ import { mergeHistory } from "./history-meta"; import { createToastMessage, type ToastMessage, type ToastTone } from "./toast"; import { subscribeEvents } from "./events"; import { systemGraphLoader } from "./system-graph-loader"; +import { + retainSystemGraphAnnouncements, + systemGraphAnnouncementsAfterMessage, + type SystemGraphAnnouncement, +} from "./system-graph-announcements"; +import type { WorkspaceKey } from "@shared/system-graph"; import { track as trackProduct } from "./analytics/events"; import { agentProvenance, @@ -319,6 +325,8 @@ export interface HarnessStateHook { showToast: (message: string, tone?: ToastTone) => void; listDir: (path?: string) => Promise; lastMessage: BusMessage | null; + /** Latest monotonic graph invalidation per retained Project scope. */ + systemGraphAnnouncements: ReadonlyMap; /** The run each session's Steps tab is showing (the latest observed by * default, or a past run picked via selectRun), with its target. */ runsBySession: Map; @@ -368,11 +376,18 @@ export interface HarnessStateHook { /** Central store for the SPA shell: fetches AppState + settings once, then keeps sessions/workflows fresh via the event bus. */ export function useHarnessState(): HarnessStateHook { const [state, setState] = useState(null); + const [systemGraphAnnouncements, setSystemGraphAnnouncements] = useState< + Map + >(new Map()); useEffect(() => { if (!state) return; - systemGraphLoader.retain( - new Set((state.workspaceScopes ?? []).map((scope) => scope.workspaceKey)), + const workspaceKeys = new Set( + (state.workspaceScopes ?? []).map((scope) => scope.workspaceKey), + ); + systemGraphLoader.retain(workspaceKeys); + setSystemGraphAnnouncements((current) => + retainSystemGraphAnnouncements(current, workspaceKeys), ); }, [state?.workspaceScopes]); const [settings, setSettings] = useState(null); @@ -1085,6 +1100,9 @@ export function useHarnessState(): HarnessStateHook { useEffect(() => { return subscribeEvents((message) => { setLastMessage(message); + setSystemGraphAnnouncements((current) => + systemGraphAnnouncementsAfterMessage(current, message), + ); if (message.type === "session.status") { setState((prev) => { if (!prev) return prev; @@ -2186,6 +2204,7 @@ export function useHarnessState(): HarnessStateHook { directActionSettleSeq, listDir, lastMessage, + systemGraphAnnouncements, runsBySession, runsByExecution, runIdsBySession, diff --git a/scripts/agent-studio-terminology-allowlist.json b/scripts/agent-studio-terminology-allowlist.json index 6ad2a873f..9b0722044 100644 --- a/scripts/agent-studio-terminology-allowlist.json +++ b/scripts/agent-studio-terminology-allowlist.json @@ -321,6 +321,13 @@ "occurrences": 1, "reason": "The internal session-binding route remains stable for existing clients." }, + { + "id": "web-system-graph-navigation-path-key", + "path": "packages/harness/web/src/lib/system-graph.ts", + "pattern": "^workflowPath$", + "occurrences": 1, + "reason": "The protected graph-navigation response key is a compatibility-sensitive private API contract." + }, { "id": "web-canvas-node-kind", "path": "packages/harness/web/src/lib/canvas-graph.ts",