From 6fc91918e05b427a305d007e0cac5d7fb333fb0f Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 10:34:43 +0000 Subject: [PATCH 1/4] feat(harness): adapt invocations to graph evidence Closes: SAP-2986 --- .changeset/steady-agents-connect.md | 8 + .../harness/docs/workspace-system-graph.md | 32 +- .../src/core/canvas-interconnections.ts | 27 + .../system-graph-invocation-evidence.test.ts | 587 ++++++++++++++ .../core/system-graph-invocation-evidence.ts | 732 ++++++++++++++++++ .../core/system-graph-relationships.test.ts | 20 + .../src/core/system-graph-relationships.ts | 9 +- .../harness/src/core/system-graph.test.ts | 50 +- packages/harness/src/core/system-graph.ts | 149 +--- 9 files changed, 1484 insertions(+), 130 deletions(-) create mode 100644 .changeset/steady-agents-connect.md create mode 100644 packages/harness/src/core/system-graph-invocation-evidence.test.ts create mode 100644 packages/harness/src/core/system-graph-invocation-evidence.ts diff --git a/.changeset/steady-agents-connect.md b/.changeset/steady-agents-connect.md new file mode 100644 index 000000000..61461f4bb --- /dev/null +++ b/.changeset/steady-agents-connect.md @@ -0,0 +1,8 @@ +--- +"@sapiom/harness": patch +--- + +Adapt direct source invocations into package-scoped graph evidence with +content-based freshness, opaque callsite references, conservative coverage, +and last-good replacement semantics while preserving the existing System Graph +edge and warning payloads. diff --git a/packages/harness/docs/workspace-system-graph.md b/packages/harness/docs/workspace-system-graph.md index efb632307..360344137 100644 --- a/packages/harness/docs/workspace-system-graph.md +++ b/packages/harness/docs/workspace-system-graph.md @@ -133,6 +133,25 @@ agent outputs through formatter/helper/router code, or scan arbitrary workspace router modules outside those roots. Cross-agent output-to-input analysis will use a separate package-level evidence provider. +The caller-scoped scanner remains an internal extraction boundary. After the +scanner returns, the Harness resolves each literal target against the selected +package inventory and adapts `(caller.agentKey, resolvedTarget.agentKey)` into +an explicit `invokes` / `static-invocation` package evidence record. That +record is scoped to the exact inventory version and carries the typed call mode, +producer/version, and an analysis fingerprint. Source locations become opaque +`source-callsite` references; paths remain server-side. The public +`StaticInvocationGraphEdge` above is derived from accepted evidence and is not +used as the evidence store. + +Evidence freshness hashes the bounded source content that was actually +analyzed. Watcher paths, mtimes, `observedPaths`, UI node IDs, path slugs, and +the cheap `fingerprintWorkflowSources` cache key do not become canonical +evidence identity. A complete refresh replaces the prior direct-invocation +result, including retracting removed calls. Missing, pending, failed, dynamic, +or otherwise incomplete caller analysis cannot be promoted to complete; the +last complete result stays visible while the new attempt is diagnosed as +partial or failed. + Projection can remain useful while reporting warnings: | Warning code | Meaning | @@ -160,11 +179,14 @@ while the snapshot is `ready`; an incomplete workspace walk, pending/retryable identity, or incomplete invocation scan keeps it `degraded`. Warnings and the Retry affordance remain visible without freezing evidence that may still change. -Package inventory protocol 1 is deliberately limited to which agents exist, -their stable identities, and their package-relative locations. It carries no -agent-owned or opaque relationship payload. Future package-wide data-flow and -cross-agent relationship evidence will use a separate versioned contract with -its own deterministic provenance and validation. +Package inventory protocol 1 remains deliberately limited to which agents +exist, their stable identities, and their package-relative locations. It +carries no agent-owned or opaque relationship payload. Relationship producers +instead use the separately versioned package graph-evidence protocol, bound to +one exact inventory version with deterministic provenance and validation. +Future package-wide data-flow analysis can produce `feeds` / +`static-dataflow` evidence through that contract without broadening this +direct-invocation scanner. ## Freshness event diff --git a/packages/harness/src/core/canvas-interconnections.ts b/packages/harness/src/core/canvas-interconnections.ts index 453ad3615..713480a2a 100644 --- a/packages/harness/src/core/canvas-interconnections.ts +++ b/packages/harness/src/core/canvas-interconnections.ts @@ -20,6 +20,7 @@ * calls are syntax-accurate (comments and strings cannot become invocations), * while dynamic targets are returned as explicit extraction warnings. */ +import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -539,6 +540,8 @@ export interface WorkflowSourceScan { observedPaths: string[]; /** False when an opaque path or work cap prevented a complete scan. */ complete: boolean; + /** Stable identity of the source content supplied to this extraction. */ + sourceFingerprint: `sha256:${string}`; } interface SupportedNamespaces { @@ -885,9 +888,20 @@ export async function scanWorkflowSources( const invocationWarnings: AgentInvocationDetectionWarning[] = []; const capabilities: DetectedCapability[] = []; const sourceSet = await listSourceFilesWithObservations(root); + const fingerprintInputs: Array<{ + file: string; + contentDigest: `sha256:${string}` | null; + }> = []; let complete = sourceSet.complete; for (const file of sourceSet.files) { const content = await readWorkflowSourceFile(root, file, readHooks); + fingerprintInputs.push({ + file: path.relative(root, file).split(path.sep).join(path.posix.sep), + contentDigest: + content === null + ? null + : `sha256:${createHash("sha256").update(content).digest("hex")}`, + }); if (content === null) { complete = false; continue; @@ -923,6 +937,18 @@ export async function scanWorkflowSources( const launches = invocations .filter((invocation) => invocation.mode === "async") .map(({ slug, fromStepId }) => ({ slug, fromStepId })); + fingerprintInputs.sort((left, right) => + left.file === right.file ? 0 : left.file < right.file ? -1 : 1, + ); + const sourceFingerprint = `sha256:${createHash("sha256") + .update( + JSON.stringify({ + protocol: 1, + complete, + sources: fingerprintInputs, + }), + ) + .digest("hex")}` as const; return { launches, invocations, @@ -930,6 +956,7 @@ export async function scanWorkflowSources( capabilities, observedPaths: sourceSet.observedPaths, complete, + sourceFingerprint, }; } diff --git a/packages/harness/src/core/system-graph-invocation-evidence.test.ts b/packages/harness/src/core/system-graph-invocation-evidence.test.ts new file mode 100644 index 000000000..8e919ec26 --- /dev/null +++ b/packages/harness/src/core/system-graph-invocation-evidence.test.ts @@ -0,0 +1,587 @@ +import { describe, expect, it } from "vitest"; + +import type { PackageInventory, PackageInventoryAgent } from "@sapiom/agent"; + +import type { SourceEvidence } from "./canvas-interconnections.js"; +import type { AgentInventoryItem } from "./system-graph-inventory.js"; +import { + adaptDirectInvocationsToGraphEvidence, + DIRECT_INVOCATION_EVIDENCE_PRODUCER, + type DirectInvocationScan, +} from "./system-graph-invocation-evidence.js"; +import type { + AgentInvocationCandidate, + AgentInvocationProviderResult, +} from "./system-graph-relationships.js"; + +const REVISION = `sha256:${"a".repeat(64)}` as const; +const SOURCE_A = `sha256:${"b".repeat(64)}` as const; +const SOURCE_B = `sha256:${"c".repeat(64)}` as const; + +interface AgentFixture { + context: AgentInventoryItem; + public: PackageInventoryAgent; +} + +function canonicalAgent( + agentKey: string, + aliases: readonly string[] = [], +): AgentFixture { + return { + context: { + agentKey, + identityStatus: "canonical", + definitionId: null, + definitionSlug: null, + label: agentKey[0]!.toUpperCase() + agentKey.slice(1), + resolutionAliases: [...aliases], + sourceRoot: `/private/workspace/${agentKey}`, + workflowPath: `/private/workspace/${agentKey}`, + path: `agents/${agentKey}`, + entrypoint: "index.ts", + }, + public: { + agentKey, + identityStatus: "canonical", + path: `agents/${agentKey}`, + entrypoint: "index.ts", + }, + }; +} + +function provisionalAgent( + agentKey: string, + aliases: readonly string[], +): AgentFixture { + const safePath = agentKey.replace(/[:/]/g, "-"); + return { + context: { + agentKey, + identityStatus: "provisional", + definitionId: null, + definitionSlug: null, + label: safePath, + resolutionAliases: [...aliases], + sourceRoot: `/private/workspace/${safePath}`, + workflowPath: `/private/workspace/${safePath}`, + path: `agents/${safePath}`, + entrypoint: "index.ts", + }, + public: { + agentKey, + identityStatus: "provisional", + identityIssue: "identity-unavailable", + path: `agents/${safePath}`, + entrypoint: "index.ts", + }, + }; +} + +function inventory(fixtures: readonly AgentFixture[]): PackageInventory { + return { + protocol: 1, + version: { + kind: "working-tree", + workspaceKey: "workspace-adapter", + revision: REVISION, + }, + status: fixtures.some( + (fixture) => fixture.public.identityStatus === "provisional", + ) + ? "degraded" + : "complete", + agents: fixtures.map((fixture) => fixture.public), + }; +} + +function source(file: string, line: number, column = 1): SourceEvidence { + return { file, line, column }; +} + +function result( + invocations: readonly AgentInvocationCandidate[] = [], + overrides: Partial = {}, +): AgentInvocationProviderResult { + return { + invocations: [...invocations], + warnings: [], + complete: true, + sourceFingerprint: SOURCE_A, + ...overrides, + }; +} + +function scan( + fixture: AgentFixture, + providerResult = result(), + flags: Pick = { + failed: false, + pending: false, + }, +): DirectInvocationScan { + return { caller: fixture.context, result: providerResult, ...flags }; +} + +function contexts(fixtures: readonly AgentFixture[]): AgentInventoryItem[] { + return fixtures.map((fixture) => fixture.context); +} + +describe("adaptDirectInvocationsToGraphEvidence", () => { + it("maps only resolved direct calls to explicit-endpoint evidence and the unchanged public edge DTO", () => { + const coordinator = canonicalAgent("coordinator", ["coordinator"]); + const research = canonicalAgent("research", ["research"]); + const growth = canonicalAgent("growth", ["growth"]); + const fixtures = [coordinator, research, growth]; + const packageInventory = inventory(fixtures); + + const adapted = adaptDirectInvocationsToGraphEvidence({ + inventory: packageInventory, + agents: contexts(fixtures), + scans: [ + scan( + coordinator, + result([ + { + target: "research", + mode: "blocking", + evidence: [source("src/private/coordinator.ts", 4, 3)], + }, + { + target: "growth", + mode: "async", + evidence: [source("src/private/coordinator.ts", 9, 5)], + }, + ]), + ), + scan(research), + scan(growth), + ], + }); + + expect(adapted.complete).toBe(true); + expect(adapted.latestResult).toMatchObject({ + protocol: 1, + kind: "static-result", + scope: packageInventory.version, + producer: DIRECT_INVOCATION_EVIDENCE_PRODUCER, + outcome: "success", + }); + expect(adapted.latestResult.analysisFingerprint).toMatch( + /^sha256:[0-9a-f]{64}$/, + ); + expect(adapted.latestResult.coverage).toEqual({ status: "complete" }); + expect( + adapted.latestResult.evidence.map((evidence) => ({ + fromAgentKey: evidence.fromAgentKey, + toAgentKey: evidence.toAgentKey, + relation: evidence.relation, + basis: evidence.basis, + mode: evidence.basis === "static-invocation" ? evidence.mode : null, + })), + ).toEqual([ + { + fromAgentKey: "coordinator", + toAgentKey: "growth", + relation: "invokes", + basis: "static-invocation", + mode: "async", + }, + { + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + basis: "static-invocation", + mode: "blocking", + }, + ]); + expect(adapted.edges).toEqual([ + { + from: "agent:coordinator", + to: "agent:growth", + kind: "invokes", + basis: "static-invocation", + mode: "async", + }, + { + from: "agent:coordinator", + to: "agent:research", + kind: "invokes", + basis: "static-invocation", + mode: "blocking", + }, + ]); + expect(adapted.edges).not.toContainEqual( + expect.objectContaining({ + from: "agent:research", + to: "agent:growth", + }), + ); + expect( + adapted.latestResult.evidence.flatMap((evidence) => + evidence.basis === "static-invocation" ? evidence.callsites : [], + ), + ).toEqual([ + { + kind: "source-callsite", + ref: expect.stringMatching(/^callsite:sha256:[0-9a-f]{64}$/), + }, + { + kind: "source-callsite", + ref: expect.stringMatching(/^callsite:sha256:[0-9a-f]{64}$/), + }, + ]); + expect(JSON.stringify(adapted)).not.toContain("src/private"); + expect(JSON.stringify(adapted)).not.toContain("/private/workspace"); + }); + + it("is deterministic, ignores watcher metadata, and changes identity for source-content freshness", () => { + const caller = canonicalAgent("caller"); + const target = canonicalAgent("target"); + const fixtures = [caller, target]; + const firstEvidence = source("src/caller.ts", 8, 2); + const secondEvidence = source("src/caller.ts", 3, 7); + const invocation = ( + evidence: SourceEvidence[], + ): AgentInvocationCandidate => ({ + target: "target", + mode: "blocking", + evidence, + }); + + const first = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [ + scan( + caller, + result([invocation([firstEvidence, secondEvidence])], { + observedPaths: ["/private/first"], + }), + ), + scan(target), + ], + }); + const reordered = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [ + scan(target, result([], { observedPaths: ["/private/other"] })), + scan( + caller, + result([invocation([secondEvidence, firstEvidence])], { + observedPaths: ["/private/reordered"], + }), + ), + ], + }); + + expect(reordered.latestResult).toEqual(first.latestResult); + expect(reordered.edges).toEqual(first.edges); + + const edited = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [ + scan( + caller, + result([invocation([firstEvidence, secondEvidence])], { + sourceFingerprint: SOURCE_B, + }), + ), + scan(target), + ], + }); + + expect(edited.latestResult.analysisFingerprint).not.toBe( + first.latestResult.analysisFingerprint, + ); + expect(edited.latestResult.resultId).not.toBe(first.latestResult.resultId); + expect(edited.latestResult.evidence[0]?.evidenceId).not.toBe( + first.latestResult.evidence[0]?.evidenceId, + ); + expect(edited.edges).toEqual(first.edges); + }); + + it("keeps last-good evidence for partial or failed refreshes and retracts it only after a complete refresh", () => { + const caller = canonicalAgent("caller"); + const target = canonicalAgent("target"); + const fixtures = [caller, target]; + const directCall = { + target: "target", + mode: "async" as const, + evidence: [source("src/caller.ts", 2)], + }; + const initial = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [scan(caller, result([directCall])), scan(target)], + }); + + const partial = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [scan(caller, result([], { complete: false })), scan(target)], + previousState: initial.state, + }); + expect(partial.complete).toBe(false); + expect(partial.state.status).toBe("stale"); + expect(partial.edges).toEqual(initial.edges); + expect(partial.latestResult.coverage.status).toBe("partial"); + + const failed = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: fixtures.map((fixture) => + scan(fixture, result(), { failed: true, pending: false }), + ), + previousState: partial.state, + }); + expect(failed.complete).toBe(false); + expect(failed.state.status).toBe("stale"); + expect(failed.latestResult.outcome).toBe("failure"); + expect(failed.latestResult.coverage.status).toBe("none"); + expect(failed.edges).toEqual(initial.edges); + + const removed = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: fixtures.map((fixture) => + scan(fixture, result([], { sourceFingerprint: SOURCE_B })), + ), + previousState: failed.state, + }); + expect(removed.complete).toBe(true); + expect(removed.state.status).toBe("ready"); + expect(removed.edges).toEqual([]); + expect(removed.latestResult.evidence).toEqual([]); + }); + + it("never upgrades missing, pending, incomplete, or freshness-less caller scans to complete", () => { + const caller = canonicalAgent("caller"); + const target = canonicalAgent("target"); + const fixtures = [caller, target]; + const attempts = [ + [scan(caller)], + [scan(caller), scan(target, result(), { failed: false, pending: true })], + [scan(caller), scan(target, result([], { complete: false }))], + [ + scan(caller), + scan(target, result([], { sourceFingerprint: undefined })), + ], + ]; + + for (const scans of attempts) { + const adapted = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans, + }); + expect(adapted.complete).toBe(false); + expect(adapted.latestResult.outcome).toBe("success"); + expect(adapted.latestResult.coverage.status).toBe("partial"); + expect( + adapted.latestResult.diagnostics.some( + (diagnostic) => diagnostic.code === "incomplete-analysis", + ), + ).toBe(true); + } + + const failed = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: fixtures.map((fixture) => + scan(fixture, result(), { failed: true, pending: false }), + ), + }); + expect(failed.complete).toBe(false); + expect(failed.latestResult.outcome).toBe("failure"); + expect(failed.latestResult.coverage.status).toBe("none"); + expect(failed.state.status).toBe("failed"); + }); + + it("uses shared diagnostics and quarantine while keeping unsafe targets out of public output", () => { + const caller = canonicalAgent("caller"); + const target = canonicalAgent("target"); + const fixtures = [caller, target]; + const adapted = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [ + scan( + caller, + result([ + { + target: "target", + mode: "blocking", + evidence: [ + source("src/caller.ts", 1), + source("src/caller.ts", 2), + ], + }, + { + target: "caller", + mode: "async", + evidence: [source("src/caller.ts", 3)], + }, + { + target: "missing", + mode: "async", + evidence: [source("src/caller.ts", 4)], + }, + { + target: "/private/secret-agent", + mode: "blocking", + evidence: [source("src/caller.ts", 5)], + }, + ]), + ), + scan(target), + ], + }); + + expect(adapted.edges).toEqual([ + { + from: "agent:caller", + to: "agent:target", + kind: "invokes", + basis: "static-invocation", + mode: "blocking", + }, + ]); + expect( + new Set(adapted.latestResult.diagnostics.map(({ code }) => code)), + ).toEqual( + new Set([ + "duplicate-evidence", + "illegal-self-relationship", + "invalid-endpoint", + "unknown-endpoint", + ]), + ); + expect( + new Set(adapted.latestResult.quarantine.map(({ code }) => code)), + ).toEqual( + new Set([ + "illegal-self-relationship", + "invalid-endpoint", + "unknown-endpoint", + ]), + ); + expect(adapted.warnings.map(({ code }) => code)).toEqual([ + "duplicate-edge", + "unresolved-target", + "unresolved-target", + ]); + expect(JSON.stringify(adapted)).not.toContain("/private/secret-agent"); + expect(adapted.warnings).toContainEqual({ + code: "unresolved-target", + agentKey: "caller", + message: "Caller invokes an invalid agent target.", + }); + }); + + it("resolves one provisional alias but diagnoses an ambiguous alias without inventing an edge", () => { + const caller = canonicalAgent("caller"); + const first = provisionalAgent("local:first", ["legacy"]); + const oneMatch = [caller, first]; + const resolved = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(oneMatch), + agents: contexts(oneMatch), + scans: [ + scan( + caller, + result([ + { + target: "legacy", + mode: "async", + evidence: [source("src/caller.ts", 1)], + }, + ]), + ), + scan(first), + ], + }); + expect(resolved.edges).toEqual([ + { + from: "agent:caller", + to: "agent:local:first", + kind: "invokes", + basis: "static-invocation", + mode: "async", + }, + ]); + + const second = provisionalAgent("local:second", ["legacy"]); + const ambiguousFixtures = [caller, first, second]; + const ambiguous = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(ambiguousFixtures), + agents: contexts(ambiguousFixtures), + scans: [ + scan( + caller, + result([ + { + target: "legacy", + mode: "async", + evidence: [source("src/caller.ts", 1)], + }, + ]), + ), + scan(first), + scan(second), + ], + }); + expect(ambiguous.edges).toEqual([]); + expect(ambiguous.latestResult.diagnostics).toContainEqual( + expect.objectContaining({ code: "ambiguous-endpoint", endpoint: "to" }), + ); + expect(ambiguous.warnings).toContainEqual({ + code: "unresolved-target", + agentKey: "caller", + message: "Caller invokes ambiguous agent legacy.", + }); + }); + + it("marks dynamic callsites partial and exposes only an opaque diagnostic reference", () => { + const caller = canonicalAgent("caller"); + const fixtures = [caller]; + const adapted = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [ + scan( + caller, + result([], { + warnings: [ + { + code: "dynamic-target", + mode: "blocking", + evidence: source("src/private/dynamic.ts", 12, 4), + }, + ], + }), + ), + ], + }); + + expect(adapted.complete).toBe(false); + expect(adapted.latestResult.coverage.status).toBe("partial"); + expect(adapted.latestResult.diagnostics).toContainEqual( + expect.objectContaining({ + code: "dynamic-target", + reference: { + kind: "source-callsite", + ref: expect.stringMatching(/^callsite:sha256:[0-9a-f]{64}$/), + }, + }), + ); + expect(adapted.warnings).toEqual([ + { + code: "dynamic-target", + agentKey: "caller", + message: "Caller has a dynamic agent target that V0 cannot resolve.", + }, + ]); + expect(JSON.stringify(adapted)).not.toContain("src/private/dynamic.ts"); + }); +}); diff --git a/packages/harness/src/core/system-graph-invocation-evidence.ts b/packages/harness/src/core/system-graph-invocation-evidence.ts new file mode 100644 index 000000000..c11412254 --- /dev/null +++ b/packages/harness/src/core/system-graph-invocation-evidence.ts @@ -0,0 +1,732 @@ +import { createHash } from "node:crypto"; + +import { + advancePackageGraphStaticEvidenceState, + createPackageGraphEvidenceStaticResult, + projectPackageGraphEvidence, + type PackageGraphEvidenceCoverageGap, + type PackageGraphEvidenceDiagnostic, + type PackageGraphEvidenceDigest, + type PackageGraphEvidenceProducer, + type PackageGraphEvidenceStaticResult, + type PackageGraphStaticEvidenceCandidate, + type PackageGraphStaticEvidenceState, + type PackageInventory, +} from "@sapiom/agent"; + +import type { + GraphWarning, + StaticInvocationGraphEdge, +} from "../shared/system-graph.js"; +import type { SourceEvidence } from "./canvas-interconnections.js"; +import type { AgentInventoryItem } from "./system-graph-inventory.js"; +import type { AgentInvocationProviderResult } from "./system-graph-relationships.js"; + +export const DIRECT_INVOCATION_EVIDENCE_PRODUCER = { + id: "sapiom.harness.direct-invocation", + version: "1.0.0", +} as const satisfies PackageGraphEvidenceProducer; + +const DIGEST = /^sha256:[0-9a-f]{64}$/; + +export interface DirectInvocationScan { + caller: AgentInventoryItem; + result: AgentInvocationProviderResult; + failed: boolean; + pending: boolean; +} + +export interface DirectInvocationEvidenceAdaptation { + /** The latest producer attempt, including deterministic diagnostics. */ + latestResult: PackageGraphEvidenceStaticResult; + /** Reference last-good state retained by the workspace builder. */ + state: PackageGraphStaticEvidenceState; + /** Existing path-free V0 projection derived only from accepted evidence. */ + edges: StaticInvocationGraphEdge[]; + warnings: GraphWarning[]; + complete: boolean; +} + +type Resolution = + | { kind: "resolved"; target: AgentInventoryItem } + | { kind: "unknown" | "ambiguous" }; + +interface DiagnosticContext { + caller: AgentInventoryItem; + reason: + | "dynamic" + | "failed" + | "incomplete" + | "invalid-fingerprint" + | "pending" + | "resolved" + | "self" + | "unknown" + | "ambiguous"; + target?: string; +} + +interface NormalizedScanFingerprint { + callerAgentKey: string; + status: "failed" | "missing" | "pending" | "ready"; + complete: boolean; + sourceFingerprint: PackageGraphEvidenceDigest | null; + candidates: PackageGraphStaticEvidenceCandidate[]; + dynamicCallsites: Array<{ + mode: "blocking" | "async"; + callsite: { kind: "source-callsite"; ref: string }; + }>; +} + +function compareText(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +function stableEvidenceJson(value: unknown): string { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" + ) { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableEvidenceJson(item)).join(",")}]`; + } + if (typeof value !== "object") { + throw new TypeError("Evidence identity accepts JSON values only"); + } + return `{${Object.entries(value as Record) + .sort(([left], [right]) => compareText(left, right)) + .map( + ([key, child]) => `${JSON.stringify(key)}:${stableEvidenceJson(child)}`, + ) + .join(",")}}`; +} + +function evidenceDigest(value: unknown): PackageGraphEvidenceDigest { + return `sha256:${createHash("sha256").update(stableEvidenceJson(value)).digest("hex")}`; +} + +function sameInventoryVersion( + left: PackageInventory["version"], + right: PackageInventory["version"], +): boolean { + if (left.kind !== right.kind) return false; + return left.kind === "working-tree" && right.kind === "working-tree" + ? left.workspaceKey === right.workspaceKey && + left.revision === right.revision + : left.kind === "bundle" && + right.kind === "bundle" && + left.bundleDigest === right.bundleDigest; +} + +function publicWarningOrder(left: GraphWarning, right: GraphWarning): number { + return ( + compareText(left.code, right.code) || + compareText(left.agentKey ?? "", right.agentKey ?? "") || + compareText(left.message, right.message) + ); +} + +function normalizeWarnings(warnings: readonly GraphWarning[]): GraphWarning[] { + return [ + ...new Map( + [...warnings] + .sort(publicWarningOrder) + .map((warning) => [ + `${warning.code}\0${warning.agentKey ?? ""}\0${warning.message}`, + warning, + ]), + ).values(), + ]; +} + +function validSourceEvidence(value: unknown): value is SourceEvidence { + if (!value || typeof value !== "object") return false; + const evidence = value as Partial; + return ( + typeof evidence.file === "string" && + evidence.file.length > 0 && + !evidence.file.startsWith("/") && + !evidence.file.includes("\\") && + evidence.file.split("/").every((part) => part !== "" && part !== "..") && + Number.isSafeInteger(evidence.line) && + evidence.line! > 0 && + Number.isSafeInteger(evidence.column) && + evidence.column! > 0 + ); +} + +function sourceCallsiteReference( + callerAgentKey: string, + evidence: SourceEvidence, +): { kind: "source-callsite"; ref: string } { + return { + kind: "source-callsite", + ref: `callsite:${evidenceDigest({ + protocol: 1, + callerAgentKey, + file: evidence.file, + line: evidence.line, + column: evidence.column, + })}`, + }; +} + +function candidateFingerprint( + candidate: PackageGraphStaticEvidenceCandidate, +): PackageGraphEvidenceDigest { + return evidenceDigest(candidate); +} + +function registerCandidateTarget( + candidates: Map, + key: string, + agent: AgentInventoryItem, +): void { + const matches = candidates.get(key) ?? []; + if (!matches.some((candidate) => candidate.agentKey === agent.agentKey)) { + matches.push(agent); + candidates.set(key, matches); + } +} + +function targetResolver(agents: readonly AgentInventoryItem[]): { + resolve(target: string): Resolution; +} { + const canonical = new Map(); + const candidates = new Map(); + for (const agent of agents) { + if (agent.identityStatus === "canonical") { + canonical.set(agent.agentKey, agent); + } else { + registerCandidateTarget(candidates, agent.agentKey, agent); + } + for (const alias of agent.resolutionAliases) { + registerCandidateTarget(candidates, alias, agent); + } + } + return { + resolve(target) { + const exact = canonical.get(target); + if (exact) return { kind: "resolved", target: exact }; + const matches = candidates.get(target) ?? []; + if (matches.length === 1) { + return { kind: "resolved", target: matches[0]! }; + } + return { kind: matches.length === 0 ? "unknown" : "ambiguous" }; + }, + }; +} + +function safeTargetLabel(target: string): string | null { + return /^[A-Za-z0-9@_.:-]+$/.test(target) ? target : null; +} + +function contextFingerprint( + callerAgentKey: string, + reason: DiagnosticContext["reason"], + input: unknown, +): PackageGraphEvidenceDigest { + return evidenceDigest({ + protocol: 1, + producer: DIRECT_INVOCATION_EVIDENCE_PRODUCER, + callerAgentKey, + reason, + input, + }); +} + +function addIncompleteDiagnostic( + diagnostics: PackageGraphEvidenceDiagnostic[], + contexts: Map, + caller: AgentInventoryItem, + reason: Extract< + DiagnosticContext["reason"], + "failed" | "incomplete" | "invalid-fingerprint" | "pending" + >, + input: unknown, +): void { + const fingerprint = contextFingerprint(caller.agentKey, reason, input); + contexts.set(fingerprint, { caller, reason }); + diagnostics.push({ + code: reason === "failed" ? "producer-failed" : "incomplete-analysis", + severity: reason === "failed" ? "error" : "warning", + candidateFingerprint: fingerprint, + }); +} + +function scanStatus( + scan: DirectInvocationScan, +): "failed" | "pending" | "ready" { + return scan.failed ? "failed" : scan.pending ? "pending" : "ready"; +} + +function isDigest(value: unknown): value is PackageGraphEvidenceDigest { + return typeof value === "string" && DIGEST.test(value); +} + +function sameProducerSlot( + state: PackageGraphStaticEvidenceState, + inventory: PackageInventory, +): boolean { + const latest = state.latestAttempt; + return ( + sameInventoryVersion(latest.scope, inventory.version) && + latest.producer.id === DIRECT_INVOCATION_EVIDENCE_PRODUCER.id && + latest.producer.version === DIRECT_INVOCATION_EVIDENCE_PRODUCER.version + ); +} + +function acceptedResult( + state: PackageGraphStaticEvidenceState, +): PackageGraphEvidenceStaticResult | null { + return state.status === "failed" ? null : state.accepted; +} + +function projectEdges( + inventory: PackageInventory, + state: PackageGraphStaticEvidenceState, +): StaticInvocationGraphEdge[] { + const accepted = acceptedResult(state); + if (!accepted) return []; + const projection = projectPackageGraphEvidence(inventory, [accepted]); + const edges: StaticInvocationGraphEdge[] = []; + for (const connector of projection.connectors) { + if (connector.relation !== "invokes") continue; + const modes = new Set<"blocking" | "async">(); + for (const support of connector.support) { + if (support.basis === "static-invocation" && support.mode) { + modes.add(support.mode); + } + } + for (const mode of ["blocking", "async"] as const) { + if (!modes.has(mode)) continue; + edges.push({ + from: `agent:${connector.fromAgentKey}`, + to: `agent:${connector.toAgentKey}`, + kind: "invokes", + basis: "static-invocation", + mode, + }); + } + } + return edges.sort( + (left, right) => + compareText(left.from, right.from) || + compareText(left.to, right.to) || + (left.mode === right.mode ? 0 : left.mode === "blocking" ? -1 : 1), + ); +} + +function projectionWarnings( + latest: PackageGraphEvidenceStaticResult, + state: PackageGraphStaticEvidenceState, + contexts: ReadonlyMap, + agents: readonly AgentInventoryItem[], +): GraphWarning[] { + const warnings: GraphWarning[] = []; + const emittedContexts = new Set(); + for (const diagnostic of latest.diagnostics) { + const fingerprint = diagnostic.candidateFingerprint; + if (!fingerprint || emittedContexts.has(fingerprint)) continue; + const context = contexts.get(fingerprint); + if (!context) continue; + const { caller } = context; + if (diagnostic.code === "dynamic-target" && context.reason === "dynamic") { + warnings.push({ + code: "dynamic-target", + agentKey: caller.agentKey, + message: `${caller.label} has a dynamic agent target that V0 cannot resolve.`, + }); + emittedContexts.add(fingerprint); + continue; + } + if ( + (diagnostic.code === "invalid-endpoint" || + diagnostic.code === "unknown-endpoint" || + diagnostic.code === "ambiguous-endpoint") && + (context.reason === "unknown" || context.reason === "ambiguous") + ) { + const target = safeTargetLabel(context.target ?? ""); + warnings.push({ + code: "unresolved-target", + agentKey: caller.agentKey, + message: + context.reason === "ambiguous" + ? `${caller.label} invokes ambiguous agent ${target ?? "target"}.` + : target + ? `${caller.label} invokes unknown agent ${target}.` + : `${caller.label} invokes an invalid agent target.`, + }); + emittedContexts.add(fingerprint); + continue; + } + if ( + (diagnostic.code === "producer-failed" || + diagnostic.code === "incomplete-analysis") && + (context.reason === "failed" || + context.reason === "incomplete" || + context.reason === "invalid-fingerprint") + ) { + warnings.push({ + code: "projection-failed", + agentKey: caller.agentKey, + message: + context.reason === "failed" + ? `Could not inspect ${caller.label}.` + : `Could not fully inspect ${caller.label}.`, + }); + emittedContexts.add(fingerprint); + } + } + + const byKey = new Map(agents.map((agent) => [agent.agentKey, agent])); + const accepted = acceptedResult(state); + if (accepted) { + const groups = new Map< + string, + { caller: AgentInventoryItem; target: AgentInventoryItem; count: number } + >(); + for (const evidence of accepted.evidence) { + if (evidence.basis !== "static-invocation") continue; + const caller = byKey.get(evidence.fromAgentKey); + const target = byKey.get(evidence.toAgentKey); + if (!caller || !target) continue; + const key = `${evidence.fromAgentKey}\0${evidence.toAgentKey}\0${evidence.mode}`; + const group = groups.get(key) ?? { caller, target, count: 0 }; + group.count += evidence.callsites.length; + groups.set(key, group); + } + for (const { caller, target, count } of groups.values()) { + if (count < 2) continue; + warnings.push({ + code: "duplicate-edge", + agentKey: caller.agentKey, + message: `${caller.label} invokes ${target.label} more than once.`, + }); + } + } + return normalizeWarnings(warnings); +} + +/** + * Adapt finalized caller-scoped scanner output into one package-scoped static + * evidence result. Scanner cache/watcher state remains an input only; it is + * never reused as protocol identity or lifecycle state. + */ +export function adaptDirectInvocationsToGraphEvidence(input: { + inventory: PackageInventory; + agents: readonly AgentInventoryItem[]; + scans: readonly DirectInvocationScan[]; + previousState?: PackageGraphStaticEvidenceState; +}): DirectInvocationEvidenceAdaptation { + const resolver = targetResolver(input.agents); + const candidates: PackageGraphStaticEvidenceCandidate[] = []; + const diagnostics: PackageGraphEvidenceDiagnostic[] = []; + const coverageGaps: PackageGraphEvidenceCoverageGap[] = []; + const contexts = new Map(); + const fingerprints: NormalizedScanFingerprint[] = []; + let complete = true; + + const orderedScans = [...input.scans].sort((left, right) => + compareText(left.caller.agentKey, right.caller.agentKey), + ); + const expectedAgentKeys = new Set( + input.agents.map((agent) => agent.agentKey), + ); + const scanCountByAgentKey = new Map(); + for (const scan of orderedScans) { + scanCountByAgentKey.set( + scan.caller.agentKey, + (scanCountByAgentKey.get(scan.caller.agentKey) ?? 0) + 1, + ); + if (expectedAgentKeys.has(scan.caller.agentKey)) continue; + complete = false; + coverageGaps.push({ code: "opaque-boundary" }); + addIncompleteDiagnostic(diagnostics, contexts, scan.caller, "incomplete", { + unexpectedCaller: true, + }); + } + for (const caller of [...input.agents].sort((left, right) => + compareText(left.agentKey, right.agentKey), + )) { + const count = scanCountByAgentKey.get(caller.agentKey) ?? 0; + if (count === 1) continue; + complete = false; + coverageGaps.push({ code: "opaque-boundary" }); + addIncompleteDiagnostic( + diagnostics, + contexts, + caller, + "incomplete", + count === 0 ? { missingScan: true } : { duplicateScans: count }, + ); + if (count === 0) { + fingerprints.push({ + callerAgentKey: caller.agentKey, + status: "missing", + complete: false, + sourceFingerprint: null, + candidates: [], + dynamicCallsites: [], + }); + } + } + for (const scan of orderedScans) { + const status = scanStatus(scan); + const validFingerprint = isDigest(scan.result.sourceFingerprint); + const normalizedCandidates: PackageGraphStaticEvidenceCandidate[] = []; + const dynamicCallsites: NormalizedScanFingerprint["dynamicCallsites"] = []; + + if (scan.pending) { + complete = false; + coverageGaps.push({ code: "other" }); + addIncompleteDiagnostic(diagnostics, contexts, scan.caller, "pending", { + status, + }); + } else if (scan.failed) { + complete = false; + coverageGaps.push({ code: "producer-failed" }); + addIncompleteDiagnostic(diagnostics, contexts, scan.caller, "failed", { + status, + }); + } else { + if (scan.result.complete !== true) { + complete = false; + coverageGaps.push({ code: "opaque-boundary" }); + addIncompleteDiagnostic( + diagnostics, + contexts, + scan.caller, + "incomplete", + { complete: scan.result.complete ?? null }, + ); + } + if (!validFingerprint) { + complete = false; + coverageGaps.push({ code: "opaque-boundary" }); + addIncompleteDiagnostic( + diagnostics, + contexts, + scan.caller, + "invalid-fingerprint", + { sourceFingerprint: null }, + ); + } + + for (const warning of scan.result.warnings) { + if (warning.code !== "dynamic-target") continue; + complete = false; + if (!validSourceEvidence(warning.evidence)) { + coverageGaps.push({ code: "opaque-boundary" }); + addIncompleteDiagnostic( + diagnostics, + contexts, + scan.caller, + "incomplete", + { invalidDynamicCallsite: true, mode: warning.mode }, + ); + continue; + } + const callsite = sourceCallsiteReference( + scan.caller.agentKey, + warning.evidence, + ); + dynamicCallsites.push({ mode: warning.mode, callsite }); + coverageGaps.push({ code: "dynamic-source", reference: callsite }); + const fingerprint = contextFingerprint( + scan.caller.agentKey, + "dynamic", + { mode: warning.mode, callsite }, + ); + contexts.set(fingerprint, { + caller: scan.caller, + reason: "dynamic", + }); + diagnostics.push({ + code: "dynamic-target", + severity: "warning", + candidateFingerprint: fingerprint, + reference: callsite, + }); + } + + for (const invocation of scan.result.invocations) { + const suppliedEvidence = Array.isArray(invocation.evidence) + ? invocation.evidence + : []; + const validEvidence = suppliedEvidence.filter(validSourceEvidence); + if ( + validEvidence.length === 0 || + validEvidence.length !== suppliedEvidence.length + ) { + complete = false; + coverageGaps.push({ code: "opaque-boundary" }); + addIncompleteDiagnostic( + diagnostics, + contexts, + scan.caller, + "incomplete", + { + invalidInvocationCallsites: true, + targetFingerprint: evidenceDigest({ + target: invocation.target, + }), + }, + ); + } + const callsites = [ + ...new Map( + validEvidence + .map((evidence) => + sourceCallsiteReference(scan.caller.agentKey, evidence), + ) + .sort((left, right) => compareText(left.ref, right.ref)) + .map((reference) => [reference.ref, reference]), + ).values(), + ]; + const resolution = resolver.resolve(invocation.target); + const candidate: PackageGraphStaticEvidenceCandidate = { + fromAgentKey: scan.caller.agentKey, + toAgentKey: + resolution.kind === "resolved" + ? resolution.target.agentKey + : invocation.target.length <= 512 + ? invocation.target + : "", + relation: "invokes", + basis: "static-invocation", + mode: invocation.mode, + callsites, + }; + const fingerprint = candidateFingerprint(candidate); + normalizedCandidates.push(candidate); + if (resolution.kind === "resolved") { + candidates.push(candidate); + contexts.set(fingerprint, { + caller: scan.caller, + reason: + resolution.target.agentKey === scan.caller.agentKey + ? "self" + : "resolved", + target: invocation.target, + }); + if ( + resolution.target.agentKey !== scan.caller.agentKey && + callsites.length > 1 + ) { + diagnostics.push({ + code: "duplicate-evidence", + severity: "warning", + candidateFingerprint: fingerprint, + reference: callsites[0], + }); + } + continue; + } + + contexts.set(fingerprint, { + caller: scan.caller, + reason: resolution.kind, + target: invocation.target, + }); + const inventoryCanClassifyAmbiguity = input.inventory.agents.some( + (agent) => + agent.identityStatus === "provisional" && + agent.identityIssue === "duplicate-agent-key" && + agent.candidateAgentKey === invocation.target, + ); + if (resolution.kind !== "ambiguous" || inventoryCanClassifyAmbiguity) { + candidates.push(candidate); + } else { + diagnostics.push({ + code: "ambiguous-endpoint", + severity: "error", + candidateFingerprint: fingerprint, + endpoint: "to", + }); + } + } + } + + normalizedCandidates.sort((left, right) => + compareText(stableEvidenceJson(left), stableEvidenceJson(right)), + ); + dynamicCallsites.sort((left, right) => + compareText(stableEvidenceJson(left), stableEvidenceJson(right)), + ); + fingerprints.push({ + callerAgentKey: scan.caller.agentKey, + status, + complete: scan.result.complete === true, + sourceFingerprint: validFingerprint + ? scan.result.sourceFingerprint! + : null, + candidates: normalizedCandidates, + dynamicCallsites, + }); + } + + fingerprints.sort((left, right) => + compareText(stableEvidenceJson(left), stableEvidenceJson(right)), + ); + + if ( + !complete && + !diagnostics.some((item) => item.code === "incomplete-analysis") + ) { + const fingerprint = contextFingerprint("package", "incomplete", { + scans: fingerprints, + }); + diagnostics.push({ + code: "incomplete-analysis", + severity: "warning", + candidateFingerprint: fingerprint, + }); + } + + const allFailed = + orderedScans.length > 0 && orderedScans.every((scan) => scan.failed); + const analysisFingerprint = evidenceDigest({ + protocol: 1, + scope: input.inventory.version, + producer: DIRECT_INVOCATION_EVIDENCE_PRODUCER, + scans: fingerprints, + }); + const latestResult = createPackageGraphEvidenceStaticResult( + { + scope: input.inventory.version, + producer: DIRECT_INVOCATION_EVIDENCE_PRODUCER, + analysisFingerprint, + outcome: allFailed ? "failure" : "success", + coverage: complete + ? { status: "complete" } + : allFailed + ? { status: "none", gaps: coverageGaps } + : { status: "partial", gaps: coverageGaps }, + candidates: allFailed ? [] : candidates, + diagnostics, + }, + input.inventory, + ); + const previousState = + input.previousState && + sameProducerSlot(input.previousState, input.inventory) + ? input.previousState + : undefined; + const state = advancePackageGraphStaticEvidenceState( + previousState, + latestResult, + ); + + return { + latestResult, + state, + edges: projectEdges(input.inventory, state), + warnings: projectionWarnings(latestResult, state, contexts, input.agents), + complete, + }; +} diff --git a/packages/harness/src/core/system-graph-relationships.test.ts b/packages/harness/src/core/system-graph-relationships.test.ts index 4961d4461..8b9e2f80b 100644 --- a/packages/harness/src/core/system-graph-relationships.test.ts +++ b/packages/harness/src/core/system-graph-relationships.test.ts @@ -94,6 +94,8 @@ ctx.sapiom.agents.launch({ definition: dynamicTarget }); evidence: { file: "index.ts", line: 5, column: 1 }, }, ]); + expect(result.complete).toBe(true); + expect(result.sourceFingerprint).toMatch(/^sha256:[0-9a-f]{64}$/); }); it("reports only the coordinator's direct invocations without inferring output data flow", async () => { @@ -145,6 +147,24 @@ await agents.run({ expect(second).toEqual(first); }); + it("uses source content, not mtimes or watcher paths, as analysis freshness", async () => { + const sourceText = 'ctx.sapiom.agents.run({ definition: "growth" });\n'; + const caller = await callerWithSource(sourceText); + const provider = new SourceAgentInvocationProvider(); + const entrypoint = path.join(caller.sourceRoot, "index.ts"); + + const first = await provider.listInvocations(caller); + await fs.utimes(entrypoint, new Date(1_000), new Date(2_000)); + const touched = await provider.listInvocations(caller); + await fs.writeFile(entrypoint, `${sourceText}// source-only edit\n`); + const edited = await provider.listInvocations(caller); + + expect(touched.sourceFingerprint).toBe(first.sourceFingerprint); + expect(edited.invocations).toEqual(first.invocations); + expect(edited.sourceFingerprint).not.toBe(first.sourceFingerprint); + expect(edited.sourceFingerprint).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + it("does not follow a TypeScript symlink outside the workflow", async () => { const caller = await callerWithSource("export const value = 1;\n"); const external = await fs.mkdtemp( diff --git a/packages/harness/src/core/system-graph-relationships.ts b/packages/harness/src/core/system-graph-relationships.ts index 89d41c92e..18e191e03 100644 --- a/packages/harness/src/core/system-graph-relationships.ts +++ b/packages/harness/src/core/system-graph-relationships.ts @@ -1,7 +1,7 @@ import * as path from "node:path"; import { - detectAgentInvocations, + scanWorkflowSources, type AgentInvocationDetectionWarning, type AgentInvocationMode, type SourceEvidence, @@ -30,6 +30,8 @@ export interface AgentInvocationProviderResult { observedPaths?: readonly string[]; /** False when an opaque path or work cap prevented a complete scan. */ complete?: boolean; + /** Stable content digest supplied by the authoritative source scan. */ + sourceFingerprint?: `sha256:${string}`; } export interface AgentInvocationSnapshot { @@ -478,7 +480,7 @@ export class SourceAgentInvocationProvider implements AgentInvocationProvider { async listInvocations( caller: AgentInventoryItem, ): Promise { - const scan = await detectAgentInvocations( + const scan = await scanWorkflowSources( caller.sourceRoot, new Set(), this.readHooks, @@ -514,9 +516,10 @@ export class SourceAgentInvocationProvider implements AgentInvocationProvider { return { invocations, - warnings: scan.warnings, + warnings: scan.invocationWarnings, observedPaths: scan.observedPaths, complete: scan.complete, + sourceFingerprint: scan.sourceFingerprint, }; } } diff --git a/packages/harness/src/core/system-graph.test.ts b/packages/harness/src/core/system-graph.test.ts index b220f616f..d39dea159 100644 --- a/packages/harness/src/core/system-graph.test.ts +++ b/packages/harness/src/core/system-graph.test.ts @@ -49,13 +49,21 @@ function invocationProvider( ) => Promise, ): AgentInvocationProvider { return { - listInvocations: vi.fn((caller) => listInvocations(caller.sourceRoot)), + listInvocations: vi.fn(async (caller) => ({ + complete: true, + sourceFingerprint: SOURCE_FINGERPRINT, + ...(await listInvocations(caller.sourceRoot)), + })), }; } +const SOURCE_FINGERPRINT = `sha256:${"b".repeat(64)}` as const; +const EDITED_SOURCE_FINGERPRINT = `sha256:${"c".repeat(64)}` as const; const EMPTY_INVOCATIONS: AgentInvocationProviderResult = { invocations: [], warnings: [], + complete: true, + sourceFingerprint: SOURCE_FINGERPRINT, }; const EVIDENCE = [{ file: "index.ts", line: 1, column: 1 }]; @@ -425,7 +433,43 @@ describe("StaticSystemGraphBuilder", () => { expect(JSON.stringify(graph)).not.toContain("/private/"); }); - it("projects dynamic extraction warnings without degrading cacheability or leaking evidence", async () => { + it("retains last-good edges across an incomplete refresh and retracts them after a complete refresh", async () => { + const resultWithAgents = inventoryResult(scope, [ + { agentKey: "caller", label: "Caller" }, + { agentKey: "target", label: "Target" }, + ]); + let callerResult: AgentInvocationProviderResult = { + invocations: [{ target: "target", mode: "blocking", evidence: EVIDENCE }], + warnings: [], + }; + const builder = new StaticSystemGraphBuilder( + { listAgents: async () => resultWithAgents }, + invocationProvider(async (sourceRoot) => + sourceRoot.endsWith("caller") ? callerResult : EMPTY_INVOCATIONS, + ), + ); + + const initial = await builder.build(scope); + expect(initial.cacheable).toBe(true); + expect(initial.graph.edges).toHaveLength(1); + + callerResult = { invocations: [], warnings: [], complete: false }; + const incomplete = await builder.build(scope); + expect(incomplete.cacheable).toBe(false); + expect(incomplete.graph.edges).toEqual(initial.graph.edges); + + callerResult = { + invocations: [], + warnings: [], + complete: true, + sourceFingerprint: EDITED_SOURCE_FINGERPRINT, + }; + const removed = await builder.build(scope); + expect(removed.cacheable).toBe(true); + expect(removed.graph.edges).toEqual([]); + }); + + it("projects dynamic extraction warnings as partial without leaking evidence", async () => { const inventory: AgentInventoryProvider = { listAgents: vi.fn(async () => inventoryResult(scope, [ @@ -455,7 +499,7 @@ describe("StaticSystemGraphBuilder", () => { invocations, ).build(scope); - expect(built.cacheable).toBe(true); + expect(built.cacheable).toBe(false); expect(built.graph.edges).toEqual([]); expect(built.graph.warnings).toEqual([ { diff --git a/packages/harness/src/core/system-graph.ts b/packages/harness/src/core/system-graph.ts index f40b4cece..700319445 100644 --- a/packages/harness/src/core/system-graph.ts +++ b/packages/harness/src/core/system-graph.ts @@ -1,10 +1,13 @@ import { createHash } from "node:crypto"; -import { packageInventorySchema } from "@sapiom/agent"; +import { + packageInventorySchema, + type PackageGraphStaticEvidenceState, + type PackageInventory, +} from "@sapiom/agent"; import type { GraphWarning, - StaticInvocationGraphEdge, SystemGraph, SystemGraphNavigationTarget, WorkspaceKey, @@ -19,6 +22,10 @@ import { type AgentInventoryWarning, type WorkspaceScope, } from "./system-graph-inventory.js"; +import { + adaptDirectInvocationsToGraphEvidence, + type DirectInvocationScan, +} from "./system-graph-invocation-evidence.js"; import { CachedAgentInvocationProvider, SourceAgentInvocationProvider, @@ -238,6 +245,7 @@ function sanitizeInventoryWarnings( } interface ConsumedInventory { + inventory: PackageInventory; agents: AgentInventoryItem[]; warnings: GraphWarning[]; /** Every identity has finished resolving, however it resolved. */ @@ -300,6 +308,7 @@ function consumeInventory( }; }); return { + inventory, agents, warnings: sanitizeInventoryWarnings( result.warnings, @@ -325,6 +334,10 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { WorkspaceKey, readonly AgentInventoryItem[] >(); + private readonly evidenceByWorkspace = new Map< + WorkspaceKey, + PackageGraphStaticEvidenceState + >(); constructor( private readonly inventory: AgentInventoryProvider, @@ -344,36 +357,6 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { agentKey: agent.agentKey, label: agent.label, })); - 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); - candidateTargets.set(key, candidates); - } - }; - for (const agent of agents) { - if (agent.identityStatus === "canonical") { - canonicalTargets.set(agent.agentKey, agent); - } else { - registerCandidate(agent.agentKey, agent); - } - for (const alias of agent.resolutionAliases) { - registerCandidate(alias, agent); - } - } - - const edges: StaticInvocationGraphEdge[] = []; - const warnings: GraphWarning[] = [...consumed.warnings]; - const seenEdges = new Set(); - let invocationsComplete = true; - const supportsBackgroundInvocations = typeof this.invocations.peekInvocations === "function" && typeof this.invocations.startInvocations === "function"; @@ -381,7 +364,7 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { // then perform bounded invocation I/O after nodes/navigation are visible. // Legacy/test providers without the cache surface retain the old awaited // adapter behavior. - const scans = supportsBackgroundInvocations + const scans: DirectInvocationScan[] = supportsBackgroundInvocations ? agents.map((caller) => { const snapshot = this.invocations.peekInvocations!(caller); return { @@ -419,91 +402,18 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { }), ); - for (const { caller, result, failed, pending } of scans) { - if (pending) { - invocationsComplete = false; - continue; - } - if (failed) { - invocationsComplete = false; - warnings.push({ - code: "projection-failed", - agentKey: caller.agentKey, - message: `Could not inspect ${caller.label}.`, - }); - continue; - } - if (result.complete === false) { - invocationsComplete = false; - warnings.push({ - code: "projection-failed", - agentKey: caller.agentKey, - message: `Could not fully inspect ${caller.label}.`, - }); - } - - for (const warning of result.warnings) { - if (warning.code === "dynamic-target") { - warnings.push({ - code: "dynamic-target", - agentKey: caller.agentKey, - message: `${caller.label} has a dynamic agent target that V0 cannot resolve.`, - }); - } - } - - for (const invocation of result.invocations) { - const exact = canonicalTargets.get(invocation.target); - const candidates = exact - ? [exact] - : (candidateTargets.get(invocation.target) ?? []); - if (candidates.length !== 1) { - const target = /^[A-Za-z0-9@_.:-]+$/.test(invocation.target) - ? invocation.target - : null; - warnings.push({ - code: "unresolved-target", - agentKey: caller.agentKey, - message: - candidates.length === 0 - ? target - ? `${caller.label} invokes unknown agent ${target}.` - : `${caller.label} invokes an invalid agent target.` - : `${caller.label} invokes ambiguous agent ${target ?? "target"}.`, - }); - continue; - } - const target = candidates[0]!; - if (target.agentKey === caller.agentKey) continue; - const from = `agent:${caller.agentKey}`; - const to = `agent:${target.agentKey}`; - const edgeKey = `${from}\0${to}\0${invocation.mode}`; - if (invocation.evidence.length > 1 || seenEdges.has(edgeKey)) { - warnings.push({ - code: "duplicate-edge", - agentKey: caller.agentKey, - message: `${caller.label} invokes ${target.label} more than once.`, - }); - } - if (seenEdges.has(edgeKey)) continue; - seenEdges.add(edgeKey); - edges.push({ - from, - to, - kind: "invokes", - basis: "static-invocation", - mode: invocation.mode, - }); - } - } - - const modeOrder = { blocking: 0, async: 1 } as const; - edges.sort( - (left, right) => - left.from.localeCompare(right.from) || - left.to.localeCompare(right.to) || - modeOrder[left.mode] - modeOrder[right.mode], - ); + const adapted = adaptDirectInvocationsToGraphEvidence({ + inventory: consumed.inventory, + agents, + scans, + previousState: this.evidenceByWorkspace.get(scope.workspaceKey), + }); + this.evidenceByWorkspace.set(scope.workspaceKey, adapted.state); + const edges = adapted.edges; + const warnings: GraphWarning[] = [ + ...consumed.warnings, + ...adapted.warnings, + ]; const uniqueWarnings = [ ...new Map( warnings.map((warning) => [ @@ -526,7 +436,7 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { cacheable: consumed.identitySettled && consumed.discoveryComplete && - invocationsComplete, + adapted.complete, graph: { kind: "system", scope: { kind: "working-tree", workspaceKey: scope.workspaceKey }, @@ -546,6 +456,7 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { for (const workspaceKey of this.callersByWorkspace.keys()) { if (!workspaceKeys.has(workspaceKey)) { this.callersByWorkspace.delete(workspaceKey); + this.evidenceByWorkspace.delete(workspaceKey); } } try { From 11cd19c0b87d5d4ef2c00d0e3f709d7208726c0c Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 18:15:20 +0000 Subject: [PATCH 2/4] fix(harness): preserve edges after partial cold scan --- .changeset/steady-agents-connect.md | 4 +- .../harness/docs/workspace-system-graph.md | 5 +- .../src/core/canvas-interconnections.ts | 8 ++- .../core/system-graph-invocation-evidence.ts | 6 ++ .../src/core/system-graph-relationships.ts | 7 +- .../harness/src/core/system-graph.test.ts | 70 +++++++++++++++++++ packages/harness/src/core/system-graph.ts | 12 +++- 7 files changed, 106 insertions(+), 6 deletions(-) diff --git a/.changeset/steady-agents-connect.md b/.changeset/steady-agents-connect.md index 61461f4bb..d5f66ad82 100644 --- a/.changeset/steady-agents-connect.md +++ b/.changeset/steady-agents-connect.md @@ -5,4 +5,6 @@ Adapt direct source invocations into package-scoped graph evidence with content-based freshness, opaque callsite references, conservative coverage, and last-good replacement semantics while preserving the existing System Graph -edge and warning payloads. +edge and warning payloads. Dynamic targets now make invocation coverage partial, +so affected graph snapshots are explicitly degraded/retryable instead of being +cached as complete. diff --git a/packages/harness/docs/workspace-system-graph.md b/packages/harness/docs/workspace-system-graph.md index 360344137..3983c1b14 100644 --- a/packages/harness/docs/workspace-system-graph.md +++ b/packages/harness/docs/workspace-system-graph.md @@ -150,7 +150,10 @@ evidence identity. A complete refresh replaces the prior direct-invocation result, including retracting removed calls. Missing, pending, failed, dynamic, or otherwise incomplete caller analysis cannot be promoted to complete; the last complete result stays visible while the new attempt is diagnosed as -partial or failed. +partial or failed. The initial cache-only phase does not seed last-good evidence: +the first settled partial scan can still expose its proven static edges. A +dynamic target therefore preserves those proven edges and warning while keeping +the graph snapshot degraded/retryable. Projection can remain useful while reporting warnings: diff --git a/packages/harness/src/core/canvas-interconnections.ts b/packages/harness/src/core/canvas-interconnections.ts index 713480a2a..e4a740142 100644 --- a/packages/harness/src/core/canvas-interconnections.ts +++ b/packages/harness/src/core/canvas-interconnections.ts @@ -960,8 +960,12 @@ export async function scanWorkflowSources( }; } -/** Direct agent invocations plus deterministic warnings for supported calls - * whose target is not a direct literal. */ +/** + * Test-only compatibility helper for direct invocation extraction. Production + * callers use `SourceAgentInvocationProvider`, which consumes the shared scan. + * + * @internal + */ export async function detectAgentInvocations( root: string, knownStepIds: ReadonlySet, diff --git a/packages/harness/src/core/system-graph-invocation-evidence.ts b/packages/harness/src/core/system-graph-invocation-evidence.ts index c11412254..64f9457a8 100644 --- a/packages/harness/src/core/system-graph-invocation-evidence.ts +++ b/packages/harness/src/core/system-graph-invocation-evidence.ts @@ -1,3 +1,9 @@ +/** + * Boundary adapter from caller-scoped, syntax-only invocation scans to the + * package-scoped graph-evidence protocol and the existing public graph DTO. + * Scanner cache state remains private input; only validated evidence is + * eligible for projection. + */ import { createHash } from "node:crypto"; import { diff --git a/packages/harness/src/core/system-graph-relationships.ts b/packages/harness/src/core/system-graph-relationships.ts index 18e191e03..4f9b6cebc 100644 --- a/packages/harness/src/core/system-graph-relationships.ts +++ b/packages/harness/src/core/system-graph-relationships.ts @@ -30,7 +30,12 @@ export interface AgentInvocationProviderResult { observedPaths?: readonly string[]; /** False when an opaque path or work cap prevented a complete scan. */ complete?: boolean; - /** Stable content digest supplied by the authoritative source scan. */ + /** + * Stable content digest supplied by the authoritative source scan. A + * successful provider result without a valid full SHA-256 digest is treated + * as incomplete: the graph remains degraded and last-good evidence stays. + * Synthetic pending/failed snapshots may omit it. + */ sourceFingerprint?: `sha256:${string}`; } diff --git a/packages/harness/src/core/system-graph.test.ts b/packages/harness/src/core/system-graph.test.ts index d39dea159..414e915dd 100644 --- a/packages/harness/src/core/system-graph.test.ts +++ b/packages/harness/src/core/system-graph.test.ts @@ -359,6 +359,76 @@ describe("StaticSystemGraphBuilder", () => { expect(enriched.cacheable).toBe(true); }); + it("seeds proven edges from the first settled partial background scan", async () => { + const inventory: AgentInventoryProvider = { + listAgents: vi.fn(async () => + inventoryResult(scope, [ + { + agentKey: "growth", + label: "Growth", + resolutionAliases: ["growth"], + }, + { + agentKey: "research", + label: "Research", + resolutionAliases: ["research"], + }, + ]), + ), + }; + const onChange = vi.fn(); + const invocations = new CachedAgentInvocationProvider( + invocationProvider(async (root) => + root.endsWith("research") + ? { + invocations: [ + { + target: "growth", + mode: "blocking", + evidence: EVIDENCE, + }, + ], + warnings: [ + { + code: "dynamic-target", + mode: "async", + evidence: { file: "index.ts", line: 2, column: 1 }, + }, + ], + } + : EMPTY_INVOCATIONS, + ), + async () => "unused", + { onChange }, + ); + const builder = new StaticSystemGraphBuilder(inventory, invocations); + + const cold = await builder.build(scope); + expect(cold.graph.edges).toEqual([]); + cold.afterCommit?.(); + await vi.waitFor(() => expect(onChange).toHaveBeenCalled()); + + const enriched = await builder.build(scope); + expect(enriched.cacheable).toBe(false); + expect(enriched.graph.edges).toEqual([ + { + from: "agent:research", + to: "agent:growth", + kind: "invokes", + basis: "static-invocation", + mode: "blocking", + }, + ]); + expect(enriched.graph.warnings).toContainEqual({ + code: "dynamic-target", + agentKey: "research", + message: "Research has a dynamic agent target that V0 cannot resolve.", + }); + + const repeated = await builder.build(scope); + expect(repeated.graph.edges).toEqual(enriched.graph.edges); + }); + it("deduplicates by mode, retains dual-mode edges, and reports duplicate and unresolved targets", async () => { const inventory: AgentInventoryProvider = { listAgents: vi.fn(async () => diff --git a/packages/harness/src/core/system-graph.ts b/packages/harness/src/core/system-graph.ts index 700319445..74691240d 100644 --- a/packages/harness/src/core/system-graph.ts +++ b/packages/harness/src/core/system-graph.ts @@ -408,7 +408,17 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { scans, previousState: this.evidenceByWorkspace.get(scope.workspaceKey), }); - this.evidenceByWorkspace.set(scope.workspaceKey, adapted.state); + const pendingOnlyPlaceholder = + scans.length > 0 && + scans.every((scan) => scan.pending) && + adapted.state.status === "partial"; + // The cache-only cold phase is not a producer result. Do not let its empty + // placeholder occupy the last-good slot before a real scan can seed it. + if (pendingOnlyPlaceholder) { + this.evidenceByWorkspace.delete(scope.workspaceKey); + } else { + this.evidenceByWorkspace.set(scope.workspaceKey, adapted.state); + } const edges = adapted.edges; const warnings: GraphWarning[] = [ ...consumed.warnings, From 5f34454043877c00f7ec36dcdf44896ef3e75dda Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 18:28:13 +0000 Subject: [PATCH 3/4] fix(harness): refresh settled partial evidence --- .changeset/steady-agents-connect.md | 6 +- .../harness/docs/workspace-system-graph.md | 6 +- .../system-graph-invocation-evidence.test.ts | 1 + .../core/system-graph-invocation-evidence.ts | 18 ++++- .../harness/src/core/system-graph.test.ts | 65 ++++++++++++------- packages/harness/src/core/system-graph.ts | 2 +- 6 files changed, 69 insertions(+), 29 deletions(-) diff --git a/.changeset/steady-agents-connect.md b/.changeset/steady-agents-connect.md index d5f66ad82..20ac0bcad 100644 --- a/.changeset/steady-agents-connect.md +++ b/.changeset/steady-agents-connect.md @@ -5,6 +5,6 @@ Adapt direct source invocations into package-scoped graph evidence with content-based freshness, opaque callsite references, conservative coverage, and last-good replacement semantics while preserving the existing System Graph -edge and warning payloads. Dynamic targets now make invocation coverage partial, -so affected graph snapshots are explicitly degraded/retryable instead of being -cached as complete. +edge, warning, and settled-cache behavior. Dynamic targets remain explicit +partial evidence without making an otherwise complete deterministic source scan +retryable. diff --git a/packages/harness/docs/workspace-system-graph.md b/packages/harness/docs/workspace-system-graph.md index 3983c1b14..e65eb9f80 100644 --- a/packages/harness/docs/workspace-system-graph.md +++ b/packages/harness/docs/workspace-system-graph.md @@ -152,8 +152,10 @@ or otherwise incomplete caller analysis cannot be promoted to complete; the last complete result stays visible while the new attempt is diagnosed as partial or failed. The initial cache-only phase does not seed last-good evidence: the first settled partial scan can still expose its proven static edges. A -dynamic target therefore preserves those proven edges and warning while keeping -the graph snapshot degraded/retryable. +complete source scan with a dynamic target can atomically refresh its settled +literal-edge subset while keeping topology coverage explicitly partial. Its +warning remains visible, but the deterministic limitation alone does not make +the graph retryable. Projection can remain useful while reporting warnings: diff --git a/packages/harness/src/core/system-graph-invocation-evidence.test.ts b/packages/harness/src/core/system-graph-invocation-evidence.test.ts index 8e919ec26..54f34978f 100644 --- a/packages/harness/src/core/system-graph-invocation-evidence.test.ts +++ b/packages/harness/src/core/system-graph-invocation-evidence.test.ts @@ -565,6 +565,7 @@ describe("adaptDirectInvocationsToGraphEvidence", () => { }); expect(adapted.complete).toBe(false); + expect(adapted.cacheable).toBe(true); expect(adapted.latestResult.coverage.status).toBe("partial"); expect(adapted.latestResult.diagnostics).toContainEqual( expect.objectContaining({ diff --git a/packages/harness/src/core/system-graph-invocation-evidence.ts b/packages/harness/src/core/system-graph-invocation-evidence.ts index 64f9457a8..813b7580b 100644 --- a/packages/harness/src/core/system-graph-invocation-evidence.ts +++ b/packages/harness/src/core/system-graph-invocation-evidence.ts @@ -50,6 +50,9 @@ export interface DirectInvocationEvidenceAdaptation { /** Existing path-free V0 projection derived only from accepted evidence. */ edges: StaticInvocationGraphEdge[]; warnings: GraphWarning[]; + /** True when the analyzed static subset is settled and safe to cache. */ + cacheable: boolean; + /** True only when no static topology path remains unresolved. */ complete: boolean; } @@ -435,6 +438,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { const coverageGaps: PackageGraphEvidenceCoverageGap[] = []; const contexts = new Map(); const fingerprints: NormalizedScanFingerprint[] = []; + let cacheable = true; let complete = true; const orderedScans = [...input.scans].sort((left, right) => @@ -450,6 +454,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { (scanCountByAgentKey.get(scan.caller.agentKey) ?? 0) + 1, ); if (expectedAgentKeys.has(scan.caller.agentKey)) continue; + cacheable = false; complete = false; coverageGaps.push({ code: "opaque-boundary" }); addIncompleteDiagnostic(diagnostics, contexts, scan.caller, "incomplete", { @@ -461,6 +466,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { )) { const count = scanCountByAgentKey.get(caller.agentKey) ?? 0; if (count === 1) continue; + cacheable = false; complete = false; coverageGaps.push({ code: "opaque-boundary" }); addIncompleteDiagnostic( @@ -488,12 +494,14 @@ export function adaptDirectInvocationsToGraphEvidence(input: { const dynamicCallsites: NormalizedScanFingerprint["dynamicCallsites"] = []; if (scan.pending) { + cacheable = false; complete = false; coverageGaps.push({ code: "other" }); addIncompleteDiagnostic(diagnostics, contexts, scan.caller, "pending", { status, }); } else if (scan.failed) { + cacheable = false; complete = false; coverageGaps.push({ code: "producer-failed" }); addIncompleteDiagnostic(diagnostics, contexts, scan.caller, "failed", { @@ -501,6 +509,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { }); } else { if (scan.result.complete !== true) { + cacheable = false; complete = false; coverageGaps.push({ code: "opaque-boundary" }); addIncompleteDiagnostic( @@ -512,6 +521,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { ); } if (!validFingerprint) { + cacheable = false; complete = false; coverageGaps.push({ code: "opaque-boundary" }); addIncompleteDiagnostic( @@ -527,6 +537,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { if (warning.code !== "dynamic-target") continue; complete = false; if (!validSourceEvidence(warning.evidence)) { + cacheable = false; coverageGaps.push({ code: "opaque-boundary" }); addIncompleteDiagnostic( diagnostics, @@ -569,6 +580,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { validEvidence.length === 0 || validEvidence.length !== suppliedEvidence.length ) { + cacheable = false; complete = false; coverageGaps.push({ code: "opaque-boundary" }); addIncompleteDiagnostic( @@ -723,8 +735,11 @@ export function adaptDirectInvocationsToGraphEvidence(input: { sameProducerSlot(input.previousState, input.inventory) ? input.previousState : undefined; + // A fully settled scan can atomically refresh the literal static subset even + // when a dynamic target keeps overall topology coverage partial. Transient + // partial/failed attempts still retain the last accepted result. const state = advancePackageGraphStaticEvidenceState( - previousState, + cacheable && latestResult.outcome === "success" ? undefined : previousState, latestResult, ); @@ -733,6 +748,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { state, edges: projectEdges(input.inventory, state), warnings: projectionWarnings(latestResult, state, contexts, input.agents), + cacheable, complete, }; } diff --git a/packages/harness/src/core/system-graph.test.ts b/packages/harness/src/core/system-graph.test.ts index 414e915dd..a53dc4f2b 100644 --- a/packages/harness/src/core/system-graph.test.ts +++ b/packages/harness/src/core/system-graph.test.ts @@ -377,26 +377,27 @@ describe("StaticSystemGraphBuilder", () => { ), }; const onChange = vi.fn(); + let researchResult: AgentInvocationProviderResult = { + invocations: [ + { + target: "growth", + mode: "blocking", + evidence: EVIDENCE, + }, + ], + warnings: [ + { + code: "dynamic-target", + mode: "async", + evidence: { file: "index.ts", line: 2, column: 1 }, + }, + ], + complete: true, + sourceFingerprint: SOURCE_FINGERPRINT, + }; const invocations = new CachedAgentInvocationProvider( invocationProvider(async (root) => - root.endsWith("research") - ? { - invocations: [ - { - target: "growth", - mode: "blocking", - evidence: EVIDENCE, - }, - ], - warnings: [ - { - code: "dynamic-target", - mode: "async", - evidence: { file: "index.ts", line: 2, column: 1 }, - }, - ], - } - : EMPTY_INVOCATIONS, + root.endsWith("research") ? researchResult : EMPTY_INVOCATIONS, ), async () => "unused", { onChange }, @@ -409,7 +410,7 @@ describe("StaticSystemGraphBuilder", () => { await vi.waitFor(() => expect(onChange).toHaveBeenCalled()); const enriched = await builder.build(scope); - expect(enriched.cacheable).toBe(false); + expect(enriched.cacheable).toBe(true); expect(enriched.graph.edges).toEqual([ { from: "agent:research", @@ -425,8 +426,28 @@ describe("StaticSystemGraphBuilder", () => { message: "Research has a dynamic agent target that V0 cannot resolve.", }); - const repeated = await builder.build(scope); - expect(repeated.graph.edges).toEqual(enriched.graph.edges); + researchResult = { + invocations: [], + warnings: researchResult.warnings, + complete: true, + sourceFingerprint: EDITED_SOURCE_FINGERPRINT, + }; + onChange.mockClear(); + invocations.invalidateSource(path.join(FIXTURE, "research")); + const refreshing = await builder.build(scope); + expect(refreshing.cacheable).toBe(false); + expect(refreshing.graph.edges).toEqual(enriched.graph.edges); + refreshing.afterCommit?.(); + await vi.waitFor(() => expect(onChange).toHaveBeenCalled()); + + const retracted = await builder.build(scope); + expect(retracted.cacheable).toBe(true); + expect(retracted.graph.edges).toEqual([]); + expect(retracted.graph.warnings).toContainEqual({ + code: "dynamic-target", + agentKey: "research", + message: "Research has a dynamic agent target that V0 cannot resolve.", + }); }); it("deduplicates by mode, retains dual-mode edges, and reports duplicate and unresolved targets", async () => { @@ -569,7 +590,7 @@ describe("StaticSystemGraphBuilder", () => { invocations, ).build(scope); - expect(built.cacheable).toBe(false); + expect(built.cacheable).toBe(true); expect(built.graph.edges).toEqual([]); expect(built.graph.warnings).toEqual([ { diff --git a/packages/harness/src/core/system-graph.ts b/packages/harness/src/core/system-graph.ts index 74691240d..4f1574bdb 100644 --- a/packages/harness/src/core/system-graph.ts +++ b/packages/harness/src/core/system-graph.ts @@ -446,7 +446,7 @@ export class StaticSystemGraphBuilder implements SystemGraphBuilder { cacheable: consumed.identitySettled && consumed.discoveryComplete && - adapted.complete, + adapted.cacheable, graph: { kind: "system", scope: { kind: "working-tree", workspaceKey: scope.workspaceKey }, From ee72cd56ff34328c2ebb1701e81dcc7f80aea9ee Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 18:40:33 +0000 Subject: [PATCH 4/4] fix(harness): refresh structurally partial scans --- .../harness/docs/workspace-system-graph.md | 19 +++-- .../system-graph-invocation-evidence.test.ts | 77 +++++++++++++++++-- .../core/system-graph-invocation-evidence.ts | 13 +++- .../harness/src/core/system-graph.test.ts | 24 ++++-- 4 files changed, 104 insertions(+), 29 deletions(-) diff --git a/packages/harness/docs/workspace-system-graph.md b/packages/harness/docs/workspace-system-graph.md index e65eb9f80..d57b04dbe 100644 --- a/packages/harness/docs/workspace-system-graph.md +++ b/packages/harness/docs/workspace-system-graph.md @@ -146,16 +146,15 @@ used as the evidence store. Evidence freshness hashes the bounded source content that was actually analyzed. Watcher paths, mtimes, `observedPaths`, UI node IDs, path slugs, and the cheap `fingerprintWorkflowSources` cache key do not become canonical -evidence identity. A complete refresh replaces the prior direct-invocation -result, including retracting removed calls. Missing, pending, failed, dynamic, -or otherwise incomplete caller analysis cannot be promoted to complete; the -last complete result stays visible while the new attempt is diagnosed as -partial or failed. The initial cache-only phase does not seed last-good evidence: -the first settled partial scan can still expose its proven static edges. A -complete source scan with a dynamic target can atomically refresh its settled -literal-edge subset while keeping topology coverage explicitly partial. Its -warning remains visible, but the deterministic limitation alone does not make -the graph retryable. +evidence identity. Every settled bounded refresh atomically replaces the prior +proven literal subset, including retracting removed calls. Dynamic targets or +structural limits keep topology coverage explicitly partial without preventing +that settled subset from refreshing. A dynamic-target limitation alone remains +cacheable; a structurally incomplete scan remains non-cacheable and retryable. +Pending, failed, missing, or inconsistent caller scans instead retain the prior +accepted subset and diagnose the latest attempt. The initial cache-only phase +does not seed last-good evidence, so the first settled partial scan can expose +its proven static edges. Projection can remain useful while reporting warnings: diff --git a/packages/harness/src/core/system-graph-invocation-evidence.test.ts b/packages/harness/src/core/system-graph-invocation-evidence.test.ts index 54f34978f..7b57c18ea 100644 --- a/packages/harness/src/core/system-graph-invocation-evidence.test.ts +++ b/packages/harness/src/core/system-graph-invocation-evidence.test.ts @@ -17,6 +17,7 @@ import type { const REVISION = `sha256:${"a".repeat(64)}` as const; const SOURCE_A = `sha256:${"b".repeat(64)}` as const; const SOURCE_B = `sha256:${"c".repeat(64)}` as const; +const SOURCE_C = `sha256:${"d".repeat(64)}` as const; interface AgentFixture { context: AgentInventoryItem; @@ -302,7 +303,7 @@ describe("adaptDirectInvocationsToGraphEvidence", () => { expect(edited.edges).toEqual(first.edges); }); - it("keeps last-good evidence for partial or failed refreshes and retracts it only after a complete refresh", () => { + it("keeps last-good evidence for pending or failed refreshes and retracts it after a complete refresh", () => { const caller = canonicalAgent("caller"); const target = canonicalAgent("target"); const fixtures = [caller, target]; @@ -317,16 +318,19 @@ describe("adaptDirectInvocationsToGraphEvidence", () => { scans: [scan(caller, result([directCall])), scan(target)], }); - const partial = adaptDirectInvocationsToGraphEvidence({ + const pending = adaptDirectInvocationsToGraphEvidence({ inventory: inventory(fixtures), agents: contexts(fixtures), - scans: [scan(caller, result([], { complete: false })), scan(target)], + scans: [ + scan(caller, result(), { failed: false, pending: true }), + scan(target), + ], previousState: initial.state, }); - expect(partial.complete).toBe(false); - expect(partial.state.status).toBe("stale"); - expect(partial.edges).toEqual(initial.edges); - expect(partial.latestResult.coverage.status).toBe("partial"); + expect(pending.complete).toBe(false); + expect(pending.state.status).toBe("stale"); + expect(pending.edges).toEqual(initial.edges); + expect(pending.latestResult.coverage.status).toBe("partial"); const failed = adaptDirectInvocationsToGraphEvidence({ inventory: inventory(fixtures), @@ -334,7 +338,7 @@ describe("adaptDirectInvocationsToGraphEvidence", () => { scans: fixtures.map((fixture) => scan(fixture, result(), { failed: true, pending: false }), ), - previousState: partial.state, + previousState: pending.state, }); expect(failed.complete).toBe(false); expect(failed.state.status).toBe("stale"); @@ -356,6 +360,63 @@ describe("adaptDirectInvocationsToGraphEvidence", () => { expect(removed.latestResult.evidence).toEqual([]); }); + it("refreshes the proven literal subset across consecutive settled partial scans", () => { + const caller = canonicalAgent("caller"); + const target = canonicalAgent("target"); + const fixtures = [caller, target]; + const directCall = { + target: "target", + mode: "async" as const, + evidence: [source("src/caller.ts", 2)], + }; + const first = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [ + scan(caller, result([directCall], { complete: false })), + scan(target), + ], + }); + + expect(first.complete).toBe(false); + expect(first.cacheable).toBe(false); + expect(first.state.status).toBe("partial"); + expect(first.edges).toHaveLength(1); + + const removed = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [ + scan( + caller, + result([], { complete: false, sourceFingerprint: SOURCE_B }), + ), + scan(target), + ], + previousState: first.state, + }); + expect(removed.state.status).toBe("partial"); + expect(removed.edges).toEqual([]); + + const added = adaptDirectInvocationsToGraphEvidence({ + inventory: inventory(fixtures), + agents: contexts(fixtures), + scans: [ + scan( + caller, + result([directCall], { + complete: false, + sourceFingerprint: SOURCE_C, + }), + ), + scan(target), + ], + previousState: removed.state, + }); + expect(added.state.status).toBe("partial"); + expect(added.edges).toEqual(first.edges); + }); + it("never upgrades missing, pending, incomplete, or freshness-less caller scans to complete", () => { const caller = canonicalAgent("caller"); const target = canonicalAgent("target"); diff --git a/packages/harness/src/core/system-graph-invocation-evidence.ts b/packages/harness/src/core/system-graph-invocation-evidence.ts index 813b7580b..1b76a006d 100644 --- a/packages/harness/src/core/system-graph-invocation-evidence.ts +++ b/packages/harness/src/core/system-graph-invocation-evidence.ts @@ -438,6 +438,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { const coverageGaps: PackageGraphEvidenceCoverageGap[] = []; const contexts = new Map(); const fingerprints: NormalizedScanFingerprint[] = []; + let settled = true; let cacheable = true; let complete = true; @@ -454,6 +455,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { (scanCountByAgentKey.get(scan.caller.agentKey) ?? 0) + 1, ); if (expectedAgentKeys.has(scan.caller.agentKey)) continue; + settled = false; cacheable = false; complete = false; coverageGaps.push({ code: "opaque-boundary" }); @@ -466,6 +468,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { )) { const count = scanCountByAgentKey.get(caller.agentKey) ?? 0; if (count === 1) continue; + settled = false; cacheable = false; complete = false; coverageGaps.push({ code: "opaque-boundary" }); @@ -494,6 +497,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { const dynamicCallsites: NormalizedScanFingerprint["dynamicCallsites"] = []; if (scan.pending) { + settled = false; cacheable = false; complete = false; coverageGaps.push({ code: "other" }); @@ -501,6 +505,7 @@ export function adaptDirectInvocationsToGraphEvidence(input: { status, }); } else if (scan.failed) { + settled = false; cacheable = false; complete = false; coverageGaps.push({ code: "producer-failed" }); @@ -735,11 +740,11 @@ export function adaptDirectInvocationsToGraphEvidence(input: { sameProducerSlot(input.previousState, input.inventory) ? input.previousState : undefined; - // A fully settled scan can atomically refresh the literal static subset even - // when a dynamic target keeps overall topology coverage partial. Transient - // partial/failed attempts still retain the last accepted result. + // A settled bounded scan can atomically refresh the proven literal subset + // even when structural limits or dynamic targets keep topology incomplete. + // Pending, failed, or inconsistent scan sets retain the last accepted result. const state = advancePackageGraphStaticEvidenceState( - cacheable && latestResult.outcome === "success" ? undefined : previousState, + settled && latestResult.outcome === "success" ? undefined : previousState, latestResult, ); diff --git a/packages/harness/src/core/system-graph.test.ts b/packages/harness/src/core/system-graph.test.ts index a53dc4f2b..bc137a842 100644 --- a/packages/harness/src/core/system-graph.test.ts +++ b/packages/harness/src/core/system-graph.test.ts @@ -524,7 +524,7 @@ describe("StaticSystemGraphBuilder", () => { expect(JSON.stringify(graph)).not.toContain("/private/"); }); - it("retains last-good edges across an incomplete refresh and retracts them after a complete refresh", async () => { + it("retains last-good edges across failure and refreshes them after settled scans", async () => { const resultWithAgents = inventoryResult(scope, [ { agentKey: "caller", label: "Caller" }, { agentKey: "target", label: "Target" }, @@ -533,21 +533,31 @@ describe("StaticSystemGraphBuilder", () => { invocations: [{ target: "target", mode: "blocking", evidence: EVIDENCE }], warnings: [], }; + let callerFails = false; const builder = new StaticSystemGraphBuilder( { listAgents: async () => resultWithAgents }, - invocationProvider(async (sourceRoot) => - sourceRoot.endsWith("caller") ? callerResult : EMPTY_INVOCATIONS, - ), + invocationProvider(async (sourceRoot) => { + if (sourceRoot.endsWith("caller") && callerFails) { + throw new Error("held scanner failure"); + } + return sourceRoot.endsWith("caller") ? callerResult : EMPTY_INVOCATIONS; + }), ); const initial = await builder.build(scope); expect(initial.cacheable).toBe(true); expect(initial.graph.edges).toHaveLength(1); + callerFails = true; + const failed = await builder.build(scope); + expect(failed.cacheable).toBe(false); + expect(failed.graph.edges).toEqual(initial.graph.edges); + + callerFails = false; callerResult = { invocations: [], warnings: [], complete: false }; - const incomplete = await builder.build(scope); - expect(incomplete.cacheable).toBe(false); - expect(incomplete.graph.edges).toEqual(initial.graph.edges); + const settledPartial = await builder.build(scope); + expect(settledPartial.cacheable).toBe(false); + expect(settledPartial.graph.edges).toEqual([]); callerResult = { invocations: [],