diff --git a/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md b/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md index a9189fc68..2ea3c92e0 100644 --- a/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md +++ b/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md @@ -1,4 +1,4 @@ -# Agent Map authority and retirement gate (SAP-3089) +# Agent Map authority and source retirement (SAP-3089 / SAP-3090 / SAP-3091) The current Studio server owns one durable Agent Map per project. Its state response always includes `studioProjects`, including an empty list when the @@ -8,9 +8,10 @@ to infer a project from its name or serve a second topology. ## Release boundary The server retirement in [#892](https://github.com/sapiom/sapiom-js/pull/892) -and client recovery in [#893](https://github.com/sapiom/sapiom-js/pull/893) must -ship together. Merge both before merging a Harness version PR, publishing npm -packages or tagging a desktop release. The server-only layer still has a +and client recovery in [#893](https://github.com/sapiom/sapiom-js/pull/893) are +the foundation of the cumulative SAP-3090 / SAP-3091 cleanup stack. Review and +ship all seven layers together before merging a Harness version PR, publishing +npm packages or tagging a desktop release. The server-only layer still has a bundled browser fallback that reaches the retired endpoint on catalog failure. The server layer carries `.changeset/quiet-project-map-authority.md`, marking @@ -20,7 +21,10 @@ makes `scripts/assert-release-ready.mjs` fail before versioning or publishing. The local version/release commands and the Release PR, npm Publish and Desktop Release workflows all run that check. The client layer removes the blocker together with the unavailable-map recovery. Its recovery changeset remains a -patch; the combined Harness release takes the higher minor bump. +patch; the combined Harness release takes the higher minor bump. SAP-3091 also +adds `.changeset/quiet-retired-server-graphs.md`. Both retirement changesets +describe the final authenticated JSON API 404, which applies to every unknown +`/api` path. The intermediate 410 fence is not a separately published contract. ## Authority matrix @@ -63,10 +67,32 @@ The public PackageInventory contract remains in `@sapiom/agent`. Canonical path caching, accepted discovery evidence, shared watch leases and individual-agent Canvas extraction keep their existing owners and regression coverage. -## Evidence required before browser deletion +## Source-deletion boundaries -Attach results to SAP-3089 at the reviewed PR head. Do not treat the presence of -this file as evidence that a host or recovery exercise passed. +All layers were implemented on one cumulative working branch. Separate snapshot +refs keep the PR diffs reviewable; the final ref contains the complete stack for +local Studio validation. Human review is intentionally deferred until the +complete cleanup is available, as authorized by the maintainer. + +| Ticket/layer | Revision | Change | +| --- | --- | --- | +| SAP-3090 1/2 ([#907](https://github.com/sapiom/sapiom-js/pull/907)) | `42fcaccf` | Remove browser entry points and older-server session handoff. | +| SAP-3090 2/2 ([#908](https://github.com/sapiom/sapiom-js/pull/908)) | `4c4b7190` | Delete unreachable browser topology and repoint retained viewport/styles/tests. | +| SAP-3091 1/3 ([#909](https://github.com/sapiom/sapiom-js/pull/909)) | `67337e6d` | Extract retained workspace scope/path owners and Canvas invocation type. | +| SAP-3091 2/3 ([#910](https://github.com/sapiom/sapiom-js/pull/910)) | `1a338947` | Remove server routes and graph composition; retain discovery/currentness/watch ownership. | +| SAP-3091 3/3 | `cab477b541b0485f22a4948075d2921ba1a3434c` | Delete the server engine/store/watchers/relationships/contracts and filter unsupported browser events. | + +The last row is the exact source-deletion revision. Subsequent review fixes +have their own runtime revision and verification record below. A fresh production +source and clean-built `dist` search finds no remaining imports or callers of +the retired modules. Old route +and event strings remain only in negative test/smoke probes. The public +`@sapiom/agent` PackageInventory source and schema are unchanged from `main`. + +## Retained verification gates + +Record results on SAP-3090, SAP-3091 and parent SAP-3083 at the accepted stack +head. The table defines the observation scope; actual run results follow below. | Gate | Reproducible evidence | | --- | --- | @@ -81,12 +107,112 @@ The Linux packaged run is Linux evidence. The required signed/notarized macOS installer and its upgrade journey remain release validation, not an inference from a Linux result. Record that platform's evidence in SAP-3086 before shipping. -## Candidate evidence — 2026-09-09 +## Review-fix verification — 2026-09-10 + +Runtime source revision: `5bfed981be12701e523e9096bdd0ab498aff0f20`, including +`main` at `8679d7457a8c4a1f40b2137da9e677de01b62a53`. The JSON record's +`reviewFixes` entry contains fresh build hashes and per-PR typecheck results. +These updates preserve the original reviewed commits and the seven PR +boundaries; the cumulative working branch still contains every layer. + +- #892 uses main's complete partial auth mock to resolve the merge conflict. + All 263 integration checks passed; that mock's four cases also passed on + #892's own tree. Main's deliberate removal of the duplicate Claude review + workflow is carried through the stack. +- #907 restores per-project manual pan and zoom across project/agent navigation. + Saved views are scoped to the current signed-in UI lifetime; offscreen maps + recover by fitting, and Fit continues following pane/layout changes. Browser + coverage restores E3.3's project tabs across different agent bindings. Its + changeset identifies the removed compatibility branch as internal cleanup. +- #908 restores `--text-faint`, `--font-mono` and `--type-meta`, with computed + style assertions and before/after mock screenshots in light and dark themes. + The stale mock failure comment is removed. The viewport and metadata tests + failed on the original code, then passed with the fixes. +- #910 reconciles both retirement changesets to the final JSON 404 contract, + including all unknown `/api` paths. Boot-token authentication remains required. + +Harness and dependency builds, Harness typecheck/lint, terminology and +provider-copy checks passed. All **3,845 Harness unit/integration cases** passed +(two explicit skips), all **10 performance cases** passed, all **634 browser +cases** passed, and all **15 Canvas browser cases** passed. All seven isolated +PR heads passed server/browser typechecks; #907's own tree also passed all ten +viewport/project-altitude cases. The fresh build contains no retired graph files. + +The first three-worker Harness run failed one archive-wiring case with an empty +turn list. The affected file passed unchanged **8/8**, and the full two-worker +rerun passed unchanged. The initial failure remains in the JSON record; no test +or admission guard was weakened. + +The package hashes and smoke evidence below belong to the earlier deletion +revision. They are historical evidence, not a newly packaged review-fix artifact. +Signed macOS installer/upgrade validation remains the SAP-3086 release gate. + +## Original cleanup evidence — 2026-09-09 + +Runtime revision: `cab477b541b0485f22a4948075d2921ba1a3434c`. The +[machine-readable verification record](./retirement-verification.json) includes +package/bundle hashes, counts, skips and initial failures. The subsequent +review-fix revision is recorded separately above. + +- A clean Harness build followed by the root build, typecheck and lint passed. + Terminology, provider-copy, PR-template/security checks and all 178 root script + tests passed. The Node 24 VM's root test command still fails the unchanged + `agent-core` unreadable-directory assertion; this is not a green root run. +- All **3,840 retained Harness unit/integration cases** passed (two explicit + skips), and all **10 isolated performance cases** passed. Remaining packages + passed separately: MCP 179 (three skips), CLI 73, desktop 205. +- The browser run passed **625/627**; two Chrome targets crashed in the template + preference file. That entire file then passed **23/23**, without code changes. + All **627** unique cases passed across the full run and scoped rerun. All + **15 Canvas browser cases** passed. The current authority tests include + omitted-catalog recovery, exact session tabs and negative legacy request/event + probes; retained map layout, navigation, history, focus and mobile checks pass. +- Fresh Linux x64 packaging uses Harness **0.16.0**, desktop **0.4.6**, Electron + **33.4.11**. The unpacked app passed **16** smoke checks (one Windows-only skip). + The actual AppImage extract-and-run wrapper also passed **16** checks on its + isolated run, using the identical artifact and unchanged smoke coverage. + Both runs recorded old graph read/refresh/navigation counts **0/0/0**, direct + removed-route responses **404/404/404**, and unchanged saved map/history. +- All **195** built Harness runtime/assets match the packaged files byte-for-byte + (the builder intentionally excludes TypeScript declarations and source maps). + The clean built and packaged trees contain no retired graph modules. Desktop + packaging used copied dependencies; shared workspace native binaries retain + their original hashes. + +AppImage: `sapiom-0.4.6-x86_64.AppImage` + +SHA-256: `e63fb39419b54edc828814a7195faca6814edfe99b1f7f6c675c89752a3293ad`. + +### Preexisting session-scope race observed during validation + +The first AppImage run passed the map and other checks but failed its initial +session creation with `409 PROJECT_SESSION_SCOPE_UNAVAILABLE`. Its catalog +shows a newly enrolled root becoming `missing` during the handoff from +`pendingProjectCwds` to `SessionManager.pendingCreates`. Concurrent scope +reconciliation can omit the root while bootstrap scheduling/claim is awaiting. +The admission guard then correctly refuses the stale identity. The source +paths and scope derivation are unchanged by server cleanup; removed graph +scopes supplied no protective lease. The race predates this work in +[`873dad63`](https://github.com/sapiom/sapiom-js/commit/873dad63928c287b35c37c5c401042d7ffa05149) +and the scheduling handoff in +[`21684912`](https://github.com/sapiom/sapiom-js/commit/21684912c20feaf1d86a84e5ab8199dc205f32cc). + +The isolated AppImage rerun passed without changing the artifact or test. That +result does not fix the race. SAP-3091 records it as a separate functional +follow-up: retain scope continuously through enrollment, scheduling, claim and +transfer to pending creation, without allowing duplicate bootstrap sessions or +weakening final admission. A deterministic regression can hold the second +outbox `beforeSchedule` callback, complete a concurrent `/api/workflows` read, +then require one successful session with the same active identity. This seam +has been identified but not implemented or run in the cleanup. + +## Historical SAP-3089 authority evidence — 2026-09-09 The server fence is commit `d3c91355`, based on main `65219660`. Browser code, screenshots, and this record are reviewed together in the next stack layer. -This records implementation evidence; the SAP-3090 deletion decision still -requires review of that final head and its CI. +This is the original authority-fence evidence, before the source-deletion +revisions above. Its temporary 410 responses and original test counts are not +the final cleanup result. - Root build, typecheck and lint passed, including the final Harness browser rebuild. Terminology and provider-copy checks passed. diff --git a/docs/plans/agent-studio-plan-first-agent-map/retirement-verification.json b/docs/plans/agent-studio-plan-first-agent-map/retirement-verification.json new file mode 100644 index 000000000..3ac142532 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/retirement-verification.json @@ -0,0 +1,245 @@ +{ + "revision": "cab477b541b0485f22a4948075d2921ba1a3434c", + "harnessVersion": "0.16.0", + "desktopVersion": "0.4.6", + "electronVersion": "33.4.11", + "platform": "linux-x64", + "appImage": "sapiom-0.4.6-x86_64.AppImage", + "appImageSha256": "e63fb39419b54edc828814a7195faca6814edfe99b1f7f6c675c89752a3293ad", + "checks": [ + { + "file": "dist/server/index.js", + "sha256": "de6c34771a6fb703f96e1a03c19983b7e5f192398232fbc7f23f3d92334d872d" + }, + { + "file": "dist/web/index.html", + "sha256": "c4da1b63ffaddaf29781f6857d1a0c865c7565523898edfd40a13028fce07d8f" + }, + { + "file": "dist/web/assets/index-BZtXtN0s.js", + "sha256": "304ecd5579eebacac3db002ac524c27fd4f06bc981e9b42a1b16a7edbeab8719" + } + ], + "retiredGraphFilesInPackagedDist": 0, + "byteMatchedRuntimeFiles": 195, + "nativeWorkspaceBinariesUnchanged": true, + "date": "2026-09-09", + "validation": { + "build": "passed after cleaning Harness dist", + "typecheck": "passed", + "lint": "passed with existing unrelated warnings", + "rootTest": { + "result": "failed", + "test": "agent-core/src/bundle-error.spec.ts: unreadable project directory", + "baseline": "same failure on unchanged prior code in the Node 24 VM" + }, + "harnessUnit": { + "passed": 3840, + "skipped": 2, + "filesPassed": 239, + "filesSkipped": 1 + }, + "harnessPerformance": { + "passed": 10 + }, + "browser": { + "uniqueCases": 627, + "fullRunPassed": 625, + "fullRunCrashed": 2, + "focusedFilePassed": 23, + "focusedFile": "web/e2e/template-harness.spec.ts", + "note": "Two Chrome target crashes; the full affected file passed unchanged with two workers." + }, + "canvas": { + "passed": 15 + }, + "remainingPackages": { + "mcpPassed": 179, + "mcpSkipped": 3, + "cliPassed": 73, + "desktopPassed": 205 + }, + "rootScriptTests": { + "passed": 178 + }, + "terminology": "passed", + "providerCopy": "passed", + "prTemplateAndSecurityChecks": "passed", + "linuxUnpackedSmoke": { + "passed": 16, + "skipped": 1, + "skippedCheck": "Windows-only agent-shim" + }, + "appImageSmoke": { + "initialPassed": 15, + "initialFailed": 1, + "initialSkipped": 1, + "initialFailure": "session-create: HTTP 409 PROJECT_SESSION_SCOPE_UNAVAILABLE", + "isolatedPassed": 16, + "isolatedSkipped": 1, + "artifactChanged": false, + "smokeCoverageChanged": false + }, + "agentMapSmoke": { + "legacyReadRequests": 0, + "legacyRefreshRequests": 0, + "legacyNavigationRequests": 0, + "directRetiredRouteStatuses": [ + 404, + 404, + 404 + ], + "savedMapAndHistoryUnchanged": true + } + }, + "limitations": [ + "Initial AppImage run exposed a preexisting session-scope retention gap between pendingProjectCwds and pendingCreates. It predates this cleanup; no admission checks or smoke coverage were weakened. A clean rerun does not fix the race.", + "Node 24 VM root test failure is recorded separately from successful retained package tests.", + "Linux evidence does not validate a signed/notarized macOS installer or installed-version upgrade. SAP-3086 retains those release gates." + ], + "reviewFixes": { + "date": "2026-09-10", + "sourceRevision": "5bfed981be12701e523e9096bdd0ab498aff0f20", + "builtAssets": [ + { + "file": "server/index.js", + "sha256": "158275759366835e841e023b64aa4cbfb76d6fab3d85e31de9694b0915938ab1" + }, + { + "file": "web/index.html", + "sha256": "58c37a75d1758897225daefb4dd6f343529bb8c82fc95c3154c94bb41a3c0456" + }, + { + "file": "web/assets/index-C-d3Neif.js", + "sha256": "9eebc5cf2d76dbff7e45c50514fe676f55aaac38d11038d81cf245e36c756f05" + }, + { + "file": "web/assets/index-xp2ouk6y.css", + "sha256": "917059001d832523c198c9e1cc36db5fa389db5a3abfcd4263a8c445c1f0d61b" + } + ], + "retiredGraphFilesInBuiltDist": 0, + "integratedMain": "8679d7457a8c4a1f40b2137da9e677de01b62a53", + "scope": [ + "Resolve the main auth mock conflict with the complete partial mock.", + "Restore manual project map viewports across project and agent navigation, with auth isolation, offscreen recovery and retained auto-fit.", + "Restore cross-agent project tab coverage and clarify the unreachable browser compatibility cleanup changeset.", + "Restore muted monospace Agent Map metadata, cover computed light/dark styles and record mock before/after screenshots.", + "Remove the stale mock graph failure comment.", + "Reconcile both retirement changesets to the final authenticated JSON 404 contract for every unknown API path." + ], + "validation": { + "harnessAndDependencyBuild": "passed", + "harnessTypecheck": "passed", + "harnessLint": "passed", + "terminology": "passed", + "providerCopy": "passed", + "mainIntegrationTests": { + "passed": 263 + }, + "harnessUnit": { + "passed": 3845, + "skipped": 2, + "filesPassed": 239, + "filesSkipped": 1, + "workers": 2 + }, + "initialHarnessUnitRun": { + "passed": 3844, + "failed": 1, + "skipped": 2, + "workers": 3, + "failure": "record-archive-wiring: keeps a session readable after its events are swept; turns[0] was undefined", + "unchangedFocusedFileRerun": { + "passed": 8 + }, + "note": "The subsequent complete two-worker run passed unchanged; the initial failure is retained rather than omitted." + }, + "harnessPerformance": { + "passed": 10 + }, + "browser": { + "passed": 634, + "workers": 2, + "browser": "installed Google Chrome" + }, + "canvas": { + "passed": 15 + }, + "focusedBrowser": { + "passed": 16, + "regressionsFailedBeforeFix": [ + "project viewport restoration", + "metadata computed styles in light and dark" + ] + }, + "isolatedPrHeads": [ + { + "number": 892, + "head": "c614bc3a457991348a3b754fcceba5cf15c3e295", + "checks": { + "server-typecheck": 0, + "web-typecheck": 0, + "auth-mock": 0 + } + }, + { + "number": 893, + "head": "1b8934720686cc46490b85cc17e9c69d46051547", + "checks": { + "server-typecheck": 0, + "web-typecheck": 0 + } + }, + { + "number": 907, + "head": "22452e67fb85dfd4f95af54876f65f1f3ec0efde", + "checks": { + "server-typecheck": 0, + "web-typecheck": 0, + "browser-contracts": 0 + } + }, + { + "number": 908, + "head": "62c2ffd79feca85d277e709053b17f44827b4d5b", + "checks": { + "server-typecheck": 0, + "web-typecheck": 0 + } + }, + { + "number": 909, + "head": "b43e79cb8effc33d6a0ad028e5c12f93b919586c", + "checks": { + "server-typecheck": 0, + "web-typecheck": 0 + } + }, + { + "number": 910, + "head": "5a1b979c0dede92bcd711ddd3ef65e6fb5ecdba2", + "checks": { + "server-typecheck": 0, + "web-typecheck": 0 + } + }, + { + "number": 912, + "head": "184c4e301cc9525545e4298934b310e0f990b851", + "checks": { + "server-typecheck": 0, + "web-typecheck": 0 + } + } + ] + }, + "screenshots": [ + ".github/screenshots/SAP-3090/metadata-before-light.png", + ".github/screenshots/SAP-3090/metadata-after-light.png", + ".github/screenshots/SAP-3090/metadata-before-dark.png", + ".github/screenshots/SAP-3090/metadata-after-dark.png" + ], + "packagingScope": "The AppImage hashes and packaged smoke results above belong to the original 2026-09-09 deletion revision. These review-fix results validate the current source and build; signed macOS installer/upgrade validation remains SAP-3086." + } +} diff --git a/packages/harness/README.md b/packages/harness/README.md index b0abd1b04..0b7cff826 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -353,9 +353,8 @@ HTTP contracts that need more than a type to use are written up under `docs/`: - [`docs/agent-canvas-graph.md`](docs/agent-canvas-graph.md) — the session-free `GET /api/workflows/:path/graph` Canvas route keyed by an agent's path. -- [`docs/workspace-system-graph.md`](docs/workspace-system-graph.md) — the - retired Project dependency-graph endpoints (`410 legacy_graph_retired`) and - migration to the durable Agent Map APIs. +- [`docs/agent-map-api.md`](docs/agent-map-api.md) — durable project identity, + map/node navigation, recovery and the removed project graph endpoints. ## Testing diff --git a/packages/harness/docs/agent-map-api.md b/packages/harness/docs/agent-map-api.md new file mode 100644 index 000000000..fcb92771b --- /dev/null +++ b/packages/harness/docs/agent-map-api.md @@ -0,0 +1,40 @@ +# Agent Map identity and navigation + +Studio uses one durable Agent Map per project. Read `GET /api/state` for +server-issued `studioProjects[].projectId` values and their exact +`workspaceScopes[].projectId` associations. A scope key identifies an allowed +workspace root; it is distinct from a durable project ID. Do not derive project +IDs from paths, names or node labels. + +Send the boot token in `X-Harness-Token` for local API requests. + +| Purpose | Endpoint | +| ----------------------------------------- | --------------------------------------------------------------------- | +| Read the saved map and shared proposal | `GET /api/projects/:projectId/agent-map/workspace` | +| Resolve an implementation-backed map node | `GET /api/projects/:projectId/agent-map/nodes/:nodeId/implementation` | + +Use the exact project and node IDs when resolving an implementation. Missing, +ambiguous or unavailable implementations remain unresolved. Viewing a project, +retrying its identity, inspecting a node or navigating to an agent does not +create, select, resume, bind or prompt a conversation. Explicit session tabs +open their exact ordinary conversation and its independent Canvas/Steps. + +When identity is unavailable, Studio preserves the selected project and +conversation and offers **Reload projects**. An omitted catalog from an older +server follows the same recovery path. Upgrade older clients and servers +together; there is no second map protocol or fallback renderer. + +The former `GET /api/workspaces/:workspaceKey/system-graph`, its `POST /refresh` +and `GET /navigation` handlers have been deleted. Without a valid boot token, +requests return 401. Authenticated requests return the generic API 404, replacing +the temporary 410 retirement response. Removed and unknown event types are +ignored before reaching browser state subscribers. + +Shared workspace discovery, watch leases, the public `@sapiom/agent` +PackageInventory contract and individual-agent Canvas source scanning remain +independent of the removed project topology. + +See the [authority and retirement record](../../../docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md) +for validation evidence and the release recovery boundary. Installed-release +recovery requires a reverted change released at strictly higher package and +desktop versions; no in-place downgrade or lossless state reset is promised. diff --git a/packages/harness/docs/workspace-system-graph.md b/packages/harness/docs/workspace-system-graph.md deleted file mode 100644 index aa5d88ee8..000000000 --- a/packages/harness/docs/workspace-system-graph.md +++ /dev/null @@ -1,232 +0,0 @@ -# Retired Project system graph HTTP contract - -**Breaking for HTTP clients:** current Studio servers retire the following -routes. The route handlers and server graph composition have been removed. -Authenticated requests return `404` with `error: "API route not found"`: - -```http -GET /api/workspaces/:workspaceKey/system-graph -POST /api/workspaces/:workspaceKey/system-graph/refresh -GET /api/workspaces/:workspaceKey/system-graph/navigation -``` - -The boot-token gate still runs first: send `X-Harness-Token`; missing or invalid -tokens return `401`. Authenticated unknown workspace keys also receive the -not-found response. Current servers do not emit `system-graph.changed` events -or return the historical snapshots/cache headers described below. - -## Migration to Agent Map - -Read `GET /api/state` for server-issued `studioProjects[].projectId` values and -their exact `workspaceScopes[].projectId` associations. A `workspaceKey` is not -a project ID; do not derive an ID from a path, name or legacy graph key. - -Use `GET /api/projects/:projectId/agent-map/workspace` to read the durable map -and shared proposal. To navigate an implementation-backed node, use -`GET /api/projects/:projectId/agent-map/nodes/:nodeId/implementation` with its -exact map node ID. Ordinary session tabs and per-agent Canvas remain available. -These APIs use the same boot-token protection and are not drop-in replacements -for the process-memory graph snapshot or its revision-matched navigation. - -If the catalog cannot resolve a project's identity, keep its conversation and -selection, show an unavailable-map retry, and re-read the catalog. Do not call -the retired routes or start a session as a fallback. Current Studio's -**Reload projects** action provides this recovery; stale clients must upgrade -to a release containing both the server retirement and client recovery. - -## Historical contract — older servers only - -The remainder documents the retired protocol for older servers. Its success -responses, lifecycle states and events do not describe the current server. - -Agent Studio exposes a local, read-only dependency graph for each opened -Project. The route keeps its historical `/workspaces/` name, but a -`workspaceKey` identifies one exact Project root in the current Studio server. -It is an opaque server-issued value, not a path and not an identifier clients -should derive. - -The API is local to the Harness process and sits behind the same boot-token -middleware as the rest of `/api`. Send the token in `X-Harness-Token`. An -unknown or retired key returns `404 { "error": "Workspace not found" }` without -scanning the supplied string as a path. - -## Discovering a Project key - -`GET /api/state` includes an optional `workspaceScopes` array: - -```ts -interface WorkspaceScopeSummary { - workspaceKey: string; - cwd: string; -} -``` - -The browser joins a displayed Project root to the entry with the same `cwd` -and then uses its `workspaceKey`. Filesystem paths stop at this state boundary: -system-graph snapshots and change events never contain a Project root or agent -source path. - -## Reading and refreshing - -```http -GET /api/workspaces/:workspaceKey/system-graph -POST /api/workspaces/:workspaceKey/system-graph/refresh -``` - -`GET` returns the current accepted process-memory snapshot. On a cold read, -known inventory nodes and revision-matched navigation render immediately in a -degraded projection; bounded direct invocation extraction and background discovery -may publish a later revision. Concurrent reads share that work, and ordinary -reads never await a filesystem baseline or discovery scan. `POST .../refresh` -reruns registry prerequisites, requests a fresh projection, waits for that -attempt, and is the explicit recovery action after an error. Both successful -routes return `200` with a -`SystemGraphSnapshot`: - -```ts -interface SystemGraphSnapshot { - workspaceKey: string; - revision: number; - state: "building" | "ready" | "stale" | "degraded"; - graph: SystemGraph | null; -} -``` - -Revisions increase for accepted lifecycle transitions. They are monotonic for -one `workspaceKey` during one server process, including a remove-and-reopen, -but they are not durable across a Studio restart. - -| State | `graph` | Meaning | -| ---------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `building` | `null` until a usable projection exists | The first projection, or a refresh without a usable graph, is in progress. | -| `ready` | non-null | The current projection completed and is cacheable. | -| `stale` | non-null | The newest usable graph remains visible while refresh is running or after refresh failed. | -| `degraded` | partial graph or `null` | Projection was not cacheable, or no usable graph could be built. The UI may offer one bounded automatic recovery followed by explicit retry. | - -Every `200` response also carries: - -```http -X-Sapiom-System-Graph-Cache: complete | degraded -``` - -`complete` means the returned snapshot is `ready`. `building`, `stale`, and -`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, or `building` state leaves graph cards inert. An exact-revision -sidecar remains usable for a stale graph while its refresh runs, but a newer -invalidation closes navigation immediately. 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 -opaque key. Nodes contain an `id`, Project-scoped `agentKey`, and display -`label`. Public direct-invocation edges are explicit and extensible: - -```ts -interface StaticInvocationGraphEdge { - from: string; - to: string; - kind: "invokes"; - basis: "static-invocation"; - mode: "blocking" | "async"; -} - -type SystemGraphEdge = StaticInvocationGraphEdge; -``` - -Blocking and asynchronous calls between the same pair remain distinct in the -JSON even when the UI groups them into one connector. - -Direct invocation analysis is syntax-only. It never creates a TypeScript -`Program` or `TypeChecker`, and it never imports, bundles, or executes customer -code. The provider scans each inventoried agent source root separately for -literal calls. It does not inspect the provenance of invocation inputs, follow -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. - -Projection can remain useful while reporting warnings: - -| Warning code | Meaning | -| ----------------------------- | ------------------------------------------------------------------------------------------------- | -| `unresolved-target` | A literal target does not resolve to an agent in the selected Project. | -| `dynamic-target` | Source contains a call whose target cannot be proven statically. | -| `duplicate-edge` | The same mode-specific direct invocation was discovered more than once. | -| `projection-failed` | A direct invocation projection failed and the remaining graph was preserved. | -| `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 and syntax-discovered agents enter a working-tree package inventory -and render immediately. A syntax-proven source definition name is canonical -without bundling or executing project code; a retained marker/cloud slug remains -only a compatibility alias. Unknown or invalid identity uses a safe provisional marker -or `local:` key. Marker-authorized legacy name inspection and direct invocation -extraction run in bounded background queues after the inventory projection -commits. Settled identities and invocation edges publish later revisions; -failures preserve provisional nodes and unambiguous direct edges. - -Cacheability keeps three private facts separate: workspace discovery must be -complete, identity work must be settled, and direct invocation extraction must -be complete. A settled unavailable identity may therefore remain provisional -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. - -## Freshness event - -After an accepted transition, the Harness event WebSocket publishes: - -```ts -{ - type: "system-graph.changed"; - workspaceKey: string; - revision: number; - state: "building" | "ready" | "stale" | "degraded"; -} -``` - -The event is an invalidation hint. Clients compare its key and revision with -the displayed snapshot and refetch when newer; the graph itself is not sent on -the event bus. - -Opening a Project graph acquires a canonical-root watcher lease. Sessions and -graphs for the same root share its bounded asynchronous fingerprint rather than -multiplying recursive walks. Relevant raw events synchronously make old -navigation inert; source/inventory reconciliation is debounced, coalesced, and -generation-guarded. Platforms without recursive watch support, or watchers that -later error, fall back to asynchronous polling over the same admitted candidate -and dependency observations. Removing the final lease retires the watcher and -degrades its accepted freshness proof before a later reopen can reuse it. diff --git a/packages/harness/src/core/__fixtures__/system-graph-workspace/growth/index.ts b/packages/harness/src/core/__fixtures__/system-graph-workspace/growth/index.ts deleted file mode 100644 index 9f057d762..000000000 --- a/packages/harness/src/core/__fixtures__/system-graph-workspace/growth/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function growth(input: unknown) { - return input; -} diff --git a/packages/harness/src/core/__fixtures__/system-graph-workspace/research/index.ts b/packages/harness/src/core/__fixtures__/system-graph-workspace/research/index.ts deleted file mode 100644 index db66c5e88..000000000 --- a/packages/harness/src/core/__fixtures__/system-graph-workspace/research/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export async function research(ctx: { - sapiom: { agents: { run: Function; launch: Function } }; -}) { - await ctx.sapiom.agents.run({ - definition: "growth", - input: { topic: "market" }, - }); - return ctx.sapiom.agents.launch({ - definition: "growth", - input: { topic: "market" }, - }); -} diff --git a/packages/harness/src/core/canvas-interconnections.ts b/packages/harness/src/core/canvas-interconnections.ts index d52b08392..062f6c650 100644 --- a/packages/harness/src/core/canvas-interconnections.ts +++ b/packages/harness/src/core/canvas-interconnections.ts @@ -499,7 +499,7 @@ export interface DetectedLaunch { fromStepId: string | null; } -/** Internal source evidence. It never crosses the system-graph HTTP boundary. */ +/** Private syntax evidence for per-agent Canvas extraction. */ export interface SourceEvidence { /** POSIX path relative to the caller's source root. */ file: string; diff --git a/packages/harness/src/core/system-graph-inventory.test.ts b/packages/harness/src/core/system-graph-inventory.test.ts deleted file mode 100644 index c1cce8ebe..000000000 --- a/packages/harness/src/core/system-graph-inventory.test.ts +++ /dev/null @@ -1,1402 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { describe, expect, it, vi } from "vitest"; - -import type { RegistryWorkflowInfo as WorkflowInfo } from "./workflow-registry.js"; -import { - dirtyGraphSourceRoots, - HarnessRegistryInventoryProvider, - inventorySourceRoot, - type AgentInventoryResult, - type HarnessRegistryInventoryProviderOptions, - type WorkspaceScope, -} from "./system-graph-inventory.js"; -import type { - ManifestNameInspection, - ManifestNameInspectionOptions, -} from "./definition-name.js"; - -const WORKSPACE = "/private/workspaces/acme"; -const SCOPE: WorkspaceScope = { - workspaceKey: "workspace-acme", - root: WORKSPACE, -}; - -function workflow( - name: string, - relativePath: string, - definitionSlug: string | null, - overrides: Partial = {}, -): WorkflowInfo { - return { - name, - path: relativePath ? `${WORKSPACE}/${relativePath}` : WORKSPACE, - definitionId: definitionSlug ? 1 : null, - definitionSlug, - markerPresent: true, - source: "scan", - ...overrides, - }; -} - -function provider( - workflows: readonly WorkflowInfo[], - options: Partial = {}, -): HarnessRegistryInventoryProvider { - return new HarnessRegistryInventoryProvider({ - listWorkflows: () => workflows, - fingerprintSource: async (sourceRoot) => `fingerprint:${sourceRoot}`, - revalidateMarker: async () => true, - ...options, - inventorySnapshot: - options.inventorySnapshot ?? - (async (scope) => ({ - workflows, - status: await (options.inventoryStatus?.(scope) ?? "complete"), - generation: 1, - canonicalScopeRoot: scope.root, - canonicalWorkflowRoots: workflows.map((item) => ({ - workflowPath: item.path, - canonicalRoot: item.path, - identityEvidence: Object.prototype.hasOwnProperty.call( - item, - "sourceDefinitionName", - ) - ? ("source" as const) - : item.markerPresent === true - ? ("marker" as const) - : ("unknown" as const), - })), - })), - }); -} - -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("uses syntax-proven source identity immediately without extraction", async () => { - const inspectManifestName = vi.fn(async () => { - throw new Error("source-only rows must never execute extraction"); - }); - const inventory = provider( - [ - workflow("Billing package", "billing", "old-marker", { - sourceDefinitionName: "CurrentSourceName", - }), - ], - { - inspectManifestName, - inventoryStatus: async () => "degraded" as const, - }, - ); - - const result = await inventory.listAgents(SCOPE); - - expect(result.inventory).toMatchObject({ - status: "degraded", - agents: [ - { - agentKey: "CurrentSourceName", - identityStatus: "canonical", - }, - ], - }); - expect(result.context[0]?.resolutionAliases).toContain("old-marker"); - expect(result.discoveryComplete).toBe(false); - expect(result.startEnrichment).toBeUndefined(); - expect(inspectManifestName).not.toHaveBeenCalled(); - }); - - it("never extracts a markerless manual row even after discovery is complete", async () => { - const inspectManifestName = vi.fn(async () => ({ - status: "found" as const, - name: "Executed", - })); - let status: "complete" | "degraded" = "degraded"; - const inventory = provider( - [ - workflow("Pending", "pending", null, { - markerPresent: undefined, - source: "connect", - }), - ], - { inspectManifestName, inventoryStatus: () => status }, - ); - - const degraded = await inventory.listAgents(SCOPE); - degraded.startEnrichment?.(); - await Promise.resolve(); - expect(degraded.startEnrichment).toBeUndefined(); - expect(inspectManifestName).not.toHaveBeenCalled(); - - status = "complete"; - const complete = await inventory.listAgents(SCOPE); - expect(complete.startEnrichment).toBeUndefined(); - complete.startEnrichment?.(); - expect(inspectManifestName).not.toHaveBeenCalled(); - }); - - it("enriches a markerless row with retained cloud-link authorization", async () => { - const inspectManifestName = vi.fn(async () => ({ - status: "found" as const, - name: "CloudLinked", - })); - const inventory = provider( - [ - workflow("Linked", "linked", "old-alias", { - markerPresent: undefined, - source: "connect", - }), - ], - { inspectManifestName }, - ); - - const initial = await inventory.listAgents(SCOPE); - expect(initial.startEnrichment).toBeTypeOf("function"); - initial.startEnrichment?.(); - await vi.waitFor(() => expect(inspectManifestName).toHaveBeenCalledOnce()); - await expect(inventory.listAgents(SCOPE)).resolves.toMatchObject({ - discoveryComplete: true, - inventory: { - agents: [{ agentKey: "CloudLinked", identityStatus: "canonical" }], - }, - }); - }); - - it("enriches a marker-proven row despite unrelated degraded discovery", async () => { - const inspectManifestName = vi.fn(async () => ({ - status: "found" as const, - name: "CurrentSource", - })); - const inventory = provider( - [ - workflow("Linked", "linked", null, { - markerPresent: true, - }), - ], - { - inspectManifestName, - inventoryStatus: () => "degraded", - }, - ); - - const degraded = await inventory.listAgents(SCOPE); - expect(degraded.inventory.status).toBe("degraded"); - expect(degraded.startEnrichment).toBeTypeOf("function"); - degraded.startEnrichment?.(); - await vi.waitFor(() => expect(inspectManifestName).toHaveBeenCalledOnce()); - }); - - it("prefers one atomic inventory snapshot over independently racing reads", async () => { - const listWorkflows = vi.fn(() => [workflow("wrong", "wrong", "wrong")]); - const inventoryStatus = vi.fn(() => "complete" as const); - const inventory = new HarnessRegistryInventoryProvider({ - listWorkflows, - inventoryStatus, - inventorySnapshot: () => ({ - workflows: [ - workflow("Atomic", "atomic", null, { - sourceDefinitionName: "atomic-source", - }), - ], - status: "degraded", - generation: 7, - }), - }); - - const result = await inventory.listAgents(SCOPE); - - expect(result.inventory).toMatchObject({ - status: "degraded", - agents: [{ agentKey: "atomic-source", path: "atomic" }], - }); - expect(listWorkflows).not.toHaveBeenCalled(); - expect(inventoryStatus).not.toHaveBeenCalled(); - }); - - it("retires an active legacy extraction when syntax evidence arrives", async () => { - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - let workflows: WorkflowInfo[] = [workflow("Legacy", "agent", "old-marker")]; - const inspectManifestName = vi.fn(async () => { - await gate; - return { status: "found" as const, name: "StaleExtracted" }; - }); - const changed = vi.fn(); - const inventory = new HarnessRegistryInventoryProvider({ - listWorkflows: () => workflows, - inventoryStatus: () => "complete", - fingerprintSource: async () => "fingerprint", - revalidateMarker: async () => true, - inspectManifestName, - onIdentityChange: changed, - }); - const legacy = await inventory.listAgents(SCOPE); - legacy.startEnrichment?.(); - await vi.waitFor(() => expect(inspectManifestName).toHaveBeenCalledOnce()); - - workflows = [ - workflow("Source", "agent", "old-marker", { - sourceDefinitionName: "CurrentSyntax", - }), - ]; - const syntax = await inventory.listAgents(SCOPE); - expect(syntax.inventory.agents[0]).toMatchObject({ - agentKey: "CurrentSyntax", - identityStatus: "canonical", - }); - release(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(changed).not.toHaveBeenCalled(); - await expect(inventory.listAgents(SCOPE)).resolves.toMatchObject({ - inventory: { - agents: [{ agentKey: "CurrentSyntax", identityStatus: "canonical" }], - }, - }); - }); - - it("derives inventory roots using POSIX, drive, and UNC workspace flavor", () => { - expect(inventorySourceRoot("/workspace", "nested/agent")).toBe( - "/workspace/nested/agent", - ); - expect(inventorySourceRoot("C:\\workspace", "nested/agent")).toBe( - "C:\\workspace\\nested\\agent", - ); - expect( - inventorySourceRoot("\\\\server\\share\\workspace", "nested/agent"), - ).toBe("\\\\server\\share\\workspace\\nested\\agent"); - }); - - 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", "old-marker")], - { inspectManifestName, onIdentityChange: changed }, - ); - - const initial = await inventory.listAgents(SCOPE); - - expect(inspectManifestName).not.toHaveBeenCalled(); - expect(initial.identitySettled).toBe(false); - expect(initial.inventory).toMatchObject({ - status: "degraded", - agents: [ - { - agentKey: "old-marker", - identityStatus: "provisional", - identityIssue: "identity-pending", - path: "research", - entrypoint: "index.ts", - }, - ], - }); - 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); - - expect(enriched.inventory).toMatchObject({ - status: "complete", - agents: [ - { - agentKey: "SourceName", - identityStatus: "canonical", - }, - ], - }); - expect(enriched.identitySettled).toBe(true); - 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("does not call two unsettled marker identities a collision", async () => { - let release!: () => void; - const inspectionGate = new Promise((resolve) => { - release = resolve; - }); - const inspectManifestName = vi.fn(async (sourceRoot: string) => { - await inspectionGate; - return { - status: "found" as const, - name: sourceRoot.endsWith("/first") ? "payments" : "billing", - }; - }); - const changed = vi.fn(); - const inventory = provider( - [ - workflow("First", "first", "shared-marker"), - workflow("Second", "second", "shared-marker"), - ], - { inspectManifestName, onIdentityChange: changed }, - ); - - const pending = await inventory.listAgents(SCOPE); - expect(pending.identitySettled).toBe(false); - expect(pending.warnings).toEqual([]); - expect(pending.inventory.agents).toMatchObject([ - { - agentKey: "local:first", - identityStatus: "provisional", - identityIssue: "identity-pending", - }, - { - agentKey: "local:second", - identityStatus: "provisional", - identityIssue: "identity-pending", - }, - ]); - expect( - pending.inventory.agents.map((agent) => agent.identityIssue), - ).toEqual(["identity-pending", "identity-pending"]); - expect(pending.context.map((item) => item.resolutionAliases)).toEqual([ - ["shared-marker"], - ["shared-marker"], - ]); - - pending.startEnrichment?.(); - release(); - await vi.waitFor(() => expect(changed).toHaveBeenCalled()); - const settled = await inventory.listAgents(SCOPE); - - expect(settled.identitySettled).toBe(true); - expect(settled.inventory.agents.map((agent) => agent.agentKey)).toEqual([ - "billing", - "payments", - ]); - expect(settled.warnings).toEqual([]); - }); - - it("keeps shared retryable marker guesses under local identities", async () => { - const changed = vi.fn(); - const inventory = provider( - [ - workflow("First", "first", "shared-marker"), - workflow("Second", "second", "shared-marker"), - ], - { - inspectManifestName: async () => ({ - status: "failed", - retryable: true, - }), - onIdentityChange: changed, - }, - ); - - const result = await enrich( - inventory, - await inventory.listAgents(SCOPE), - changed, - ); - - expect(result.identitySettled).toBe(false); - expect(result.inventory.agents).toMatchObject([ - { - agentKey: "local:first", - identityStatus: "provisional", - identityIssue: "identity-unavailable", - }, - { - agentKey: "local:second", - identityStatus: "provisional", - identityIssue: "identity-unavailable", - }, - ]); - expect(result.warnings).toHaveLength(2); - expect( - result.warnings.every( - (warning) => warning.code === "inventory-extraction-failed", - ), - ).toBe(true); - }); - - 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: "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", - }, - { - agentKey: "payments", - identityStatus: "canonical", - path: "canonical", - entrypoint: "index.ts", - }, - ]); - 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", retryable: true }) - .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"); - expect(failed.identitySettled).toBe(false); - 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("settles an unnameable identity without hiding its warning", async () => { - const changed = vi.fn(); - const inventory = provider([workflow("Dashboard", "dashboard", null)], { - inspectManifestName: async () => ({ - status: "failed", - retryable: false, - }), - onIdentityChange: changed, - }); - - const result = await enrich( - inventory, - await inventory.listAgents(SCOPE), - changed, - ); - - expect(result.inventory).toMatchObject({ - status: "degraded", - agents: [ - { - agentKey: "local:dashboard", - identityIssue: "identity-unavailable", - }, - ], - }); - expect(result.identitySettled).toBe(true); - expect(result.warnings).toEqual([ - expect.objectContaining({ - code: "inventory-extraction-failed", - agentKey: "local:dashboard", - }), - ]); - }); - - 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([]); - expect(result.identitySettled).toBe(true); - - 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("caps background source inspection concurrency at four", async () => { - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - let active = 0; - let maximum = 0; - const inspectManifestName = vi.fn(async (sourceRoot: string) => { - active += 1; - maximum = Math.max(maximum, active); - await gate; - 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 }, - ); - - (await inventory.listAgents(SCOPE)).startEnrichment?.(); - await vi.waitFor(() => - expect(inspectManifestName).toHaveBeenCalledTimes(4), - ); - expect(maximum).toBe(4); - release(); - await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); - expect(changed).toHaveBeenCalledWith( - Array.from({ length: 7 }, (_, index) => `${WORKSPACE}/agent-${index}`), - ); - expect(maximum).toBe(4); - }); - - it("retires queued and active marker inspections synchronously on a raw scope edit", async () => { - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const inspectManifestName = vi.fn(async (sourceRoot: string) => { - await gate; - return { status: "found" as const, name: path.basename(sourceRoot) }; - }); - const changed = vi.fn(); - const inventory = provider( - Array.from({ length: 5 }, (_, index) => - workflow(`Agent ${index}`, `agent-${index}`, null), - ), - { inspectManifestName, onIdentityChange: changed }, - ); - - (await inventory.listAgents(SCOPE)).startEnrichment?.(); - await vi.waitFor(() => - expect(inspectManifestName).toHaveBeenCalledTimes(4), - ); - inventory.invalidateScope(WORKSPACE); - release(); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(inspectManifestName).toHaveBeenCalledTimes(4); - expect(changed).not.toHaveBeenCalled(); - }); - - it("queues fresh proof behind an invalidated active inspection and converges", async () => { - let releaseFirst!: () => void; - const firstGate = new Promise((resolve) => { - releaseFirst = resolve; - }); - const inspectManifestName = vi.fn(async () => { - if (inspectManifestName.mock.calls.length === 1) await firstGate; - return { status: "found" as const, name: "FreshIdentity" }; - }); - const changed = vi.fn(); - const inventory = provider([workflow("Agent", "agent", null)], { - inspectManifestName, - onIdentityChange: changed, - }); - - (await inventory.listAgents(SCOPE)).startEnrichment?.(); - await vi.waitFor(() => expect(inspectManifestName).toHaveBeenCalledOnce()); - - // A raw edit in another active scope conservatively invalidates the shared - // provider epoch. A later ordinary read of this scope must enqueue fresh - // work instead of mistaking the stale active task for the same request. - inventory.invalidateScope("/private/workspaces/unrelated"); - (await inventory.listAgents(SCOPE)).startEnrichment?.(); - releaseFirst(); - - await vi.waitFor(() => - expect(inspectManifestName).toHaveBeenCalledTimes(2), - ); - await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); - await expect(inventory.listAgents(SCOPE)).resolves.toMatchObject({ - inventory: { - agents: [{ agentKey: "FreshIdentity", identityStatus: "canonical" }], - }, - }); - }); - - it("revalidates marker proof after fingerprinting immediately before inspection", async () => { - let releaseFingerprint!: () => void; - const fingerprintGate = new Promise((resolve) => { - releaseFingerprint = resolve; - }); - let markerPresent = true; - const fingerprintEntered = vi.fn(); - const inspectManifestName = vi.fn(async () => ({ - status: "found" as const, - name: "must-not-run", - })); - const changed = vi.fn(); - const inventory = provider([workflow("Agent", "agent", null)], { - fingerprintSource: async () => { - fingerprintEntered(); - await fingerprintGate; - return "fingerprint"; - }, - revalidateMarker: async () => markerPresent, - inspectManifestName, - onIdentityChange: changed, - }); - - (await inventory.listAgents(SCOPE)).startEnrichment?.(); - await vi.waitFor(() => expect(fingerprintEntered).toHaveBeenCalledOnce()); - markerPresent = false; - releaseFingerprint(); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(inspectManifestName).not.toHaveBeenCalled(); - expect(changed).not.toHaveBeenCalled(); - }); - - it("threads current marker proof to the inspector's actual child-launch boundary", async () => { - let releaseInnerFingerprint!: () => void; - const innerFingerprintGate = new Promise((resolve) => { - releaseInnerFingerprint = resolve; - }); - let markerPresent = true; - const innerFingerprintEntered = vi.fn(); - const actualExtractorLaunch = vi.fn(); - const inspectManifestName = vi.fn( - async ( - _sourceRoot: string, - options?: ManifestNameInspectionOptions, - ): Promise => { - innerFingerprintEntered(); - await innerFingerprintGate; - if (!(await options?.authorizeBeforeLaunch?.())) { - return { status: "failed", retryable: true }; - } - actualExtractorLaunch(); - return { status: "found", name: "must-not-run" }; - }, - ); - const changed = vi.fn(); - const inventory = provider([workflow("Agent", "agent", null)], { - revalidateMarker: async () => markerPresent, - inspectManifestName, - onIdentityChange: changed, - }); - - (await inventory.listAgents(SCOPE)).startEnrichment?.(); - await vi.waitFor(() => - expect(innerFingerprintEntered).toHaveBeenCalledOnce(), - ); - markerPresent = false; - releaseInnerFingerprint(); - await vi.waitFor(() => expect(changed).toHaveBeenCalledOnce()); - - expect(actualExtractorLaunch).not.toHaveBeenCalled(); - expect( - (await inventory.listAgents(SCOPE)).inventory.agents[0], - ).toMatchObject({ identityIssue: "identity-unavailable" }); - }); - - 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)], - { - inspectManifestName, - onIdentityChange: changed, - }, - ); - - (await inventory.listAgents(SCOPE)).startEnrichment?.(); - await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1), { - timeout: 5_000, - }); - expect(changed).toHaveBeenNthCalledWith(1, [`${WORKSPACE}/fast`]); - - 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("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)], - { - inspectManifestName, - onIdentityChange: changed, - identityChangeCoalesceMs: 10_000, - }, - ); - - (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, - revalidateMarker: async () => true, - 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); - - 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(degraded.inventory.agents[0]).toMatchObject({ - agentKey: "marker", - identityIssue: "identity-unavailable", - }); - expect(degraded.warnings).toEqual([ - expect.objectContaining({ code: "inventory-extraction-failed" }), - ]); - }); - - 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, - revalidateMarker: async () => true, - onIdentityChange: changed, - }); - await enrich(inventory, await inventory.listAgents(SCOPE), changed); - - 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([ - { - ...workflow("Private label changed", "alpha", "alpha", { - definitionId: 999, - }), - path: `${otherRoot}/alpha`, - }, - { - ...workflow("Other private label", "zeta", "zeta", { - definitionId: null, - }), - path: `${otherRoot}/zeta`, - }, - ]); - - 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([ - { - ...workflow("Right checkout", "", null), - path: otherRoot, - }, - ]).listAgents({ workspaceKey: SCOPE.workspaceKey, root: otherRoot }); - - expect(left.inventory.agents).toEqual([ - expect.objectContaining({ agentKey: "local:root", path: "." }), - ]); - expect(left.inventory.version).toEqual(right.inventory.version); - }); - - 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(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(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(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"), - { - ...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("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"); - - 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 () => { - const inventory = new HarnessRegistryInventoryProvider({ - listWorkflows: async () => { - 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 deleted file mode 100644 index 337b2c2bc..000000000 --- a/packages/harness/src/core/system-graph-inventory.ts +++ /dev/null @@ -1,1033 +0,0 @@ -import type { WorkspaceScope } from "./workspace-scope-catalog.js"; -import type { WorkspaceKey } from "../shared/workspace-scope.js"; -export type { WorkspaceScope } from "./workspace-scope-catalog.js"; -import { isWithinWorkspacePath as isWithinGraphPath, sourceRootsWithinScope as graphSourceRootsWithinScope } from "./workspace-path.js"; -export { isWithinWorkspacePath as isWithinGraphPath, sourceRootsWithinScope as graphSourceRootsWithinScope } from "./workspace-path.js"; -import { createHash } from "node:crypto"; -import * as path from "node:path"; - -import { - PACKAGE_INVENTORY_PROTOCOL, - packageInventorySchema, - type PackageInventory, - type PackageInventoryAgent, -} from "@sapiom/agent"; - -import { - type AgentKey, - type GraphWarning, -} from "../shared/system-graph.js"; -import type { RegistryWorkflowInfo as WorkflowInfo } from "./workflow-registry.js"; -import { fingerprintWorkflowSources } from "./canvas-cache.js"; -import { canonicalGraphPath } from "./canonical-graph-path.js"; -import type { - ManifestNameInspection, - ManifestNameInspectionOptions, -} from "./definition-name.js"; -import { inspectAgentProjectMarker } from "./agent-project-discovery.js"; - -export { canonicalGraphPath } from "./canonical-graph-path.js"; - -/** 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[]; - /** 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 { - code: Extract< - GraphWarning["code"], - "duplicate-agent-key" | "inventory-extraction-failed" - >; - agentKey: AgentKey; - message: string; -} - -export interface AgentInventoryResult { - inventory: PackageInventory; - context: AgentInventoryContextItem[]; - warnings: AgentInventoryWarning[]; - /** - * Private lifecycle state: false while an unchanged source could still - * resolve to a different identity. This stays outside PackageInventory so - * the public contract continues to answer only what exists and where. - */ - identitySettled: boolean; - /** - * Private discovery coverage: false when the accepted workspace scan could - * not prove that every eligible agent was considered. This is independent - * of identity settlement because a settled provisional identity is still a - * cacheable result, while an incomplete workspace walk is not. - */ - discoveryComplete: boolean; - /** Starts source identity work only after the provisional graph is committed. */ - startEnrichment?: () => void; -} - -/** 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 = ( - sourceRoot: string, - options?: ManifestNameInspectionOptions, -) => Promise; - -export interface HarnessRegistryInventoryProviderOptions { - listWorkflows: () => - | readonly WorkflowInfo[] - | Promise; - inspectManifestName?: ManifestNameInspector; - /** Hardened launch-time marker proof; injectable for filesystem-free tests. */ - revalidateMarker?: (sourceRoot: string) => Promise; - /** Last discovery completeness for this exact selected scope. */ - inventoryStatus?: ( - scope: WorkspaceScope, - ) => "complete" | "degraded" | Promise<"complete" | "degraded">; - /** Atomic registry/cache projection; preferred over separate legacy reads. */ - inventorySnapshot?: (scope: WorkspaceScope) => - | { - workflows: readonly WorkflowInfo[]; - status: "complete" | "degraded"; - generation: number; - canonicalScopeRoot?: string; - canonicalWorkflowRoots?: readonly { - workflowPath: string; - canonicalRoot: string; - identityEvidence: "marker" | "source" | "not-agent" | "unknown"; - }[]; - } - | Promise<{ - workflows: readonly WorkflowInfo[]; - status: "complete" | "degraded"; - generation: number; - canonicalScopeRoot?: string; - canonicalWorkflowRoots?: readonly { - workflowPath: string; - canonicalRoot: string; - identityEvidence: "marker" | "source" | "not-agent" | "unknown"; - }[]; - }>; - /** - * 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; -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); - }); -} - -type InventoryIdentityIssue = Exclude< - PackageInventoryAgent["identityIssue"], - undefined ->; - -function isWindowsAbsolute(input: string): boolean { - return ( - /^[A-Za-z]:[\\/]/.test(input) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(input) - ); -} - -function pathApi(input: string): typeof path.posix { - return isWindowsAbsolute(input) ? path.win32 : path.posix; -} - -/** - * Resolve with the input path's own flavor so mixed Windows separators remain - * comparable even when the test process (or a future remote host) is POSIX. - */ -/** - * 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); -} - -/** - * Attribute exact source edits to the deepest registered project roots. - * A null path list is the polling/ambiguous-event fallback. - */ -export function dirtyGraphSourceRoots( - scopeRoot: string, - sourceRoots: readonly string[], - sourcePaths: readonly string[] | null, -): string[] { - const roots = graphSourceRootsWithinScope(scopeRoot, sourceRoots); - if (sourcePaths === null) return roots; - - const dirty = new Set(); - for (const sourcePath of sourcePaths) { - const canonicalSourcePath = canonicalGraphPath(sourcePath); - const matches = roots.filter((sourceRoot) => - isWithinGraphPath(sourceRoot, canonicalSourcePath), - ); - for (const match of matches) { - if ( - !matches.some( - (candidate) => - candidate !== match && isWithinGraphPath(match, candidate), - ) - ) { - dirty.add(match); - } - } - } - return [...dirty].sort(); -} - -function canonicalIdentity(value: string | null): string | null { - const identity = value?.trim() ?? ""; - if ( - identity === "" || - identity === "." || - identity === ".." || - identity.startsWith("local:") || - hasControlCharacter(identity) || - identity.includes("/") || - identity.includes("\\") || - path.posix.isAbsolute(identity) || - path.win32.isAbsolute(identity) - ) { - return null; - } - return identity; -} - -function safeLabel(value: string, fallback: string): string { - const label = value.trim(); - if ( - label === "" || - hasControlCharacter(label) || - path.posix.isAbsolute(label) || - path.win32.isAbsolute(label) - ) { - return fallback; - } - return label; -} - -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, - knownCanonicalScope?: string, - knownCanonicalSource?: 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 = knownCanonicalScope ?? canonicalGraphPath(scopeRoot); - const canonicalSource = - knownCanonicalSource ?? 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[], - discoveryStatus: "complete" | "degraded", -): PackageInventory { - const status = - discoveryStatus === "degraded" || - 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; - identitySettled: boolean; - definitionId: number | null; - definitionSlug: string | null; - label: 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; - freshnessEpoch: number; - authorization: "marker" | "linked"; -} - -function preparedOrder(left: PreparedAgent, right: PreparedAgent): number { - return ( - compareText(left.candidateKey, right.candidateKey) || - compareText(left.path, right.path) || - compareText(left.sourceRoot, right.sourceRoot) - ); -} - -function warningOrder( - left: AgentInventoryWarning, - right: AgentInventoryWarning, -): number { - return ( - 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) - ); -} - -/** - * 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; - private latestInventoryGeneration = -1; - private freshnessEpoch = 0; - - constructor( - private readonly options: HarnessRegistryInventoryProviderOptions, - ) {} - - async listAgents(scope: WorkspaceScope): Promise { - let snapshot = this.options.inventorySnapshot - ? await this.options.inventorySnapshot(scope) - : null; - for ( - let retry = 0; - snapshot && - snapshot.generation < this.latestInventoryGeneration && - retry < 3; - retry += 1 - ) { - snapshot = await this.options.inventorySnapshot!(scope); - } - if (snapshot && snapshot.generation < this.latestInventoryGeneration) { - throw new Error( - "Inventory snapshot was superseded by a newer generation", - ); - } - if (snapshot) { - this.latestInventoryGeneration = Math.max( - this.latestInventoryGeneration, - snapshot.generation, - ); - } - const [workflows, discoveryStatus] = snapshot - ? [snapshot.workflows, snapshot.status] - : await Promise.all([ - this.options.listWorkflows(), - this.options.inventoryStatus?.(scope) ?? "complete", - ]); - const canonicalEntries = new Map( - snapshot?.canonicalWorkflowRoots?.map((entry) => [ - entry.workflowPath, - entry, - ]) ?? [], - ); - const canonicalScopeRoot = - snapshot?.canonicalScopeRoot ?? canonicalGraphPath(scope.root); - const bySourceRoot = new Map< - string, - { - workflow: WorkflowInfo; - sourceRoot: string; - identityEvidence: "marker" | "source" | "not-agent" | "unknown"; - } - >(); - const projected = workflows.map((workflow) => { - const canonicalEntry = canonicalEntries.get(workflow.path); - return { - workflow, - sourceRoot: - canonicalEntry?.canonicalRoot ?? canonicalGraphPath(workflow.path), - identityEvidence: - canonicalEntry?.identityEvidence ?? - (Object.prototype.hasOwnProperty.call( - workflow, - "sourceDefinitionName", - ) - ? "source" - : workflow.markerPresent === true - ? "marker" - : "unknown"), - } as const; - }); - this.retainSources(new Set(projected.map(({ sourceRoot }) => sourceRoot))); - const contained = projected - .filter(({ sourceRoot }) => - isWithinGraphPath(canonicalScopeRoot, sourceRoot), - ) - .sort((left, right) => workflowRegistryOrder(scope.root, left, right)); - for (const { workflow, sourceRoot, identityEvidence } 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, - identityEvidence, - }); - } - } - - const inspectionRoots: Array<{ - sourceRoot: string; - authorization: "marker" | "linked"; - }> = []; - let consumedUnknownPersistedIdentity = false; - const prepared = [...bySourceRoot.values()] - .map(({ workflow, sourceRoot, identityEvidence }): PreparedAgent => { - const inventoryPath = packageRelativePath( - scope.root, - workflow.path, - canonicalScopeRoot, - sourceRoot, - ); - 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 identitySettled = true; - let warnOnIdentityFailure = false; - const hasPersistedSourceIdentity = - Object.prototype.hasOwnProperty.call( - workflow, - "sourceDefinitionName", - ) && - (identityEvidence === "source" || identityEvidence === "unknown"); - if (hasPersistedSourceIdentity) { - this.retireInspectionSource(sourceRoot); - consumedUnknownPersistedIdentity ||= identityEvidence === "unknown"; - const sourceDefinitionName = workflow.sourceDefinitionName ?? null; - canonicalName = canonicalIdentity(sourceDefinitionName); - if (!canonicalName) { - identityIssue = sourceDefinitionName - ? "identity-invalid" - : "identity-unavailable"; - } - } else if ( - identityEvidence !== "marker" && - workflow.definitionId === null - ) { - this.retireInspectionSource(sourceRoot); - identityIssue = "identity-unavailable"; - } else if (!this.options.inspectManifestName) { - identityIssue = "identity-unavailable"; - } else { - inspectionRoots.push({ - sourceRoot, - authorization: identityEvidence === "marker" ? "marker" : "linked", - }); - this.ensureGeneration(sourceRoot); - if (!cached) { - identityIssue = "identity-pending"; - identitySettled = false; - } 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"; - identitySettled = - cached.inspection.status !== "failed" || - !cached.inspection.retryable; - } - } - const candidateKey = canonicalName ?? markerAlias ?? fallbackKey; - return { - canonicalName, - candidateKey, - fallbackKey, - identityIssue, - identitySettled, - 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(); - const candidateCounts = new Map(); - for (const agent of prepared) { - candidateCounts.set( - agent.candidateKey, - (candidateCounts.get(agent.candidateKey) ?? 0) + 1, - ); - // An unsettled candidate is a guess, not a claim. Two agents that share - // a registry marker while source identity is pending or retryable are - // not yet a collision. - if (!agent.identitySettled) 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 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; - // A shared unsettled marker is still only a guess, so it cannot honestly - // serve as either agent's unique public key. Keep both rows provisional - // under their deterministic locations until inspection settles them. - const sharedUnsettledCandidate = - !agent.identitySettled && - (candidateCounts.get(agent.candidateKey) ?? 0) > 1; - let agentKey = - duplicate || shadowedByCanonical || sharedUnsettledCandidate - ? agent.fallbackKey - : agent.candidateKey; - const base = agentKey; - let suffix = 2; - while (used.has(agentKey)) { - agentKey = `${base}~${suffix}`; - suffix += 1; - } - used.add(agentKey); - - 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, - definitionId: agent.definitionId, - definitionSlug: agent.definitionSlug, - label: agent.label, - resolutionAliases, - sourceRoot: agent.sourceRoot, - workflowPath: agent.workflowPath, - path: agent.path, - entrypoint: ENTRYPOINT, - }); - if (!canonical && agent.warnOnIdentityFailure && !duplicate) { - warnings.push({ - code: "inventory-extraction-failed", - agentKey, - message: `Could not resolve ${agent.label}'s source identity; using its provisional identity.`, - }); - } - } - - const inventory = buildWorkingTreeInventory( - scope.workspaceKey, - publicAgents, - consumedUnknownPersistedIdentity ? "degraded" : discoveryStatus, - ); - 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 Map( - inspectionRoots.map((entry) => [entry.sourceRoot, entry]), - ).values(), - ].sort((left, right) => compareText(left.sourceRoot, right.sourceRoot)); - const enrichmentEpoch = this.epoch; - const tasks = roots.map(({ sourceRoot, authorization }) => ({ - sourceRoot, - authorization, - generation: this.generations.get(sourceRoot)!, - epoch: enrichmentEpoch, - freshnessEpoch: this.freshnessEpoch, - })); - return { - inventory, - context: normalizedContext, - warnings, - identitySettled: prepared.every((agent) => agent.identitySettled), - discoveryComplete: - discoveryStatus === "complete" && !consumedUnknownPersistedIdentity, - ...(tasks.length > 0 - ? { - startEnrichment: () => - this.enqueueInspections(tasks, enrichmentEpoch), - } - : {}), - }; - } - - /** 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(); - } - } - - /** O(1) fail-closed invalidation for a raw change beneath a graph scope. */ - invalidateScope(_scopeRoot: string): void { - // Conservatively invalidate every queued/active V0 task. This is a true - // O(1) raw-event operation; queue cleanup happens when a worker slot next - // drains, and each task's captured epoch prevents it from launching or - // publishing in the meantime. - this.freshnessEpoch += 1; - } - - private retireInspectionSource(sourceRoot: string): void { - this.identityCache.delete(sourceRoot); - this.generations.delete(sourceRoot); - this.dropQueuedTasks(sourceRoot); - this.pendingIdentityChanges.delete(sourceRoot); - 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); - } - } - } - - clear(): void { - this.epoch += 1; - this.identityCache.clear(); - this.generations.clear(); - this.queuedTasks.length = 0; - this.pendingIdentityChanges.clear(); - this.freshnessEpoch += 1; - 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), - ]); - 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 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 && - active.freshnessEpoch === task.freshnessEpoch - ) { - 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 { - if (task.authorization === "marker") { - const validMarker = this.options.revalidateMarker - ? await this.options.revalidateMarker(task.sourceRoot) - : (await inspectAgentProjectMarker(task.sourceRoot)).status === - "valid"; - if (!validMarker || !this.isCurrentTask(task)) return; - } - fingerprint = await ( - this.options.fingerprintSource ?? fingerprintWorkflowSources - )(task.sourceRoot); - if (!this.isCurrentTask(task)) return; - if (task.authorization === "marker") { - const validMarker = this.options.revalidateMarker - ? await this.options.revalidateMarker(task.sourceRoot) - : (await inspectAgentProjectMarker(task.sourceRoot)).status === - "valid"; - if (!validMarker || !this.isCurrentTask(task)) return; - } - const hit = this.identityCache.get(task.sourceRoot); - if (hit?.fingerprint === fingerprint) return; - inspection = await inspect(task.sourceRoot, { - authorizeBeforeLaunch: async () => { - if (!this.isCurrentTask(task)) return false; - if (task.authorization === "linked") return true; - const validMarker = this.options.revalidateMarker - ? await this.options.revalidateMarker(task.sourceRoot) - : (await inspectAgentProjectMarker(task.sourceRoot)).status === - "valid"; - return validMarker && this.isCurrentTask(task); - }, - }); - } catch { - if (!this.isCurrentTask(task)) return; - const hit = this.identityCache.get(task.sourceRoot); - if (fingerprint === null && hit) return; - // The inspector threw, so nothing proved this unchanged source can never - // resolve. Preserve the Retry path instead of freezing a provisional ID. - inspection = { status: "failed", retryable: true }; - } - 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) && - task.freshnessEpoch === this.freshnessEpoch - ); - } - - 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 deleted file mode 100644 index 4961d4461..000000000 --- a/packages/harness/src/core/system-graph-relationships.test.ts +++ /dev/null @@ -1,590 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import type { AgentInventoryItem } from "./system-graph-inventory.js"; -import { - CachedAgentInvocationProvider, - SourceAgentInvocationProvider, - type AgentInvocationProvider, - type AgentInvocationProviderResult, -} from "./system-graph-relationships.js"; - -const temporaryRoots: string[] = []; -const EMPTY_RESULT: AgentInvocationProviderResult = { - invocations: [], - warnings: [], -}; - -function deferred(): { - promise: Promise; - resolve: (value: T) => void; - reject: (error: unknown) => void; -} { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((onResolve, onReject) => { - resolve = onResolve; - reject = onReject; - }); - return { promise, resolve, reject }; -} - -async function callerWithSource(source: string): Promise { - const sourceRoot = await fs.mkdtemp( - path.join(os.tmpdir(), "system-graph-invocations-test-"), - ); - temporaryRoots.push(sourceRoot); - 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", - }; -} - -afterEach(async () => { - await Promise.all( - temporaryRoots - .splice(0) - .map((root) => fs.rm(root, { recursive: true, force: true })), - ); -}); - -describe("SourceAgentInvocationProvider", () => { - it("aggregates evidence by target and mode while preserving distinct modes", async () => { - const caller = await callerWithSource(` -ctx.sapiom.agents.run({ definition: "growth" }); -ctx.sapiom.agents.run({ definition: "growth" }); -ctx.sapiom.agents.launch({ definition: "growth" }); -ctx.sapiom.agents.launch({ definition: dynamicTarget }); -`); - - const result = await new SourceAgentInvocationProvider().listInvocations( - caller, - ); - - expect(result.invocations).toEqual([ - { - target: "growth", - mode: "blocking", - evidence: [ - { file: "index.ts", line: 2, column: 1 }, - { file: "index.ts", line: 3, column: 1 }, - ], - }, - { - target: "growth", - mode: "async", - evidence: [{ file: "index.ts", line: 4, column: 1 }], - }, - ]); - expect(result.warnings).toEqual([ - { - code: "dynamic-target", - mode: "async", - evidence: { file: "index.ts", line: 5, column: 1 }, - }, - ]); - }); - - it("reports only the coordinator's direct invocations without inferring output data flow", async () => { - const caller = { - ...(await callerWithSource(` -import { agents } from "@sapiom/tools"; - -const research = await agents.run({ - definition: "research", -}); - -const summary = formatResearch(research.output); - -await agents.run({ - definition: "growth", - input: { summary }, -}); -`)), - agentKey: "coordinator", - definitionSlug: "coordinator", - label: "Coordinator", - resolutionAliases: ["coordinator"], - }; - - const result = await new SourceAgentInvocationProvider().listInvocations( - caller, - ); - const directInvocations = result.invocations.map(({ target }) => [ - caller.agentKey, - target, - ]); - - expect(directInvocations).toEqual([ - ["coordinator", "research"], - ["coordinator", "growth"], - ]); - expect(directInvocations).not.toContainEqual(["research", "growth"]); - }); - - it("returns identical invocation semantics for unchanged caller input", async () => { - const caller = await callerWithSource( - 'ctx.sapiom.agents.run({ definition: "growth" });\n', - ); - const provider = new SourceAgentInvocationProvider(); - - const first = await provider.listInvocations(caller); - const second = await provider.listInvocations(caller); - - expect(second).toEqual(first); - }); - - 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( - path.join(os.tmpdir(), "system-graph-invocations-external-"), - ); - temporaryRoots.push(external); - await fs.writeFile( - path.join(external, "secret.ts"), - 'ctx.sapiom.agents.run({ definition: "outside" });\n', - ); - await fs.symlink( - path.join(external, "secret.ts"), - path.join(caller.sourceRoot, "evil.ts"), - ); - const onBytesRead = vi.fn(); - - const result = await new SourceAgentInvocationProvider({ - onBytesRead, - }).listInvocations(caller); - - expect(result.invocations).toEqual([]); - expect(result.complete).toBe(false); - expect(onBytesRead).toHaveBeenCalledTimes(1); - expect(onBytesRead).toHaveBeenCalledWith( - path.join(caller.sourceRoot, "index.ts"), - expect.any(Number), - ); - expect(onBytesRead).not.toHaveBeenCalledWith( - path.join(external, "secret.ts"), - expect.any(Number), - ); - }); - - it("rejects an ancestor swap before reading invocation source bytes", async () => { - const caller = await callerWithSource("export const value = 1;\n"); - const inside = path.join(caller.sourceRoot, "inside"); - await fs.mkdir(inside); - await fs.writeFile( - path.join(inside, "edge.ts"), - 'ctx.sapiom.agents.run({ definition: "inside" });\n', - ); - const external = await fs.mkdtemp( - path.join(os.tmpdir(), "system-graph-invocations-swap-"), - ); - temporaryRoots.push(external); - await fs.writeFile( - path.join(external, "edge.ts"), - 'ctx.sapiom.agents.run({ definition: "outside" });\n', - ); - const onBytesRead = vi.fn(); - let swapped = false; - - const result = await new SourceAgentInvocationProvider({ - beforeOpen: async (file) => { - if (!file.endsWith(`${path.sep}inside${path.sep}edge.ts`) || swapped) { - return; - } - swapped = true; - await fs.rename(inside, `${inside}-original`); - await fs.symlink(external, inside, "dir"); - }, - onBytesRead, - }).listInvocations(caller); - - expect(result.invocations).toEqual([]); - expect(result.complete).toBe(false); - expect(onBytesRead).not.toHaveBeenCalledWith( - path.join(external, "edge.ts"), - expect.any(Number), - ); - }); -}); - -describe("CachedAgentInvocationProvider", () => { - it("coalesces and reuses unchanged caller extraction", async () => { - const caller = await callerWithSource("export const value = 1;\n"); - const inner: AgentInvocationProvider = { - listInvocations: vi.fn(async () => ({ - invocations: [], - warnings: [], - })), - }; - const fingerprint = vi.fn(async () => "fingerprint-one"); - const provider = new CachedAgentInvocationProvider(inner, fingerprint); - - const first = provider.listInvocations(caller); - await expect(provider.listInvocations(caller)).resolves.toEqual( - await first, - ); - await provider.listInvocations(caller); - - expect(inner.listInvocations).toHaveBeenCalledTimes(1); - expect(fingerprint).toHaveBeenCalledTimes(3); - }); - - it("rescans only after the caller fingerprint changes", async () => { - const caller = await callerWithSource("export const value = 1;\n"); - const inner: AgentInvocationProvider = { - listInvocations: vi.fn(async () => ({ - invocations: [], - warnings: [], - })), - }; - const fingerprint = vi - .fn() - .mockResolvedValueOnce("one") - .mockResolvedValueOnce("two"); - const provider = new CachedAgentInvocationProvider(inner, fingerprint); - - await provider.listInvocations(caller); - await provider.listInvocations(caller); - - expect(inner.listInvocations).toHaveBeenCalledTimes(2); - }); - - it("does not retain a failed caller result", async () => { - const caller = await callerWithSource("export const value = 1;\n"); - const inner: AgentInvocationProvider = { - listInvocations: vi - .fn() - .mockRejectedValueOnce(new Error("not installed")) - .mockResolvedValueOnce({ invocations: [], warnings: [] }), - }; - const provider = new CachedAgentInvocationProvider( - inner, - async () => "same", - ); - - await expect(provider.listInvocations(caller)).rejects.toThrow( - "not installed", - ); - await expect(provider.listInvocations(caller)).resolves.toEqual({ - invocations: [], - warnings: [], - }); - expect(inner.listInvocations).toHaveBeenCalledTimes(2); - }); - - it("evicts removed callers while preserving retained callers", async () => { - const first = await callerWithSource("export const first = 1;\n"); - const second = await callerWithSource("export const second = 1;\n"); - const inner: AgentInvocationProvider = { - listInvocations: vi.fn(async () => ({ - invocations: [], - warnings: [], - })), - }; - const provider = new CachedAgentInvocationProvider( - inner, - async () => "same", - ); - await provider.listInvocations(first); - await provider.listInvocations(second); - - provider.retainCallers([second]); - await provider.listInvocations(first); - await provider.listInvocations(second); - - expect(inner.listInvocations).toHaveBeenCalledTimes(3); - }); - - it("runs background invocation extraction with a fixed concurrency cap", async () => { - const callers = await Promise.all( - Array.from({ length: 9 }, (_, index) => - callerWithSource(`export const value = ${index};\n`), - ), - ); - const releases: Array<() => void> = []; - let active = 0; - let maxActive = 0; - const onChange = vi.fn(); - const inner: AgentInvocationProvider = { - listInvocations: vi.fn(async () => { - active += 1; - maxActive = Math.max(maxActive, active); - await new Promise((resolve) => releases.push(resolve)); - active -= 1; - return EMPTY_RESULT; - }), - }; - const provider = new CachedAgentInvocationProvider( - inner, - async () => "unused", - { concurrency: 4, onChange }, - ); - - provider.startInvocations(callers); - await vi.waitFor(() => { - expect(inner.listInvocations).toHaveBeenCalledTimes(4); - }); - expect(maxActive).toBe(4); - - while (releases.length > 0 || active > 0) { - releases.splice(0).forEach((release) => release()); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - await vi.waitFor(() => { - expect(inner.listInvocations).toHaveBeenCalledTimes(9); - expect(onChange).toHaveBeenCalledTimes(1); - }); - expect(maxActive).toBe(4); - expect( - callers.every( - (caller) => provider.peekInvocations(caller)?.status === "ready", - ), - ).toBe(true); - }); - - it("discards a superseded result and runs the fresh generation afterward", async () => { - const caller = await callerWithSource("export const value = 1;\n"); - const first = deferred(); - const inner: AgentInvocationProvider = { - listInvocations: vi - .fn() - .mockImplementationOnce(() => first.promise) - .mockResolvedValueOnce({ - invocations: [ - { - target: "fresh", - mode: "blocking", - evidence: [{ file: "index.ts", line: 1, column: 1 }], - }, - ], - warnings: [], - }), - }; - const onChange = vi.fn(); - const provider = new CachedAgentInvocationProvider( - inner, - async () => "unused", - { concurrency: 1, onChange }, - ); - - provider.startInvocations([caller]); - await vi.waitFor(() => - expect(inner.listInvocations).toHaveBeenCalledTimes(1), - ); - provider.invalidateSource(caller.sourceRoot); - provider.startInvocations([caller]); - first.resolve({ - invocations: [ - { - target: "stale", - mode: "blocking", - evidence: [{ file: "index.ts", line: 1, column: 1 }], - }, - ], - warnings: [], - }); - - await vi.waitFor(() => { - expect(inner.listInvocations).toHaveBeenCalledTimes(2); - expect( - provider.peekInvocations(caller)?.result.invocations[0]?.target, - ).toBe("fresh"); - }); - expect(onChange).toHaveBeenCalledTimes(1); - }); - - it("keeps failures stable until explicit Retry rearms them", async () => { - const caller = await callerWithSource("export const value = 1;\n"); - const inner: AgentInvocationProvider = { - listInvocations: vi - .fn() - .mockRejectedValueOnce(new Error("transient")) - .mockResolvedValueOnce(EMPTY_RESULT), - }; - const provider = new CachedAgentInvocationProvider(inner); - - provider.startInvocations([caller]); - await vi.waitFor(() => { - expect(provider.peekInvocations(caller)?.status).toBe("failed"); - }); - provider.startInvocations([caller]); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(inner.listInvocations).toHaveBeenCalledTimes(1); - - provider.retryFailed(caller.sourceRoot); - provider.startInvocations([caller]); - await vi.waitFor(() => { - expect(provider.peekInvocations(caller)?.status).toBe("ready"); - }); - expect(inner.listInvocations).toHaveBeenCalledTimes(2); - }); - - it("rearms an incomplete bounded scan only on explicit Retry", async () => { - const caller = await callerWithSource("export const value = 1;\n"); - const inner: AgentInvocationProvider = { - listInvocations: vi - .fn() - .mockResolvedValueOnce({ ...EMPTY_RESULT, complete: false }) - .mockResolvedValueOnce({ ...EMPTY_RESULT, complete: true }), - }; - const provider = new CachedAgentInvocationProvider(inner); - - provider.startInvocations([caller]); - await vi.waitFor(() => { - expect(provider.peekInvocations(caller)?.result.complete).toBe(false); - }); - provider.startInvocations([caller]); - expect(inner.listInvocations).toHaveBeenCalledTimes(1); - - provider.retryFailed(caller.sourceRoot); - provider.startInvocations([caller]); - await vi.waitFor(() => { - expect(provider.peekInvocations(caller)?.result.complete).toBe(true); - }); - expect(inner.listInvocations).toHaveBeenCalledTimes(2); - }); - - it("caps retained watcher observations fairly and degrades overflow", async () => { - const callers = await Promise.all([ - callerWithSource("export const first = 1;\n"), - callerWithSource("export const second = 2;\n"), - ]); - const inner: AgentInvocationProvider = { - listInvocations: vi.fn(async (caller) => ({ - ...EMPTY_RESULT, - complete: true, - observedPaths: Array.from({ length: 6_000 }, (_, index) => - path.join(caller.sourceRoot, `observed-${index}.ts`), - ), - })), - }; - const provider = new CachedAgentInvocationProvider(inner); - - provider.startInvocations(callers); - await vi.waitFor(() => { - expect( - callers.every( - (caller) => - provider.peekInvocations(caller)?.result.complete === false, - ), - ).toBe(true); - }); - const observations = provider.invocationObservations(); - - expect(observations).toHaveLength(2); - expect(observations.map((entry) => entry.paths.length)).toEqual([ - 5_000, 5_000, - ]); - }); - - it("notifies every retained root when global observation coverage changes", async () => { - const callers = await Promise.all([ - callerWithSource("export const first = 1;\n"), - callerWithSource("export const second = 2;\n"), - ]); - const inner: AgentInvocationProvider = { - listInvocations: vi.fn(async (caller) => ({ - ...EMPTY_RESULT, - complete: true, - observedPaths: Array.from({ length: 6_000 }, (_, index) => - path.join(caller.sourceRoot, `observed-${index}.ts`), - ), - })), - }; - const onChange = vi.fn(); - const provider = new CachedAgentInvocationProvider( - inner, - async () => "unused", - { onChange }, - ); - - provider.startInvocations([callers[0]!]); - await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); - expect(provider.peekInvocations(callers[0]!)?.result.complete).toBe(true); - onChange.mockClear(); - - provider.startInvocations(callers); - await vi.waitFor(() => expect(onChange).toHaveBeenCalled()); - expect(onChange.mock.calls.flat(2)).toEqual( - expect.arrayContaining(callers.map((caller) => caller.sourceRoot)), - ); - expect(provider.peekInvocations(callers[0]!)?.result.complete).toBe(false); - onChange.mockClear(); - - provider.retainCallers([callers[0]!]); - await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); - expect(onChange).toHaveBeenCalledWith([callers[0]!.sourceRoot]); - expect(provider.peekInvocations(callers[0]!)?.result.complete).toBe(true); - }); - - it("does not notify for a settled result invalidated before its batch flush", async () => { - const callers = await Promise.all([ - callerWithSource("export const fast = 1;\n"), - callerWithSource("export const slow = 2;\n"), - ]); - const slow = deferred(); - const inner: AgentInvocationProvider = { - listInvocations: vi - .fn() - .mockResolvedValueOnce(EMPTY_RESULT) - .mockImplementationOnce(() => slow.promise), - }; - const onChange = vi.fn(); - const provider = new CachedAgentInvocationProvider( - inner, - async () => "unused", - { concurrency: 2, onChange, changeBatchMs: 50 }, - ); - - provider.startInvocations(callers); - await vi.waitFor(() => { - expect(provider.peekInvocations(callers[0]!)?.status).toBe("ready"); - }); - provider.invalidateScope(os.tmpdir()); - slow.resolve(EMPTY_RESULT); - await new Promise((resolve) => setTimeout(resolve, 70)); - - expect(onChange).not.toHaveBeenCalled(); - }); - - it("publishes a settled workspace while an unrelated task remains held", async () => { - const callers = await Promise.all([ - callerWithSource("export const quick = 1;\n"), - callerWithSource("export const held = 2;\n"), - ]); - const held = deferred(); - const inner: AgentInvocationProvider = { - listInvocations: vi - .fn() - .mockResolvedValueOnce(EMPTY_RESULT) - .mockImplementationOnce(() => held.promise), - }; - const onChange = vi.fn(); - const provider = new CachedAgentInvocationProvider( - inner, - async () => "unused", - { concurrency: 2, onChange }, - ); - - provider.startInvocations(callers); - await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); - - expect(onChange).toHaveBeenLastCalledWith([callers[0]!.sourceRoot]); - expect(provider.peekInvocations(callers[0]!)?.status).toBe("ready"); - expect(provider.peekInvocations(callers[1]!)).toBeUndefined(); - - held.resolve(EMPTY_RESULT); - await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(2)); - }); -}); diff --git a/packages/harness/src/core/system-graph-relationships.ts b/packages/harness/src/core/system-graph-relationships.ts deleted file mode 100644 index 89d41c92e..000000000 --- a/packages/harness/src/core/system-graph-relationships.ts +++ /dev/null @@ -1,522 +0,0 @@ -import * as path from "node:path"; - -import { - detectAgentInvocations, - type AgentInvocationDetectionWarning, - type AgentInvocationMode, - type SourceEvidence, - type WorkflowSourceReadHooks, -} from "./canvas-interconnections.js"; -import { fingerprintWorkflowSources } from "./canvas-cache.js"; -import { canonicalGraphPath } from "./canonical-graph-path.js"; -import type { AgentInventoryItem } from "./system-graph-inventory.js"; - -export const INVOCATION_OBSERVATION_MAX_PATHS = 10_000; - -export interface AgentInvocationCandidate { - /** Inventory key or definition slug to resolve after extraction. */ - target: string; - mode: AgentInvocationMode; - /** Internal-only evidence retained across callsite deduplication. */ - evidence: SourceEvidence[]; -} - -export type AgentInvocationWarning = AgentInvocationDetectionWarning; - -export interface AgentInvocationProviderResult { - invocations: AgentInvocationCandidate[]; - warnings: AgentInvocationWarning[]; - /** Confined files considered by this bounded extraction generation. */ - observedPaths?: readonly string[]; - /** False when an opaque path or work cap prevented a complete scan. */ - complete?: boolean; -} - -export interface AgentInvocationSnapshot { - status: "ready" | "failed"; - result: AgentInvocationProviderResult; -} - -export interface AgentInvocationObservation { - candidateRoot: string; - workspaceRoot: string; - paths: readonly string[]; -} - -/** - * Replaceable per-caller boundary for literal direct invocations consumed by - * the workspace graph projector. The caller is always the source endpoint. - */ -export interface AgentInvocationProvider { - listInvocations( - caller: AgentInventoryItem, - ): Promise; - /** Optional lifecycle hook for providers that retain per-caller state. */ - retainCallers?(callers: readonly AgentInventoryItem[]): void; - /** Cache-only projection used by the first graph phase. */ - peekInvocations?( - caller: AgentInventoryItem, - ): AgentInvocationSnapshot | undefined; - /** Starts bounded work only after the inventory-only graph is committed. */ - startInvocations?(callers: readonly AgentInventoryItem[]): void; - /** Accepted/current invocation metadata consumed by polling watchers. */ - invocationObservations?(): readonly AgentInvocationObservation[]; -} - -interface CachedInvocationEntry { - fingerprint: string; - result: Promise; -} - -interface InvocationTask { - sourceRoot: string; - caller: AgentInventoryItem; - generation: number; - scopeEpoch: number; -} - -interface BackgroundInvocationEntry { - generation: number; - scopeEpoch: number; - snapshot?: AgentInvocationSnapshot; -} - -export interface CachedAgentInvocationProviderOptions { - concurrency?: number; - onChange?: (sourceRoots: readonly string[]) => void | Promise; - /** Small testable coalescing window; settled scopes never await global idle. */ - changeBatchMs?: number; -} - -/** - * Successful per-caller invocation extraction behind the same cheap source - * fingerprint used by Canvas. Projection can therefore rebuild against a new - * inventory without re-walking unchanged caller trees. - */ -export class CachedAgentInvocationProvider implements AgentInvocationProvider { - private readonly entries = new Map(); - private readonly background = new Map(); - private readonly queued: InvocationTask[] = []; - private readonly active = new Map(); - private readonly pendingChanges = new Set(); - private readonly invalidatedScopes = new Map(); - private nextGeneration = 1; - private nextScopeEpoch = 1; - private activeCount = 0; - private observationsTruncated = false; - private changeFlushTimer: ReturnType | null = null; - - constructor( - private readonly inner: AgentInvocationProvider = new SourceAgentInvocationProvider(), - private readonly fingerprint: ( - sourceRoot: string, - ) => Promise = fingerprintWorkflowSources, - private readonly options: CachedAgentInvocationProviderOptions = {}, - ) {} - - async listInvocations( - caller: AgentInventoryItem, - ): Promise { - const key = canonicalGraphPath(caller.sourceRoot); - let fingerprint: string; - try { - fingerprint = await this.fingerprint(key); - } catch (error) { - this.entries.delete(key); - throw error; - } - - const hit = this.entries.get(key); - if (hit?.fingerprint === fingerprint) return hit.result; - - let result: Promise; - try { - result = Promise.resolve(this.inner.listInvocations(caller)); - } catch (error) { - result = Promise.reject(error); - } - const entry = { fingerprint, result }; - this.entries.set(key, entry); - void result.catch(() => { - if (this.entries.get(key) === entry) this.entries.delete(key); - }); - return result; - } - - invalidateSource(sourceRoot: string): void { - const key = canonicalGraphPath(sourceRoot); - this.entries.delete(key); - this.background.set(key, { - generation: this.nextGeneration++, - scopeEpoch: this.scopeEpochForSource(key), - }); - this.dropQueued(key); - this.pendingChanges.delete(key); - } - - /** O(1) conservative invalidation for an ambiguous workspace event. */ - invalidateScope(scopeRoot: string): void { - this.invalidatedScopes.set( - canonicalGraphPath(scopeRoot), - this.nextScopeEpoch++, - ); - } - - /** Explicit graph Retry re-arms terminal failures without read-loop churn. */ - retryFailed(scopeRoot: string): void { - const root = canonicalGraphPath(scopeRoot); - for (const [sourceRoot, entry] of this.background) { - const relative = path.relative(root, sourceRoot); - if ( - (entry.snapshot?.status !== "failed" && - !( - entry.snapshot?.status === "ready" && - entry.snapshot.result.complete === false - )) || - relative === ".." || - relative.startsWith(`..${path.sep}`) || - path.isAbsolute(relative) - ) { - continue; - } - this.background.set(sourceRoot, { - generation: this.nextGeneration++, - scopeEpoch: this.scopeEpochForSource(sourceRoot), - }); - this.dropQueued(sourceRoot); - } - } - - retainCallers(callers: readonly AgentInventoryItem[]): void { - const retained = new Set( - callers.map((caller) => canonicalGraphPath(caller.sourceRoot)), - ); - for (const sourceRoot of this.entries.keys()) { - if (!retained.has(sourceRoot)) this.entries.delete(sourceRoot); - } - for (const sourceRoot of this.background.keys()) { - if (retained.has(sourceRoot)) continue; - this.background.delete(sourceRoot); - this.dropQueued(sourceRoot); - this.pendingChanges.delete(sourceRoot); - } - for (const scopeRoot of this.invalidatedScopes.keys()) { - if ( - [...retained].some((sourceRoot) => - this.scopeContainsSource(scopeRoot, sourceRoot), - ) - ) { - continue; - } - this.invalidatedScopes.delete(scopeRoot); - } - this.refreshObservationCoverage(); - } - - clear(): void { - this.entries.clear(); - this.background.clear(); - this.queued.length = 0; - this.pendingChanges.clear(); - if (this.changeFlushTimer) clearTimeout(this.changeFlushTimer); - this.changeFlushTimer = null; - this.invalidatedScopes.clear(); - this.nextGeneration += 1; - this.nextScopeEpoch += 1; - this.observationsTruncated = false; - } - - peekInvocations( - caller: AgentInventoryItem, - ): AgentInvocationSnapshot | undefined { - const sourceRoot = canonicalGraphPath(caller.sourceRoot); - const entry = this.background.get(sourceRoot); - if (entry?.scopeEpoch !== this.scopeEpochForSource(sourceRoot)) { - return undefined; - } - if ( - this.observationsTruncated && - entry.snapshot?.status === "ready" && - entry.snapshot.result.complete !== false - ) { - return { - status: "ready", - result: { ...entry.snapshot.result, complete: false }, - }; - } - return entry.snapshot; - } - - startInvocations(callers: readonly AgentInventoryItem[]): void { - for (const caller of callers) { - const sourceRoot = canonicalGraphPath(caller.sourceRoot); - let entry = this.background.get(sourceRoot); - const scopeEpoch = this.scopeEpochForSource(sourceRoot); - if (!entry || entry.scopeEpoch !== scopeEpoch) { - entry = { generation: this.nextGeneration++, scopeEpoch }; - this.background.set(sourceRoot, entry); - this.dropQueued(sourceRoot); - } - if (entry.snapshot) continue; - if ( - this.active.get(sourceRoot)?.generation === entry.generation || - this.queued.some( - (task) => - task.sourceRoot === sourceRoot && - task.generation === entry!.generation, - ) - ) { - continue; - } - this.dropQueued(sourceRoot); - this.queued.push({ - sourceRoot, - caller, - generation: entry.generation, - scopeEpoch: entry.scopeEpoch, - }); - } - this.drain(); - } - - invocationObservations(): readonly AgentInvocationObservation[] { - const entries = [...this.background.entries()] - .filter( - ([sourceRoot, entry]) => - entry.scopeEpoch === this.scopeEpochForSource(sourceRoot) && - (entry.snapshot?.result.observedPaths?.length ?? 0) > 0, - ) - .sort(([left], [right]) => left.localeCompare(right)); - const selected = new Map(); - let remaining = INVOCATION_OBSERVATION_MAX_PATHS; - let round = 0; - while (remaining > 0) { - let added = false; - for (const [sourceRoot, entry] of entries) { - if (remaining === 0) break; - const observed = entry.snapshot?.result.observedPaths?.[round]; - if (!observed) continue; - const paths = selected.get(sourceRoot) ?? []; - paths.push(observed); - selected.set(sourceRoot, paths); - remaining -= 1; - added = true; - } - if (!added) break; - round += 1; - } - return [...selected.entries()].map(([sourceRoot, paths]) => ({ - candidateRoot: sourceRoot, - workspaceRoot: sourceRoot, - paths, - })); - } - - private current(task: InvocationTask): boolean { - const entry = this.background.get(task.sourceRoot); - return ( - entry?.generation === task.generation && - entry.scopeEpoch === task.scopeEpoch && - task.scopeEpoch === this.scopeEpochForSource(task.sourceRoot) - ); - } - - private scopeContainsSource(scopeRoot: string, sourceRoot: string): boolean { - const relative = path.relative(scopeRoot, sourceRoot); - return ( - relative !== ".." && - !relative.startsWith(`..${path.sep}`) && - !path.isAbsolute(relative) - ); - } - - private scopeEpochForSource(sourceRoot: string): number { - let epoch = 0; - for (const [scopeRoot, candidateEpoch] of this.invalidatedScopes) { - if (this.scopeContainsSource(scopeRoot, sourceRoot)) { - epoch = Math.max(epoch, candidateEpoch); - } - } - return epoch; - } - - private refreshObservationCoverage(): void { - let count = 0; - let truncated = false; - for (const [sourceRoot, entry] of this.background) { - if (entry.scopeEpoch !== this.scopeEpochForSource(sourceRoot)) continue; - count += entry.snapshot?.result.observedPaths?.length ?? 0; - if (count > INVOCATION_OBSERVATION_MAX_PATHS) { - truncated = true; - break; - } - } - if (truncated === this.observationsTruncated) return; - this.observationsTruncated = truncated; - // Coverage is part of every retained ready snapshot's effective - // completeness. Crossing the global observation cap (in either direction) - // therefore changes more than the task that happened to settle/retire. - for (const [sourceRoot, entry] of this.background) { - if ( - entry.snapshot && - entry.scopeEpoch === this.scopeEpochForSource(sourceRoot) - ) { - this.pendingChanges.add(sourceRoot); - } - } - this.scheduleChanges(); - } - - private dropQueued(sourceRoot: string): void { - for (let index = this.queued.length - 1; index >= 0; index -= 1) { - if (this.queued[index]!.sourceRoot === sourceRoot) { - this.queued.splice(index, 1); - } - } - } - - private drain(): void { - for (let index = this.queued.length - 1; index >= 0; index -= 1) { - if (!this.current(this.queued[index]!)) this.queued.splice(index, 1); - } - const concurrency = Math.max(1, this.options.concurrency ?? 4); - while (this.activeCount < concurrency) { - const index = this.queued.findIndex( - (task) => !this.active.has(task.sourceRoot), - ); - if (index === -1) break; - const [task] = this.queued.splice(index, 1); - if (!task || !this.current(task)) continue; - this.active.set(task.sourceRoot, task); - this.activeCount += 1; - void this.run(task).finally(() => { - if (this.active.get(task.sourceRoot) === task) { - this.active.delete(task.sourceRoot); - this.activeCount -= 1; - } - this.drain(); - }); - } - } - - private async run(task: InvocationTask): Promise { - let snapshot: AgentInvocationSnapshot; - try { - snapshot = { - status: "ready", - result: await this.inner.listInvocations(task.caller), - }; - } catch { - snapshot = { - status: "failed", - result: { invocations: [], warnings: [] }, - }; - } - if (!this.current(task)) return; - this.background.set(task.sourceRoot, { - generation: task.generation, - scopeEpoch: task.scopeEpoch, - snapshot, - }); - this.refreshObservationCoverage(); - this.pendingChanges.add(task.sourceRoot); - this.scheduleChanges(); - } - - private scheduleChanges(): void { - if (this.changeFlushTimer) return; - this.changeFlushTimer = setTimeout(() => { - this.changeFlushTimer = null; - this.flushChanges(); - }, this.options.changeBatchMs ?? 0); - } - - private flushChanges(): void { - if (this.pendingChanges.size === 0) return; - const changed = [...this.pendingChanges] - .filter((sourceRoot) => { - const entry = this.background.get(sourceRoot); - return ( - entry?.snapshot !== undefined && - entry.scopeEpoch === this.scopeEpochForSource(sourceRoot) - ); - }) - .sort(); - this.pendingChanges.clear(); - if (changed.length === 0) return; - void Promise.resolve() - .then(() => this.options.onChange?.(changed)) - .catch(() => { - // Refresh hints cannot invalidate current invocation evidence. - }); - } -} - -function evidenceOrder(left: SourceEvidence, right: SourceEvidence): number { - return ( - left.file.localeCompare(right.file) || - left.line - right.line || - left.column - right.column - ); -} - -const MODE_ORDER: Record = { - blocking: 0, - async: 1, -}; - -/** - * V0 per-agent filesystem adapter for literal direct invocations. - * - * It scans only the caller's inventoried source root. It remains syntax-only - * and has no inventory target resolution, input-provenance analysis, package - * router scan, renderer, transport, deployment, or session dependencies. - */ -export class SourceAgentInvocationProvider implements AgentInvocationProvider { - constructor(private readonly readHooks: WorkflowSourceReadHooks = {}) {} - - async listInvocations( - caller: AgentInventoryItem, - ): Promise { - const scan = await detectAgentInvocations( - caller.sourceRoot, - new Set(), - this.readHooks, - ); - const grouped = new Map(); - - for (const detectedInvocation of scan.invocations) { - const key = `${detectedInvocation.slug}\0${detectedInvocation.mode}`; - const candidate = grouped.get(key); - if (candidate) { - candidate.evidence.push(detectedInvocation.evidence); - } else { - grouped.set(key, { - target: detectedInvocation.slug, - mode: detectedInvocation.mode, - evidence: [detectedInvocation.evidence], - }); - } - } - - const invocations = [...grouped.values()]; - for (const candidate of invocations) { - candidate.evidence.sort(evidenceOrder); - } - invocations.sort( - (left, right) => - left.evidence[0]!.file.localeCompare(right.evidence[0]!.file) || - left.evidence[0]!.line - right.evidence[0]!.line || - left.evidence[0]!.column - right.evidence[0]!.column || - MODE_ORDER[left.mode] - MODE_ORDER[right.mode] || - left.target.localeCompare(right.target), - ); - - return { - invocations, - warnings: scan.warnings, - observedPaths: scan.observedPaths, - complete: scan.complete, - }; - } -} diff --git a/packages/harness/src/core/system-graph-store.test.ts b/packages/harness/src/core/system-graph-store.test.ts deleted file mode 100644 index cc14f7ae6..000000000 --- a/packages/harness/src/core/system-graph-store.test.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { - SystemGraph, - SystemGraphSnapshot, -} from "../shared/system-graph.js"; -import type { - SystemGraphBuildResult, - SystemGraphBuilder, - WorkspaceScope, -} from "./system-graph.js"; -import { SystemGraphStore } from "./system-graph-store.js"; - -const scope: WorkspaceScope = { - workspaceKey: "workspace-one", - root: "/private/one", -}; - -function graphFor(label: string): SystemGraph { - return { - kind: "system", - scope: { kind: "working-tree", workspaceKey: scope.workspaceKey }, - nodes: [{ id: `agent:${label}`, agentKey: label, label }], - edges: [], - warnings: [], - }; -} - -function buildResult(label: string, cacheable = true): SystemGraphBuildResult { - return { - cacheable, - graph: graphFor(label), - navigation: [{ agentKey: label, workflowPath: `/private/${label}` }], - }; -} - -function deferred(): { - promise: Promise; - resolve: (value: T) => void; - reject: (error: Error) => void; -} { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -describe("SystemGraphStore", () => { - it("coalesces cold reads and retains an unchanged ready snapshot", async () => { - const pending = deferred(); - const builder: SystemGraphBuilder = { - build: vi.fn(() => pending.promise), - }; - const store = new SystemGraphStore(builder); - - const first = store.get(scope); - expect(store.get(scope)).toBe(first); - pending.resolve(buildResult("first")); - - 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); - }); - - it("keeps the last-good graph visible while a refresh runs", async () => { - const refresh = deferred(); - const build = vi - .fn() - .mockResolvedValueOnce(buildResult("first")) - .mockReturnValueOnce(refresh.promise); - const changes: SystemGraphSnapshot[] = []; - const store = new SystemGraphStore( - { build }, - { onChange: (snapshot) => changes.push(snapshot) }, - ); - await store.get(scope); - - 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(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "ready", - graph: graphFor("second"), - }); - }); - 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(); - const build = vi - .fn() - .mockResolvedValueOnce(buildResult("initial")) - .mockReturnValueOnce(oldRefresh.promise) - .mockReturnValueOnce(newestRefresh.promise); - const store = new SystemGraphStore({ build }); - await store.get(scope); - - store.requestRefresh(scope); - store.requestRefresh(scope); - oldRefresh.resolve(buildResult("obsolete")); - await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(3)); - expect(store.peek(scope.workspaceKey)?.graph).toEqual(graphFor("initial")); - - newestRefresh.resolve(buildResult("newest")); - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "ready", - graph: graphFor("newest"), - }); - }); - }); - - it("preserves last-good data after a hard refresh failure", async () => { - const failed = deferred(); - const build = vi - .fn() - .mockResolvedValueOnce(buildResult("initial")) - .mockReturnValueOnce(failed.promise) - .mockResolvedValueOnce(buildResult("recovered")); - const store = new SystemGraphStore({ build }); - await store.get(scope); - - const refreshing = store.requestRefresh(scope); - failed.reject(new Error("scan failed")); - - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "stale", - graph: graphFor("initial"), - }); - expect(store.peek(scope.workspaceKey)!.revision).toBeGreaterThan( - 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(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "ready", - graph: graphFor("recovered"), - }); - }); - }); - - it("publishes the current partial graph instead of retaining failed edges", async () => { - const build = vi - .fn() - .mockResolvedValueOnce(buildResult("initial")) - .mockResolvedValueOnce(buildResult("partial", false)); - const store = new SystemGraphStore({ build }); - await store.get(scope); - - store.requestRefresh(scope); - - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "degraded", - graph: graphFor("partial"), - }); - }); - }); - - it("keeps the newest partial graph visible during its next refresh", async () => { - const recovery = deferred(); - const build = vi - .fn() - .mockResolvedValueOnce(buildResult("initial")) - .mockResolvedValueOnce(buildResult("partial", false)) - .mockReturnValueOnce(recovery.promise); - const store = new SystemGraphStore({ build }); - await store.get(scope); - - store.requestRefresh(scope); - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "degraded", - graph: graphFor("partial"), - }); - }); - - const refreshing = store.requestRefresh(scope); - expect(refreshing).toMatchObject({ - state: "stale", - graph: graphFor("partial"), - }); - expect(store.peekNavigation(scope.workspaceKey)).toEqual({ - workspaceKey: scope.workspaceKey, - revision: refreshing.revision, - targets: [{ agentKey: "partial", workflowPath: "/private/partial" }], - }); - - recovery.resolve(buildResult("recovered")); - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "ready", - graph: graphFor("recovered"), - }); - }); - }); - - it("keeps the newest partial graph after an inventory refresh failure", async () => { - const build = vi - .fn() - .mockResolvedValueOnce(buildResult("initial")) - .mockResolvedValueOnce(buildResult("partial", false)); - const store = new SystemGraphStore({ build }); - await store.get(scope); - - store.requestRefresh(scope); - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "degraded", - graph: graphFor("partial"), - }); - }); - - const stale = store.reportRefreshFailure(scope); - expect(stale).toMatchObject({ - state: "stale", - graph: graphFor("partial"), - }); - expect(store.peekNavigation(scope.workspaceKey)).toEqual({ - workspaceKey: scope.workspaceKey, - revision: stale.revision, - targets: [{ agentKey: "partial", workflowPath: "/private/partial" }], - }); - }); - - it("keeps last-good data stale after a failed inventory refresh", async () => { - const pending = deferred(); - const build = vi - .fn() - .mockResolvedValueOnce(buildResult("initial")) - .mockReturnValueOnce(pending.promise); - const store = new SystemGraphStore({ build }); - await store.get(scope); - - store.requestRefresh(scope); - const stale = store.reportRefreshFailure(scope); - expect(stale).toMatchObject({ - 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)); - expect(store.peek(scope.workspaceKey)).toBe(stale); - }); - - it("does not publish a new revision for repeated inventory failures", async () => { - const onChange = vi.fn(); - const store = new SystemGraphStore( - { build: vi.fn().mockResolvedValue(buildResult("initial")) }, - { onChange }, - ); - await store.get(scope); - onChange.mockClear(); - - const firstFailure = store.reportRefreshFailure(scope); - const repeatedFailure = store.reportRefreshFailure(scope); - - expect(repeatedFailure).toBe(firstFailure); - expect(firstFailure.state).toBe("stale"); - expect(onChange).toHaveBeenCalledTimes(1); - }); - - it("arms background enrichment even when its build is superseded", async () => { - 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() - .mockResolvedValueOnce(buildResult("initial")) - .mockResolvedValueOnce(buildResult("recovered")); - const store = new SystemGraphStore({ build }); - await store.get(scope); - store.reportRefreshFailure(scope); - - await expect(store.refresh(scope)).resolves.toMatchObject({ - state: "ready", - graph: graphFor("recovered"), - }); - expect(build).toHaveBeenCalledTimes(2); - }); - - it("returns an honest cold degraded snapshot when no graph can be built", async () => { - const store = new SystemGraphStore({ - build: vi.fn().mockRejectedValue(new Error("unavailable")), - }); - - await expect(store.get(scope)).resolves.toMatchObject({ - state: "degraded", - graph: null, - }); - }); - - 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 - .fn() - .mockResolvedValueOnce(buildResult("partial", false)) - .mockReturnValueOnce(retry.promise); - const store = new SystemGraphStore({ build }); - - const degraded = await store.get(scope); - expect(degraded.state).toBe("degraded"); - const retrying = store.get(scope); - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "degraded", - graph: graphFor("partial"), - }); - await store.get(scope); - expect(build).toHaveBeenCalledTimes(2); - - retry.resolve(buildResult("recovered")); - await expect(retrying).resolves.toMatchObject({ - state: "degraded", - graph: graphFor("partial"), - }); - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)?.state).toBe("ready"); - }); - }); - - 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(); - const store = new SystemGraphStore( - { build: vi.fn(() => pending.promise) }, - { onChange }, - ); - const initial = store.get(scope); - store.retire(scope.workspaceKey); - pending.resolve(buildResult("retired")); - await initial; - - expect(store.peek(scope.workspaceKey)).toBeNull(); - expect(onChange).not.toHaveBeenCalled(); - }); - - it("retires only projections outside the retained workspace set", async () => { - const secondScope = { - workspaceKey: "workspace-two", - root: "/private/two", - }; - const store = new SystemGraphStore({ - build: vi.fn().mockResolvedValue(buildResult("ready")), - }); - await Promise.all([store.get(scope), store.get(secondScope)]); - - store.retain(new Set([scope.workspaceKey])); - - expect(store.peek(scope.workspaceKey)).not.toBeNull(); - expect(store.peek(secondScope.workspaceKey)).toBeNull(); - }); - - it("keeps revisions monotonic when a retired workspace returns", async () => { - const store = new SystemGraphStore({ - build: vi.fn().mockResolvedValue(buildResult("ready")), - }); - const first = await store.get(scope); - store.retire(scope.workspaceKey); - - const returned = await store.get(scope); - - expect(returned.revision).toBeGreaterThan(first.revision); - }); - - it("does not build a cold projection until its inventory prerequisite is accepted", async () => { - const build = vi.fn().mockResolvedValue(buildResult("accepted")); - const store = new SystemGraphStore({ build }); - - const blocked = store.markStale(scope, "scan:workspace"); - await expect(store.get(scope)).resolves.toBe(blocked); - expect(build).not.toHaveBeenCalled(); - - store.releasePrerequisite(scope, "scan:workspace"); - await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(1)); - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "ready", - graph: graphFor("accepted"), - }); - }); - }); - - it("keeps overlapping prerequisites stale until every scan accepts", async () => { - const build = vi - .fn() - .mockResolvedValueOnce(buildResult("initial")) - .mockResolvedValueOnce(buildResult("accepted")); - const store = new SystemGraphStore({ build }); - await store.get(scope); - - store.markStale(scope, "scan:parent"); - store.markStale(scope, "scan:child"); - store.releasePrerequisite(scope, "scan:child"); - store.requestRefresh(scope); // Late identity enrichment cannot bypass it. - - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "stale", - graph: graphFor("initial"), - }); - expect(build).toHaveBeenCalledTimes(1); - - store.releasePrerequisite(scope, "scan:parent"); - await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(2)); - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "ready", - graph: graphFor("accepted"), - }); - }); - }); - - it("does not let a superseded build or identity refresh bypass a prerequisite", async () => { - const obsolete = deferred(); - const accepted = deferred(); - const build = vi - .fn() - .mockResolvedValueOnce(buildResult("initial")) - .mockReturnValueOnce(obsolete.promise) - .mockReturnValueOnce(accepted.promise); - const store = new SystemGraphStore({ build }); - await store.get(scope); - store.requestRefresh(scope); - - store.markStale(scope, "scan:workspace"); - store.requestRefresh(scope); // An older identity task settles while blocked. - obsolete.resolve(buildResult("obsolete")); - await Promise.resolve(); - await Promise.resolve(); - - expect(build).toHaveBeenCalledTimes(2); - expect(store.peek(scope.workspaceKey)).toMatchObject({ - state: "stale", - graph: graphFor("initial"), - }); - - store.releasePrerequisite(scope, "scan:workspace"); - await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(3)); - accepted.resolve(buildResult("accepted")); - await vi.waitFor(() => { - expect(store.peek(scope.workspaceKey)?.graph).toEqual( - graphFor("accepted"), - ); - }); - }); - - it("does not recreate a retired scope while awaiting an accepted build", async () => { - const store = new SystemGraphStore({ - build: vi.fn().mockResolvedValue(buildResult("ready")), - }); - store.markStale(scope, "scan:workspace"); - store.retire(scope.workspaceKey); - - await expect( - store.waitForCurrentRefresh(scope.workspaceKey), - ).resolves.toBeNull(); - expect(store.peek(scope.workspaceKey)).toBeNull(); - }); -}); diff --git a/packages/harness/src/core/system-graph-store.ts b/packages/harness/src/core/system-graph-store.ts deleted file mode 100644 index 2fdec923a..000000000 --- a/packages/harness/src/core/system-graph-store.ts +++ /dev/null @@ -1,484 +0,0 @@ -import type { - SystemGraph, - SystemGraphLifecycleState, - SystemGraphNavigationResponse, - SystemGraphNavigationTarget, - SystemGraphSnapshot, - WorkspaceKey, -} from "../shared/system-graph.js"; -import type { SystemGraphBuilder, WorkspaceScope } from "./system-graph.js"; - -export interface SystemGraphStoreOptions { - /** Called only for an accepted lifecycle transition. */ - onChange?: (snapshot: SystemGraphSnapshot) => void; -} - -interface SystemGraphEntry { - scope: WorkspaceScope; - snapshot: SystemGraphSnapshot; - navigation: SystemGraphNavigationResponse; - activeBuild: Promise | null; - generation: number; - refreshPending: boolean; - automaticRetryUsed: boolean; - prerequisiteTokens: Set; - retired: boolean; -} - -function visibleProjection(entry: SystemGraphEntry): { - graph: SystemGraph | null; - navigation: readonly SystemGraphNavigationTarget[]; -} { - return { - 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. - * - * Cold reads coalesce behind one build. Later refreshes keep the last usable - * graph visible as stale, serialize rebuilds, and reject an older generation's - * result when another edit arrives while it is in flight. - */ -export class SystemGraphStore { - private readonly entries = new Map(); - private readonly revisionFloors = new Map(); - - constructor( - private readonly builder: SystemGraphBuilder, - private readonly options: SystemGraphStoreOptions = {}, - ) {} - - get(scope: WorkspaceScope): Promise { - const entry = this.ensureEntry(scope); - if (entry.prerequisiteTokens.size > 0) { - return Promise.resolve(entry.snapshot); - } - if (entry.activeBuild) { - return entry.snapshot.graph === null - ? entry.activeBuild - : Promise.resolve(entry.snapshot); - } - if (entry.snapshot.state === "building" && entry.snapshot.graph === null) { - return this.startBuild(entry); - } - - // Non-ready projections are deliberately not healthy cache hits. One - // later open awaits a recovery build, while a permanent failure cannot - // charge every collapse/expand forever. - if ( - (entry.snapshot.state === "degraded" || - entry.snapshot.state === "stale") && - !entry.automaticRetryUsed - ) { - entry.automaticRetryUsed = true; - this.queueRefresh(entry, entry.snapshot.state === "degraded"); - } - 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.prerequisiteTokens.size > 0) { - return Promise.resolve(entry.snapshot); - } - 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); - entry.automaticRetryUsed = false; - if (entry.prerequisiteTokens.size > 0) { - entry.refreshPending = true; - return entry.snapshot; - } - this.queueRefresh(entry); - return entry.snapshot; - } - - /** - * Fails closed on a raw inventory/source signal without projecting the old - * registry snapshot. The reconciliation coordinator starts the replacement - * build only after its atomic snapshot commit succeeds. - */ - markStale( - scope: WorkspaceScope, - prerequisiteToken = "inventory", - ): SystemGraphSnapshot { - const entry = this.ensureEntry(scope); - entry.prerequisiteTokens.add(prerequisiteToken); - entry.generation += 1; - entry.refreshPending = false; - entry.automaticRetryUsed = true; - const visible = visibleProjection(entry); - return visible.graph === null - ? this.transition(entry, "building", null) - : this.transition( - entry, - "stale", - visible.graph, - false, - visible.navigation, - ); - } - - /** Explicit user recovery: start a fresh projection and await its result. */ - refresh(scope: WorkspaceScope): Promise { - const entry = this.ensureEntry(scope); - entry.automaticRetryUsed = false; - if (entry.prerequisiteTokens.size > 0) { - entry.refreshPending = true; - return entry.activeBuild ?? Promise.resolve(entry.snapshot); - } - return this.queueRefresh(entry) ?? Promise.resolve(entry.snapshot); - } - - /** - * Releases one accepted inventory prerequisite. A projection is rebuilt - * only after every overlapping scan has accepted its current generation. - */ - releasePrerequisite( - scope: WorkspaceScope, - prerequisiteToken = "inventory", - ): SystemGraphSnapshot { - const entry = this.ensureEntry(scope); - if (!entry.prerequisiteTokens.delete(prerequisiteToken)) { - return entry.snapshot; - } - if (entry.prerequisiteTokens.size > 0) return entry.snapshot; - entry.automaticRetryUsed = false; - this.queueRefresh(entry); - return entry.snapshot; - } - - /** Retires a failed prerequisite without projecting the pre-mutation cache. */ - cancelPrerequisite( - workspaceKey: WorkspaceKey, - prerequisiteToken = "inventory", - ): SystemGraphSnapshot | null { - const entry = this.entries.get(workspaceKey); - if (!entry) return null; - entry.prerequisiteTokens.delete(prerequisiteToken); - return entry.snapshot; - } - - /** Awaits the already-started accepted-snapshot build without queuing one. */ - waitForCurrentRefresh( - workspaceKey: WorkspaceKey, - ): Promise { - const entry = this.entries.get(workspaceKey); - return entry?.activeBuild ?? Promise.resolve(entry?.snapshot ?? null); - } - - peek(workspaceKey: WorkspaceKey): SystemGraphSnapshot | null { - 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); - entry.generation += 1; - entry.refreshPending = false; - // Registry recovery must happen before projection. Do not let a later - // graph read rebuild from the stale inventory and relabel it ready; the - // watcher retries the failed inventory callback instead. - entry.automaticRetryUsed = true; - const visible = visibleProjection(entry); - return visible.graph === null - ? this.transition(entry, "degraded", null) - : this.transition( - entry, - "stale", - visible.graph, - false, - visible.navigation, - ); - } - - /** Retires projections for workspace scopes Studio no longer exposes. */ - retain(workspaceKeys: ReadonlySet): void { - for (const workspaceKey of [...this.entries.keys()]) { - if (!workspaceKeys.has(workspaceKey)) this.retire(workspaceKey); - } - } - - /** Stops retaining a scope that Studio no longer exposes. */ - retire(workspaceKey: WorkspaceKey): void { - const entry = this.entries.get(workspaceKey); - if (!entry) return; - entry.retired = true; - entry.generation += 1; - this.revisionFloors.set(workspaceKey, entry.snapshot.revision); - this.entries.delete(workspaceKey); - this.retainBuilderWorkspaces(); - } - - /** Backward-compatible alias for callers that explicitly drop a snapshot. */ - invalidate(workspaceKey: WorkspaceKey): void { - this.retire(workspaceKey); - } - - clear(): void { - for (const workspaceKey of [...this.entries.keys()]) { - this.retire(workspaceKey); - } - this.revisionFloors.clear(); - } - - private ensureEntry(scope: WorkspaceScope): SystemGraphEntry { - const existing = this.entries.get(scope.workspaceKey); - if (existing) { - existing.scope = scope; - return existing; - } - const entry: SystemGraphEntry = { - scope, - snapshot: { - workspaceKey: scope.workspaceKey, - revision: this.revisionFloors.get(scope.workspaceKey) ?? 0, - state: "building", - graph: null, - }, - navigation: { - workspaceKey: scope.workspaceKey, - revision: this.revisionFloors.get(scope.workspaceKey) ?? 0, - targets: [], - }, - activeBuild: null, - generation: 0, - refreshPending: false, - automaticRetryUsed: false, - prerequisiteTokens: new Set(), - retired: false, - }; - this.entries.set(scope.workspaceKey, entry); - return entry; - } - - private queueRefresh( - entry: SystemGraphEntry, - preserveLifecycle = false, - ): Promise | null { - if (entry.retired) return null; - entry.generation += 1; - entry.refreshPending = true; - const visible = visibleProjection(entry); - if (!preserveLifecycle) { - this.transition( - entry, - visible.graph === null ? "building" : "stale", - visible.graph, - false, - visible.navigation, - ); - } - if (entry.activeBuild) return entry.activeBuild; - entry.refreshPending = false; - return this.startBuild(entry); - } - - private startBuild(entry: SystemGraphEntry): Promise { - if (entry.activeBuild) return entry.activeBuild; - const generation = entry.generation; - entry.refreshPending = false; - const visible = visibleProjection(entry); - if (entry.snapshot.state !== "degraded") { - this.transition( - entry, - visible.graph === null ? "building" : "stale", - visible.graph, - false, - visible.navigation, - ); - } - - let build: Promise>>; - try { - build = Promise.resolve(this.builder.build(entry.scope)); - } catch (error) { - build = Promise.reject(error); - } - - const active = build.then( - (result) => this.finishBuild(entry, generation, result), - () => this.finishFailure(entry, generation), - ); - entry.activeBuild = active; - return active; - } - - private finishBuild( - entry: SystemGraphEntry, - generation: number, - result: Awaited>, - ): SystemGraphSnapshot | Promise { - // The superseded build can carry the only callback that starts background - // identity or invocation enrichment. Arm it after the commit decision on - // both paths: before that decision a synchronous refresh could supersede - // the result being committed; omitting it from the losing path can leave - // enrichment pending forever with no follow-up queued. - if (!this.canCommit(entry, generation)) { - 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.automaticRetryUsed = false; - const snapshot = this.transition( - entry, - "ready", - result.graph, - false, - navigation, - ); - this.afterCommit(result.afterCommit); - return snapshot; - } - const snapshot = this.transition( - entry, - "degraded", - result.graph, - false, - navigation, - ); - this.afterCommit(result.afterCommit); - return snapshot; - } - - private finishFailure( - entry: SystemGraphEntry, - generation: number, - ): SystemGraphSnapshot | Promise { - if (!this.canCommit(entry, generation)) { - return this.continueAfterSupersededBuild(entry); - } - entry.activeBuild = null; - const visible = visibleProjection(entry); - return visible.graph === null - ? this.transition(entry, "degraded", null) - : this.transition( - entry, - "stale", - visible.graph, - true, - visible.navigation, - ); - } - - private canCommit(entry: SystemGraphEntry, generation: number): boolean { - return ( - !entry.retired && - this.entries.get(entry.scope.workspaceKey) === entry && - entry.generation === generation - ); - } - - private continueAfterSupersededBuild( - entry: SystemGraphEntry, - ): SystemGraphSnapshot | Promise { - entry.activeBuild = null; - if (entry.retired || this.entries.get(entry.scope.workspaceKey) !== entry) { - this.retainBuilderWorkspaces(); - return entry.snapshot; - } - if (entry.prerequisiteTokens.size > 0) { - return entry.snapshot; - } - if (entry.refreshPending) { - entry.refreshPending = false; - return this.startBuild(entry); - } - return entry.snapshot; - } - - private retainBuilderWorkspaces(): void { - try { - this.builder.retainWorkspaces?.(new Set(this.entries.keys())); - } catch { - // Cache pruning cannot make graph reads or scope retirement fail. - } - } - - private transition( - entry: SystemGraphEntry, - 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 && - sameNavigation(entry.navigation.targets, navigation) - ) { - return entry.snapshot; - } - entry.snapshot = { - workspaceKey: entry.scope.workspaceKey, - revision: entry.snapshot.revision + 1, - state, - graph, - }; - 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 { - // Observers (the event bus) cannot make graph refreshes fail. - } - 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-watcher.test.ts b/packages/harness/src/core/system-graph-watcher.test.ts deleted file mode 100644 index 1a33ee4b2..000000000 --- a/packages/harness/src/core/system-graph-watcher.test.ts +++ /dev/null @@ -1,657 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import type { WorkspaceScope } from "./system-graph.js"; -import { SystemGraphWatcherManager } from "./system-graph-watcher.js"; -import { - SharedWorkspaceWatchBroker, - type WorkspaceWatchFactory, - type WorkspaceWatchHandle, -} from "./workspace-watch-broker.js"; -import { - snapshotWorkflowSourceRootsAsync, - snapshotWorkspaceWorkflowsAsync, - WorkspaceWatcherManager, -} from "./workspace-watcher.js"; - -const sleep = (ms: number): Promise => - new Promise((resolve) => setTimeout(resolve, ms)); - -let root: string; -let scope: WorkspaceScope; -let manager: SystemGraphWatcherManager; -let onSourceChange: ReturnType; -let onInventoryChange: ReturnType; -let sourceRoots: Set; -let sourceObservations: Map>; - -async function scaffoldAgent(name: string): Promise { - const agentRoot = path.join(root, name); - await fs.mkdir(agentRoot, { recursive: true }); - await fs.writeFile( - path.join(agentRoot, "sapiom.json"), - JSON.stringify({ name }), - ); - await fs.writeFile(path.join(agentRoot, "index.ts"), "export {};\n"); - sourceRoots.add(agentRoot); - sourceObservations.set( - agentRoot, - new Set([path.join(agentRoot, "index.ts")]), - ); - return agentRoot; -} - -describe("SystemGraphWatcherManager", () => { - beforeEach(async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), "system-graph-watch-")); - scope = { workspaceKey: "workspace-test", root }; - onSourceChange = vi.fn(); - onInventoryChange = vi.fn(); - sourceRoots = new Set(); - sourceObservations = new Map(); - manager = new SystemGraphWatcherManager( - { - listSourceRoots: () => [...sourceRoots], - listSourceObservations: () => - [...sourceObservations].map(([candidateRoot, paths]) => ({ - candidateRoot, - workspaceRoot: root, - paths: [...paths], - })), - onSourceChange, - onInventoryChange, - }, - { - forcePolling: true, - sourceDebounceMs: 10, - inventoryDebounceMs: 10, - inventoryRetryBaseMs: 20, - maxInventoryRetries: 2, - pollIntervalMs: 25, - }, - ); - }); - - afterEach(async () => { - manager.stopAll(); - await fs.rm(root, { recursive: true, force: true }); - }); - - it("refreshes source invocations without reporting inventory churn", async () => { - const agentRoot = await scaffoldAgent("research"); - await scaffoldAgent("growth"); - await manager.start(scope); - await sleep(100); - onSourceChange.mockClear(); - onInventoryChange.mockClear(); - - await fs.writeFile( - path.join(agentRoot, "index.ts"), - 'ctx.sapiom.agents.run({ definition: "growth" });\n', - ); - await vi.waitFor(() => expect(onSourceChange).toHaveBeenCalled(), { - timeout: 2_000, - interval: 20, - }); - - expect(onSourceChange).toHaveBeenCalled(); - expect(onSourceChange.mock.calls.at(-1)?.[0]).toEqual(scope); - expect(onSourceChange.mock.calls.at(-1)?.[1]).toEqual([agentRoot]); - expect(onInventoryChange).not.toHaveBeenCalled(); - }); - - it("routes native paths, ignores generated churn, and recovers via polling", async () => { - let watchListener!: Parameters[1]; - let errorListener!: (error: Error) => void; - const close = vi.fn(); - const watchFactory: WorkspaceWatchFactory = (_watchRoot, listener) => { - watchListener = listener; - const handle: WorkspaceWatchHandle = { - close, - on: (_event, onError) => { - errorListener = onError; - return handle; - }, - }; - return handle; - }; - manager = new SystemGraphWatcherManager( - { - listSourceRoots: () => [...sourceRoots], - listSourceObservations: () => - [...sourceObservations].map(([candidateRoot, paths]) => ({ - candidateRoot, - workspaceRoot: root, - paths: [...paths], - })), - onSourceChange, - onInventoryChange, - }, - { - watchFactory, - sourceDebounceMs: 10, - inventoryDebounceMs: 10, - pollIntervalMs: 25, - }, - ); - const agentRoot = await scaffoldAgent("research"); - await manager.start(scope); - await sleep(100); - onSourceChange.mockClear(); - - watchListener("change", "research/index.ts"); - await vi.waitFor(() => expect(onSourceChange).toHaveBeenCalled(), { - timeout: 2_000, - interval: 20, - }); - expect(onSourceChange.mock.calls.at(-1)?.[1]).toEqual([ - path.join(agentRoot, "index.ts"), - ]); - - onSourceChange.mockClear(); - watchListener("change", "node_modules/pkg/index.ts"); - await sleep(50); - expect(onSourceChange).not.toHaveBeenCalled(); - - onInventoryChange.mockClear(); - watchListener("rename", "research/.git"); - await vi.waitFor(() => expect(onInventoryChange).toHaveBeenCalledOnce(), { - timeout: 2_000, - interval: 20, - }); - onInventoryChange.mockClear(); - watchListener("rename", "research/.git/objects/pack-1"); - await sleep(50); - expect(onInventoryChange).not.toHaveBeenCalled(); - - errorListener(new Error("recursive watch unavailable")); - expect(close).toHaveBeenCalledTimes(1); - await sleep(100); - onSourceChange.mockClear(); - await fs.writeFile( - path.join(agentRoot, "index.ts"), - "export const recovered = true;\n", - ); - await vi.waitFor(() => expect(onSourceChange).toHaveBeenCalled(), { - timeout: 2_000, - interval: 20, - }); - expect(onSourceChange.mock.calls.at(-1)?.[1]).toEqual([agentRoot]); - }); - - it("reports agent inventory additions and removals", async () => { - await manager.start(scope); - await sleep(100); - const agentRoot = await scaffoldAgent("growth"); - await vi.waitFor( - () => expect(onInventoryChange).toHaveBeenCalledWith(scope), - { timeout: 2_000, interval: 20 }, - ); - - onInventoryChange.mockClear(); - sourceRoots.delete(agentRoot); - await fs.rm(agentRoot, { recursive: true, force: true }); - await vi.waitFor( - () => expect(onInventoryChange).toHaveBeenCalledWith(scope), - { timeout: 2_000, interval: 20 }, - ); - }); - - it("retries a failed inventory refresh without another filesystem edit", async () => { - onInventoryChange - .mockRejectedValueOnce(new Error("registry unavailable")) - .mockResolvedValue(undefined); - await manager.start(scope); - await scaffoldAgent("growth"); - - await vi.waitFor(() => expect(onInventoryChange).toHaveBeenCalledTimes(2), { - timeout: 2_000, - interval: 20, - }); - }); - - it("bounds inventory retries until a later filesystem change", async () => { - onInventoryChange.mockRejectedValue(new Error("registry unavailable")); - await manager.start(scope); - await scaffoldAgent("growth"); - - await vi.waitFor(() => expect(onInventoryChange).toHaveBeenCalledTimes(3), { - timeout: 2_000, - interval: 20, - }); - await sleep(200); - expect(onInventoryChange).toHaveBeenCalledTimes(3); - - await scaffoldAgent("reporting"); - await vi.waitFor( - () => expect(onInventoryChange.mock.calls.length).toBeGreaterThan(3), - { timeout: 2_000, interval: 20 }, - ); - }); - - it("bounds source retries until a later raw event rearms recovery", async () => { - let watchListener!: Parameters[1]; - const watchFactory: WorkspaceWatchFactory = (_watchRoot, listener) => { - watchListener = listener; - const handle: WorkspaceWatchHandle = { - close: vi.fn(), - on: () => handle, - }; - return handle; - }; - const agentRoot = await scaffoldAgent("retry-source"); - onSourceChange.mockRejectedValue(new Error("registry unavailable")); - manager = new SystemGraphWatcherManager( - { - listSourceRoots: () => [...sourceRoots], - listSourceObservations: () => [], - onSourceChange, - onInventoryChange, - }, - { - watchFactory, - sourceDebounceMs: 1, - inventoryRetryBaseMs: 10, - maxSourceRetries: 2, - }, - ); - await manager.start(scope); - - vi.useFakeTimers(); - try { - watchListener( - "change", - path.relative(root, path.join(agentRoot, "index.ts")), - ); - await vi.advanceTimersByTimeAsync(1); - expect(onSourceChange).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(10); - expect(onSourceChange).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(20); - expect(onSourceChange).toHaveBeenCalledTimes(3); - await vi.advanceTimersByTimeAsync(10_000); - expect(onSourceChange).toHaveBeenCalledTimes(3); - - watchListener( - "change", - path.relative(root, path.join(agentRoot, "index.ts")), - ); - await vi.advanceTimersByTimeAsync(1); - await vi.advanceTimersByTimeAsync(10); - await vi.advanceTimersByTimeAsync(20); - expect(onSourceChange).toHaveBeenCalledTimes(6); - } finally { - manager.stopAll(); - vi.useRealTimers(); - } - }); - - it("polls every registered source file past Canvas's project-sized cap", async () => { - const agentRoot = await scaffoldAgent("large"); - await Promise.all( - Array.from({ length: 425 }, (_, index) => - fs.writeFile( - path.join(agentRoot, `step-${index.toString().padStart(3, "0")}.ts`), - `export const step${index} = ${index};\n`, - ), - ), - ); - sourceObservations.get(agentRoot)?.add(path.join(agentRoot, "step-424.ts")); - await manager.start(scope); - await sleep(200); - onSourceChange.mockClear(); - - await fs.writeFile( - path.join(agentRoot, "step-424.ts"), - "export const step424 = 424_424;\n", - ); - - await vi.waitFor(() => expect(onSourceChange).toHaveBeenCalled(), { - timeout: 2_000, - interval: 20, - }); - }); - - it("ignores non-source/generated churn but notices an unregistered source candidate", async () => { - await manager.start(scope); - await sleep(100); - onSourceChange.mockClear(); - onInventoryChange.mockClear(); - await fs.writeFile(path.join(root, "README.md"), "notes\n"); - await fs.mkdir(path.join(root, "node_modules", "pkg"), { - recursive: true, - }); - await fs.writeFile( - path.join(root, "node_modules", "pkg", "index.ts"), - "export {};\n", - ); - await fs.mkdir(path.join(root, "unregistered"), { recursive: true }); - await fs.writeFile( - path.join(root, "unregistered", "index.ts"), - "export const ignored = true;\n", - ); - await sleep(300); - - expect(onSourceChange).not.toHaveBeenCalled(); - expect(onInventoryChange).toHaveBeenCalledWith(scope); - }); - - it("does not re-baseline an existing workspace on repeated opens", async () => { - await Promise.all([manager.start(scope), manager.start(scope)]); - expect(manager.size).toBe(1); - }); - - it("retires watchers for scopes no longer exposed by Studio", async () => { - await manager.start(scope); - manager.retain(new Set()); - expect(manager.size).toBe(0); - }); - - it("closes and suppresses a pending watcher immediately when its scope retires", async () => { - let releaseBaseline!: () => void; - const baselineGate = new Promise((resolve) => { - releaseBaseline = resolve; - }); - let watchListener!: Parameters[1]; - const close = vi.fn(); - const onPotentialChange = vi.fn(); - const watchFactory: WorkspaceWatchFactory = (_watchRoot, listener) => { - watchListener = listener; - const handle: WorkspaceWatchHandle = { - close, - on: () => handle, - }; - return handle; - }; - manager = new SystemGraphWatcherManager( - { - listSourceRoots: () => [], - onSourceChange, - onInventoryChange, - onPotentialChange, - }, - { - watchFactory, - beforeInitialSnapshot: () => baselineGate, - sourceDebounceMs: 5, - inventoryDebounceMs: 5, - }, - ); - - const starting = manager.start(scope); - manager.retain(new Set()); - - expect(close).toHaveBeenCalledTimes(1); - expect(manager.size).toBe(0); - watchListener("rename", "agent/index.ts"); - await sleep(20); - expect(onPotentialChange).not.toHaveBeenCalled(); - expect(onSourceChange).not.toHaveBeenCalled(); - expect(onInventoryChange).not.toHaveBeenCalled(); - - releaseBaseline(); - await starting; - expect(manager.size).toBe(0); - expect(close).toHaveBeenCalledTimes(1); - }); - - it("closes an armed watcher when its initial baseline rejects", async () => { - let watchListener!: Parameters[1]; - const close = vi.fn(); - const onPotentialChange = vi.fn(); - const watchFactory: WorkspaceWatchFactory = (_watchRoot, listener) => { - watchListener = listener; - const handle: WorkspaceWatchHandle = { - close, - on: () => handle, - }; - return handle; - }; - manager = new SystemGraphWatcherManager( - { - listSourceRoots: () => [], - onSourceChange, - onInventoryChange, - onPotentialChange, - }, - { - watchFactory, - beforeInitialSnapshot: () => - Promise.reject(new Error("baseline unavailable")), - sourceDebounceMs: 5, - inventoryDebounceMs: 5, - }, - ); - - await expect(manager.start(scope)).rejects.toThrow("baseline unavailable"); - expect(close).toHaveBeenCalledTimes(1); - expect(manager.size).toBe(0); - watchListener("rename", "agent/index.ts"); - await sleep(20); - expect(onPotentialChange).not.toHaveBeenCalled(); - expect(onSourceChange).not.toHaveBeenCalled(); - expect(onInventoryChange).not.toHaveBeenCalled(); - }); - - it("reconciles every retained root when an ambiguous event races the initial baseline", async () => { - const agentRoot = await scaffoldAgent("nested-checkout"); - let releaseBaseline!: () => void; - const baselineGate = new Promise((resolve) => { - releaseBaseline = resolve; - }); - let watchListener!: Parameters[1]; - const watchFactory: WorkspaceWatchFactory = (_watchRoot, listener) => { - watchListener = listener; - const handle: WorkspaceWatchHandle = { - close: vi.fn(), - on: () => handle, - }; - return handle; - }; - manager = new SystemGraphWatcherManager( - { - listSourceRoots: () => [...sourceRoots], - listSourceObservations: () => [], - onSourceChange, - onInventoryChange, - }, - { - watchFactory, - beforeInitialSnapshot: () => baselineGate, - sourceDebounceMs: 5, - inventoryDebounceMs: 5, - }, - ); - - const starting = manager.start(scope); - watchListener("rename", null); - - await vi.waitFor(() => expect(onSourceChange).toHaveBeenCalled(), { - timeout: 2_000, - interval: 10, - }); - expect(onSourceChange.mock.calls.at(-1)?.[1]).toEqual([agentRoot]); - - releaseBaseline(); - await starting; - }); - - it("owns one polling baseline and reconciles an edit absorbed into it", async () => { - const agentRoot = await scaffoldAgent("polling-baseline"); - let releaseBaseline!: () => void; - const baselineGate = new Promise((resolve) => { - releaseBaseline = resolve; - }); - const snapshotWorkspace = vi.fn(snapshotWorkspaceWorkflowsAsync); - const snapshotSources = vi.fn(snapshotWorkflowSourceRootsAsync); - const onPotentialChange = vi.fn(); - manager = new SystemGraphWatcherManager( - { - listSourceRoots: () => [...sourceRoots], - listSourceObservations: () => [], - onSourceChange, - onInventoryChange, - onPotentialChange, - }, - { - forcePolling: true, - beforeInitialSnapshot: () => baselineGate, - snapshotWorkspace, - snapshotSources, - sourceDebounceMs: 5, - pollIntervalMs: 60_000, - }, - ); - - const starting = manager.start(scope); - await Promise.resolve(); - expect(snapshotWorkspace).not.toHaveBeenCalled(); - expect(snapshotSources).not.toHaveBeenCalled(); - - await fs.writeFile( - path.join(agentRoot, "index.ts"), - "export const changedDuringBaseline = true;\n", - ); - releaseBaseline(); - await starting; - - await vi.waitFor(() => expect(onSourceChange).toHaveBeenCalledOnce(), { - timeout: 2_000, - interval: 10, - }); - expect(snapshotWorkspace).toHaveBeenCalledTimes(1); - expect(snapshotSources).toHaveBeenCalledTimes(1); - expect(onPotentialChange).toHaveBeenCalledOnce(); - expect(onPotentialChange).toHaveBeenCalledWith(scope, null); - expect(onSourceChange).toHaveBeenCalledWith(scope, [agentRoot]); - }); - - it("shares one canonical-root watcher across two sessions and a graph caller", async () => { - const agentRoot = await scaffoldAgent("shared-agent"); - let watchListener!: Parameters[1]; - const close = vi.fn(); - const watchFactory = vi.fn( - (_watchRoot, listener) => { - watchListener = listener; - const handle: WorkspaceWatchHandle = { - close, - on: () => handle, - }; - return handle; - }, - ); - const broker = new SharedWorkspaceWatchBroker({ - watchFactory, - sourceDebounceMs: 5, - inventoryDebounceMs: 5, - }); - const sessionChange = vi.fn(); - const sessionPotential = vi.fn(); - const sessions = new WorkspaceWatcherManager({ - sharedWatchBroker: broker, - listSourceRoots: () => [...sourceRoots], - listSourceObservations: () => [], - onPotentialChange: sessionPotential, - onChange: sessionChange, - }); - const graphSourceChange = vi.fn(); - const graphPotential = vi.fn(); - manager = new SystemGraphWatcherManager( - { - listSourceRoots: () => [...sourceRoots], - listSourceObservations: () => [], - onPotentialChange: graphPotential, - onSourceChange: graphSourceChange, - onInventoryChange, - }, - { sharedBroker: broker }, - ); - - sessions.start("session-a", root); - sessions.start("session-b", path.join(root, ".")); - await manager.start(scope); - - expect(watchFactory).toHaveBeenCalledTimes(1); - expect(broker.size).toBe(1); - watchListener( - "change", - path.relative(root, path.join(agentRoot, "index.ts")), - ); - - await vi.waitFor(() => expect(graphSourceChange).toHaveBeenCalledOnce(), { - timeout: 2_000, - interval: 10, - }); - expect(sessionChange).toHaveBeenCalledTimes(2); - expect(sessionChange.mock.calls.map((call) => call[0]).sort()).toEqual([ - "session-a", - "session-b", - ]); - expect(sessionChange.mock.calls.map((call) => call[1])).toEqual([ - [agentRoot], - [agentRoot], - ]); - expect(sessionPotential).toHaveBeenCalledTimes(2); - expect(graphPotential).toHaveBeenCalledOnce(); - - sessions.stopAll(); - expect(broker.size).toBe(1); - expect(close).not.toHaveBeenCalled(); - sessionChange.mockClear(); - graphSourceChange.mockClear(); - watchListener( - "change", - path.relative(root, path.join(agentRoot, "index.ts")), - ); - await vi.waitFor(() => expect(graphSourceChange).toHaveBeenCalledOnce(), { - timeout: 2_000, - interval: 10, - }); - expect(sessionChange).not.toHaveBeenCalled(); - - manager.retain(new Set()); - expect(broker.size).toBe(0); - expect(close).toHaveBeenCalledTimes(1); - }); - - it("cleans a rejected shared subscription so the same scope can retry", async () => { - let attempts = 0; - const closes: Array> = []; - const watchFactory = vi.fn(() => { - const close = vi.fn(); - closes.push(close); - const handle: WorkspaceWatchHandle = { - close, - on: () => handle, - }; - return handle; - }); - const broker = new SharedWorkspaceWatchBroker({ - watchFactory, - beforeInitialSnapshot: () => { - attempts += 1; - if (attempts === 1) throw new Error("first baseline failed"); - }, - }); - manager = new SystemGraphWatcherManager( - { - listSourceRoots: () => [], - onSourceChange, - onInventoryChange, - }, - { sharedBroker: broker }, - ); - - await expect(manager.start(scope)).rejects.toThrow("first baseline failed"); - expect(manager.size).toBe(0); - expect(broker.size).toBe(0); - expect(closes[0]).toHaveBeenCalledTimes(1); - - await manager.start(scope); - expect(watchFactory).toHaveBeenCalledTimes(2); - expect(manager.size).toBe(1); - expect(broker.size).toBe(1); - }); -}); diff --git a/packages/harness/src/core/system-graph-watcher.ts b/packages/harness/src/core/system-graph-watcher.ts deleted file mode 100644 index ab94d577b..000000000 --- a/packages/harness/src/core/system-graph-watcher.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { WorkspaceKey } from "../shared/system-graph.js"; -import { canonicalGraphPath } from "./canonical-graph-path.js"; -import type { WorkspaceScope } from "./system-graph.js"; -import type { WorkflowSourceObservation } from "./workspace-watcher.js"; -import { - type SharedWorkspaceWatchBrokerLike, - WorkspaceRootWatcher, - type WorkspaceWatchOptions, -} from "./workspace-watch-broker.js"; - -export interface SystemGraphWatcherCallbacks { - /** Current registry roots inside this scope; read lazily on every poll. */ - listSourceRoots: (scope: WorkspaceScope) => readonly string[]; - listSourceObservations?: ( - scope: WorkspaceScope, - ) => readonly WorkflowSourceObservation[]; - onSourceChange: ( - scope: WorkspaceScope, - /** Null when the platform can only report a workspace-level change. */ - sourcePaths: readonly string[] | null, - ) => void | Promise; - onInventoryChange: (scope: WorkspaceScope) => void | Promise; - /** Synchronous raw-event fail-close hook, before debounce or async I/O. */ - onPotentialChange?: ( - scope: WorkspaceScope, - sourcePaths: readonly string[] | null, - ) => void; -} - -export interface SystemGraphWatcherOptions extends WorkspaceWatchOptions { - /** Optional process-wide root broker shared with session watchers. */ - sharedBroker?: SharedWorkspaceWatchBrokerLike; -} - -/** One watcher per requested workspace, independent of harness sessions. */ -export class SystemGraphWatcherManager { - private readonly watchers = new Map(); - private readonly pendingStarts = new Map< - WorkspaceKey, - { - root: string; - token: object; - watcher: WorkspaceRootWatcher; - promise: Promise; - } - >(); - private readonly sharedSubscriptions = new Map< - WorkspaceKey, - { root: string; key: object } - >(); - - constructor( - private readonly callbacks: SystemGraphWatcherCallbacks, - private readonly options: SystemGraphWatcherOptions = {}, - ) {} - - start(scope: WorkspaceScope): Promise { - const sharedBroker = this.options.sharedBroker; - if (sharedBroker) { - const canonicalRoot = canonicalGraphPath(scope.root); - const existingShared = this.sharedSubscriptions.get(scope.workspaceKey); - if (existingShared?.root === canonicalRoot) return Promise.resolve(); - this.stop(scope.workspaceKey); - const key = {}; - this.sharedSubscriptions.set(scope.workspaceKey, { - root: canonicalRoot, - key, - }); - return sharedBroker - .subscribe(key, { - root: scope.root, - listSourceRoots: () => this.callbacks.listSourceRoots(scope), - listSourceObservations: () => - this.callbacks.listSourceObservations?.(scope) ?? [], - onPotentialChange: (paths) => - this.callbacks.onPotentialChange?.(scope, paths), - onSourceChange: (paths) => - this.callbacks.onSourceChange(scope, paths), - onInventoryChange: () => this.callbacks.onInventoryChange(scope), - }) - .catch((error: unknown) => { - if (this.sharedSubscriptions.get(scope.workspaceKey)?.key === key) { - this.sharedSubscriptions.delete(scope.workspaceKey); - } - throw error; - }); - } - const existing = this.watchers.get(scope.workspaceKey); - if (existing?.root === scope.root) return Promise.resolve(); - const pending = this.pendingStarts.get(scope.workspaceKey); - if (pending?.root === scope.root) return pending.promise; - this.stop(scope.workspaceKey); - const token = {}; - const started = WorkspaceRootWatcher.begin( - scope.root, - { - listSourceRoots: () => this.callbacks.listSourceRoots(scope), - listSourceObservations: () => - this.callbacks.listSourceObservations?.(scope) ?? [], - onPotentialChange: (paths) => - this.callbacks.onPotentialChange?.(scope, paths), - onSourceChange: (paths) => this.callbacks.onSourceChange(scope, paths), - onInventoryChange: () => this.callbacks.onInventoryChange(scope), - }, - this.options, - ); - const promise = started.ready - .then( - () => { - const current = this.pendingStarts.get(scope.workspaceKey); - if (current?.token !== token) { - started.watcher.close(); - return; - } - this.watchers.set(scope.workspaceKey, started.watcher); - }, - (error: unknown) => { - started.watcher.close(); - throw error; - }, - ) - .finally(() => { - if (this.pendingStarts.get(scope.workspaceKey)?.token === token) { - this.pendingStarts.delete(scope.workspaceKey); - } - }); - this.pendingStarts.set(scope.workspaceKey, { - root: scope.root, - token, - watcher: started.watcher, - promise, - }); - return promise; - } - - retain(workspaceKeys: ReadonlySet): void { - const tracked = new Set([ - ...this.watchers.keys(), - ...this.pendingStarts.keys(), - ...this.sharedSubscriptions.keys(), - ]); - for (const workspaceKey of tracked) { - if (!workspaceKeys.has(workspaceKey)) this.stop(workspaceKey); - } - } - - stop(workspaceKey: WorkspaceKey): void { - const shared = this.sharedSubscriptions.get(workspaceKey); - if (shared) this.options.sharedBroker?.unsubscribe(shared.key); - this.sharedSubscriptions.delete(workspaceKey); - this.pendingStarts.get(workspaceKey)?.watcher.close(); - this.pendingStarts.delete(workspaceKey); - this.watchers.get(workspaceKey)?.close(); - this.watchers.delete(workspaceKey); - } - - stopAll(): void { - const tracked = new Set([ - ...this.watchers.keys(), - ...this.pendingStarts.keys(), - ...this.sharedSubscriptions.keys(), - ]); - for (const workspaceKey of tracked) this.stop(workspaceKey); - } - - get size(): number { - return this.watchers.size + this.sharedSubscriptions.size; - } -} diff --git a/packages/harness/src/core/system-graph.test.ts b/packages/harness/src/core/system-graph.test.ts deleted file mode 100644 index 67c1427ca..000000000 --- a/packages/harness/src/core/system-graph.test.ts +++ /dev/null @@ -1,1423 +0,0 @@ -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, - CachedAgentInvocationProvider, - StaticSystemGraphBuilder, - type AgentInventoryProvider, - type AgentInventoryResult, - type AgentInvocationProvider, - type AgentInvocationProviderResult, - type WorkspaceScope, -} from "./system-graph.js"; - -const FIXTURE = path.join( - path.dirname(fileURLToPath(import.meta.url)), - "__fixtures__", - "system-graph-workspace", -); - -function workflow( - name: string, - relativePath: string, - definitionSlug: string | null, -): WorkflowInfo { - return { - name, - path: path.join(FIXTURE, relativePath), - definitionId: definitionSlug ? 1 : null, - definitionSlug, - source: "scan", - }; -} - -async function buildGraph( - builder: StaticSystemGraphBuilder, - scope: WorkspaceScope, -) { - return (await builder.build(scope)).graph; -} - -function invocationProvider( - listInvocations: ( - sourceRoot: string, - ) => Promise, -): AgentInvocationProvider { - return { - listInvocations: vi.fn((caller) => listInvocations(caller.sourceRoot)), - }; -} - -const EMPTY_INVOCATIONS: AgentInvocationProviderResult = { - invocations: [], - warnings: [], -}; - -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; - identitySettled?: boolean; - discoveryComplete?: 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 ?? [], - identitySettled: - options.identitySettled ?? - !records.some( - (record) => - record.public.identityStatus === "provisional" && - record.public.identityIssue === "identity-pending", - ), - discoveryComplete: options.discoveryComplete ?? true, - }; -} - -describe("StaticSystemGraphBuilder", () => { - const scope: WorkspaceScope = { - workspaceKey: "workspace-fixture", - root: FIXTURE, - }; - - it("projects literal Research -> Growth blocking and async calls into the public contract", async () => { - 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 builder = new StaticSystemGraphBuilder(inventory); - const first = await builder.build(scope); - - // The cold phase is inventory-only: all cards and navigation are available - // before bounded project invocation I/O starts. - expect(first.cacheable).toBe(false); - expect(first.graph.nodes).toHaveLength(2); - expect(first.graph.edges).toEqual([]); - expect(first.navigation).toHaveLength(2); - first.afterCommit?.(); - - let graph = first.graph; - await vi.waitFor(async () => { - graph = (await builder.build(scope)).graph; - expect(graph.edges).toHaveLength(2); - }); - - expect(graph).toEqual({ - kind: "system", - scope: { kind: "working-tree", workspaceKey: "workspace-fixture" }, - nodes: [ - { id: "agent:growth", agentKey: "growth", label: "Growth" }, - { id: "agent:research", agentKey: "research", label: "Research" }, - ], - edges: [ - { - from: "agent:research", - to: "agent:growth", - kind: "invokes", - basis: "static-invocation", - mode: "blocking", - }, - { - from: "agent:research", - to: "agent:growth", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - ], - warnings: [], - }); - expect(JSON.stringify(graph)).not.toContain(FIXTURE); - }); - - it("returns inventory navigation while invocation extraction is still held", async () => { - const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => - inventoryResult(scope, [ - { - agentKey: "growth", - label: "Growth", - resolutionAliases: ["growth"], - }, - { - agentKey: "research", - label: "Research", - resolutionAliases: ["research"], - }, - ]), - ), - }; - let release!: (result: AgentInvocationProviderResult) => void; - const held = new Promise((resolve) => { - release = resolve; - }); - const inner = invocationProvider(async (root) => - root.endsWith("research") ? held : EMPTY_INVOCATIONS, - ); - const onChange = vi.fn(); - const invocations = new CachedAgentInvocationProvider( - inner, - async () => "unused", - { concurrency: 1, onChange }, - ); - const builder = new StaticSystemGraphBuilder(inventory, invocations); - - const cold = await builder.build(scope); - - expect(inner.listInvocations).not.toHaveBeenCalled(); - expect(cold.cacheable).toBe(false); - expect(cold.graph.nodes.map((node) => node.agentKey)).toEqual([ - "growth", - "research", - ]); - expect(cold.graph.edges).toEqual([]); - expect(cold.navigation?.map((target) => target.agentKey)).toEqual([ - "growth", - "research", - ]); - - cold.afterCommit?.(); - await vi.waitFor(() => expect(inner.listInvocations).toHaveBeenCalled()); - // The held project task cannot withhold the already-returned inventory. - expect(cold.graph.nodes).toHaveLength(2); - release({ - invocations: [ - { - target: "growth", - mode: "blocking", - evidence: EVIDENCE, - }, - ], - warnings: [], - }); - - await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); - const enriched = await builder.build(scope); - expect(enriched.graph.edges).toEqual([ - { - from: "agent:research", - to: "agent:growth", - kind: "invokes", - basis: "static-invocation", - mode: "blocking", - }, - ]); - expect(enriched.cacheable).toBe(true); - }); - - it("deduplicates by mode, retains dual-mode edges, and reports duplicate and unresolved targets", async () => { - const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => - inventoryResult(scope, [ - { - agentKey: "research", - definitionId: 1, - definitionSlug: "research", - label: "Research", - resolutionAliases: ["research"], - }, - { - agentKey: "growth", - definitionId: 2, - definitionSlug: "growth", - label: "Growth", - resolutionAliases: ["growth"], - }, - ]), - ), - }; - const invocations = invocationProvider(async (root) => - root.endsWith("research") - ? { - invocations: [ - { - target: "growth", - mode: "blocking", - evidence: [ - ...EVIDENCE, - { file: "second.ts", line: 2, column: 1 }, - ], - }, - { target: "growth", mode: "async", evidence: EVIDENCE }, - { target: "research", mode: "async", evidence: EVIDENCE }, - { target: "missing", mode: "async", evidence: EVIDENCE }, - ], - warnings: [], - } - : EMPTY_INVOCATIONS, - ); - - const graph = await buildGraph( - new StaticSystemGraphBuilder(inventory, invocations), - scope, - ); - expect(graph.edges).toEqual([ - { - from: "agent:research", - to: "agent:growth", - kind: "invokes", - basis: "static-invocation", - mode: "blocking", - }, - { - from: "agent:research", - to: "agent:growth", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - ]); - expect(graph.warnings.map((warning) => warning.code)).toEqual([ - "duplicate-edge", - "unresolved-target", - ]); - expect(graph.warnings[0]).toEqual({ - code: "duplicate-edge", - agentKey: "research", - message: "Research invokes Growth more than once.", - }); - expect(JSON.stringify(graph)).not.toContain("/private/"); - }); - - it("projects dynamic extraction warnings without degrading cacheability or leaking evidence", async () => { - const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => - inventoryResult(scope, [ - { - agentKey: "research", - definitionId: 1, - definitionSlug: "research", - label: "Research", - resolutionAliases: ["research"], - }, - ]), - ), - }; - const invocations = invocationProvider(async () => ({ - invocations: [], - warnings: [ - { - code: "dynamic-target", - mode: "blocking", - evidence: { file: "private/index.ts", line: 8, column: 5 }, - }, - ], - })); - - const built = await new StaticSystemGraphBuilder( - inventory, - invocations, - ).build(scope); - - expect(built.cacheable).toBe(true); - expect(built.graph.edges).toEqual([]); - expect(built.graph.warnings).toEqual([ - { - code: "dynamic-target", - agentKey: "research", - message: "Research has a dynamic agent target that V0 cannot resolve.", - }, - ]); - expect(JSON.stringify(built.graph)).not.toContain("private/index.ts"); - }); - - it("keeps duplicate definition slugs as unique nodes and reports ambiguous launches", async () => { - 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 invocations = invocationProvider(async (root) => - root.endsWith("caller") - ? { - invocations: [ - { target: "shared", mode: "async", evidence: EVIDENCE }, - ], - warnings: [], - } - : EMPTY_INVOCATIONS, - ); - - const graph = await buildGraph( - new StaticSystemGraphBuilder(inventory, invocations), - scope, - ); - - expect(graph.nodes.map((node) => node.id)).toEqual([ - "agent:caller", - "agent:local:growth", - "agent:local:research", - ]); - expect(new Set(graph.nodes.map((node) => node.id)).size).toBe(3); - expect(graph.edges).toEqual([]); - expect(graph.warnings).toEqual([ - { - code: "duplicate-agent-key", - agentKey: "shared", - message: "Multiple agents use shared; kept each with a local identity.", - }, - { - code: "unresolved-target", - agentKey: "caller", - message: "Caller invokes ambiguous agent shared.", - }, - ]); - expect(JSON.stringify(graph)).not.toContain(FIXTURE); - }); - - 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 }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - 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 }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - 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: [ - { - code: "duplicate-agent-key", - agentKey: "unsupported", - message: "private", - }, - ], - }, - ); - - await expect( - buildGraph( - new StaticSystemGraphBuilder( - { listAgents: async () => result }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - 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( - { listAgents: async () => inventory }, - invocationProvider(async (sourceRoot) => - sourceRoot.endsWith("caller") - ? { - invocations: [ - { target: "payments", mode: "async", evidence: EVIDENCE }, - ], - warnings: [], - } - : EMPTY_INVOCATIONS, - ), - ), - scope, - ); - - expect(graph.edges).toEqual([ - { - from: "agent:caller", - to: "agent:payments", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - ]); - }); - - it("keeps a invocation 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 }, - invocationProvider(async (sourceRoot) => - sourceRoot.endsWith("caller") - ? { - invocations: [ - { target: "legacy", mode: "async", evidence: EVIDENCE }, - ], - warnings: [], - } - : EMPTY_INVOCATIONS, - ), - ), - scope, - ); - - 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 }, - ); - const graph = await buildGraph( - new StaticSystemGraphBuilder( - { listAgents: async () => inventory }, - invocationProvider(async (sourceRoot) => - sourceRoot.endsWith("caller") - ? { - invocations: [ - { target: "legacy", mode: "async", evidence: EVIDENCE }, - ], - warnings: [], - } - : EMPTY_INVOCATIONS, - ), - ), - scope, - ); - - 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, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - scope, - ), - ).rejects.toThrow(); - - const outside = inventoryResult(scope, [ - { - agentKey: "alpha", - label: "Alpha", - sourceRoot: path.join(FIXTURE, "growth"), - }, - ]); - outside.context[0]!.sourceRoot = "/outside/private-agent"; - await expect( - buildGraph( - new StaticSystemGraphBuilder( - { listAgents: async () => outside }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - scope, - ), - ).rejects.toThrow("inventory context was invalid"); - - const mismatchedLocation = inventoryResult(scope, [ - { - agentKey: "alpha", - label: "Alpha", - sourceRoot: path.join(FIXTURE, "alpha"), - }, - ]); - mismatchedLocation.context[0]!.sourceRoot = path.join(FIXTURE, "growth"); - mismatchedLocation.context[0]!.workflowPath = path.join(FIXTURE, "growth"); - await expect( - buildGraph( - new StaticSystemGraphBuilder( - { listAgents: async () => mismatchedLocation }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - 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 () => - inventoryResult(scope, [ - { - agentKey: "research", - definitionId: 1, - definitionSlug: "research", - label: "Research", - resolutionAliases: ["research"], - }, - ]), - ), - }; - const built = await new StaticSystemGraphBuilder( - inventory, - invocationProvider(async () => { - throw new Error("boom at private source"); - }), - ).build(scope); - const graph = built.graph; - - expect(built.cacheable).toBe(false); - expect(graph.warnings).toEqual([ - { - code: "projection-failed", - agentKey: "research", - message: "Could not inspect Research.", - }, - ]); - expect(JSON.stringify(graph)).not.toContain("/private/"); - }); - - it("keeps path-shaped registry and extraction values out of the public graph", async () => { - const inventory = new HarnessRegistryInventoryProvider({ - listWorkflows: () => [ - { - ...workflow(FIXTURE, "reporting", FIXTURE), - definitionId: null, - }, - ], - inspectManifestName: vi.fn(async () => ({ - status: "found" as const, - name: FIXTURE, - })), - }); - - const graph = await buildGraph( - new StaticSystemGraphBuilder( - inventory, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - scope, - ); - - expect(graph.nodes).toEqual([ - { - id: "agent:local:reporting", - agentKey: "local:reporting", - label: "reporting", - }, - ]); - expect(graph.warnings).toEqual([]); - expect(JSON.stringify(graph)).not.toContain(FIXTURE); - }); - - 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, - retryable: false, - })), - fingerprintSource: async (sourceRoot) => `fingerprint:${sourceRoot}`, - onIdentityChange: changed, - }); - const builder = new StaticSystemGraphBuilder( - inventory, - invocationProvider(async (sourceRoot) => - sourceRoot.endsWith("caller") - ? { - invocations: [ - { - target: "target-marker", - mode: "async", - evidence: EVIDENCE, - }, - ], - warnings: [], - } - : EMPTY_INVOCATIONS, - ), - ); - 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-invocation", - 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", - message: `inspection failed at ${leakedPath}`, - }, - ], - }, - ); - - const graph = await buildGraph( - new StaticSystemGraphBuilder( - { listAgents: async () => result }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - 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 }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - 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", - agentKey: "/private/provider-secret", - message: "private", - }, - ], - }, - ); - - await expect( - buildGraph( - new StaticSystemGraphBuilder( - { listAgents: async () => result }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - 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 }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - 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 }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - scope, - ), - ).rejects.toThrow("inventory context was invalid"); - }); - - it("normalizes arbitrary provider aliases before invocation resolution", async () => { - const result = inventoryResult(scope, [ - { - agentKey: "reporting", - label: "Reporting", - sourceRoot: path.join(FIXTURE, "reporting"), - resolutionAliases: ["zeta", "alpha", "zeta"], - }, - ]); - const listInvocations = vi.fn( - async () => EMPTY_INVOCATIONS, - ); - - await buildGraph( - new StaticSystemGraphBuilder( - { listAgents: async () => result }, - { listInvocations }, - ), - scope, - ); - - expect(listInvocations.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( - new StaticSystemGraphBuilder( - inventory, - invocationProvider(async () => EMPTY_INVOCATIONS), - ), - scope, - ); - - expect(graph.nodes.map((node) => node.agentKey)).toEqual([ - "local:reporting", - "research", - ]); - expect(graph.edges).toEqual([]); - 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("/private/"); - }); - - it("keeps degraded cache policy outside the public graph contract", async () => { - const inventory: AgentInventoryProvider = { - listAgents: vi.fn(async () => - inventoryResult( - scope, - [ - { - agentKey: "local:pending", - label: "Pending", - provisional: true, - identityIssue: "identity-pending", - }, - ], - { degraded: true }, - ), - ), - }; - - const built = await new StaticSystemGraphBuilder( - inventory, - invocationProvider(async () => EMPTY_INVOCATIONS), - ).build(scope); - - expect(built.cacheable).toBe(false); - expect(built.graph).toEqual({ - kind: "system", - scope: { kind: "working-tree", workspaceKey: scope.workspaceKey }, - nodes: [ - { - id: "agent:local:pending", - agentKey: "local:pending", - label: "Pending", - }, - ], - edges: [], - warnings: [], - }); - expect(built.graph).not.toHaveProperty("cacheable"); - }); - - it("caches a settled provisional identity while preserving its warning", async () => { - const result = inventoryResult( - scope, - [ - { - agentKey: "local:dashboard", - label: "Dashboard", - provisional: true, - identityIssue: "identity-unavailable", - }, - ], - { - degraded: true, - identitySettled: true, - warnings: [ - { - code: "inventory-extraction-failed", - agentKey: "local:dashboard", - message: "private provider wording", - }, - ], - }, - ); - - const built = await new StaticSystemGraphBuilder( - { listAgents: async () => result }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ).build(scope); - - expect(built.cacheable).toBe(true); - expect(built.graph.warnings).toEqual([ - { - code: "inventory-extraction-failed", - agentKey: "local:dashboard", - message: - "Could not resolve Dashboard's source identity; using its provisional identity.", - }, - ]); - }); - - it("does not cache a settled identity when workspace discovery was incomplete", async () => { - const result = inventoryResult( - scope, - [ - { - agentKey: "dashboard", - label: "Dashboard", - }, - ], - { - degraded: true, - identitySettled: true, - discoveryComplete: false, - }, - ); - - const built = await new StaticSystemGraphBuilder( - { listAgents: async () => result }, - invocationProvider(async () => EMPTY_INVOCATIONS), - ).build(scope); - - expect(built.cacheable).toBe(false); - }); - - it("uses normalized identity state even if a provider mutates inventory status", async () => { - let release!: () => void; - const invocationsPending = new Promise((resolve) => { - release = resolve; - }); - const result = inventoryResult( - scope, - [ - { - agentKey: "local:pending", - label: "Pending", - sourceRoot: path.join(FIXTURE, "pending"), - provisional: true, - identityIssue: "identity-pending", - }, - ], - { degraded: true }, - ); - const listInvocations = vi.fn(async () => { - await invocationsPending; - return EMPTY_INVOCATIONS; - }); - const building = new StaticSystemGraphBuilder( - { listAgents: async () => result }, - { listInvocations }, - ).build(scope); - await vi.waitFor(() => expect(listInvocations).toHaveBeenCalledTimes(1)); - - (result.inventory as { status: "complete" | "degraded" }).status = - "complete"; - release(); - const built = await building; - - expect(built.cacheable).toBe(false); - }); - - 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 inventoryResult(activeScope, [ - { - agentKey, - definitionSlug: agentKey, - label: agentKey, - resolutionAliases: [agentKey], - sourceRoot: path.join(activeScope.root, agentKey), - }, - ]); - }), - retainSources, - }; - const retainCallers = vi.fn(); - const invocations: AgentInvocationProvider = { - listInvocations: vi.fn(async () => EMPTY_INVOCATIONS), - retainCallers, - }; - const builder = new StaticSystemGraphBuilder(inventory, invocations); - - await builder.build(scope); - await builder.build(secondScope); - expect( - retainCallers.mock.calls - .at(-1)?.[0] - .map((caller: { agentKey: string }) => caller.agentKey), - ).toEqual(["first", "second"]); - - builder.retainWorkspaces(new Set([secondScope.workspaceKey])); - expect( - retainCallers.mock.calls - .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 deleted file mode 100644 index 74c0aa5f8..000000000 --- a/packages/harness/src/core/system-graph.ts +++ /dev/null @@ -1,524 +0,0 @@ -export { LocalWorkspaceScopeCatalog } from "./workspace-scope-catalog.js"; -export type { WorkspaceScopeResolver, WorkspaceScopeCatalog } from "./workspace-scope-catalog.js"; -import { packageInventorySchema } from "@sapiom/agent"; - -import type { - GraphWarning, - StaticInvocationGraphEdge, - SystemGraph, - SystemGraphNavigationTarget, - WorkspaceKey, -} from "../shared/system-graph.js"; -import { - canonicalGraphPath, - inventorySourceRoot, - isWithinGraphPath, - type AgentInventoryItem, - type AgentInventoryProvider, - type AgentInventoryWarning, - type WorkspaceScope, -} from "./system-graph-inventory.js"; -import { - CachedAgentInvocationProvider, - SourceAgentInvocationProvider, - type AgentInvocationProvider, - type AgentInvocationProviderResult, -} from "./system-graph-relationships.js"; - -export { HarnessRegistryInventoryProvider } from "./system-graph-inventory.js"; -export type { - AgentInventoryItem, - AgentInventoryProvider, - AgentInventoryResult, - AgentInventoryWarning, - WorkspaceScope, -} from "./system-graph-inventory.js"; -export { - CachedAgentInvocationProvider, - SourceAgentInvocationProvider, -} from "./system-graph-relationships.js"; -export type { - AgentInvocationCandidate, - AgentInvocationProvider, - AgentInvocationProviderResult, - AgentInvocationWarning, -} from "./system-graph-relationships.js"; - -export interface SystemGraphBuilder { - build(scope: WorkspaceScope): Promise; - /** Optional lifecycle hook for builders with workspace-scoped caches. */ - retainWorkspaces?(workspaceKeys: ReadonlySet): void; -} - -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 warningOrder(left: GraphWarning, right: GraphWarning): number { - return ( - left.code.localeCompare(right.code) || - (left.agentKey ?? "").localeCompare(right.agentKey ?? "") || - left.message.localeCompare(right.message) - ); -} - -function hasControlCharacter(value: string): boolean { - return [...value].some((character) => { - const code = character.codePointAt(0)!; - return code <= 0x1f || (code >= 0x7f && code <= 0x9f); - }); -} - -function fallbackLabel(agentKey: string): string { - if (!agentKey.startsWith("local:")) return agentKey; - return agentKey.slice("local:".length).split("/").at(-1) ?? "Agent"; -} - -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"); - } - 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 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); - } - } - - 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 sanitized.sort(warningOrder); -} - -interface ConsumedInventory { - agents: AgentInventoryItem[]; - warnings: GraphWarning[]; - /** Every identity has finished resolving, however it resolved. */ - identitySettled: boolean; - /** The accepted workspace walk considered every eligible discovery path. */ - discoveryComplete: boolean; - startEnrichment?: () => void; -} - -/** - * 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 consumeInventory( - scope: WorkspaceScope, - 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 canonicalScope = canonicalGraphPath(scope.root); - const context = new Map( - result.context.map((item) => [`${item.path}\0${item.entrypoint}`, item]), - ); - 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"); - } - return { - ...item, - identityStatus: agent.identityStatus, - label: safeContextLabel(item.label, item.agentKey), - resolutionAliases: normalizeResolutionAliases(item.resolutionAliases), - }; - }); - return { - agents, - warnings: sanitizeInventoryWarnings( - result.warnings, - inventory.agents, - agents, - ), - identitySettled: - result.identitySettled && - !inventory.agents.some( - (agent) => - agent.identityStatus === "provisional" && - agent.identityIssue === "identity-pending", - ), - discoveryComplete: result.discoveryComplete === true, - ...(typeof result.startEnrichment === "function" - ? { startEnrichment: result.startEnrichment } - : {}), - }; -} - -export class StaticSystemGraphBuilder implements SystemGraphBuilder { - private readonly callersByWorkspace = new Map< - WorkspaceKey, - readonly AgentInventoryItem[] - >(); - - constructor( - private readonly inventory: AgentInventoryProvider, - private readonly invocations: AgentInvocationProvider = new CachedAgentInvocationProvider( - new SourceAgentInvocationProvider(), - ), - ) {} - - async build(scope: WorkspaceScope): Promise { - const inventory = await this.inventory.listAgents(scope); - const consumed = consumeInventory(scope, inventory); - const agents = consumed.agents; - this.callersByWorkspace.set(scope.workspaceKey, agents); - this.retainInvocationCallers(); - const nodes = agents.map((agent) => ({ - id: `agent:${agent.agentKey}`, - 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"; - // Production is deliberately two-phase: project cache-only inventory now, - // 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 - ? agents.map((caller) => { - const snapshot = this.invocations.peekInvocations!(caller); - return { - caller, - result: - snapshot?.result ?? - ({ - invocations: [], - warnings: [], - } satisfies AgentInvocationProviderResult), - failed: snapshot?.status === "failed", - pending: snapshot === undefined, - }; - }) - : await Promise.all( - agents.map(async (caller) => { - try { - return { - caller, - result: await this.invocations.listInvocations(caller), - failed: false, - pending: false, - }; - } catch { - return { - caller, - result: { - invocations: [], - warnings: [], - } satisfies AgentInvocationProviderResult, - failed: true, - pending: false, - }; - } - }), - ); - - 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 uniqueWarnings = [ - ...new Map( - warnings.map((warning) => [ - `${warning.code}\0${warning.agentKey ?? ""}\0${warning.message}`, - warning, - ]), - ).values(), - ].sort(warningOrder); - - const afterCommit = - consumed.startEnrichment || supportsBackgroundInvocations - ? () => { - consumed.startEnrichment?.(); - if (supportsBackgroundInvocations) { - this.invocations.startInvocations!(agents); - } - } - : undefined; - return { - cacheable: - consumed.identitySettled && - consumed.discoveryComplete && - invocationsComplete, - graph: { - kind: "system", - scope: { kind: "working-tree", workspaceKey: scope.workspaceKey }, - nodes, - edges, - warnings: uniqueWarnings, - }, - navigation: agents.map(({ agentKey, workflowPath }) => ({ - agentKey, - workflowPath, - })), - ...(afterCommit ? { afterCommit } : {}), - }; - } - - retainWorkspaces(workspaceKeys: ReadonlySet): void { - for (const workspaceKey of this.callersByWorkspace.keys()) { - if (!workspaceKeys.has(workspaceKey)) { - 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.retainInvocationCallers(); - } - - private retainInvocationCallers(): void { - const callers = [...this.callersByWorkspace.values()].flat(); - try { - this.invocations.retainCallers?.(callers); - } catch { - // Cache pruning is an optimization and cannot make projection fail. - } - } -} diff --git a/packages/harness/src/server/events-ws.test.ts b/packages/harness/src/server/events-ws.test.ts index 517b66f07..0e1bbc55b 100644 --- a/packages/harness/src/server/events-ws.test.ts +++ b/packages/harness/src/server/events-ws.test.ts @@ -42,33 +42,6 @@ describe("createEventsWebSocketHandler", () => { expect(sent).toEqual([JSON.stringify({ type: "canvas.reload", harnessSessionId: "sess-1" })]); }); - it("forwards path-free workspace graph revisions", () => { - const bus = new EventBus(); - const handler = createEventsWebSocketHandler(bus, BOOT_TOKEN); - const { ws, sent } = createFakeWs(); - - handler( - ws, - {} as IncomingMessage, - new URLSearchParams({ token: BOOT_TOKEN }), - ); - bus.publish({ - type: "system-graph.changed", - workspaceKey: "workspace-test", - revision: 4, - state: "stale", - }); - - expect(sent).toEqual([ - JSON.stringify({ - type: "system-graph.changed", - workspaceKey: "workspace-test", - revision: 4, - state: "stale", - }), - ]); - }); - it("unsubscribes from the bus once the socket closes", () => { const bus = new EventBus(); const handler = createEventsWebSocketHandler(bus, BOOT_TOKEN); diff --git a/packages/harness/src/server/system-graph.test.ts b/packages/harness/src/server/system-graph.test.ts deleted file mode 100644 index 7e1014db5..000000000 --- a/packages/harness/src/server/system-graph.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import type { AddressInfo } from "node:net"; -import express from "express"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { SystemGraphStore } from "../core/system-graph-store.js"; -import type { - SystemGraphBuilder, - WorkspaceScopeResolver, -} from "../core/system-graph.js"; -import { - SYSTEM_GRAPH_CACHE_HEADER, - type SystemGraph, - type SystemGraphSnapshot, -} from "../shared/system-graph.js"; -import { createBootTokenMiddleware } from "./auth.js"; -import { createSystemGraphRouter } from "./system-graph.js"; - -const workspaceKey = "workspace-known"; -const graph: SystemGraph = { - kind: "system", - scope: { kind: "working-tree", workspaceKey }, - nodes: [ - { id: "agent:research", agentKey: "research", label: "Research" }, - { id: "agent:growth", agentKey: "growth", label: "Growth" }, - ], - edges: [ - { - from: "agent:research", - to: "agent:growth", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - ], - warnings: [], -}; - -describe("createSystemGraphRouter", () => { - let server: ReturnType | undefined; - - afterEach(async () => { - if (server) - await new Promise((resolve) => server!.close(() => resolve())); - server = undefined; - }); - - function start(cacheable = true, onScopeAccess = vi.fn()) { - const scopeResolver: WorkspaceScopeResolver = { - resolve: vi.fn(async (key: string) => - key === workspaceKey - ? { workspaceKey: key, root: "/private/workspace" } - : null, - ), - }; - const builder: SystemGraphBuilder = { - 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, - onScopeAccess, - }), - ); - server = app.listen(0); - const address = server.address() as AddressInfo; - return { - baseUrl: `http://127.0.0.1:${address.port}`, - scopeResolver, - builder, - store, - onScopeAccess, - }; - } - - it("is boot-token protected and returns the cached public graph", async () => { - const { baseUrl, builder, onScopeAccess } = start(); - const route = `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`; - - expect((await fetch(route)).status).toBe(401); - const first = await fetch(route, { - headers: { "X-Harness-Token": "test-token" }, - }); - const second = await fetch(route, { - headers: { "X-Harness-Token": "test-token" }, - }); - - expect(first.status).toBe(200); - expect(first.headers.get(SYSTEM_GRAPH_CACHE_HEADER)).toBe("complete"); - expect((await first.json()) as SystemGraphSnapshot).toEqual({ - workspaceKey, - revision: 1, - state: "ready", - graph, - }); - expect(second.status).toBe(200); - expect(builder.build).toHaveBeenCalledTimes(1); - expect(onScopeAccess).toHaveBeenCalledTimes(2); - }); - - it("reports degradation and bounds re-enrichment to one later request", async () => { - const { baseUrl, builder } = start(false); - const route = `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`; - const request = () => - fetch(route, { headers: { "X-Harness-Token": "test-token" } }); - - const first = await request(); - const second = await request(); - const third = await request(); - - expect(first.headers.get(SYSTEM_GRAPH_CACHE_HEADER)).toBe("degraded"); - expect(second.headers.get(SYSTEM_GRAPH_CACHE_HEADER)).toBe("degraded"); - expect(third.headers.get(SYSTEM_GRAPH_CACHE_HEADER)).toBe("degraded"); - expect((await third.json()) as SystemGraphSnapshot).toMatchObject({ - workspaceKey, - state: "degraded", - graph, - }); - expect(builder.build).toHaveBeenCalledTimes(2); - }); - - it("rebuilds the projection through the protected explicit refresh route", async () => { - const { baseUrl, builder } = start(); - const route = `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`; - const headers = { "X-Harness-Token": "test-token" }; - await fetch(route, { headers }); - - expect((await fetch(`${route}/refresh`, { method: "POST" })).status).toBe( - 401, - ); - const refreshed = await fetch(`${route}/refresh`, { - method: "POST", - headers, - }); - - expect(refreshed.status).toBe(200); - expect((await refreshed.json()) as SystemGraphSnapshot).toMatchObject({ - workspaceKey, - state: "ready", - graph, - }); - 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( - `${baseUrl}/api/workspaces/unknown/system-graph`, - { - headers: { "X-Harness-Token": "test-token" }, - }, - ); - - 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 deleted file mode 100644 index 40b36366b..000000000 --- a/packages/harness/src/server/system-graph.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - Router, - type NextFunction, - type Request, - type Response, -} from "express"; - -import type { - WorkspaceScope, - WorkspaceScopeResolver, -} from "../core/system-graph.js"; -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"; - -export interface SystemGraphRouterOptions { - scopeResolver: WorkspaceScopeResolver; - store: SystemGraphStore; - onScopeAccess?: (scope: WorkspaceScope) => void | Promise; - /** Re-run registry prerequisites before an explicit user retry. */ - onScopeRefresh?: ( - scope: WorkspaceScope, - ) => SystemGraphSnapshot | Promise; -} - -/** Mounted beneath the boot-token-protected `/api` boundary. */ -export function createSystemGraphRouter( - options: SystemGraphRouterOptions, -): Router { - const router = Router(); - const route = "/workspaces/:workspaceKey/system-graph"; - - const serve = async ( - req: Request, - res: Response, - next: NextFunction, - refresh: boolean, - ): Promise => { - try { - const scope = await options.scopeResolver.resolve( - req.params.workspaceKey, - ); - if (!scope) { - res.status(404).json({ error: "Workspace not found" }); - return; - } - try { - void Promise.resolve(options.onScopeAccess?.(scope)).catch(() => { - // Watcher/discovery setup is best-effort and detached. Graph reads - // are strictly cache-backed and never await a baseline or scan. - }); - } catch { - // Synchronous setup failures are best-effort too. - } - 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); - } catch (err) { - next(err); - } - }; - - router.get(route, (req, res, next) => { - void serve(req, res, next, false); - }); - router.post(`${route}/refresh`, (req, res, next) => { - 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 { - void Promise.resolve(options.onScopeAccess?.(scope)).catch(() => { - // Resolver reads remain cache-backed while freshness arms in the - // background. - }); - } catch { - // Synchronous setup failures are best-effort too. - } - 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 deleted file mode 100644 index 152d890ff..000000000 --- a/packages/harness/src/shared/system-graph.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** Retired graph contracts, pending engine deletion. */ -import type { WorkspaceKey } from "./workspace-scope.js"; -import type { AgentInvocationMode } from "../core/canvas-interconnections.js"; -export type { WorkspaceKey, WorkspaceScopeSummary } from "./workspace-scope.js"; -export { workspaceRelativeLocalKey } from "./workspace-scope.js"; -export type { AgentInvocationMode } from "../core/canvas-interconnections.js"; -export type AgentKey = string; - -/** Internal HTTP metadata; it is deliberately not part of SystemGraph JSON. */ -export const SYSTEM_GRAPH_CACHE_HEADER = "X-Sapiom-System-Graph-Cache"; -export type SystemGraphCacheStatus = "complete" | "degraded"; - -export interface SystemGraphNode { - id: string; - agentKey: AgentKey; - label: string; -} - -export interface StaticInvocationGraphEdge { - from: string; - to: string; - kind: "invokes"; - basis: "static-invocation"; - mode: AgentInvocationMode; -} - -export type SystemGraphEdge = StaticInvocationGraphEdge; - -export interface GraphWarning { - code: - | "unresolved-target" - | "dynamic-target" - | "duplicate-edge" - | "projection-failed" - | "duplicate-agent-key" - | "inventory-extraction-failed"; - message: string; - agentKey?: AgentKey; -} - -export interface SystemGraph { - kind: "system"; - scope: { - kind: "working-tree"; - workspaceKey: WorkspaceKey; - }; - nodes: SystemGraphNode[]; - edges: SystemGraphEdge[]; - warnings: GraphWarning[]; -} -export type SystemGraphLifecycleState = - | "building" - | "ready" - | "stale" - | "degraded"; - -/** Path-free lifecycle envelope for one workspace projection. */ -export interface SystemGraphSnapshot { - workspaceKey: WorkspaceKey; - /** Monotonic within one server process and workspace. */ - revision: number; - state: SystemGraphLifecycleState; - /** 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/src/shared/types.ts b/packages/harness/src/shared/types.ts index 46a1c4a20..8ca43656a 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -6,11 +6,6 @@ * integration boundary. */ -import type { - SystemGraphLifecycleState, - WorkspaceKey, -} from "./system-graph.js"; - // --------------------------------------------------------------------------- // Constants & well-known paths // --------------------------------------------------------------------------- @@ -592,12 +587,6 @@ export type BusMessage = target: "prod" | "local"; } | { type: "workflows.changed" } - | { - type: "system-graph.changed"; - workspaceKey: WorkspaceKey; - revision: number; - state: SystemGraphLifecycleState; - } | { type: "agent-map.proposal.changed"; delta: import("./agent-map.js").AcceptedProposalDelta; diff --git a/packages/harness/web/src/lib/bus-message-type.test.ts b/packages/harness/web/src/lib/bus-message-type.test.ts new file mode 100644 index 000000000..9ae4a2309 --- /dev/null +++ b/packages/harness/web/src/lib/bus-message-type.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { hasKnownBusMessageType } from "./bus-message-type"; + +describe("event transport discriminators", () => { + it("keeps session, Canvas and durable-map events supported", () => { + for (const type of [ + "session.status", + "canvas.reload", + "agent-map.proposal.changed", + ]) + expect(hasKnownBusMessageType({ type })).toBe(true); + }); + + it.each([ + null, + [], + "canvas.reload", + {}, + { type: "system-graph.changed" }, + { type: "future.event" }, + { type: "toString" }, + { type: 1 }, + ])("ignores retired, unknown or malformed event envelopes: %j", (value) => + expect(hasKnownBusMessageType(value)).toBe(false), + ); +}); diff --git a/packages/harness/web/src/lib/bus-message-type.ts b/packages/harness/web/src/lib/bus-message-type.ts new file mode 100644 index 000000000..5e0237334 --- /dev/null +++ b/packages/harness/web/src/lib/bus-message-type.ts @@ -0,0 +1,30 @@ +import type { BusMessage } from "@shared/types"; + +// Keep the transport's supported discriminators exhaustive as the protocol +// evolves. Removed or unknown events never reach mounted state subscribers. +const supportedTypes: Record = { + "session.status": true, + "session.record.changed": true, + "canvas.reload": true, + "port.detected": true, + "execution.started": true, + "workflows.changed": true, + "agent-map.proposal.changed": true, + "agent-map.initialization.changed": true, + "task.status": true, + "session.activity": true, + "auth.changed": true, +}; + +/** Checks the envelope's type only; consumers still own payload validation. */ +export function hasKnownBusMessageType( + value: unknown, +): value is { type: BusMessage["type"] } { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" && + Object.hasOwn(supportedTypes, value.type) + ); +} diff --git a/packages/harness/web/src/lib/events.test.ts b/packages/harness/web/src/lib/events.test.ts new file mode 100644 index 000000000..a7f05013f --- /dev/null +++ b/packages/harness/web/src/lib/events.test.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, it, vi } from "vitest"; +import type { BusMessage } from "@shared/types"; + +vi.mock("./api", () => ({ + isMockMode: () => true, + isDemoSeedEnabled: () => false, + getBootToken: () => "test-token", + DEMO_SESSION_ID: "demo", +})); + +import { publishMockBusMessage, subscribeEvents } from "./events"; + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +it("delivers supported events, ignores retired frames and removes its exact subscription", () => { + vi.useFakeTimers(); + const listener = vi.fn(); + const unsubscribe = subscribeEvents(listener); + const message: BusMessage = { + type: "canvas.reload", + harnessSessionId: "session-1", + }; + try { + for (const type of ["system-graph.changed", "future.event", "toString"]) + publishMockBusMessage({ type } as unknown as BusMessage); + expect(listener).not.toHaveBeenCalled(); + publishMockBusMessage(message); + expect(listener).toHaveBeenCalledExactlyOnceWith(message); + } finally { + unsubscribe(); + } + publishMockBusMessage(message); + expect(listener).toHaveBeenCalledTimes(1); +}); diff --git a/packages/harness/web/src/lib/events.ts b/packages/harness/web/src/lib/events.ts index d5ff0d591..69f7b1db1 100644 --- a/packages/harness/web/src/lib/events.ts +++ b/packages/harness/web/src/lib/events.ts @@ -12,6 +12,7 @@ import { isMockMode, } from "./api"; import { MOCK_ACTIVITY_SESSION_ID } from "./mock-data"; +import { hasKnownBusMessageType } from "./bus-message-type"; export type BusListener = (message: BusMessage) => void; export type EventReconnectListener = () => void; @@ -65,8 +66,11 @@ export function subscribeEvents( onMessage: BusListener, onReconnect?: EventReconnectListener, ): () => void { + const deliver = (message: unknown): void => { + if (hasKnownBusMessageType(message)) onMessage(message as BusMessage); + }; if (isMockMode()) { - mockListeners.add(onMessage); + mockListeners.add(deliver); if (!mockActivitySimulated) { mockActivitySimulated = true; // Fixture nicety, not test infrastructure: shows the tab strip's busy @@ -102,7 +106,7 @@ export function subscribeEvents( ); }, DEMO_RUN_DELAY_MS); } - return () => mockListeners.delete(onMessage); + return () => mockListeners.delete(deliver); } const url = new URL("/ws/events", window.location.href); @@ -122,7 +126,7 @@ export function subscribeEvents( }); socket.addEventListener("message", (event) => { try { - onMessage(JSON.parse(event.data as string) as BusMessage); + deliver(JSON.parse(event.data as string)); } catch { // Ignore malformed frames rather than tearing down the socket. } diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index e860f4048..e186c453e 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -1217,7 +1217,6 @@ export function useHarnessState(): HarnessStateHook { useEffect(() => { return subscribeEvents( (message) => { - if (message.type === "system-graph.changed") return; // SessionRecord invalidations have a targeted listener below. Keeping // them out of the legacy last-message slot avoids repainting the entire // Studio for records no mounted transcript is watching. diff --git a/scripts/agent-studio-terminology-allowlist.json b/scripts/agent-studio-terminology-allowlist.json index e96a91213..f153ceaf7 100644 --- a/scripts/agent-studio-terminology-allowlist.json +++ b/scripts/agent-studio-terminology-allowlist.json @@ -517,6 +517,13 @@ "occurrences": 2, "reason": "Test IDs inside the mock canvas document; the e2e suite selects on them." }, + { + "id": "web-bus-workflow-change-event", + "path": "packages/harness/web/src/lib/bus-message-type.ts", + "pattern": "^workflows\\.changed$", + "occurrences": 1, + "reason": "The transport accepts the retained source-discovery WebSocket event by its existing protocol name." + }, { "id": "web-api-workflow-change-event", "path": "packages/harness/web/src/lib/api.ts",