From 304a92e86a38a11aeea56f3f436e5186d1452e83 Mon Sep 17 00:00:00 2001 From: James3014 <185240413+James3014@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:51:26 +0800 Subject: [PATCH] fix: requalify issue 399 cutover authority cleanup on current main --- src/capability-generation-convergence.test.ts | 6 +- src/cutover-http.test.ts | 286 ++++------------- src/cutover-progression.test.ts | 11 +- src/mcp-cutover.test.ts | 15 +- src/mcp-cutover.ts | 297 +----------------- src/server.test.ts | 2 +- 6 files changed, 89 insertions(+), 528 deletions(-) diff --git a/src/capability-generation-convergence.test.ts b/src/capability-generation-convergence.test.ts index 36b6b237c..dc0d1b48b 100644 --- a/src/capability-generation-convergence.test.ts +++ b/src/capability-generation-convergence.test.ts @@ -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", () => { diff --git a/src/cutover-http.test.ts b/src/cutover-http.test.ts index 7feab076e..39e0f14be 100644 --- a/src/cutover-http.test.ts +++ b/src/cutover-http.test.ts @@ -3,31 +3,29 @@ 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(); +function fixture() { + const stateDir = mkdtempSync(join(tmpdir(), "devspace-cutover-http-readonly-")); + const handlers = new Map(); 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, @@ -35,230 +33,54 @@ test("cutover HTTP lifecycle is durable, exact-bound, and secret-free", async () 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; + }; + 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(); - 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(); - 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(); } }); diff --git a/src/cutover-progression.test.ts b/src/cutover-progression.test.ts index bfa45da9b..d6a33fda0 100644 --- a/src/cutover-progression.test.ts +++ b/src/cutover-progression.test.ts @@ -11,7 +11,7 @@ import { import { McpCutoverController, type DurableReconciliationWitness, - CUTOVER_SAFE_TOOLS, + classifyCutoverEffect, } from "./mcp-cutover.js"; import { CutoverOrchestrator, @@ -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"); }); // --------------------------------------------------------------------------- diff --git a/src/mcp-cutover.test.ts b/src/mcp-cutover.test.ts index 072568086..d572be504 100644 --- a/src/mcp-cutover.test.ts +++ b/src/mcp-cutover.test.ts @@ -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, @@ -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`, ); } diff --git a/src/mcp-cutover.ts b/src/mcp-cutover.ts index d98324e7d..3b69235b7 100644 --- a/src/mcp-cutover.ts +++ b/src/mcp-cutover.ts @@ -53,62 +53,22 @@ export interface CutoverIdentityComparison { } /** - * Authoritative fail-closed allowlist for tools allowed to execute during - * cutover drain or reconcile-only modes. Any tool not in this set is strictly - * blocked with CUTOVER_RECONCILIATION_REQUIRED. + * Only known cutover lifecycle operations get their own coordination-path + * admission. Ordinary inspection and unrelated work are never admitted by + * a global safe-tool allowlist; effect classification below owns the fence. */ -export const CUTOVER_SAFE_TOOLS: ReadonlySet = new Set([ - // Core cutover control & recovery +const CUTOVER_LIFECYCLE_TOOLS: ReadonlySet = new Set([ "cutover_status", - "cutover_start", // Guarded handler admits only authorized starts or exact replay. + "cutover_start", "cutover_drain", "cutover_restart_self", "cutover_reconcile", "cutover_finish", "cutover_recover", "cutover_repair_binding", - "capability_convergence_status", "cutover_advance", - - // Agent inspection & reconciliation - "agent_status", - "agent_reconcile", - "agent_list", - "agent_preflight", - - // Workspace, host, storage, and file inspection (read-only) - "workspace_inspect", - "storage_inventory", - "host_capability_snapshot", - "read", - "grep", - "glob", - "ls", - "show_changes", - - // Existing-task completion only; lifecycle admission still rejects new claims. - "chat_swarm_next", - "chat_swarm_submit", - "chat_swarm_status", - "chat_swarm_collect", - "chat_swarm_reconcile", - "chat_swarm_cancel", - - // Operation & command inspection - "operation_status", - "operation_reconcile", - "command_status", - "codex_goal_status", - - // Safe read/preflight inspection - "candidate_integration_readiness", - "remote_writability_probe", ]); -export function isCutoverSafeTool(toolName: string): boolean { - return CUTOVER_SAFE_TOOLS.has(toolName); -} - export type CutoverEffectDomain = | "CUTOVER_CONTROL_OR_OBSERVATION" | "DEPLOYMENT_CONFLICT" @@ -153,7 +113,7 @@ export function classifyCutoverEffect( toolName: string, target: CutoverEffectTarget = {}, ): CutoverEffectDomain { - if (CUTOVER_SAFE_TOOLS.has(toolName)) return "CUTOVER_CONTROL_OR_OBSERVATION"; + if (CUTOVER_LIFECYCLE_TOOLS.has(toolName)) return "CUTOVER_CONTROL_OR_OBSERVATION"; if (CUTOVER_DEPLOYMENT_CONFLICT_TOOLS.has(toolName)) return "DEPLOYMENT_CONFLICT"; // Unknown future cutover/deployment primitives fail closed by namespace. A // future unrelated tool does not inherit global deployment authority merely @@ -323,10 +283,13 @@ export class McpCutoverController { } assertToolAllowed(toolName: string, target: CutoverEffectTarget = {}): void { - const mode = this.mode(); - if (mode === "normal") return; + const record = this.store.get(); + if (!record || record.phase === "closed") return; if (classifyCutoverEffect(toolName, target) !== "DEPLOYMENT_CONFLICT") return; - throw new CutoverBlockedError(this.store.get()!.cutoverId, mode); + const observedGeneration = record.oldServerIdentity.serverInstanceId === this.currentIdentity.serverInstanceId + ? "drain" + : "reconcile-only"; + throw new CutoverBlockedError(record.cutoverId, observedGeneration); } status(transportEvidence: CutoverDrainEvidence): Record { @@ -674,242 +637,12 @@ interface RouteRegistrar { post(path: string, ...handlers: Array<(req: Request, res: Response, next: NextFunction) => unknown>): unknown; } -/** Authenticated controller recovery API. Responses contain aggregate metrics only. */ +/** Read-only legacy HTTP observability; mutations use the governed MCP lifecycle. */ export function registerCutoverHttpRoutes( app: RouteRegistrar, dependencies: CutoverHttpDependencies, ): void { - const { - controller, - authenticate, - transportEvidence, - reconcileDurableState, - restartSelf, - ensureActivationBound, - probeBuildReady, - advance, - } = dependencies; - - const requireUnbound=(req:Request,res:Response,next:NextFunction)=>{ - try {assertLegacyCutoverUnbound(controller.record());next();} catch(error){sendCutoverError(res,error);} - }; - - app.get("/api/cutover/status", authenticate, (_req, res) => { - res.json(controller.status(transportEvidence())); - }); - - app.post("/api/cutover/start", authenticate, requireUnbound, (req, res) => { - try { - const body = objectBody(req.body); - const sourceCommit = requiredString(body.expectedSourceCommit, "expectedSourceCommit"); - const buildId = requiredString(body.expectedBuildId, "expectedBuildId"); - const capabilityManifestSha256 = optionalString(body.expectedCapabilityManifestSha256) - ?? controller.currentIdentity.capabilityManifestSha256; - const record = controller.begin( - { sourceCommit, buildId, ...(capabilityManifestSha256 ? { capabilityManifestSha256 } : {}) }, - optionalString(body.expiresAt), - ); - res.status(201).json({ cutover: record, mode: controller.mode() }); - } catch (error) { - sendCutoverError(res, error); - } - }); - - app.post("/api/cutover/drain", authenticate, requireUnbound, (req, res) => { - try { - const cutoverId = requiredString(objectBody(req.body).cutoverId, "cutoverId"); - const evidence=transportEvidence(); - assertLegacyCutoverUnbound(controller.record()); - const record = controller.recordDrain(cutoverId, evidence); - res.json({ cutover: record, mode: controller.mode() }); - } catch (error) { - sendCutoverError(res, error); - } - }); - - app.post("/api/cutover/restart", authenticate, requireUnbound, async (req, res) => { - try { - const body = objectBody(req.body); - const cutoverId = requiredString(body.cutoverId, "cutoverId"); - if (body.buildReady === undefined) { - throw new CutoverBuildNotReadyError(`Restart request ${cutoverId} lacks a build-ready attestation.`); - } - const buildReady = buildReadyReceipt(body.buildReady); - if (!ensureActivationBound) { - throw new CutoverStateError( - "Cutover restart is unavailable without a canonical activation-binding owner.", - ); - } - ensureActivationBound(cutoverId); - const request = controller.requestRestart(cutoverId, buildReady); - const restart = request.record.restartRequest; - if (!restart?.buildReady) { - throw new CutoverBuildNotReadyError( - `Restart request ${cutoverId} lacks a build-ready attestation.`, - ); - } - if (restart.restartScheduledAt) { - res.status(200).json({ - cutover: request.record, - mode: controller.mode(), - restart: { scheduled: false, alreadyRequested: true, scheduleBlocked: false }, - }); - return; - } - if (probeBuildReady) { - const probe = await probeBuildReady(request.record.expectedNewIdentity); - if (!probe.buildReady) { - throw new CutoverBuildNotReadyError(probe.detail); - } - } - if (!restartSelf) { - throw new CutoverStateError("Restart scheduling is unavailable in this environment."); - } - assertLegacyCutoverUnbound(controller.record()); - const mark = controller.markRestartScheduled(cutoverId); - const scheduled = mark.newlyScheduled ? restartSelf.schedule() : undefined; - if (!scheduled) { - res.status(200).json({ - cutover: mark.record, - mode: controller.mode(), - restart: { scheduled: false, alreadyRequested: true, scheduleBlocked: false }, - }); - return; - } - res.status(200).json({ - cutover: mark.record, - mode: controller.mode(), - restart: { - scheduled: true, - alreadyRequested: false, - actuator: "launchd-self", - serviceLabel: restartSelf.serviceLabel, - launchdTarget: restartSelf.launchdTarget, - }, - }); - } catch (error) { - sendCutoverError(res, error); - } - }); - - app.post("/api/cutover/advance", authenticate, requireUnbound, async (_req, res) => { - if (!advance) { - sendCutoverError( - res, - new CutoverStateError("Cutover orchestration is unavailable in this environment."), - ); - return; - } - try { - const outcome = await advance(); - if (outcome.outcome === "blocked") { - res.status(409).json({ - error: { code: outcome.code, message: outcome.reason }, - outcome, - }); - return; - } - res.status(200).json({ outcome }); - } catch (error) { - sendCutoverError(res, error); - } - }); - - app.post("/api/cutover/recover", authenticate, requireUnbound, (req, res) => { - try { - const body = objectBody(req.body); - const cutoverId = requiredString(body.cutoverId, "cutoverId"); - const sourceCommit = requiredString(body.expectedSourceCommit, "expectedSourceCommit"); - const buildId = requiredString(body.expectedBuildId, "expectedBuildId"); - const capabilityManifestSha256 = optionalString(body.expectedCapabilityManifestSha256); - const recovered = controller.recoverCutover({ - cutoverId, - expectedNewIdentity: { - sourceCommit, - buildId, - ...(capabilityManifestSha256 ? { capabilityManifestSha256 } : {}), - }, - ...(optionalString(body.expiresAt) ? { expiresAt: body.expiresAt as string } : {}), - }); - res.json({ - terminal: recovered.terminal, - successor: recovered.successor, - newlyRecovered: recovered.newlyRecovered, - mode: controller.mode(), - }); - } catch (error) { - sendCutoverError(res, error); - } - }); - - app.post("/api/cutover/finish", authenticate, requireUnbound, async (req, res) => { - try { - const body = objectBody(req.body); - const cutoverId = requiredString(body.cutoverId, "cutoverId"); - const workspaceId = requiredString(body.workspaceId, "workspaceId"); - const agentId = requiredString(body.agentId, "agentId"); - const record = await controller.finish( - cutoverId, - () => reconcileDurableState({ workspaceId, agentId }), - ); - res.json({ cutover: record, mode: controller.mode() }); - } catch (error) { - sendCutoverError(res, error); - } - }); -} - -function objectBody(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new CutoverStateError("Request body must be a JSON object."); - } - return value as Record; -} - -function requiredString(value: unknown, field: string): string { - if (typeof value !== "string" || value.trim() === "") { - throw new CutoverStateError(`${field} must be a non-empty string.`); - } - return value; -} - -function optionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() !== "" ? value : undefined; -} - -function buildReadyReceipt(value: unknown): BuildReadyReceipt { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new CutoverStateError("buildReady must be a build-ready attestation object."); - } - const receipt = value as Partial; - if ( - typeof receipt.verifiedBy !== "string" || - receipt.verifiedBy.trim() === "" || - typeof receipt.verifiedAt !== "string" || - !Number.isFinite(Date.parse(receipt.verifiedAt)) || - (receipt.evidence !== undefined && typeof receipt.evidence !== "string") - ) { - throw new CutoverStateError("buildReady must contain a non-empty verifiedBy and an ISO verifiedAt."); - } - return { - verifiedBy: receipt.verifiedBy, - verifiedAt: receipt.verifiedAt, - ...(receipt.evidence !== undefined ? { evidence: receipt.evidence } : {}), - }; -} - -function sendCutoverError(res: Response, error: unknown): void { - const message = error instanceof Error ? error.message : String(error); - if (error instanceof CutoverBuildNotReadyError) { - res.status(409).json({ - error: { code: "CUTOVER_BUILD_NOT_READY", message }, - }); - return; - } - res.status(error instanceof CutoverStateError ? 409 : 500).json({ - error: { - code: error instanceof CutoverStateError ? "CUTOVER_RECONCILIATION_REQUIRED" : "CUTOVER_INTERNAL_ERROR", - message, - }, + app.get("/api/cutover/status", dependencies.authenticate, (_req, res) => { + res.json(dependencies.controller.status(dependencies.transportEvidence())); }); } diff --git a/src/server.test.ts b/src/server.test.ts index 3645404c6..3f08863cd 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -28,7 +28,7 @@ import { resolveDurableReconciliationWitnessFromInventory, } from "./server.js"; import { CUTOVER_ACTIVATION_BINDING_SCHEMA, CutoverStateStore } from "./cutover-state.js"; -import { CONSEQUENTIAL_MCP_TOOLS, CUTOVER_SAFE_TOOLS, McpCutoverController } from "./mcp-cutover.js"; +import { CONSEQUENTIAL_MCP_TOOLS, McpCutoverController } from "./mcp-cutover.js"; import { LocalAgentStore } from "./local-agent-store.js"; import { LocalAgentSessionManager } from "./local-agent-sessions.js"; import { SqliteWorkspaceStore } from "./workspace-store.js";