Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/quiet-project-map-authority.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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+)

Expand Down
2 changes: 2 additions & 0 deletions .release-blocked
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 40 additions & 1 deletion packages/harness/docs/workspace-system-graph.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 24 additions & 7 deletions packages/harness/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, WorkspaceScope>();
const systemGraphInvocations = new CachedAgentInvocationProvider(
new SourceAgentInvocationProvider(),
Expand Down Expand Up @@ -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);
},
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string>();
systemGraphWatcher.retain(retained);
systemGraphStore.retain(retained);
for (const workspaceKey of activeSystemGraphScopes.keys()) {
Expand Down Expand Up @@ -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({
Expand Down
91 changes: 72 additions & 19 deletions packages/harness/src/server/studio-workspace-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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 () => {
Expand Down
Loading
Loading