diff --git a/packages/mcp-server/src/workflow-tools.test.ts b/packages/mcp-server/src/workflow-tools.test.ts index c4dc15387..0dfdbabd6 100644 --- a/packages/mcp-server/src/workflow-tools.test.ts +++ b/packages/mcp-server/src/workflow-tools.test.ts @@ -84,6 +84,7 @@ describe("workflow MCP tools", () => { registerWorkflowTools(server as any); const tool = server.tools.find((t) => t.name === "gsd_summary_save"); assert.ok(tool, "summary tool should be registered"); + const originalCwd = process.cwd(); const result = await tool!.handler({ projectDir: base, @@ -95,6 +96,7 @@ describe("workflow MCP tools", () => { const text = (result as any).content[0].text as string; assert.match(text, /Saved SUMMARY artifact/); + assert.equal(process.cwd(), originalCwd, "workflow MCP tools should not mutate process.cwd"); assert.ok( existsSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md")), "summary file should exist on disk", diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts index 4784863fa..d1503f920 100644 --- a/packages/mcp-server/src/workflow-tools.ts +++ b/packages/mcp-server/src/workflow-tools.ts @@ -10,7 +10,7 @@ const SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", type WorkflowToolExecutors = { SUPPORTED_SUMMARY_ARTIFACT_TYPES: readonly string[]; - executeMilestoneStatus: (params: { milestoneId: string }) => Promise; + executeMilestoneStatus: (params: { milestoneId: string }, basePath?: string) => Promise; executePlanMilestone: ( params: { milestoneId: string; @@ -185,6 +185,7 @@ type WorkflowToolExecutors = { rationale: string; findings?: string; }, + basePath?: string, ) => Promise; executeSummarySave: ( params: { @@ -218,6 +219,7 @@ type WorkflowToolExecutors = { }; let workflowToolExecutorsPromise: Promise | null = null; +let workflowExecutionQueue: Promise = Promise.resolve(); function toFileUrl(modulePath: string): string { return pathToFileURL(resolve(modulePath)).href; @@ -272,13 +274,20 @@ interface McpToolServer { ): unknown; } -async function withProjectDir(projectDir: string, fn: () => Promise): Promise { - const originalCwd = process.cwd(); +async function runSerializedWorkflowOperation(fn: () => Promise): Promise { + // The shared DB adapter and workflow log base path are process-global, so + // workflow MCP mutations must not overlap within a single server process. + const prior = workflowExecutionQueue; + let release!: () => void; + workflowExecutionQueue = new Promise((resolve) => { + release = resolve; + }); + + await prior; try { - process.chdir(projectDir); return await fn(); } finally { - process.chdir(originalCwd); + release(); } } @@ -316,7 +325,7 @@ async function handleTaskComplete( >; }; const { executeTaskComplete } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => + return runSerializedWorkflowOperation(() => executeTaskComplete( { taskId, @@ -342,7 +351,7 @@ async function handleSliceComplete( args: Record, ): Promise { const { executeSliceComplete } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => executeSliceComplete(args as any, projectDir)); + return runSerializedWorkflowOperation(() => executeSliceComplete(args as any, projectDir)); } async function handleReplanSlice( @@ -350,7 +359,7 @@ async function handleReplanSlice( args: Record, ): Promise { const { executeReplanSlice } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => executeReplanSlice(args as any, projectDir)); + return runSerializedWorkflowOperation(() => executeReplanSlice(args as any, projectDir)); } async function handleCompleteMilestone( @@ -358,7 +367,7 @@ async function handleCompleteMilestone( args: Record, ): Promise { const { executeCompleteMilestone } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => executeCompleteMilestone(args as any, projectDir)); + return runSerializedWorkflowOperation(() => executeCompleteMilestone(args as any, projectDir)); } async function handleValidateMilestone( @@ -366,7 +375,7 @@ async function handleValidateMilestone( args: Record, ): Promise { const { executeValidateMilestone } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => executeValidateMilestone(args as any, projectDir)); + return runSerializedWorkflowOperation(() => executeValidateMilestone(args as any, projectDir)); } async function handleReassessRoadmap( @@ -374,7 +383,7 @@ async function handleReassessRoadmap( args: Record, ): Promise { const { executeReassessRoadmap } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => executeReassessRoadmap(args as any, projectDir)); + return runSerializedWorkflowOperation(() => executeReassessRoadmap(args as any, projectDir)); } async function handleSaveGateResult( @@ -382,7 +391,7 @@ async function handleSaveGateResult( args: Record, ): Promise { const { executeSaveGateResult } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => executeSaveGateResult(args as any)); + return runSerializedWorkflowOperation(() => executeSaveGateResult(args as any, projectDir)); } const completeMilestoneSchema = { @@ -513,7 +522,7 @@ export function registerWorkflowTools(server: McpToolServer): void { async (args: Record) => { const { projectDir, ...params } = args as { projectDir: string } & Record; const { executePlanMilestone } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => executePlanMilestone(params as any, projectDir)); + return runSerializedWorkflowOperation(() => executePlanMilestone(params as any, projectDir)); }, ); @@ -544,7 +553,7 @@ export function registerWorkflowTools(server: McpToolServer): void { async (args: Record) => { const { projectDir, ...params } = args as { projectDir: string } & Record; const { executePlanSlice } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => executePlanSlice(params as any, projectDir)); + return runSerializedWorkflowOperation(() => executePlanSlice(params as any, projectDir)); }, ); @@ -759,7 +768,7 @@ export function registerWorkflowTools(server: McpToolServer): void { content: string; }; const { executeSummarySave } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => + return runSerializedWorkflowOperation(() => executeSummarySave({ milestone_id, slice_id, task_id, artifact_type, content }, projectDir), ); }, @@ -839,7 +848,7 @@ export function registerWorkflowTools(server: McpToolServer): void { async (args: Record) => { const { projectDir, milestoneId } = args as { projectDir: string; milestoneId: string }; const { executeMilestoneStatus } = await getWorkflowToolExecutors(); - return withProjectDir(projectDir, () => executeMilestoneStatus({ milestoneId })); + return runSerializedWorkflowOperation(() => executeMilestoneStatus({ milestoneId }, projectDir)); }, ); } diff --git a/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts b/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts index 8061c1b20..b4371f483 100644 --- a/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts @@ -75,12 +75,9 @@ export function resolveProjectRootDbPath(basePath: string): string { return join(basePath, ".gsd", "gsd.db"); } -export async function ensureDbOpen(): Promise { +export async function ensureDbOpen(basePath: string = process.cwd()): Promise { try { const db = await import("../gsd-db.js"); - if (db.isDbAvailable()) return true; - - const basePath = process.cwd(); const dbPath = resolveProjectRootDbPath(basePath); const gsdDir = join(basePath, ".gsd"); @@ -194,4 +191,3 @@ export function registerDynamicTools(pi: ExtensionAPI): void { }, } as any); } - diff --git a/src/resources/extensions/gsd/tests/ensure-db-open.test.ts b/src/resources/extensions/gsd/tests/ensure-db-open.test.ts index d68438cf4..73a0cdec3 100644 --- a/src/resources/extensions/gsd/tests/ensure-db-open.test.ts +++ b/src/resources/extensions/gsd/tests/ensure-db-open.test.ts @@ -77,6 +77,36 @@ describe('ensure-db-open', () => { } }); + test('ensureDbOpen: explicit basePath opens target project without cwd override', async () => { + const tmpDir = makeTmpDir(); + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'DECISIONS.md'), `# Decisions + +| # | When | Scope | Decision | Choice | Rationale | Revisable | +|---|------|-------|----------|--------|-----------|-----------| +| D777 | M001 | architecture | Use explicit basePath | BasePath | Avoid cwd coupling | Yes | +`); + + try { + closeDatabase(); + } catch { /* ok */ } + + const originalCwd = process.cwd(); + try { + const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts'); + const result = await ensureDbOpen(tmpDir); + + assert.ok(result === true, 'ensureDbOpen should honor explicit basePath'); + assert.equal(process.cwd(), originalCwd, 'ensureDbOpen should not mutate process.cwd'); + assert.ok(isDbAvailable(), 'DB should be available after explicit open'); + assert.ok(getDecisionById('D777') !== null, 'explicit basePath DB should be opened'); + } finally { + closeDatabase(); + cleanupDir(tmpDir); + } + }); + // ═══════════════════════════════════════════════════════════════════════════ // ensureDbOpen returns false when no .gsd/ exists // ═══════════════════════════════════════════════════════════════════════════ @@ -159,6 +189,42 @@ describe('ensure-db-open', () => { } }); + test('ensureDbOpen: switches open database when basePath changes', async () => { + const firstDir = makeTmpDir(); + const secondDir = makeTmpDir(); + fs.mkdirSync(path.join(firstDir, '.gsd'), { recursive: true }); + fs.mkdirSync(path.join(secondDir, '.gsd'), { recursive: true }); + fs.writeFileSync(path.join(firstDir, '.gsd', 'DECISIONS.md'), `# Decisions + +| # | When | Scope | Decision | Choice | Rationale | Revisable | +|---|------|-------|----------|--------|-----------|-----------| +| D101 | M001 | architecture | First DB | First | First rationale | Yes | +`); + fs.writeFileSync(path.join(secondDir, '.gsd', 'DECISIONS.md'), `# Decisions + +| # | When | Scope | Decision | Choice | Rationale | Revisable | +|---|------|-------|----------|--------|-----------|-----------| +| D202 | M001 | architecture | Second DB | Second | Second rationale | Yes | +`); + + try { + closeDatabase(); + } catch { /* ok */ } + + try { + const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts'); + assert.equal(await ensureDbOpen(firstDir), true); + assert.ok(getDecisionById('D101') !== null, 'first DB should be active'); + assert.equal(await ensureDbOpen(secondDir), true); + assert.ok(getDecisionById('D202') !== null, 'second DB should be active after switch'); + assert.equal(getDecisionById('D101'), null, 'first DB should no longer be active after switch'); + } finally { + closeDatabase(); + cleanupDir(firstDir); + cleanupDir(secondDir); + } + }); + // ═══════════════════════════════════════════════════════════════════════════ }); diff --git a/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts b/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts index a10494685..06c01c419 100644 --- a/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts @@ -153,7 +153,7 @@ test("executeMilestoneStatus returns milestone metadata and slice counts", async "INSERT OR REPLACE INTO tasks (milestone_id, slice_id, id, title, status) VALUES (?, ?, ?, ?, ?)", ).run("M001", "S01", "T01", "Task T01", "pending"); - const result = await inProjectDir(base, () => executeMilestoneStatus({ milestoneId: "M001" })); + const result = await inProjectDir(base, () => executeMilestoneStatus({ milestoneId: "M001" }, base)); const parsed = JSON.parse(result.content[0].text); assert.equal(parsed.milestoneId, "M001"); @@ -495,7 +495,7 @@ test("executeSaveGateResult validates inputs and persists verdicts", async () => verdict: "pass", rationale: "Looks good.", findings: "No issues found.", - })); + }, base)); assert.equal(result.details.operation, "save_gate_result"); const db = _getAdapter(); diff --git a/src/resources/extensions/gsd/tools/workflow-tool-executors.ts b/src/resources/extensions/gsd/tools/workflow-tool-executors.ts index 92a43133e..036c0b326 100644 --- a/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +++ b/src/resources/extensions/gsd/tools/workflow-tool-executors.ts @@ -52,7 +52,7 @@ export async function executeSummarySave( params: SummarySaveParams, basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available. Cannot save artifact." }], @@ -157,7 +157,7 @@ export async function executeTaskComplete( params: TaskCompleteParams, basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available. Cannot complete task." }], @@ -201,7 +201,7 @@ export async function executeSliceComplete( params: SliceCompleteExecutorParams, basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available. Cannot complete slice." }], @@ -282,7 +282,7 @@ export async function executeCompleteMilestone( params: CompleteMilestoneExecutorParams, basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available. Cannot complete milestone." }], @@ -320,7 +320,7 @@ export async function executeValidateMilestone( params: ValidateMilestoneExecutorParams, basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available. Cannot validate milestone." }], @@ -358,7 +358,7 @@ export async function executeReassessRoadmap( params: ReassessRoadmapExecutorParams, basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available. Cannot reassess roadmap." }], @@ -395,8 +395,9 @@ export async function executeReassessRoadmap( export async function executeSaveGateResult( params: SaveGateResultParams, + basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available." }], @@ -449,7 +450,7 @@ export async function executePlanMilestone( params: PlanMilestoneExecutorParams, basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available. Cannot plan milestone." }], @@ -486,7 +487,7 @@ export async function executePlanSlice( params: PlanSliceExecutorParams, basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available. Cannot plan slice." }], @@ -525,7 +526,7 @@ export async function executeReplanSlice( params: ReplanSliceExecutorParams, basePath: string = process.cwd(), ): Promise { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available. Cannot replan slice." }], @@ -566,9 +567,10 @@ export interface MilestoneStatusParams { export async function executeMilestoneStatus( params: MilestoneStatusParams, + basePath: string = process.cwd(), ): Promise { try { - const dbAvailable = await ensureDbOpen(); + const dbAvailable = await ensureDbOpen(basePath); if (!dbAvailable) { return { content: [{ type: "text", text: "Error: GSD database is not available." }],