Skip to content
This repository was archived by the owner on Oct 23, 2026. It is now read-only.
Closed
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
6 changes: 3 additions & 3 deletions src/capability-generation-convergence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import {
type SessionGenerationSnapshot,
type ServiceRoleDeploymentIdentity,
} from "./deployment-convergence.js";
import { CUTOVER_SAFE_TOOLS } from "./mcp-cutover.js";
import { classifyCutoverEffect } from "./mcp-cutover.js";
import { McpSessionRegistry } from "./mcp-sessions.js";

test("Issue #133: CUTOVER_SAFE_TOOLS includes capability_convergence_status", () => {
assert.equal(CUTOVER_SAFE_TOOLS.has("capability_convergence_status"), true);
test("Issue #133: capability convergence observation remains independent of deployment conflicts", () => {
assert.equal(classifyCutoverEffect("capability_convergence_status"), "INDEPENDENT");
});

test("Issue #133: SessionConvergence detects stale server across restart", () => {
Expand Down
286 changes: 54 additions & 232 deletions src/cutover-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,262 +3,84 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { CUTOVER_ACTIVATION_BINDING_SCHEMA, CutoverStateStore } from "./cutover-state.js";
import type { OrchestrationOutcome } from "./cutover-orchestration.js";
import type { SelfRestartActuator } from "./cutover-restart.js";
import { CutoverStateStore } from "./cutover-state.js";
import { McpCutoverController, registerCutoverHttpRoutes } from "./mcp-cutover.js";

test("cutover HTTP lifecycle is durable, exact-bound, and secret-free", async () => {
const stateDir = mkdtempSync(join(tmpdir(), "devspace-cutover-http-"));
const routes = new Map<string, Function>();
function fixture() {
const stateDir = mkdtempSync(join(tmpdir(), "devspace-cutover-http-readonly-"));
const handlers = new Map<string, Function[]>();
const app = {
get(path: string, ...handlers: Function[]) { routes.set(`GET ${path}`, handlers.at(-1)!); },
post(path: string, ...handlers: Function[]) { routes.set(`POST ${path}`, handlers.at(-1)!); },
get(path: string, ...fns: Function[]) { handlers.set(`GET ${path}`, fns); },
post(path: string, ...fns: Function[]) { handlers.set(`POST ${path}`, fns); },
};
const old = new McpCutoverController(
new CutoverStateStore(stateDir, { newId: () => "cutover-http" }),
const controller = new McpCutoverController(
new CutoverStateStore(stateDir, { newId: () => "readonly-cutover" }),
{
serverInstanceId: "old-server",
sourceCommit: "old-source",
buildId: "old-build",
capabilityManifestSha256: "cap",
serverInstanceId: "source-server",
sourceCommit: "source-commit",
buildId: "source-build",
},
);
let activeSessions = 3;
let activeSessions = 2;
const authenticate = (_req: unknown, _res: unknown, next: () => void) => next();
registerCutoverHttpRoutes(app, {
controller: old,
authenticate: (_req, _res, next) => next(),
controller,
authenticate,
transportEvidence: () => ({ activeSessions, oldestAgeMs: 12_000 }),
reconcileDurableState: async () => ({
workspaceQueryable: true,
agentQueryable: true,
agentReconciled: true,
}),
});
try {
const start = await invoke(routes.get("POST /api/cutover/start")!, {
expectedSourceCommit: "new-source",
expectedBuildId: "new-build",
});
assert.equal(start.statusCode, 201);
assert.equal(start.body.cutover.cutoverId, "cutover-http");

const statusBody = (await invoke(routes.get("GET /api/cutover/status")!)).body;
assert.equal(statusBody.transportEvidence.oldestAgeMs, 12_000);
assert.equal(statusBody.reconciliationRequired, true);
assert.equal(statusBody.comparison.serverInstanceChanged, false);
const serialized = JSON.stringify(statusBody).toLowerCase();
assert.equal(serialized.includes("mcp-session-id"), false);
assert.equal(serialized.includes("token"), false);

activeSessions = 1;
const drain = await invoke(routes.get("POST /api/cutover/drain")!, { cutoverId: "cutover-http" });
assert.equal(drain.body.cutover.drainEvidence.activeSessions, 1);
} finally {
rmSync(stateDir, { recursive: true, force: true });
}
});

async function invoke(handler: Function, body?: unknown): Promise<{ statusCode: number; body: any }> {
const response = {
statusCode: 200,
body: undefined as any,
status(code: number) { this.statusCode = code; return this; },
json(value: unknown) { this.body = value; return this; },
const invokeStatus = () => {
const middleware = handlers.get("GET /api/cutover/status");
assert.ok(middleware);
assert.equal(middleware.length, 2, "status must retain authentication");
let authObserved = false;
middleware[0]({}, {}, () => { authObserved = true; });
assert.ok(authObserved);
let body: unknown;
middleware[1]({}, { json(value: unknown) { body = value; } }, () => {});
assert.ok(body);
return body as Record<string, unknown>;
};
return {
handlers,
controller,
invokeStatus,
setActiveSessions(value: number) { activeSessions = value; },
close() { rmSync(stateDir, { recursive: true, force: true }); },
};
await handler({ body }, response, () => {});
return response;
}

test("restart route gates on attestation and probe and schedules exactly once; advance route reports outcomes", async () => {
const stateDir = mkdtempSync(join(tmpdir(), "devspace-cutover-http-restart-"));
test("legacy cutover HTTP is read-only and cannot grant release authority", () => {
const f = fixture();
try {
const routes = new Map<string, Function>();
const app = {
get(path: string, ...handlers: Function[]) { routes.set(`GET ${path}`, handlers.at(-1)!); },
post(path: string, ...handlers: Function[]) { routes.set(`POST ${path}`, handlers.at(-1)!); },
};
const old = new McpCutoverController(
new CutoverStateStore(stateDir, { newId: () => "cutover-restart-http" }),
{
serverInstanceId: "old-server",
sourceCommit: "old-source",
buildId: "old-build",
capabilityManifestSha256: "cap",
},
);
const actuator: SelfRestartActuator & { calls: number } = {
calls: 0,
actuator: "launchd-self",
serviceLabel: "com.example.devspace",
launchdTarget: "gui/501/com.example.devspace",
schedule: () => {
actuator.calls += 1;
return {
scheduled: true,
actuator: "launchd-self",
serviceLabel: "com.example.devspace",
launchdTarget: "gui/501/com.example.devspace",
};
},
};
let probeBuildReady = true;
let advanceOutcome: OrchestrationOutcome = {
outcome: "restart_already_scheduled",
reason: "already scheduled durably",
scheduledFor: "com.example.devspace",
};
registerCutoverHttpRoutes(app, {
controller: old,
authenticate: (_req, _res, next) => next(),
transportEvidence: () => ({ activeSessions: 0, oldestAgeMs: 0 }),
reconcileDurableState: async () => ({
workspaceQueryable: true,
agentQueryable: true,
agentReconciled: true,
}),
restartSelf: actuator,
ensureActivationBound: (cutoverId) => {
const current=old.record();
assert.ok(current);
if(current.activationBinding) return current.activationBinding;
const binding={
schema:CUTOVER_ACTIVATION_BINDING_SCHEMA,
cutoverId,
sourceCommit:current.expectedNewIdentity.sourceCommit,
buildId:current.expectedNewIdentity.buildId,
releaseSha256:"e".repeat(64),
releasePath:join(stateDir,"releases","release-target"),
pointerPath:join(stateDir,"current-release.json"),
boundAt:"2026-10-06T00:00:00.000Z",
};
return new CutoverStateStore(stateDir).recordActivationBinding(cutoverId,binding).record.activationBinding!;
},
probeBuildReady: (expected) => ({
buildReady: probeBuildReady,
verifiedBy: "build-identity-file",
verifiedAt: new Date().toISOString(),
expectedSourceCommit: expected.sourceCommit,
expectedBuildId: expected.buildId,
actualSourceCommit: expected.sourceCommit,
actualBuildId: expected.buildId,
detail: probeBuildReady ? "matches" : "mismatch",
}),
advance: async () => advanceOutcome,
});

await invoke(routes.get("POST /api/cutover/start")!, {
expectedSourceCommit: "new-source",
expectedBuildId: "new-build",
});
await invoke(routes.get("POST /api/cutover/drain")!, { cutoverId: "cutover-restart-http" });

const ungated = await invoke(routes.get("POST /api/cutover/restart")!, {
cutoverId: "cutover-restart-http",
});
assert.equal(ungated.statusCode, 409);
assert.equal(ungated.body.error.code, "CUTOVER_BUILD_NOT_READY");

const malformed = await invoke(routes.get("POST /api/cutover/restart")!, {
cutoverId: "cutover-restart-http",
buildReady: { verifiedBy: "op", verifiedAt: "not-a-date" },
});
assert.equal(malformed.statusCode, 409);

probeBuildReady = false;
const probeBlocked = await invoke(routes.get("POST /api/cutover/restart")!, {
cutoverId: "cutover-restart-http",
buildReady: { verifiedBy: "op", verifiedAt: new Date().toISOString() },
});
assert.equal(probeBlocked.statusCode, 409);
assert.equal(probeBlocked.body.error.code, "CUTOVER_BUILD_NOT_READY");
assert.equal(actuator.calls, 0);

probeBuildReady = true;
const ok = await invoke(routes.get("POST /api/cutover/restart")!, {
cutoverId: "cutover-restart-http",
buildReady: { verifiedBy: "op", verifiedAt: new Date().toISOString() },
});
assert.equal(ok.statusCode, 200);
assert.equal(ok.body.restart.scheduled, true);
assert.equal(ok.body.restart.alreadyRequested, false);
assert.equal(actuator.calls, 1);

const duplicate = await invoke(routes.get("POST /api/cutover/restart")!, {
cutoverId: "cutover-restart-http",
buildReady: { verifiedBy: "op", verifiedAt: new Date().toISOString() },
});
assert.equal(duplicate.statusCode, 200);
assert.equal(duplicate.body.restart.alreadyRequested, true);
assert.equal(duplicate.body.restart.scheduled, false);
assert.equal(actuator.calls, 1);

advanceOutcome = {
outcome: "blocked",
code: "CUTOVER_RECONCILIATION_REQUIRED",
reason: "no durable drain evidence",
};
const blocked = await invoke(routes.get("POST /api/cutover/advance")!, {});
assert.equal(blocked.statusCode, 409);
assert.equal(blocked.body.error.code, "CUTOVER_RECONCILIATION_REQUIRED");

advanceOutcome = {
outcome: "restart_scheduled",
reason: "scheduled",
scheduledFor: "com.example.devspace",
};
const advanced = await invoke(routes.get("POST /api/cutover/advance")!, {});
assert.equal(advanced.statusCode, 200);
assert.equal(advanced.body.outcome.outcome, "restart_scheduled");
assert.deepEqual([...f.handlers.keys()], ["GET /api/cutover/status"]);
const status = f.invokeStatus();
assert.equal(status.reconciliationRequired, false);
assert.equal((status.transportEvidence as { activeSessions: number }).activeSessions, 2);
assert.equal(f.controller.record(), undefined, "HTTP status cannot create a cutover");
const response = JSON.stringify(status).toLowerCase();
assert.equal(response.includes("token"), false);
assert.equal(response.includes("mcp-session-id"), false);
} finally {
rmSync(stateDir, { recursive: true, force: true });
f.close();
}
});

test("advance and restart routes fail closed when their dependencies are unavailable", async () => {
const stateDir = mkdtempSync(join(tmpdir(), "devspace-cutover-http-unavailable-"));
test("HTTP status still reports the durable cutover/reconciliation state", () => {
const f = fixture();
try {
const routes = new Map<string, Function>();
const app = {
get(path: string, ...handlers: Function[]) { routes.set(`GET ${path}`, handlers.at(-1)!); },
post(path: string, ...handlers: Function[]) { routes.set(`POST ${path}`, handlers.at(-1)!); },
};
const controller = new McpCutoverController(
new CutoverStateStore(stateDir, { newId: () => "cutover-unavailable" }),
{
serverInstanceId: "old-server",
sourceCommit: "old-source",
buildId: "old-build",
},
);
registerCutoverHttpRoutes(app, {
controller,
authenticate: (_req, _res, next) => next(),
transportEvidence: () => ({ activeSessions: 0, oldestAgeMs: 0 }),
reconcileDurableState: async () => ({
workspaceQueryable: true,
agentQueryable: true,
agentReconciled: true,
}),
});

await invoke(routes.get("POST /api/cutover/start")!, {
expectedSourceCommit: "new-source",
expectedBuildId: "new-build",
});
await invoke(routes.get("POST /api/cutover/drain")!, { cutoverId: "cutover-unavailable" });

const ungated = await invoke(routes.get("POST /api/cutover/restart")!, {
cutoverId: "cutover-unavailable",
buildReady: { verifiedBy: "op", verifiedAt: new Date().toISOString() },
});
assert.equal(ungated.statusCode, 409);
assert.match(ungated.body.error.message, /activation-binding owner|unavailable in this environment/);

const advance = await invoke(routes.get("POST /api/cutover/advance")!, {});
assert.equal(advance.statusCode, 409);
assert.match(advance.body.error.message, /unavailable in this environment/);
f.controller.begin({ sourceCommit: "approved-source", buildId: "approved-build" });
f.setActiveSessions(1);
const status = f.invokeStatus();
assert.equal(status.reconciliationRequired, true);
assert.equal((status.cutover as { cutoverId: string }).cutoverId, "readonly-cutover");
assert.equal((status.transportEvidence as { activeSessions: number }).activeSessions, 1);
assert.deepEqual([...f.handlers.keys()], ["GET /api/cutover/status"]);
} finally {
rmSync(stateDir, { recursive: true, force: true });
f.close();
}
});
11 changes: 6 additions & 5 deletions src/cutover-progression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import {
McpCutoverController,
type DurableReconciliationWitness,
CUTOVER_SAFE_TOOLS,
classifyCutoverEffect,
} from "./mcp-cutover.js";
import {
CutoverOrchestrator,
Expand Down Expand Up @@ -108,10 +108,11 @@ function createOrchestratorFixture(stateDir: string, options?: {
// G0 & G2: Surface and Inventory Verification
// ---------------------------------------------------------------------------

test("devspace#263 G0/G2: cutover_advance is registered in CUTOVER_SAFE_TOOLS allowlist", () => {
assert.equal(CUTOVER_SAFE_TOOLS.has("cutover_advance"), true);
assert.equal(CUTOVER_SAFE_TOOLS.has("cutover_status"), true);
assert.equal(CUTOVER_SAFE_TOOLS.has("capability_convergence_status"), true);
test("devspace#263 G0/G2: lifecycle tools remain coordinated without global admission allowlist", () => {
assert.equal(classifyCutoverEffect("cutover_advance"), "CUTOVER_CONTROL_OR_OBSERVATION");
assert.equal(classifyCutoverEffect("cutover_status"), "CUTOVER_CONTROL_OR_OBSERVATION");
assert.equal(classifyCutoverEffect("capability_convergence_status"), "INDEPENDENT");
assert.equal(classifyCutoverEffect("cutover_unknown_future_mutation"), "DEPLOYMENT_CONFLICT");
});

// ---------------------------------------------------------------------------
Expand Down
15 changes: 10 additions & 5 deletions src/mcp-cutover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { CutoverStateStore } from "./cutover-state.js";
import { CUTOVER_SAFE_TOOLS, CONSEQUENTIAL_MCP_TOOLS, classifyCutoverEffect, McpCutoverController, type DurableReconciliationWitness } from "./mcp-cutover.js";
import { CONSEQUENTIAL_MCP_TOOLS, classifyCutoverEffect, McpCutoverController, type DurableReconciliationWitness } from "./mcp-cutover.js";

const identity = (serverInstanceId: string, sourceCommit: string, buildId: string, capability = "cap") => ({
serverInstanceId,
Expand Down Expand Up @@ -565,13 +565,18 @@ test("#386: cutover fencing is deployment-effect scoped while unrelated mutation
// can be reconciled or terminalized. Removed governance tools must not remain
// as dead cutover allowlist entries.
for (const removedTool of ["coordination_recovery_request", "coordination_handoff_readback"]) {
assert.equal(CUTOVER_SAFE_TOOLS.has(removedTool), false);
assert.notEqual(classifyCutoverEffect(removedTool), "CUTOVER_CONTROL_OR_OBSERVATION");
assert.equal(CONSEQUENTIAL_MCP_TOOLS.has(removedTool), false);
}
for (const safeTool of CUTOVER_SAFE_TOOLS) {
for (const tool of [
"cutover_status", "cutover_start", "cutover_drain", "cutover_advance",
"cutover_reconcile", "cutover_finish", "cutover_recover",
"capability_convergence_status", "agent_status", "agent_reconcile",
"read", "workspace_inspect", "remote_writability_probe",
]) {
assert.doesNotThrow(
() => old.assertToolAllowed(safeTool),
`Expected safe tool ${safeTool} to be allowed during drain`,
() => old.assertToolAllowed(tool),
`Expected non-conflicting operation ${tool} to remain reachable`,
);
}

Expand Down
Loading
Loading