diff --git a/.changeset/quiet-project-map-authority.md b/.changeset/quiet-project-map-authority.md new file mode 100644 index 000000000..beb1118c9 --- /dev/null +++ b/.changeset/quiet-project-map-authority.md @@ -0,0 +1,28 @@ +--- +"@sapiom/harness": minor +--- + +**Breaking for HTTP clients** (minor while `@sapiom/harness` is pre-1.0): retire +the documented project System Graph endpoints. Authenticated requests to all +three routes now return `410` with `error: "legacy_graph_retired"`: + +- `GET /api/workspaces/:workspaceKey/system-graph` +- `POST /api/workspaces/:workspaceKey/system-graph/refresh` +- `GET /api/workspaces/:workspaceKey/system-graph/navigation` + +The boot token remains required. These requests no longer resolve a scope, +read or refresh a legacy graph, or activate graph watchers. + +Migrate to `GET /api/projects/:projectId/agent-map/workspace` for the durable +Agent Map and shared proposal, and +`GET /api/projects/:projectId/agent-map/nodes/:nodeId/implementation` for exact +implementation navigation. Obtain server-issued project IDs from +`GET /api/state`; a workspace key, path or display name is not a project ID. +The durable APIs do not use the old process-memory graph snapshots or revision +matching protocol. + +This release includes the matching Studio client recovery: an unresolved +project shows **Agent Map unavailable** with **Reload projects**, preserves its +conversation, and no longer starts or selects a session on a project click. +Shared workspace discovery, explicit session creation and ordinary session +navigation remain available independently of the retired graph. diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 73fa8a77c..218f3d502 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -71,6 +71,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Check release readiness + run: node scripts/assert-release-ready.mjs + - name: Resolve version and update channel id: meta shell: bash diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 24d12453c..f91e941cf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,6 +36,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Check release readiness + run: node scripts/assert-release-ready.mjs + - uses: pnpm/action-setup@v4 # pnpm version comes from package.json `packageManager` (pnpm 10+ for OIDC). # Keep all workflows in sync with it. diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index b912367f8..062958154 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -21,6 +21,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Check release readiness + run: node scripts/assert-release-ready.mjs + - uses: pnpm/action-setup@v4 # pnpm version comes from package.json `packageManager` (pnpm 10+) diff --git a/.release-blocked b/.release-blocked new file mode 100644 index 000000000..1d3422ee1 --- /dev/null +++ b/.release-blocked @@ -0,0 +1,2 @@ +SAP-3089: the retired System Graph routes require the matching Studio client recovery. +Include PR #893 before versioning or publishing this server layer (PR #892). diff --git a/package.json b/package.json index 64b5fbec7..157fe755a 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,9 @@ "format": "pnpm -r --filter='./packages/*' format", "typecheck": "pnpm -r --filter='./packages/*' typecheck", "changeset": "changeset", - "version-packages": "changeset version && node packages/agent-core/scripts/gen-version-fallback.mjs && pnpm install --lockfile-only", - "release": "pnpm build && changeset publish", + "release:check": "node scripts/assert-release-ready.mjs", + "version-packages": "pnpm release:check && changeset version && node packages/agent-core/scripts/gen-version-fallback.mjs && pnpm install --lockfile-only", + "release": "pnpm release:check && pnpm build && changeset publish", "dev:watch": "pnpm -r --filter='./packages/*' --parallel run dev", "registry:local": "npx -y verdaccio@6 --config .verdaccio/config.yaml", "publish:local": "node scripts/publish-local.mjs", diff --git a/packages/harness/README.md b/packages/harness/README.md index 274a16a8a..b0abd1b04 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -354,8 +354,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 - Project dependency-graph endpoints, lifecycle states, cache signal, warnings, - and `system-graph.changed` event. + retired Project dependency-graph endpoints (`410 legacy_graph_retired`) and + migration to the durable Agent Map APIs. ## Testing diff --git a/packages/harness/docs/workspace-system-graph.md b/packages/harness/docs/workspace-system-graph.md index efb632307..1edc1bc7c 100644 --- a/packages/harness/docs/workspace-system-graph.md +++ b/packages/harness/docs/workspace-system-graph.md @@ -1,4 +1,43 @@ -# Project system graph HTTP contract +# Retired Project system graph HTTP contract + +**Breaking for HTTP clients:** current Studio servers retire the following +routes. Authenticated requests return `410` with `error: "legacy_graph_retired"` +before any scope lookup, graph read, refresh, navigation or watcher activation: + +```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 +retirement 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 diff --git a/packages/harness/src/server/definition-list-enrichment.test.ts b/packages/harness/src/server/definition-list-enrichment.test.ts index ad05e99c0..d435d38ef 100644 --- a/packages/harness/src/server/definition-list-enrichment.test.ts +++ b/packages/harness/src/server/definition-list-enrichment.test.ts @@ -13,8 +13,10 @@ import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { credentialsFilePath } from "@sapiom/mcp/auth"; vi.mock("@sapiom/mcp/auth", () => ({ + credentialsFilePath: vi.fn(), resolveEnvironment: vi.fn(async (environment?: string) => ({ name: environment === "dev" ? "staging" : "production", appURL: "https://app.example.test", @@ -90,6 +92,9 @@ describe("definition list enrichment wiring (SAP-3214)", () => { tempDir = await fs.mkdtemp( path.join(os.tmpdir(), "harness-definition-list-enrichment-"), ); + vi.mocked(credentialsFilePath).mockReturnValue( + path.join(tempDir, "credentials.json"), + ); previousAgentsUrl = process.env.SAPIOM_AGENTS_URL; api = { listStatus: 200, diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 1bbea2d5b..5b041d6d5 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -1722,6 +1722,8 @@ export const startServer = async ( ensureCanvasTemplate, }); await sessionManager.init(); + // Always empty on this host: the retired HTTP routes cannot reach + // onScopeAccess. The legacy plumbing remains only until SAP-3091 deletion. const activeSystemGraphScopes = new Map(); const systemGraphInvocations = new CachedAgentInvocationProvider( new SourceAgentInvocationProvider(), @@ -2053,8 +2055,10 @@ export const startServer = async ( // the next lease's normal background scan will reconcile the interval. supersedePublication(); coordinatorEpoch += 1; - systemGraphInventory.invalidateScope(root); - systemGraphInvocations.invalidateScope(root); + if (activeSystemGraphScopes.size > 0) { + systemGraphInventory.invalidateScope(root); + systemGraphInvocations.invalidateScope(root); + } workflowRegistry.markDiscoveryDirty(root); markAcceptedInventoryDirty(root); }, @@ -2379,8 +2383,10 @@ export const startServer = async ( const token = tokenOverride ?? `inventory:${canonicalRoot}`; supersedePublication(); coordinatorEpoch += 1; - systemGraphInventory.invalidateScope(lexicalRoot); - systemGraphInvocations.invalidateScope(lexicalRoot); + if (activeSystemGraphScopes.size > 0) { + systemGraphInventory.invalidateScope(lexicalRoot); + systemGraphInvocations.invalidateScope(lexicalRoot); + } workflowRegistry.markDiscoveryDirty(lexicalRoot); markAcceptedInventoryDirty(lexicalRoot); outstandingDirtyPrerequisites.set(token, canonicalRoot); @@ -2988,11 +2994,14 @@ export const startServer = async ( left.cwd.localeCompare(right.cwd), ); } catch { - // Agent Map is additive in E1. A bad/unavailable new catalog cannot - // strand the legacy rail or System Graph during coexistence. + // Keep folders/sessions reachable when identity storage is unavailable. + // Missing or ambiguous identity never establishes a legacy map owner. console.error("[harness] Studio project catalog is unavailable"); } - const retained = new Set(scopes.map((scope) => scope.workspaceKey)); + // This host has one map authority, including on identity/storage failure. + // Keep the legacy implementation until SAP-3091, with no live owners. + // Both the retained set and activeSystemGraphScopes are always empty. + const retained = new Set(); systemGraphWatcher.retain(retained); systemGraphStore.retain(retained); for (const workspaceKey of activeSystemGraphScopes.keys()) { @@ -4065,6 +4074,14 @@ export const startServer = async ( }, }), ); + // Old tabs must reload into Agent Map before they can select a topology. + // The common boot-token gate runs first; no scope lookup/watch/refresh runs. + app.use("/api/workspaces/:workspaceKey/system-graph", (_req, res) => { + res.status(410).json({ + error: "legacy_graph_retired", + message: "Reload Studio to use Agent Map.", + }); + }); app.use( "/api", createSystemGraphRouter({ diff --git a/packages/harness/src/server/studio-workspace-wiring.test.ts b/packages/harness/src/server/studio-workspace-wiring.test.ts index 446f87b68..301852b46 100644 --- a/packages/harness/src/server/studio-workspace-wiring.test.ts +++ b/packages/harness/src/server/studio-workspace-wiring.test.ts @@ -25,22 +25,36 @@ describe("real Studio workspace wiring", () => { vi.restoreAllMocks(); }); - it("resolves and retains a published durable root when only its descendant session remains", async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), "studio-root-scope-wiring-")); + it("keeps a durable root and descendant session without admitting legacy graph work", async () => { + root = await fs.mkdtemp( + path.join(os.tmpdir(), "studio-root-scope-wiring-"), + ); const projectRoot = path.join(root, "project"); const descendant = path.join(projectRoot, "src"); await fs.mkdir(descendant, { recursive: true }); - const catalog = new StudioProjectCatalog(path.join(root, "studio-projects.json")); - const project = (await catalog.reconcile([ - { workspaceKey: "legacy-project", cwd: projectRoot }, - ])).projects[0]!; - await fs.writeFile(path.join(root, "settings.json"), JSON.stringify({ recentDirs: [projectRoot] })); + const catalog = new StudioProjectCatalog( + path.join(root, "studio-projects.json"), + ); + const project = ( + await catalog.reconcile([ + { workspaceKey: "legacy-project", cwd: projectRoot }, + ]) + ).projects[0]!; + await fs.writeFile( + path.join(root, "settings.json"), + JSON.stringify({ recentDirs: [projectRoot] }), + ); const adapter: HarnessAdapter = { id: "claude-code", eventSource: "hooks", doctor: async () => [], launch: (opts) => ({ command: "bash", args: [], env: {}, cwd: opts.cwd }), - resume: (_id, opts) => ({ command: "bash", args: [], env: {}, cwd: opts.cwd }), + resume: (_id, opts) => ({ + command: "bash", + args: [], + env: {}, + cwd: opts.cwd, + }), listPastSessions: async () => [], canResume: async () => true, }; @@ -54,24 +68,63 @@ describe("real Studio workspace wiring", () => { autoCreateSession: false, loadSystemPrompt: async () => "", }); - const session = await server.sessionManager.create({ cwd: descendant, harness: "claude-code" }); + const session = await server.sessionManager.create({ + cwd: descendant, + harness: "claude-code", + }); expect(session.agentMapIdentity?.projectId).toBe(project.projectId); - await fs.writeFile(path.join(root, "settings.json"), JSON.stringify({ recentDirs: [] })); - const watcherRetain = vi.spyOn(SystemGraphWatcherManager.prototype, "retain"); + await fs.writeFile( + path.join(root, "settings.json"), + JSON.stringify({ recentDirs: [] }), + ); + const watcherRetain = vi.spyOn( + SystemGraphWatcherManager.prototype, + "retain", + ); const storeRetain = vi.spyOn(SystemGraphStore.prototype, "retain"); + const graphWatch = vi.spyOn(SystemGraphWatcherManager.prototype, "start"); + const graphRead = vi.spyOn(SystemGraphStore.prototype, "get"); + const graphRefresh = vi.spyOn(SystemGraphStore.prototype, "refresh"); const headers = { "X-Harness-Token": "test-token" }; const baseUrl = `http://127.0.0.1:${server.port}`; - const state = await (await fetch(`${baseUrl}/api/state`, { headers })).json() as AppState; + const state = (await ( + await fetch(`${baseUrl}/api/state`, { headers }) + ).json()) as AppState; const scope = state.workspaceScopes?.find(({ cwd }) => cwd === projectRoot); expect(scope?.projectId).toBe(project.projectId); - const graph = await fetch(`${baseUrl}/api/workspaces/${scope!.workspaceKey}/system-graph`, { headers }); - expect(graph.status).toBe(200); - expect(watcherRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); - expect(storeRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); + // Includes identity-less descendant scopes and unknown/stale keys: neither + // is permission to serve a second topology or disclose private navigation. + for (const key of [ + ...state.workspaceScopes!.map((scope) => scope.workspaceKey), + "missing", + ]) { + for (const [suffix, method] of [ + ["", "GET"], + ["/refresh", "POST"], + ["/navigation", "GET"], + ]) { + const url = `${baseUrl}/api/workspaces/${key}/system-graph${suffix}`; + expect((await fetch(url, { method })).status).toBe(401); + const response = await fetch(url, { method, headers }); + expect(response.status).toBe(410); + expect(await response.json()).toEqual({ + error: "legacy_graph_retired", + message: "Reload Studio to use Agent Map.", + }); + } + } + expect(graphWatch).not.toHaveBeenCalled(); + expect(graphRead).not.toHaveBeenCalled(); + expect(graphRefresh).not.toHaveBeenCalled(); await fetch(`${baseUrl}/api/state`, { headers }); - expect(watcherRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); - expect(storeRetain.mock.calls.at(-1)?.[0].has(scope!.workspaceKey)).toBe(true); - expect((await fs.readFile(path.join(root, "settings.json"), "utf8"))).not.toContain(projectRoot); + expect(watcherRetain.mock.calls.at(-1)?.[0].size).toBe(0); + expect(storeRetain.mock.calls.at(-1)?.[0].size).toBe(0); + expect( + server.sessionManager.get(session.id)?.agentMapIdentity?.projectId, + ).toBe(project.projectId); + expect( + await fs.readFile(path.join(root, "settings.json"), "utf8"), + ).not.toContain(projectRoot); }); it("publishes opaque AppState bindings and restores one across a null-definition move and restart", async () => { diff --git a/packages/harness/src/server/system-graph-freshness.test.ts b/packages/harness/src/server/system-graph-freshness.test.ts index d12d4b9f7..dd98f92be 100644 --- a/packages/harness/src/server/system-graph-freshness.test.ts +++ b/packages/harness/src/server/system-graph-freshness.test.ts @@ -12,7 +12,6 @@ import type { SpawnSpec, WorkflowInfo, } from "../shared/types.js"; -import type { SystemGraphSnapshot } from "../shared/system-graph.js"; import { CachedAgentInvocationProvider } from "../core/system-graph-relationships.js"; import type { RegistryWorkflowInfo } from "../core/workflow-registry.js"; import { startServer, type HarnessServer } from "./index.js"; @@ -81,7 +80,7 @@ function fakeClaudeAdapter(): HarnessAdapter { }; } -describe("workspace graph freshness wiring", () => { +describe("workspace discovery freshness without legacy graph authority", () => { let tempRoot: string; let stateRoot: string; let workspaceRoot: string; @@ -116,221 +115,92 @@ describe("workspace graph freshness wiring", () => { }); }); - it( - "refreshes source invocations and agent inventory without a session", - { retry: 1, timeout: 30_000 }, - async () => { - const invocationObservations = vi.spyOn( - CachedAgentInvocationProvider.prototype, - "invocationObservations", - ); - const researchRoot = await scaffoldAgent(workspaceRoot, "research"); - await scaffoldAgent(workspaceRoot, "growth"); - server = await startServer({ - port: 0, - bootToken: "test-token", - telemetryOptIn: false, - adapters: {}, - stateRoot, - launchDir: workspaceRoot, - autoCreateSession: false, - }); - const baseUrl = `http://127.0.0.1:${server.port}`; - const headers = { "X-Harness-Token": "test-token" }; - - await vi.waitFor( - async () => { - const response = await fetch(`${baseUrl}/api/workflows`, { headers }); - const workflows = (await response.json()) as WorkflowInfo[]; - expect( - workflows.map((workflow) => workflow.definitionSlug).sort(), - ).toEqual(["growth", "research"]); - }, - { timeout: 8_000, interval: 150 }, - ); - - const stateResponse = await fetch(`${baseUrl}/api/state`, { headers }); - const state = (await stateResponse.json()) as AppState; - const workspaceKey = state.workspaceScopes?.find( - (scope) => scope.cwd === workspaceRoot, - )?.workspaceKey; - expect(workspaceKey).toBeTruthy(); - const graphUrl = `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`; - - const graphEvents: Array< - Extract - > = []; - socket = new WebSocket( - `ws://127.0.0.1:${server.port}/ws/events?token=test-token`, - ); - await new Promise((resolve, reject) => { - socket!.once("open", resolve); - socket!.once("error", reject); - }); - socket.on("message", (raw) => { - const message = JSON.parse(raw.toString()) as BusMessage; - if (message.type === "system-graph.changed") graphEvents.push(message); - }); - - const readGraph = async (): Promise => { - const response = await fetch(graphUrl, { headers }); - expect(response.status).toBe(200); - const raw = await response.text(); - expect(raw).not.toContain(workspaceRoot); - return JSON.parse(raw) as SystemGraphSnapshot; - }; - const initial = await readGraph(); - await vi.waitFor(() => expect(invocationObservations).toHaveBeenCalled()); - // Cold discovery is detached: cached inventory renders immediately, - // conservatively degraded until this process accepts fresh evidence. - expect(initial.state).toBe("degraded"); - expect(initial.graph?.nodes.map((node) => node.agentKey).sort()).toEqual([ - "growth", - "research", - ]); - let absentSettled!: SystemGraphSnapshot; - await vi.waitFor( - async () => { - absentSettled = await readGraph(); - expect(absentSettled.revision).toBeGreaterThan(initial.revision); - expect(absentSettled.state).toBe("degraded"); - expect(absentSettled.graph?.edges).toEqual([]); - expect( - absentSettled.graph?.warnings.some( - (warning) => warning.code === "inventory-extraction-failed", - ), - ).toBe(false); - }, - { timeout: 8_000, interval: 150 }, - ); - graphEvents.length = 0; - - await fs.writeFile( - path.join(researchRoot, "index.ts"), - 'ctx.sapiom.agents.run({ definition: "growth" });\n', - ); - let sourceRefresh!: SystemGraphSnapshot; - await vi.waitFor( - async () => { - sourceRefresh = await readGraph(); - expect(sourceRefresh.revision).toBeGreaterThan(initial.revision); - expect(sourceRefresh.state).toBe("degraded"); - expect(sourceRefresh.graph?.edges).toEqual([ - expect.objectContaining({ - from: "agent:research", - to: "agent:growth", - mode: "blocking", - }), - ]); - }, - { timeout: 8_000, interval: 150 }, - ); - expect(graphEvents.some((event) => event.state === "stale")).toBe(true); - expect(graphEvents.some((event) => event.state === "degraded")).toBe( - true, - ); - - await fs.writeFile(path.join(researchRoot, "index.ts"), "export {};\n"); - let sourceRemoved!: SystemGraphSnapshot; - await vi.waitFor( - async () => { - sourceRemoved = await readGraph(); - expect(sourceRemoved.revision).toBeGreaterThan( - sourceRefresh.revision, - ); - expect(sourceRemoved.graph?.edges).toEqual([]); - }, - { timeout: 8_000, interval: 150 }, - ); - - const reportingRoot = await scaffoldAgent(workspaceRoot, "reporting"); - let added!: SystemGraphSnapshot; - await vi.waitFor( - async () => { - added = await readGraph(); - expect(added.revision).toBeGreaterThan(sourceRemoved.revision); - expect( - added.graph?.nodes.some((node) => node.agentKey === "reporting"), - ).toBe(true); - }, - { timeout: 8_000, interval: 150 }, - ); - - const insightsRoot = path.join(workspaceRoot, "insights"); - await fs.rename(reportingRoot, insightsRoot); - await fs.writeFile( - path.join(insightsRoot, "sapiom.json"), - JSON.stringify({ name: "insights", definitionId: null }), - ); - await fs.writeFile( - path.join(insightsRoot, "index.ts"), - 'ctx.sapiom.agents.run({ definition: "growth" });\n', - ); - let renamed!: SystemGraphSnapshot; - await vi.waitFor( - async () => { - renamed = await readGraph(); - expect(renamed.revision).toBeGreaterThan(added.revision); - expect( - renamed.graph?.nodes.some((node) => node.agentKey === "reporting"), - ).toBe(false); - expect( - renamed.graph?.edges.some( - (edge) => - edge.from === "agent:insights" && edge.to === "agent:growth", - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 150 }, - ); - - await fs.writeFile( - path.join(insightsRoot, "sapiom.json"), - JSON.stringify({ name: "insights-v2", definitionId: null }), - ); - let renamedSlug!: SystemGraphSnapshot; - await vi.waitFor( - async () => { - renamedSlug = await readGraph(); - expect(renamedSlug.revision).toBeGreaterThan(renamed.revision); - expect( - renamedSlug.graph?.edges.some( - (edge) => - edge.from === "agent:insights-v2" && edge.to === "agent:growth", - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 150 }, - ); - - await fs.rm(insightsRoot, { recursive: true, force: true }); - await vi.waitFor( - async () => { - const removed = await readGraph(); - expect(removed.revision).toBeGreaterThan(renamedSlug.revision); - expect( - removed.graph?.nodes.some( - (node) => node.agentKey === "insights-v2", - ), - ).toBe(false); - }, - { timeout: 8_000, interval: 150 }, - ); - - const beforeManualRetry = await readGraph(); - const manualRetryResponse = await fetch(`${graphUrl}/refresh`, { - method: "POST", - headers, - }); - expect(manualRetryResponse.status).toBe(200); - const manualRetry = - (await manualRetryResponse.json()) as SystemGraphSnapshot; - // Manual Retry rebuilds immediately from accepted inventory, then direct - // invocation extraction completes in the background. - expect(manualRetry).toMatchObject({ state: "degraded" }); - expect(manualRetry.revision).toBeGreaterThan(beforeManualRetry.revision); - }, - ); + it("updates inventory through explicit scans without legacy graph work or a session", async () => { + const invocationObservations = vi.spyOn( + CachedAgentInvocationProvider.prototype, + "invocationObservations", + ); + await scaffoldAgent(workspaceRoot, "research"); + await scaffoldAgent(workspaceRoot, "growth"); + server = await startServer({ + port: 0, + bootToken: "test-token", + telemetryOptIn: false, + adapters: {}, + stateRoot, + launchDir: workspaceRoot, + autoCreateSession: false, + }); + const baseUrl = `http://127.0.0.1:${server.port}`; + const headers = { + "X-Harness-Token": "test-token", + "Content-Type": "application/json", + }; + const events: BusMessage[] = []; + socket = new WebSocket( + `ws://127.0.0.1:${server.port}/ws/events?token=test-token`, + ); + await new Promise((resolve, reject) => { + socket!.once("open", resolve); + socket!.once("error", reject); + }); + socket.on("message", (raw) => + events.push(JSON.parse(raw.toString()) as BusMessage), + ); + const scan = async () => { + expect( + ( + await fetch(`${baseUrl}/api/workflows/scan`, { + method: "POST", + headers, + body: JSON.stringify({ root: workspaceRoot }), + }) + ).status, + ).toBe(200); + return (await ( + await fetch(`${baseUrl}/api/workflows`, { headers }) + ).json()) as WorkflowInfo[]; + }; + expect((await scan()).map((row) => row.definitionSlug).sort()).toEqual([ + "growth", + "research", + ]); + const reporting = await scaffoldAgent(workspaceRoot, "reporting"); + expect((await scan()).map((row) => row.path)).toContain(reporting); + const insights = path.join(workspaceRoot, "insights"); + await fs.rename(reporting, insights); + await fs.writeFile( + path.join(insights, "sapiom.json"), + JSON.stringify({ name: "insights", definitionId: null }), + ); + const renamed = await scan(); + expect(renamed.map((row) => row.path)).not.toContain(reporting); + expect(renamed.find((row) => row.path === insights)?.definitionSlug).toBe( + "insights", + ); + await fs.writeFile( + path.join(insights, "sapiom.json"), + JSON.stringify({ name: "insights-v2", definitionId: null }), + ); + expect( + (await scan()).find((row) => row.path === insights)?.definitionSlug, + ).toBe("insights-v2"); + await fs.rm(insights, { recursive: true, force: true }); + expect((await scan()).map((row) => row.definitionSlug).sort()).toEqual([ + "growth", + "research", + ]); + await vi.waitFor(() => + expect( + events.filter((message) => message.type === "workflows.changed").length, + ).toBeGreaterThanOrEqual(4), + ); + expect( + events.filter((message) => message.type === "system-graph.changed"), + ).toEqual([]); + expect(invocationObservations).not.toHaveBeenCalled(); + expect(server.sessionManager.list()).toEqual([]); + }); it("serves persisted cold inventory without awaiting discovery", async () => { const within = async (promise: Promise, label: string): Promise => @@ -385,22 +255,15 @@ describe("workspace graph freshness wiring", () => { const state = (await ( await within(fetch(`${baseUrl}/api/state`, { headers }), "state") ).json()) as AppState; - const workspaceKey = state.workspaceScopes?.find( - (scope) => scope.cwd === workspaceRoot, - )?.workspaceKey; - expect(workspaceKey).toBeTruthy(); - const response = await within( - fetch(`${baseUrl}/api/workspaces/${workspaceKey}/system-graph`, { - headers, - }), - "graph", - ); - expect(response.status).toBe(200); - const cached = (await response.json()) as SystemGraphSnapshot; - expect(cached.state).toBe("degraded"); - expect(cached.graph?.nodes.some((node) => node.agentKey === "cold")).toBe( - true, + expect(state.workflows.map((row) => row.path)).toEqual([coldRoot]); + const cached = await within( + fetch(`${baseUrl}/api/workflows`, { headers }), + "cached inventory", ); + expect(cached.status).toBe(200); + expect( + ((await cached.json()) as WorkflowInfo[]).map((row) => row.path), + ).toEqual([coldRoot]); scanGate.resolve(); const acceptedScan = await fetch(`${baseUrl}/api/workflows/scan`, { @@ -409,18 +272,13 @@ describe("workspace graph freshness wiring", () => { body: JSON.stringify({ root: workspaceRoot }), }); expect(acceptedScan.status).toBe(200); - await within( - vi.waitFor(async () => { - const settled = (await ( - await fetch( - `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`, - { headers }, - ) - ).json()) as SystemGraphSnapshot; - expect(settled.graph?.nodes).toHaveLength(1); - }), - "settled graph", - ); + expect( + ( + (await ( + await fetch(`${baseUrl}/api/workflows`, { headers }) + ).json()) as WorkflowInfo[] + ).map((row) => row.path), + ).toEqual([coldRoot]); }); it("supersedes a paused publication and commits only the newest scan", async () => { @@ -525,16 +383,6 @@ export const agent = defineAgent({ name: "budget-v1" });`, ).json()) as WorkflowInfo[], ).toHaveLength(1); }); - const state = (await ( - await fetch(`${baseUrl}/api/state`, { headers }) - ).json()) as AppState; - const workspaceKey = state.workspaceScopes?.find( - (scope) => scope.cwd === workspaceRoot, - )?.workspaceKey; - expect(workspaceKey).toBeTruthy(); - const graphUrl = `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`; - await fetch(graphUrl, { headers }); - blockNextRequestedResult = true; const first = fetch(`${baseUrl}/api/workflows/scan`, { method: "POST", @@ -556,18 +404,22 @@ export const agent = defineAgent({ name: "budget-v2-final" });`, expect( (await Promise.all([first, second])).map((response) => response.status), ).toEqual([200, 200]); - await vi.waitFor(async () => { - const snapshot = (await ( - await fetch(graphUrl, { headers }) - ).json()) as SystemGraphSnapshot; - expect(snapshot.graph?.nodes.map((node) => node.agentKey)).toEqual([ - "budget-v2-final", - ]); - }); + const workflows = (await ( + await fetch(`${baseUrl}/api/workflows`, { headers }) + ).json()) as WorkflowInfo[]; + expect(workflows.map((row) => row.path)).toEqual([workspaceRoot]); + // Public rows intentionally omit syntax-only source names. Inspect the + // accepted persisted evidence so stale source memoization fails this test. + const persisted = JSON.parse( + await fs.readFile(path.join(stateRoot, "workflows.json"), "utf8"), + ) as RegistryWorkflowInfo[]; + expect( + persisted.find((row) => row.path === workspaceRoot)?.sourceDefinitionName, + ).toBe("budget-v2-final"); }); - it("lets an ordinary first graph scan release a failed dirty prerequisite", async () => { - await scaffoldAgent(workspaceRoot, "recoverable"); + it("retains accepted inventory after a failed dirty scan and recovers on retry", async () => { + const agent = await scaffoldAgent(workspaceRoot, "recoverable"); let failuresRemaining = 0; server = await startServer({ port: 0, @@ -598,14 +450,10 @@ export const agent = defineAgent({ name: "budget-v2-final" });`, "recoverable", ]); }); - const state = (await ( - await fetch(`${baseUrl}/api/state`, { headers }) - ).json()) as AppState; - const workspaceKey = state.workspaceScopes?.find( - (scope) => scope.cwd === workspaceRoot, - )?.workspaceKey; - expect(workspaceKey).toBeTruthy(); - + await fs.writeFile( + path.join(agent, "sapiom.json"), + JSON.stringify({ name: "recovered", definitionId: null }), + ); failuresRemaining = 4; const failed = await fetch(`${baseUrl}/api/workflows/scan`, { method: "POST", @@ -615,26 +463,35 @@ export const agent = defineAgent({ name: "budget-v2-final" });`, expect(failed.status).toBe(500); expect(failuresRemaining).toBe(0); - const graphUrl = `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`; - // The first GET attaches the prior dirty token before it starts the - // ordinary background recovery scan. It may initially be building, but - // the accepted exact-root proof must release that inherited token. - await fetch(graphUrl, { headers }); - await vi.waitFor( - async () => { - const response = await fetch(graphUrl, { headers }); - const snapshot = (await response.json()) as SystemGraphSnapshot; - expect(snapshot.state).not.toBe("building"); - expect( - snapshot.graph?.nodes.some((node) => node.agentKey === "recoverable"), - ).toBe(true); - }, - { timeout: 8_000, interval: 100 }, - ); + expect( + ( + (await ( + await fetch(`${baseUrl}/api/workflows`, { headers }) + ).json()) as WorkflowInfo[] + ).map((row) => row.definitionSlug), + ).toEqual(["recoverable"]); + const retried = await fetch(`${baseUrl}/api/workflows/scan`, { + method: "POST", + headers, + body: JSON.stringify({ root: workspaceRoot }), + }); + expect(retried.status).toBe(200); + expect( + ((await retried.json()) as { found: WorkflowInfo[] }).found.map( + (row) => row.definitionSlug, + ), + ).toEqual(["recovered"]); + expect( + ( + (await ( + await fetch(`${baseUrl}/api/workflows`, { headers }) + ).json()) as WorkflowInfo[] + ).map((row) => row.definitionSlug), + ).toEqual(["recovered"]); }); it( - "coalesces two sessions and a graph subscriber into one pass plus one held-edit trailing pass", + "coalesces two sessions into one pass plus one held-edit trailing pass", { timeout: 25_000 }, async () => { await fs.writeFile( @@ -664,8 +521,6 @@ export const agent = defineAgent({ name: "shared-v0" });`, }, }, }); - const headers = { "X-Harness-Token": "test-token" }; - const baseUrl = `http://127.0.0.1:${server.port}`; await server.sessionManager.create({ cwd: workspaceRoot, harness: "claude-code", @@ -674,17 +529,6 @@ export const agent = defineAgent({ name: "shared-v0" });`, cwd: workspaceRoot, harness: "claude-code", }); - const state = (await ( - await fetch(`${baseUrl}/api/state`, { headers }) - ).json()) as AppState; - const workspaceKey = state.workspaceScopes?.find( - (scope) => scope.cwd === workspaceRoot, - )?.workspaceKey; - expect(workspaceKey).toBeTruthy(); - await fetch(`${baseUrl}/api/workspaces/${workspaceKey}/system-graph`, { - headers, - }); - // Let the shared broker's one conservative initial reconciliation drain // before counting the edit under test. await new Promise((resolve) => setTimeout(resolve, 2_500)); @@ -716,87 +560,68 @@ export const agent = defineAgent({ name: "shared-v2-final" });`, }, ); - it.each(["graph-first", "session-first"] as const)( - "reconciles a newly foreign repository from the parent regardless of %s subscriber order", - async (subscriberOrder) => { - const checkout = path.join(workspaceRoot, "checkout"); - await fs.mkdir(checkout, { recursive: true }); - await fs.writeFile( - path.join(checkout, "index.ts"), - `import { defineAgent } from "@sapiom/agent"; + it("reconciles a newly foreign repository from its parent session watcher", async () => { + const checkout = path.join(workspaceRoot, "checkout"); + await fs.mkdir(checkout, { recursive: true }); + await fs.writeFile( + path.join(checkout, "index.ts"), + `import { defineAgent } from "@sapiom/agent"; export const agent = defineAgent({ name: "checkout-agent" });`, - ); - server = await startServer({ - port: 0, - bootToken: "test-token", - telemetryOptIn: false, - adapters: { "claude-code": fakeClaudeAdapter() }, - stateRoot, - launchDir: workspaceRoot, - autoCreateSession: false, - }); - const baseUrl = `http://127.0.0.1:${server.port}`; - const headers = { - "X-Harness-Token": "test-token", - "Content-Type": "application/json", - }; - await vi.waitFor(async () => { - const workflows = (await ( - await fetch(`${baseUrl}/api/workflows`, { headers }) - ).json()) as WorkflowInfo[]; - expect(workflows.map((workflow) => workflow.path)).toEqual([checkout]); - }); - const state = (await ( - await fetch(`${baseUrl}/api/state`, { headers }) - ).json()) as AppState; - const workspaceKey = state.workspaceScopes?.find( - (scope) => scope.cwd === workspaceRoot, - )?.workspaceKey; - expect(workspaceKey).toBeTruthy(); - const startGraph = () => - fetch(`${baseUrl}/api/workspaces/${workspaceKey}/system-graph`, { - headers, - }); - const startSession = () => - server!.sessionManager.create({ - cwd: workspaceRoot, - harness: "claude-code", - }); - if (subscriberOrder === "graph-first") { - await startGraph(); - await startSession(); - } else { - await startSession(); - await startGraph(); - } - await new Promise((resolve) => setTimeout(resolve, 2_500)); - - await fs.mkdir(path.join(checkout, ".git")); - await vi.waitFor( - async () => { - const workflows = (await ( - await fetch(`${baseUrl}/api/workflows`, { headers }) - ).json()) as WorkflowInfo[]; - expect(workflows).toEqual([]); - }, - { timeout: 8_000, interval: 100 }, - ); + ); + server = await startServer({ + port: 0, + bootToken: "test-token", + telemetryOptIn: false, + adapters: { "claude-code": fakeClaudeAdapter() }, + stateRoot, + launchDir: workspaceRoot, + autoCreateSession: false, + }); + const baseUrl = `http://127.0.0.1:${server.port}`; + const headers = { + "X-Harness-Token": "test-token", + "Content-Type": "application/json", + }; + await vi.waitFor(async () => { + const workflows = (await ( + await fetch(`${baseUrl}/api/workflows`, { headers }) + ).json()) as WorkflowInfo[]; + expect(workflows.map((workflow) => workflow.path)).toEqual([checkout]); + }); + await server.sessionManager.create({ + cwd: workspaceRoot, + harness: "claude-code", + }); + await server.sessionManager.create({ + cwd: workspaceRoot, + harness: "claude-code", + }); + await new Promise((resolve) => setTimeout(resolve, 2_500)); - const direct = await fetch(`${baseUrl}/api/workflows/scan`, { - method: "POST", - headers, - body: JSON.stringify({ root: checkout }), - }); - expect(direct.status).toBe(200); - await vi.waitFor(async () => { + await fs.mkdir(path.join(checkout, ".git")); + await vi.waitFor( + async () => { const workflows = (await ( await fetch(`${baseUrl}/api/workflows`, { headers }) ).json()) as WorkflowInfo[]; - expect(workflows.map((workflow) => workflow.path)).toEqual([checkout]); - }); - }, - 20_000, - ); + expect(workflows).toEqual([]); + }, + { timeout: 8_000, interval: 100 }, + ); + + const direct = await fetch(`${baseUrl}/api/workflows/scan`, { + method: "POST", + headers, + body: JSON.stringify({ root: checkout }), + }); + expect(direct.status).toBe(200); + await vi.waitFor(async () => { + const workflows = (await ( + await fetch(`${baseUrl}/api/workflows`, { headers }) + ).json()) as WorkflowInfo[]; + expect(workflows.map((workflow) => workflow.path)).toEqual([checkout]); + }); + }, 20_000); it( "keeps staged session contexts invisible when publication is superseded and commits only the newest rows", @@ -969,14 +794,12 @@ export const agent = defineAgent({ name: "checkout-agent" });`, const agentRoot = await scaffoldAgent(workspaceRoot, "offline-edit"); const publicationGate = deferred(); const publicationEntered = deferred(); - const reopenScanGate = deferred(); let blockPublication = false; - let blockReopenScan = false; server = await startServer({ port: 0, bootToken: "test-token", telemetryOptIn: false, - adapters: {}, + adapters: { "claude-code": fakeClaudeAdapter() }, stateRoot, launchDir: workspaceRoot, autoCreateSession: false, @@ -987,9 +810,6 @@ export const agent = defineAgent({ name: "checkout-agent" });`, publicationEntered.resolve(); await publicationGate.promise; }, - beforeScan: async () => { - if (blockReopenScan) await reopenScanGate.promise; - }, }, }); const baseUrl = `http://127.0.0.1:${server.port}`; @@ -1003,16 +823,12 @@ export const agent = defineAgent({ name: "checkout-agent" });`, ).json()) as WorkflowInfo[]; expect(workflows).toHaveLength(1); }); - const initialState = (await ( - await fetch(`${baseUrl}/api/state`, { headers }) - ).json()) as AppState; - const workspaceKey = initialState.workspaceScopes?.find( - (scope) => scope.cwd === workspaceRoot, - )?.workspaceKey; - expect(workspaceKey).toBeTruthy(); - const graphUrl = `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`; - await fetch(graphUrl, { headers }); // acquire the only continuous lease - + // Project enrollment creates an ordinary bootstrap session. Use that sole + // owner so killing it really retires the final shared lease. + await vi.waitFor(() => expect(server!.sessionManager.list().filter((session) => session.status !== "exited")).toHaveLength(1)); + const session = server.sessionManager.list().find((session) => session.status !== "exited")!; + // Drain the shared watcher's initial reconciliation before holding a scan. + await new Promise((resolve) => setTimeout(resolve, 2_500)); blockPublication = true; const oldScan = fetch(`${baseUrl}/api/workflows/scan`, { method: "POST", @@ -1021,50 +837,45 @@ export const agent = defineAgent({ name: "checkout-agent" });`, }); await publicationEntered.promise; - await fs.writeFile( - path.join(stateRoot, "settings.json"), - JSON.stringify({ recentDirs: [] }), - ); - await fetch(`${baseUrl}/api/state`, { headers }); // retires the last lease + await server.sessionManager.kill(session.id); + await vi.waitFor(() => expect(server!.sessionManager.get(session.id)?.status).toBe("exited")); + expect(server.sessionManager.list().filter((candidate) => candidate.status !== "exited")).toEqual([]); await fs.rm(path.join(agentRoot, "sapiom.json")); // unobserved interval publicationGate.resolve(); - expect((await oldScan).status).toBe(200); - - await fs.writeFile( - path.join(stateRoot, "settings.json"), - JSON.stringify({ recentDirs: [workspaceRoot] }), - ); - const restoredState = (await ( - await fetch(`${baseUrl}/api/state`, { headers }) - ).json()) as AppState; + const oldResponse = await oldScan; + expect(oldResponse.status).toBe(200); expect( - restoredState.workspaceScopes?.some( - (scope) => scope.workspaceKey === workspaceKey, - ), - ).toBe(true); - blockReopenScan = true; - - const reopened = (await ( - await fetch(graphUrl, { headers }) - ).json()) as SystemGraphSnapshot; - - expect(reopened.state).toBe("degraded"); - expect(reopened.state).not.toBe("ready"); - expect(reopened.graph?.nodes).toHaveLength(1); - reopenScanGate.resolve(); + ((await oldResponse.json()) as { found: WorkflowInfo[] }).found, + ).toEqual([]); + const retained = (await ( + await fetch(`${baseUrl}/api/workflows`, { headers }) + ).json()) as WorkflowInfo[]; + expect(retained.map((row) => row.path)).toEqual([agentRoot]); + const recovery = await fetch(`${baseUrl}/api/workflows/scan`, { + method: "POST", + headers, + body: JSON.stringify({ root: workspaceRoot }), + }); + expect(recovery.status).toBe(200); + expect( + await (await fetch(`${baseUrl}/api/workflows`, { headers })).json(), + ).toEqual([]); }); it( - "registers each agent once when the launch directory is a symlink", + "deduplicates a symlinked launch directory and excludes linked node_modules", { timeout: 30_000 }, async () => { - // The registry keys rows by path. Boot scanned the launch directory as - // given while the first graph open scanned its resolved form, so every - // agent registered twice; the duplicates collided into `local:` fallback - // keys and each cross-agent target became ambiguous, dropping its edge. - // macOS hits this on any `os.tmpdir()` path (`/var` -> `/private/var`). + // Both spellings must reach the same registry rows when explicitly scanned. + // A discoverable dependency must stay excluded through either spelling. + const dependenciesRoot = path.join(tempRoot, "dependencies"); + await scaffoldAgent( + dependenciesRoot, + "dependency-agent", + installedAgentSource("dependency-agent"), + ); await fs.symlink( - path.join(process.cwd(), "node_modules"), + dependenciesRoot, path.join(workspaceRoot, "node_modules"), "dir", ); @@ -1106,50 +917,37 @@ export const agent = defineAgent({ name: "checkout-agent" });`, { timeout: 8_000, interval: 150 }, ); - const state = (await ( - await fetch(`${baseUrl}/api/state`, { headers }) - ).json()) as AppState; - const workspaceKey = state.workspaceScopes?.[0]?.workspaceKey; - expect(workspaceKey).toBeTruthy(); - - const readGraph = async (): Promise => - (await ( - await fetch( - `${baseUrl}/api/workspaces/${workspaceKey}/system-graph`, - { - headers, - }, - ) - ).json()) as SystemGraphSnapshot; - - await vi.waitFor( - async () => { - expect((await readGraph()).state).toBe("ready"); - }, - { timeout: 8_000, interval: 150 }, - ); - - // A second scan under the resolved spelling must not register duplicate - // rows or make the existing invocation target ambiguous. + const scan = async (root: string) => { + const response = await fetch(`${baseUrl}/api/workflows/scan`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ root }), + }); + expect(response.status).toBe(200); + return (await ( + await fetch(`${baseUrl}/api/workflows`, { headers }) + ).json()) as WorkflowInfo[]; + }; + const initial = await scan(workspaceRoot); + expect(initial.map((row) => row.definitionSlug).sort()).toEqual([ + "growth", + "research", + ]); + expect( + new Set(await Promise.all(initial.map((row) => fs.realpath(row.path)))) + .size, + ).toBe(2); await scaffoldAgent(workspaceRoot, "reporting"); - await vi.waitFor( - async () => { - const graph = await readGraph(); - expect(graph.graph?.nodes.map((node) => node.agentKey)).toEqual([ - "growth", - "reporting", - "research", - ]); - expect(graph.graph?.warnings).toEqual([]); - expect(graph.graph?.edges).toEqual([ - expect.objectContaining({ - from: "agent:research", - to: "agent:growth", - }), - ]); - }, - { timeout: 10_000, interval: 150 }, - ); + const added = await scan(linkedRoot); + expect(added.map((row) => row.definitionSlug).sort()).toEqual([ + "growth", + "reporting", + "research", + ]); + expect( + new Set(await Promise.all(added.map((row) => fs.realpath(row.path)))) + .size, + ).toBe(3); }, ); }); diff --git a/scripts/assert-release-ready.mjs b/scripts/assert-release-ready.mjs new file mode 100644 index 000000000..b54a5963c --- /dev/null +++ b/scripts/assert-release-ready.mjs @@ -0,0 +1,7 @@ +import { existsSync, readFileSync } from "node:fs"; + +const blocker = new URL("../.release-blocked", import.meta.url); +if (existsSync(blocker)) { + console.error(`Release blocked:\n${readFileSync(blocker, "utf8").trim()}`); + process.exitCode = 1; +} diff --git a/scripts/assert-release-ready.test.mjs b/scripts/assert-release-ready.test.mjs new file mode 100644 index 000000000..d95b23fd5 --- /dev/null +++ b/scripts/assert-release-ready.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +test("release readiness follows the checked-out blocker, independent of cwd", async (t) => { + const root = await mkdtemp(join(tmpdir(), "release-readiness-")); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, "scripts")); + const script = join(root, "scripts", "assert-release-ready.mjs"); + await copyFile( + new URL("./assert-release-ready.mjs", import.meta.url), + script, + ); + const run = () => + spawnSync(process.execPath, [script], { cwd: tmpdir(), encoding: "utf8" }); + assert.equal(run().status, 0); + const blocker = join(root, ".release-blocked"); + await writeFile(blocker, "Include the client recovery before release.\n"); + const blocked = run(); + assert.equal(blocked.status, 1); + assert.match(blocked.stderr, /Release blocked:\nInclude the client recovery/); + await rm(blocker); + assert.equal(run().status, 0); +});