diff --git a/packages/mcp-server/src/workflow-tools.test.ts b/packages/mcp-server/src/workflow-tools.test.ts index b03d5e2b9..8435203c6 100644 --- a/packages/mcp-server/src/workflow-tools.test.ts +++ b/packages/mcp-server/src/workflow-tools.test.ts @@ -384,6 +384,116 @@ describe("workflow MCP tools", () => { } }); + it("gsd_requirement_save opens the DB before inline requirement writes", async () => { + const base = makeTmpBase(); + try { + const server = makeMockServer(); + registerWorkflowTools(server as any); + const requirementTool = server.tools.find((t) => t.name === "gsd_requirement_save"); + assert.ok(requirementTool, "requirement tool should be registered"); + + closeDatabase(); + + const result = await requirementTool!.handler({ + projectDir: base, + class: "operability", + description: "Inline MCP requirement save regression", + why: "Reproduce missing ensureDbOpen in workflow-tools", + source: "user", + status: "active", + primary_owner: "M010/S10", + validation: "n/a", + }); + + assert.match((result as any).content[0].text as string, /Saved requirement R\d+/); + assert.ok(existsSync(join(base, ".gsd", "REQUIREMENTS.md")), "REQUIREMENTS.md should be written to disk"); + const row = _getAdapter()! + .prepare("SELECT id, class, description FROM requirements WHERE description = ?") + .get("Inline MCP requirement save regression") as Record | undefined; + assert.ok(row, "requirement should be written to the database"); + assert.equal(row["class"], "operability"); + } finally { + cleanup(base); + } + }); + + it("gsd_plan_task reopens the DB before inline task planning writes", async () => { + const base = makeTmpBase(); + try { + const server = makeMockServer(); + registerWorkflowTools(server as any); + const milestoneTool = server.tools.find((t) => t.name === "gsd_plan_milestone"); + const sliceTool = server.tools.find((t) => t.name === "gsd_plan_slice"); + const taskTool = server.tools.find((t) => t.name === "gsd_plan_task"); + assert.ok(milestoneTool, "milestone planning tool should be registered"); + assert.ok(sliceTool, "slice planning tool should be registered"); + assert.ok(taskTool, "task planning tool should be registered"); + + await milestoneTool!.handler({ + projectDir: base, + milestoneId: "M010", + title: "Inline task planning DB reopen", + vision: "Seed a slice, close the DB, then plan another task inline.", + slices: [ + { + sliceId: "S10", + title: "Inline task planning", + risk: "medium", + depends: [], + demo: "Inline gsd_plan_task reopens the DB after it was closed.", + goal: "Preserve MCP task planning after the DB adapter is closed.", + successCriteria: "The second task plan persists after a closed DB is reopened.", + proofLevel: "integration", + integrationClosure: "The inline MCP handler reopens the DB before planning.", + observabilityImpact: "workflow-tools MCP tests cover the inline reopen path.", + }, + ], + }); + await sliceTool!.handler({ + projectDir: base, + milestoneId: "M010", + sliceId: "S10", + goal: "Create the initial slice plan before closing the DB.", + tasks: [ + { + taskId: "T10", + title: "Seed existing task", + description: "Create the initial task plan before closing the DB.", + estimate: "5m", + files: ["packages/mcp-server/src/workflow-tools.ts"], + verify: "node --test", + inputs: ["M010-ROADMAP.md"], + expectedOutput: ["T10-PLAN.md"], + }, + ], + }); + + closeDatabase(); + + const result = await taskTool!.handler({ + projectDir: base, + milestoneId: "M010", + sliceId: "S10", + taskId: "T11", + title: "Reopen and plan", + description: "Exercise the inline plan-task path after the DB was closed.", + estimate: "5m", + files: ["packages/mcp-server/src/workflow-tools.ts"], + verify: "node --test", + inputs: ["M010-ROADMAP.md", "S10-PLAN.md"], + expectedOutput: ["T11-PLAN.md"], + }); + + assert.match((result as any).content[0].text as string, /Planned task T11/); + assert.ok( + existsSync(join(base, ".gsd", "milestones", "M010", "slices", "S10", "tasks", "T11-PLAN.md")), + "T11 plan should be written after reopening the DB", + ); + } finally { + cleanup(base); + } + }); + it("gsd_replan_slice and gsd_slice_replan work end-to-end", async () => { const base = makeTmpBase(); try { diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts index 130eac7eb..9abbddbeb 100644 --- a/packages/mcp-server/src/workflow-tools.ts +++ b/packages/mcp-server/src/workflow-tools.ts @@ -244,6 +244,10 @@ type WorkflowWriteGateModule = { ) => { block: boolean; reason?: string }; }; +type WorkflowDbBootstrapModule = { + ensureDbOpen: (basePath?: string) => Promise; +}; + let workflowToolExecutorsPromise: Promise | null = null; let workflowExecutionQueue: Promise = Promise.resolve(); let workflowWriteGatePromise: Promise | null = null; @@ -506,6 +510,22 @@ async function runSerializedWorkflowOperation(fn: () => Promise): Promise< } } +async function runSerializedWorkflowDbOperation( + projectDir: string, + fn: () => Promise, +): Promise { + return runSerializedWorkflowOperation(async () => { + const { ensureDbOpen } = await importLocalModule( + "../../../src/resources/extensions/gsd/bootstrap/dynamic-tools.js", + ); + const dbAvailable = await ensureDbOpen(projectDir); + if (!dbAvailable) { + throw new Error("GSD database is not available"); + } + return fn(); + }); +} + async function enforceWorkflowWriteGate( toolName: string, projectDir: string, @@ -969,7 +989,7 @@ export function registerWorkflowTools(server: McpToolServer): void { const parsed = parseWorkflowArgs(decisionSaveSchema, args); const { projectDir, ...params } = parsed; await enforceWorkflowWriteGate("gsd_decision_save", projectDir); - const result = await runSerializedWorkflowOperation(async () => { + const result = await runSerializedWorkflowDbOperation(projectDir, async () => { const { saveDecisionToDb } = await importLocalModule("../../../src/resources/extensions/gsd/db-writer.js"); return saveDecisionToDb(params, projectDir); }); @@ -985,7 +1005,7 @@ export function registerWorkflowTools(server: McpToolServer): void { const parsed = parseWorkflowArgs(decisionSaveSchema, args); const { projectDir, ...params } = parsed; await enforceWorkflowWriteGate("gsd_decision_save", projectDir); - const result = await runSerializedWorkflowOperation(async () => { + const result = await runSerializedWorkflowDbOperation(projectDir, async () => { const { saveDecisionToDb } = await importLocalModule("../../../src/resources/extensions/gsd/db-writer.js"); return saveDecisionToDb(params, projectDir); }); @@ -1001,7 +1021,7 @@ export function registerWorkflowTools(server: McpToolServer): void { const parsed = parseWorkflowArgs(requirementUpdateSchema, args); const { projectDir, id, ...updates } = parsed; await enforceWorkflowWriteGate("gsd_requirement_update", projectDir); - await runSerializedWorkflowOperation(async () => { + await runSerializedWorkflowDbOperation(projectDir, async () => { const { updateRequirementInDb } = await importLocalModule("../../../src/resources/extensions/gsd/db-writer.js"); return updateRequirementInDb(id, updates, projectDir); }); @@ -1017,7 +1037,7 @@ export function registerWorkflowTools(server: McpToolServer): void { const parsed = parseWorkflowArgs(requirementUpdateSchema, args); const { projectDir, id, ...updates } = parsed; await enforceWorkflowWriteGate("gsd_requirement_update", projectDir); - await runSerializedWorkflowOperation(async () => { + await runSerializedWorkflowDbOperation(projectDir, async () => { const { updateRequirementInDb } = await importLocalModule("../../../src/resources/extensions/gsd/db-writer.js"); return updateRequirementInDb(id, updates, projectDir); }); @@ -1033,7 +1053,7 @@ export function registerWorkflowTools(server: McpToolServer): void { const parsed = parseWorkflowArgs(requirementSaveSchema, args); const { projectDir, ...params } = parsed; await enforceWorkflowWriteGate("gsd_requirement_save", projectDir); - const result = await runSerializedWorkflowOperation(async () => { + const result = await runSerializedWorkflowDbOperation(projectDir, async () => { const { saveRequirementToDb } = await importLocalModule("../../../src/resources/extensions/gsd/db-writer.js"); return saveRequirementToDb(params, projectDir); }); @@ -1049,7 +1069,7 @@ export function registerWorkflowTools(server: McpToolServer): void { const parsed = parseWorkflowArgs(requirementSaveSchema, args); const { projectDir, ...params } = parsed; await enforceWorkflowWriteGate("gsd_requirement_save", projectDir); - const result = await runSerializedWorkflowOperation(async () => { + const result = await runSerializedWorkflowDbOperation(projectDir, async () => { const { saveRequirementToDb } = await importLocalModule("../../../src/resources/extensions/gsd/db-writer.js"); return saveRequirementToDb(params, projectDir); }); @@ -1064,7 +1084,7 @@ export function registerWorkflowTools(server: McpToolServer): void { async (args: Record) => { const { projectDir } = parseWorkflowArgs(milestoneGenerateIdSchema, args); await enforceWorkflowWriteGate("gsd_milestone_generate_id", projectDir); - const id = await runSerializedWorkflowOperation(async () => { + const id = await runSerializedWorkflowDbOperation(projectDir, async () => { const { claimReservedId, findMilestoneIds, @@ -1092,7 +1112,7 @@ export function registerWorkflowTools(server: McpToolServer): void { async (args: Record) => { const { projectDir } = parseWorkflowArgs(milestoneGenerateIdSchema, args); await enforceWorkflowWriteGate("gsd_milestone_generate_id", projectDir); - const id = await runSerializedWorkflowOperation(async () => { + const id = await runSerializedWorkflowDbOperation(projectDir, async () => { const { claimReservedId, findMilestoneIds, @@ -1147,7 +1167,7 @@ export function registerWorkflowTools(server: McpToolServer): void { const parsed = parseWorkflowArgs(planTaskSchema, args); const { projectDir, ...params } = parsed; await enforceWorkflowWriteGate("gsd_plan_task", projectDir, params.milestoneId); - const result = await runSerializedWorkflowOperation(async () => { + const result = await runSerializedWorkflowDbOperation(projectDir, async () => { const { handlePlanTask } = await importLocalModule("../../../src/resources/extensions/gsd/tools/plan-task.js"); return handlePlanTask(params, projectDir); }); @@ -1168,7 +1188,7 @@ export function registerWorkflowTools(server: McpToolServer): void { const parsed = parseWorkflowArgs(planTaskSchema, args); const { projectDir, ...params } = parsed; await enforceWorkflowWriteGate("gsd_plan_task", projectDir, params.milestoneId); - const result = await runSerializedWorkflowOperation(async () => { + const result = await runSerializedWorkflowDbOperation(projectDir, async () => { const { handlePlanTask } = await importLocalModule("../../../src/resources/extensions/gsd/tools/plan-task.js"); return handlePlanTask(params, projectDir); }); @@ -1228,7 +1248,7 @@ export function registerWorkflowTools(server: McpToolServer): void { async (args: Record) => { const { projectDir, milestoneId, sliceId, reason } = parseWorkflowArgs(skipSliceSchema, args); await enforceWorkflowWriteGate("gsd_skip_slice", projectDir, milestoneId); - await runSerializedWorkflowOperation(async () => { + await runSerializedWorkflowDbOperation(projectDir, async () => { const { getSlice, updateSliceStatus } = await importLocalModule("../../../src/resources/extensions/gsd/gsd-db.js"); const { invalidateStateCache } = await importLocalModule("../../../src/resources/extensions/gsd/state.js"); const { rebuildState } = await importLocalModule("../../../src/resources/extensions/gsd/doctor.js");