From d3c91355862611ba1332db778db6aef0ef891515 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 9 Sep 2026 20:05:01 +0000 Subject: [PATCH 1/4] fix(harness): retire current-server legacy graph authority Refs SAP-3089. Reject protected legacy requests before resolving scopes or retaining graph owners, while preserving shared discovery and session watcher coverage. --- .changeset/quiet-project-map-authority.md | 5 + packages/harness/src/server/index.ts | 28 +- .../server/studio-workspace-wiring.test.ts | 91 ++- .../src/server/system-graph-freshness.test.ts | 733 ++++++------------ 4 files changed, 356 insertions(+), 501 deletions(-) create mode 100644 .changeset/quiet-project-map-authority.md diff --git a/.changeset/quiet-project-map-authority.md b/.changeset/quiet-project-map-authority.md new file mode 100644 index 000000000..fcd64e77f --- /dev/null +++ b/.changeset/quiet-project-map-authority.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Reject legacy project graph reads, refreshes, and navigation before they can start background work. Retain shared workspace discovery and ordinary sessions independently of the retired graph. diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index d3d905dcd..f1949209d 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -1944,8 +1944,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); }, @@ -2270,8 +2272,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); @@ -2879,11 +2883,13 @@ 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. + const retained = new Set(); systemGraphWatcher.retain(retained); systemGraphStore.retain(retained); for (const workspaceKey of activeSystemGraphScopes.keys()) { @@ -3956,6 +3962,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..285fb4770 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,53 +837,36 @@ 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", { 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`). - await fs.symlink( - path.join(process.cwd(), "node_modules"), - path.join(workspaceRoot, "node_modules"), - "dir", - ); + // Both spellings must reach the same registry rows when explicitly scanned. await scaffoldAgent( workspaceRoot, "research", @@ -1106,50 +905,34 @@ 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).toHaveLength(2); + 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); }, ); }); From 551837e637b3d6de706dd82c1e110291ec1772ca Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 9 Sep 2026 20:57:11 +0000 Subject: [PATCH 2/4] fix(harness): clarify retired graph release boundary Hold the breaking release note for the client recovery layer and document the retired HTTP contract. Restore linked dependency exclusion coverage. Refs: SAP-3089 --- .changeset/quiet-project-map-authority.md | 5 --- packages/harness/README.md | 4 +- .../harness/docs/workspace-system-graph.md | 41 ++++++++++++++++++- packages/harness/src/server/index.ts | 3 ++ .../src/server/system-graph-freshness.test.ts | 19 ++++++++- 5 files changed, 62 insertions(+), 10 deletions(-) delete mode 100644 .changeset/quiet-project-map-authority.md diff --git a/.changeset/quiet-project-map-authority.md b/.changeset/quiet-project-map-authority.md deleted file mode 100644 index fcd64e77f..000000000 --- a/.changeset/quiet-project-map-authority.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@sapiom/harness": patch ---- - -Reject legacy project graph reads, refreshes, and navigation before they can start background work. Retain shared workspace discovery and ordinary sessions independently of the retired graph. diff --git a/packages/harness/README.md b/packages/harness/README.md index 34ca20bd9..3decee224 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -333,8 +333,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/index.ts b/packages/harness/src/server/index.ts index f1949209d..59a2d30a0 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -1679,6 +1679,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(), @@ -2889,6 +2891,7 @@ export const startServer = async ( } // 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); diff --git a/packages/harness/src/server/system-graph-freshness.test.ts b/packages/harness/src/server/system-graph-freshness.test.ts index 285fb4770..dd98f92be 100644 --- a/packages/harness/src/server/system-graph-freshness.test.ts +++ b/packages/harness/src/server/system-graph-freshness.test.ts @@ -863,10 +863,22 @@ export const agent = defineAgent({ name: "checkout-agent" });`, }); 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 () => { // 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( + dependenciesRoot, + path.join(workspaceRoot, "node_modules"), + "dir", + ); await scaffoldAgent( workspaceRoot, "research", @@ -917,7 +929,10 @@ export const agent = defineAgent({ name: "checkout-agent" });`, ).json()) as WorkflowInfo[]; }; const initial = await scan(workspaceRoot); - expect(initial).toHaveLength(2); + expect(initial.map((row) => row.definitionSlug).sort()).toEqual([ + "growth", + "research", + ]); expect( new Set(await Promise.all(initial.map((row) => fs.realpath(row.path)))) .size, From 1ba4b393a407cab562f0227fb091756175d5b96b Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 9 Sep 2026 21:16:02 +0000 Subject: [PATCH 3/4] fix(harness): block releases until client recovery is included Restore the breaking minor retirement changeset on the server layer. Check the pending client blocker before versioning, npm publishing and desktop releases so unrelated patch changesets cannot ship it alone. Refs: SAP-3089 --- .changeset/quiet-project-map-authority.md | 28 +++++++++++++++++++++++ .github/workflows/desktop-release.yml | 3 +++ .github/workflows/publish.yml | 3 +++ .github/workflows/release-pr.yml | 3 +++ .release-blocked | 2 ++ package.json | 5 ++-- scripts/assert-release-ready.mjs | 7 ++++++ scripts/assert-release-ready.test.mjs | 27 ++++++++++++++++++++++ 8 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-project-map-authority.md create mode 100644 .release-blocked create mode 100644 scripts/assert-release-ready.mjs create mode 100644 scripts/assert-release-ready.test.mjs 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/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); +}); From 5870d0f5743c0c30f96fb9f67ace690685ccc590 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 9 Sep 2026 21:31:27 +0000 Subject: [PATCH 4/4] test(harness): isolate credentials in definition list wiring Provide the credentialsFilePath export used by the new shared credential observer, pointing it inside each fixture's temporary directory. This repairs the four CI startup failures after the main-branch auth integration. Refs: SAP-3089 --- .../harness/src/server/definition-list-enrichment.test.ts | 5 +++++ 1 file changed, 5 insertions(+) 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,