test(S02/T02): Implement DB-backed gsd_plan_slice and gsd_plan_task han…
- .gsd/milestones/M001/slices/S02/S02-PLAN.md - src/resources/extensions/gsd/tools/plan-slice.ts - src/resources/extensions/gsd/tools/plan-task.ts - src/resources/extensions/gsd/bootstrap/db-tools.ts - src/resources/extensions/gsd/gsd-db.ts - src/resources/extensions/gsd/tests/plan-slice.test.ts - src/resources/extensions/gsd/tests/plan-task.test.ts
This commit is contained in:
parent
752b26d542
commit
a380b8ed77
9 changed files with 883 additions and 1 deletions
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts`
|
||||
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/markdown-renderer.test.ts src/resources/extensions/gsd/tests/auto-recovery.test.ts src/resources/extensions/gsd/tests/prompt-contracts.test.ts --test-name-pattern="plan-slice|plan-task|renderPlanFromDb|renderTaskPlanFromDb|task plan|DB-backed planning"`
|
||||
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts --test-name-pattern="validation failed|render failed|cache|missing parent"`
|
||||
|
||||
## Observability / Diagnostics
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ I’m splitting this into three tasks because there are three distinct failure b
|
|||
- Do: Implement `renderPlanFromDb()` and `renderTaskPlanFromDb()` using existing DB query helpers, emit slice/task markdown that preserves `parsePlan()` and `parseTaskPlanFile()` expectations, include conservative task-plan frontmatter (`estimated_steps`, `estimated_files`, `skills_used`), and add tests that prove rendered slice plans plus task plan files satisfy `verifyExpectedArtifact("plan-slice", ...)`.
|
||||
- Verify: `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/markdown-renderer.test.ts src/resources/extensions/gsd/tests/auto-recovery.test.ts --test-name-pattern="renderPlanFromDb|renderTaskPlanFromDb|plan-slice|task plan"`
|
||||
- Done when: DB rows can be rendered into `S##-PLAN.md` and `tasks/T##-PLAN.md` files that parse cleanly and pass the existing plan-slice runtime artifact checks.
|
||||
- [ ] **T02: Implement and register gsd_plan_slice and gsd_plan_task** `est:1.5h`
|
||||
- [x] **T02: Implement and register gsd_plan_slice and gsd_plan_task** `est:1.5h`
|
||||
- Why: This delivers the actual S02 capability: flat DB-backed planning tools for slices and tasks that write structured planning state, render truthful markdown, and clear stale caches after success.
|
||||
- Files: `src/resources/extensions/gsd/tools/plan-slice.ts`, `src/resources/extensions/gsd/tools/plan-task.ts`, `src/resources/extensions/gsd/bootstrap/db-tools.ts`, `src/resources/extensions/gsd/gsd-db.ts`, `src/resources/extensions/gsd/tests/plan-slice.test.ts`, `src/resources/extensions/gsd/tests/plan-task.test.ts`
|
||||
- Do: Follow the S01 handler pattern exactly for both tools, add any missing DB upsert/query helpers needed to populate task planning fields and retrieve slice/task planning state, register canonical tools plus aliases in `db-tools.ts`, and test validation, missing-parent rejection, transactional DB writes, render-failure handling, idempotent reruns, and observable cache invalidation.
|
||||
|
|
|
|||
18
.gsd/milestones/M001/slices/S02/tasks/T01-VERIFY.json
Normal file
18
.gsd/milestones/M001/slices/S02/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T01",
|
||||
"unitId": "M001/S02/T01",
|
||||
"timestamp": 1774281533617,
|
||||
"passed": false,
|
||||
"discoverySource": "package-json",
|
||||
"checks": [
|
||||
{
|
||||
"command": "npm run test",
|
||||
"exitCode": 1,
|
||||
"durationMs": 11123,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
60
.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md
Normal file
60
.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
---
|
||||
id: T02
|
||||
parent: S02
|
||||
milestone: M001
|
||||
key_files:
|
||||
- .gsd/milestones/M001/slices/S02/S02-PLAN.md
|
||||
- src/resources/extensions/gsd/tools/plan-slice.ts
|
||||
- src/resources/extensions/gsd/tools/plan-task.ts
|
||||
- src/resources/extensions/gsd/bootstrap/db-tools.ts
|
||||
- src/resources/extensions/gsd/gsd-db.ts
|
||||
- src/resources/extensions/gsd/tests/plan-slice.test.ts
|
||||
- src/resources/extensions/gsd/tests/plan-task.test.ts
|
||||
key_decisions:
|
||||
- Slice/task planning writes use dedicated `upsertTaskPlanning()` updates layered on top of `insertTask()` seed rows so rerunning planning does not erase execution/completion fields stored on existing tasks.
|
||||
- `handlePlanSlice()` follows a DB-first flow that writes slice/task planning rows transactionally, then renders the slice plan plus all task-plan files; cache invalidation remains post-render only, and observability is proven through parse-visible file state rather than internal spies.
|
||||
- `handlePlanTask()` creates a pending task row only when absent, then updates planning fields and renders the task plan artifact, preserving idempotence for reruns against existing tasks.
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-23T16:05:04.223Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Implement DB-backed gsd_plan_slice and gsd_plan_task handlers with registrations and regression tests
|
||||
|
||||
**Implement DB-backed gsd_plan_slice and gsd_plan_task handlers with registrations and regression tests**
|
||||
|
||||
## What Happened
|
||||
|
||||
Implemented the DB-backed slice/task planning write path for S02. I first verified the local contracts in `plan-milestone.ts`, `db-tools.ts`, `gsd-db.ts`, `markdown-renderer.ts`, and the existing renderer/handler tests, then patched the slice plan’s verification section with an explicit diagnostic check because the pre-flight called that gap out. Added `src/resources/extensions/gsd/tools/plan-slice.ts` and `src/resources/extensions/gsd/tools/plan-task.ts`, each mirroring the S01 pattern: flat validation, parent-slice existence checks, DB writes, renderer invocation, and cache invalidation only after successful render. In `gsd-db.ts` I added `upsertTaskPlanning()` and extended the planning record shape with optional title support so planning reruns update task planning fields without overwriting completion metadata. In `src/resources/extensions/gsd/bootstrap/db-tools.ts` I registered canonical `gsd_plan_slice` and `gsd_plan_task` tools plus aliases `gsd_slice_plan` and `gsd_task_plan`, with DB-availability checks and structured handler result payloads. Finally, I added focused regression suites in `src/resources/extensions/gsd/tests/plan-slice.test.ts` and `src/resources/extensions/gsd/tests/plan-task.test.ts` covering validation failures, missing-parent rejection, successful DB-backed renders, render-failure behavior, idempotent reruns, and parse-visible cache refresh behavior via reparsed plan artifacts.
|
||||
|
||||
## Verification
|
||||
|
||||
Verified the new handlers with the task’s targeted resolver-harness command for `plan-slice.test.ts` and `plan-task.test.ts`; all validation, parent-check, render-failure, idempotence, and parse-visible cache refresh assertions passed. Then ran the task’s second verification command against `plan-slice.test.ts`, `plan-task.test.ts`, and `markdown-renderer.test.ts` filtered to cache/idempotence/render-failure coverage; it passed and preserved truthful stale-render diagnostics on stderr. Finally ran the broader slice-level verification command including `markdown-renderer.test.ts`, `auto-recovery.test.ts`, and `prompt-contracts.test.ts` filtered to plan-slice/plan-task and DB-backed planning coverage; it passed, confirming the new handlers coexist with existing renderer/recovery/prompt contracts.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts` | 0 | ✅ pass | 180ms |
|
||||
| 2 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts src/resources/extensions/gsd/tests/markdown-renderer.test.ts --test-name-pattern="cache|idempotent|render failed|validation failed|plan-slice|plan-task"` | 0 | ✅ pass | 228ms |
|
||||
| 3 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts src/resources/extensions/gsd/tests/markdown-renderer.test.ts src/resources/extensions/gsd/tests/auto-recovery.test.ts src/resources/extensions/gsd/tests/prompt-contracts.test.ts --test-name-pattern="plan-slice|plan-task|renderPlanFromDb|renderTaskPlanFromDb|task plan|DB-backed planning"` | 0 | ✅ pass | 731ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Updated `.gsd/milestones/M001/slices/S02/S02-PLAN.md` with an explicit diagnostic verification command to satisfy the task pre-flight requirement. The implementation reused the existing DB schema and renderer contracts already present locally, so no broader replan was needed. I also added a narrow `upsertTaskPlanning()` DB helper instead of changing `insertTask()` semantics, because planning reruns must not clobber completion-state fields.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `.gsd/milestones/M001/slices/S02/S02-PLAN.md`
|
||||
- `src/resources/extensions/gsd/tools/plan-slice.ts`
|
||||
- `src/resources/extensions/gsd/tools/plan-task.ts`
|
||||
- `src/resources/extensions/gsd/bootstrap/db-tools.ts`
|
||||
- `src/resources/extensions/gsd/gsd-db.ts`
|
||||
- `src/resources/extensions/gsd/tests/plan-slice.test.ts`
|
||||
- `src/resources/extensions/gsd/tests/plan-task.test.ts`
|
||||
|
|
@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
|||
import { findMilestoneIds, nextMilestoneId, claimReservedId, getReservedMilestoneIds } from "../guided-flow.js";
|
||||
import { loadEffectiveGSDPreferences } from "../preferences.js";
|
||||
import { ensureDbOpen } from "./dynamic-tools.js";
|
||||
import { StringEnum } from "@gsd/pi-ai";
|
||||
|
||||
/**
|
||||
* Register an alias tool that shares the same execute function as its canonical counterpart.
|
||||
|
|
@ -382,6 +383,153 @@ export function registerDbTools(pi: ExtensionAPI): void {
|
|||
pi.registerTool(planMilestoneTool);
|
||||
registerAlias(pi, planMilestoneTool, "gsd_milestone_plan", "gsd_plan_milestone");
|
||||
|
||||
// ─── gsd_plan_slice (gsd_slice_plan alias) ─────────────────────────────
|
||||
|
||||
const planSliceExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
|
||||
const dbAvailable = await ensureDbOpen();
|
||||
if (!dbAvailable) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot plan slice." }],
|
||||
details: { operation: "plan_slice", error: "db_unavailable" } as any,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { handlePlanSlice } = await import("../tools/plan-slice.js");
|
||||
const result = await handlePlanSlice(params, process.cwd());
|
||||
if ("error" in result) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error planning slice: ${result.error}` }],
|
||||
details: { operation: "plan_slice", error: result.error } as any,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Planned slice ${result.sliceId} (${result.milestoneId})` }],
|
||||
details: {
|
||||
operation: "plan_slice",
|
||||
milestoneId: result.milestoneId,
|
||||
sliceId: result.sliceId,
|
||||
planPath: result.planPath,
|
||||
taskPlanPaths: result.taskPlanPaths,
|
||||
} as any,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`gsd-db: plan_slice tool failed: ${msg}\n`);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error planning slice: ${msg}` }],
|
||||
details: { operation: "plan_slice", error: msg } as any,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const planSliceTool = {
|
||||
name: "gsd_plan_slice",
|
||||
label: "Plan Slice",
|
||||
description:
|
||||
"Write slice planning state to the GSD database, render S##-PLAN.md plus task PLAN artifacts from DB, and clear caches after a successful render.",
|
||||
promptSnippet: "Plan a slice via DB write + PLAN render + cache invalidation",
|
||||
promptGuidelines: [
|
||||
"Use gsd_plan_slice for slice planning instead of writing S##-PLAN.md or task PLAN files directly.",
|
||||
"Keep parameters flat and provide the full slice planning payload, including tasks.",
|
||||
"The tool validates input, requires an existing parent slice, writes slice/task planning data, renders PLAN.md and task plan files from DB, and clears both state and parse caches after success.",
|
||||
"Use the canonical name gsd_plan_slice; gsd_slice_plan is only an alias.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }),
|
||||
sliceId: Type.String({ description: "Slice ID (e.g. S01)" }),
|
||||
goal: Type.String({ description: "Slice goal" }),
|
||||
successCriteria: Type.String({ description: "Slice success criteria block" }),
|
||||
proofLevel: Type.String({ description: "Slice proof level" }),
|
||||
integrationClosure: Type.String({ description: "Slice integration closure" }),
|
||||
observabilityImpact: Type.String({ description: "Slice observability impact" }),
|
||||
tasks: Type.Array(Type.Object({
|
||||
taskId: Type.String({ description: "Task ID (e.g. T01)" }),
|
||||
title: Type.String({ description: "Task title" }),
|
||||
description: Type.String({ description: "Task description / steps block" }),
|
||||
estimate: Type.String({ description: "Task estimate string" }),
|
||||
files: Type.Array(Type.String(), { description: "Files likely touched" }),
|
||||
verify: Type.String({ description: "Verification command or block" }),
|
||||
inputs: Type.Array(Type.String(), { description: "Input files or references" }),
|
||||
expectedOutput: Type.Array(Type.String(), { description: "Expected output files or artifacts" }),
|
||||
observabilityImpact: Type.Optional(Type.String({ description: "Task observability impact" })),
|
||||
}), { description: "Planned tasks for the slice" }),
|
||||
}),
|
||||
execute: planSliceExecute,
|
||||
};
|
||||
|
||||
pi.registerTool(planSliceTool);
|
||||
registerAlias(pi, planSliceTool, "gsd_slice_plan", "gsd_plan_slice");
|
||||
|
||||
// ─── gsd_plan_task (gsd_task_plan alias) ───────────────────────────────
|
||||
|
||||
const planTaskExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
|
||||
const dbAvailable = await ensureDbOpen();
|
||||
if (!dbAvailable) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot plan task." }],
|
||||
details: { operation: "plan_task", error: "db_unavailable" } as any,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { handlePlanTask } = await import("../tools/plan-task.js");
|
||||
const result = await handlePlanTask(params, process.cwd());
|
||||
if ("error" in result) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error planning task: ${result.error}` }],
|
||||
details: { operation: "plan_task", error: result.error } as any,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Planned task ${result.taskId} (${result.sliceId}/${result.milestoneId})` }],
|
||||
details: {
|
||||
operation: "plan_task",
|
||||
milestoneId: result.milestoneId,
|
||||
sliceId: result.sliceId,
|
||||
taskId: result.taskId,
|
||||
taskPlanPath: result.taskPlanPath,
|
||||
} as any,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`gsd-db: plan_task tool failed: ${msg}\n`);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error planning task: ${msg}` }],
|
||||
details: { operation: "plan_task", error: msg } as any,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const planTaskTool = {
|
||||
name: "gsd_plan_task",
|
||||
label: "Plan Task",
|
||||
description:
|
||||
"Write task planning state to the GSD database, render tasks/T##-PLAN.md from DB, and clear caches after a successful render.",
|
||||
promptSnippet: "Plan a task via DB write + task PLAN render + cache invalidation",
|
||||
promptGuidelines: [
|
||||
"Use gsd_plan_task for task planning instead of writing tasks/T##-PLAN.md directly.",
|
||||
"Keep parameters flat and provide the full task planning payload.",
|
||||
"The tool validates input, requires an existing parent slice, writes task planning data, renders the task PLAN file from DB, and clears both state and parse caches after success.",
|
||||
"Use the canonical name gsd_plan_task; gsd_task_plan is only an alias.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }),
|
||||
sliceId: Type.String({ description: "Slice ID (e.g. S01)" }),
|
||||
taskId: Type.String({ description: "Task ID (e.g. T01)" }),
|
||||
title: Type.String({ description: "Task title" }),
|
||||
description: Type.String({ description: "Task description / steps block" }),
|
||||
estimate: Type.String({ description: "Task estimate string" }),
|
||||
files: Type.Array(Type.String(), { description: "Files likely touched" }),
|
||||
verify: Type.String({ description: "Verification command or block" }),
|
||||
inputs: Type.Array(Type.String(), { description: "Input files or references" }),
|
||||
expectedOutput: Type.Array(Type.String(), { description: "Expected output files or artifacts" }),
|
||||
observabilityImpact: Type.Optional(Type.String({ description: "Task observability impact" })),
|
||||
}),
|
||||
execute: planTaskExecute,
|
||||
};
|
||||
|
||||
pi.registerTool(planTaskTool);
|
||||
registerAlias(pi, planTaskTool, "gsd_task_plan", "gsd_plan_task");
|
||||
|
||||
// ─── gsd_task_complete (gsd_complete_task alias) ────────────────────────
|
||||
|
||||
const taskCompleteExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
|
||||
|
|
|
|||
|
|
@ -877,6 +877,7 @@ export interface SlicePlanningRecord {
|
|||
}
|
||||
|
||||
export interface TaskPlanningRecord {
|
||||
title?: string;
|
||||
description: string;
|
||||
estimate: string;
|
||||
files: string[];
|
||||
|
|
@ -1087,6 +1088,34 @@ export function updateTaskStatus(milestoneId: string, sliceId: string, taskId: s
|
|||
});
|
||||
}
|
||||
|
||||
export function upsertTaskPlanning(milestoneId: string, sliceId: string, taskId: string, planning: Partial<TaskPlanningRecord>): void {
|
||||
if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
|
||||
currentDb.prepare(
|
||||
`UPDATE tasks SET
|
||||
title = COALESCE(:title, title),
|
||||
description = COALESCE(:description, description),
|
||||
estimate = COALESCE(:estimate, estimate),
|
||||
files = COALESCE(:files, files),
|
||||
verify = COALESCE(:verify, verify),
|
||||
inputs = COALESCE(:inputs, inputs),
|
||||
expected_output = COALESCE(:expected_output, expected_output),
|
||||
observability_impact = COALESCE(:observability_impact, observability_impact)
|
||||
WHERE milestone_id = :milestone_id AND slice_id = :slice_id AND id = :id`,
|
||||
).run({
|
||||
":milestone_id": milestoneId,
|
||||
":slice_id": sliceId,
|
||||
":id": taskId,
|
||||
":title": planning.title ?? null,
|
||||
":description": planning.description ?? null,
|
||||
":estimate": planning.estimate ?? null,
|
||||
":files": planning.files ? JSON.stringify(planning.files) : null,
|
||||
":verify": planning.verify ?? null,
|
||||
":inputs": planning.inputs ? JSON.stringify(planning.inputs) : null,
|
||||
":expected_output": planning.expectedOutput ? JSON.stringify(planning.expectedOutput) : null,
|
||||
":observability_impact": planning.observabilityImpact ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export interface SliceRow {
|
||||
milestone_id: string;
|
||||
id: string;
|
||||
|
|
|
|||
178
src/resources/extensions/gsd/tests/plan-slice.test.ts
Normal file
178
src/resources/extensions/gsd/tests/plan-slice.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { openDatabase, closeDatabase, insertMilestone, insertSlice, getSlice, getSliceTasks, getTask } from '../gsd-db.ts';
|
||||
import { handlePlanSlice } from '../tools/plan-slice.ts';
|
||||
import { parsePlan, parseTaskPlanFile } from '../files.ts';
|
||||
|
||||
function makeTmpBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-plan-slice-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
try { closeDatabase(); } catch { /* noop */ }
|
||||
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
function seedParentSlice(): void {
|
||||
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
|
||||
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Planning slice', status: 'pending', demo: 'Rendered plans exist.' });
|
||||
}
|
||||
|
||||
function validParams() {
|
||||
return {
|
||||
milestoneId: 'M001',
|
||||
sliceId: 'S02',
|
||||
goal: 'Persist slice planning through the DB.',
|
||||
successCriteria: '- Slice plan renders from DB\n- Task plan files are regenerated',
|
||||
proofLevel: 'integration',
|
||||
integrationClosure: 'Planning handlers now write DB rows and render plan artifacts.',
|
||||
observabilityImpact: '- Validation failures return structured errors\n- Cache invalidation is proven by parse-visible state updates',
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'T01',
|
||||
title: 'Write slice handler',
|
||||
description: 'Implement the slice planning handler.',
|
||||
estimate: '45m',
|
||||
files: ['src/resources/extensions/gsd/tools/plan-slice.ts'],
|
||||
verify: 'node --test src/resources/extensions/gsd/tests/plan-slice.test.ts',
|
||||
inputs: ['src/resources/extensions/gsd/tools/plan-milestone.ts'],
|
||||
expectedOutput: ['src/resources/extensions/gsd/tools/plan-slice.ts'],
|
||||
observabilityImpact: 'Tests exercise cache invalidation and render failure paths.',
|
||||
},
|
||||
{
|
||||
taskId: 'T02',
|
||||
title: 'Write task handler',
|
||||
description: 'Implement the task planning handler.',
|
||||
estimate: '30m',
|
||||
files: ['src/resources/extensions/gsd/tools/plan-task.ts'],
|
||||
verify: 'node --test src/resources/extensions/gsd/tests/plan-task.test.ts',
|
||||
inputs: ['src/resources/extensions/gsd/tools/plan-task.ts'],
|
||||
expectedOutput: ['src/resources/extensions/gsd/tests/plan-task.test.ts'],
|
||||
observabilityImpact: 'Task-plan renders remain parse-compatible.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('handlePlanSlice writes slice/task planning state and renders plan artifacts', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
seedParentSlice();
|
||||
|
||||
const result = await handlePlanSlice(validParams(), base);
|
||||
assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`);
|
||||
|
||||
const slice = getSlice('M001', 'S02');
|
||||
assert.ok(slice);
|
||||
assert.equal(slice?.goal, 'Persist slice planning through the DB.');
|
||||
assert.equal(slice?.proof_level, 'integration');
|
||||
|
||||
const tasks = getSliceTasks('M001', 'S02');
|
||||
assert.equal(tasks.length, 2);
|
||||
assert.equal(tasks[0]?.title, 'Write slice handler');
|
||||
assert.equal(tasks[0]?.description, 'Implement the slice planning handler.');
|
||||
assert.equal(tasks[1]?.estimate, '30m');
|
||||
|
||||
const planPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md');
|
||||
assert.ok(existsSync(planPath), 'slice plan should be rendered to disk');
|
||||
const parsedPlan = parsePlan(readFileSync(planPath, 'utf-8'));
|
||||
assert.equal(parsedPlan.goal, 'Persist slice planning through the DB.');
|
||||
assert.equal(parsedPlan.tasks.length, 2);
|
||||
assert.equal(parsedPlan.tasks[0]?.id, 'T01');
|
||||
|
||||
const taskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T01-PLAN.md');
|
||||
assert.ok(existsSync(taskPlanPath), 'task plan should be rendered to disk');
|
||||
const taskPlan = parseTaskPlanFile(readFileSync(taskPlanPath, 'utf-8'));
|
||||
assert.deepEqual(taskPlan.frontmatter.skills_used, []);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('handlePlanSlice rejects invalid payloads', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
seedParentSlice();
|
||||
const result = await handlePlanSlice({ ...validParams(), tasks: [] }, base);
|
||||
assert.ok('error' in result);
|
||||
assert.match(result.error, /validation failed: tasks must be a non-empty array/);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('handlePlanSlice rejects missing parent slice', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
|
||||
const result = await handlePlanSlice(validParams(), base);
|
||||
assert.ok('error' in result);
|
||||
assert.match(result.error, /missing parent slice: M001\/S02/);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('handlePlanSlice surfaces render failures without changing parse-visible task-plan state for the failing task', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
seedParentSlice();
|
||||
const failingTaskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T01-PLAN.md');
|
||||
writeFileSync(failingTaskPlanPath, '---\nestimated_steps: 1\nestimated_files: 1\nskills_used: []\n---\n\n# T01: Cached task\n', 'utf-8');
|
||||
rmSync(failingTaskPlanPath, { force: true });
|
||||
mkdirSync(failingTaskPlanPath, { recursive: true });
|
||||
|
||||
const result = await handlePlanSlice(validParams(), base);
|
||||
assert.ok('error' in result);
|
||||
assert.match(result.error, /render failed:/);
|
||||
|
||||
assert.ok(existsSync(failingTaskPlanPath), 'failing task plan path should remain the blocking directory');
|
||||
assert.equal(getTask('M001', 'S02', 'T01')?.description, 'Implement the slice planning handler.');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('handlePlanSlice reruns idempotently and refreshes parse-visible state', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
seedParentSlice();
|
||||
writeFileSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md'), '# S02: Cached\n\n**Goal:** old value\n\n## Tasks\n\n- [ ] **T01: Cached task**\n', 'utf-8');
|
||||
|
||||
const first = await handlePlanSlice(validParams(), base);
|
||||
assert.ok(!('error' in first));
|
||||
|
||||
const second = await handlePlanSlice({
|
||||
...validParams(),
|
||||
goal: 'Updated goal from rerun.',
|
||||
tasks: [
|
||||
{ ...validParams().tasks[0], description: 'Updated slice handler description.' },
|
||||
validParams().tasks[1],
|
||||
],
|
||||
}, base);
|
||||
assert.ok(!('error' in second));
|
||||
|
||||
const parsedAfter = parsePlan(readFileSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md'), 'utf-8'));
|
||||
assert.equal(parsedAfter.goal, 'Updated goal from rerun.');
|
||||
const task = getTask('M001', 'S02', 'T01');
|
||||
assert.equal(task?.description, 'Updated slice handler description.');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
145
src/resources/extensions/gsd/tests/plan-task.test.ts
Normal file
145
src/resources/extensions/gsd/tests/plan-task.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, getTask } from '../gsd-db.ts';
|
||||
import { handlePlanTask } from '../tools/plan-task.ts';
|
||||
import { parseTaskPlanFile } from '../files.ts';
|
||||
|
||||
function makeTmpBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-plan-task-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
try { closeDatabase(); } catch { /* noop */ }
|
||||
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
function seedParent(): void {
|
||||
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
|
||||
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Planning slice', status: 'pending', demo: 'Rendered plans exist.' });
|
||||
}
|
||||
|
||||
function validParams() {
|
||||
return {
|
||||
milestoneId: 'M001',
|
||||
sliceId: 'S02',
|
||||
taskId: 'T02',
|
||||
title: 'Write task handler',
|
||||
description: 'Implement the DB-backed task planning handler.',
|
||||
estimate: '30m',
|
||||
files: ['src/resources/extensions/gsd/tools/plan-task.ts'],
|
||||
verify: 'node --test src/resources/extensions/gsd/tests/plan-task.test.ts',
|
||||
inputs: ['src/resources/extensions/gsd/tools/plan-task.ts'],
|
||||
expectedOutput: ['src/resources/extensions/gsd/tests/plan-task.test.ts'],
|
||||
observabilityImpact: 'Tests exercise validation, render failure, and cache refresh behavior.',
|
||||
};
|
||||
}
|
||||
|
||||
test('handlePlanTask writes planning state and renders task plan', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
seedParent();
|
||||
const result = await handlePlanTask(validParams(), base);
|
||||
assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`);
|
||||
|
||||
const task = getTask('M001', 'S02', 'T02');
|
||||
assert.ok(task);
|
||||
assert.equal(task?.title, 'Write task handler');
|
||||
assert.equal(task?.description, 'Implement the DB-backed task planning handler.');
|
||||
assert.equal(task?.estimate, '30m');
|
||||
|
||||
const taskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T02-PLAN.md');
|
||||
assert.ok(existsSync(taskPlanPath), 'task plan should be rendered to disk');
|
||||
const taskPlan = parseTaskPlanFile(readFileSync(taskPlanPath, 'utf-8'));
|
||||
assert.equal(taskPlan.frontmatter.estimated_files, 1);
|
||||
assert.deepEqual(taskPlan.frontmatter.skills_used, []);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('handlePlanTask rejects invalid payloads', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
seedParent();
|
||||
const result = await handlePlanTask({ ...validParams(), files: [''] }, base);
|
||||
assert.ok('error' in result);
|
||||
assert.match(result.error, /validation failed: files must contain only non-empty strings/);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('handlePlanTask rejects missing parent slice', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
|
||||
const result = await handlePlanTask(validParams(), base);
|
||||
assert.ok('error' in result);
|
||||
assert.match(result.error, /missing parent slice: M001\/S02/);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('handlePlanTask surfaces render failures without changing parse-visible task plan state', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
seedParent();
|
||||
insertTask({ id: 'T02', sliceId: 'S02', milestoneId: 'M001', title: 'Cached task', status: 'pending' });
|
||||
const taskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T02-PLAN.md');
|
||||
writeFileSync(taskPlanPath, '---\nestimated_steps: 1\nestimated_files: 1\nskills_used: []\n---\n\n# T02: Cached task\n', 'utf-8');
|
||||
rmSync(taskPlanPath, { force: true });
|
||||
mkdirSync(taskPlanPath, { recursive: true });
|
||||
|
||||
const result = await handlePlanTask(validParams(), base);
|
||||
assert.ok('error' in result);
|
||||
assert.match(result.error, /render failed:/);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('handlePlanTask reruns idempotently and refreshes parse-visible state', async () => {
|
||||
const base = makeTmpBase();
|
||||
openDatabase(join(base, '.gsd', 'gsd.db'));
|
||||
|
||||
try {
|
||||
seedParent();
|
||||
const taskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T02-PLAN.md');
|
||||
writeFileSync(taskPlanPath, '---\nestimated_steps: 1\nestimated_files: 1\nskills_used: []\n---\n\n# T02: Cached task\n', 'utf-8');
|
||||
|
||||
const first = await handlePlanTask(validParams(), base);
|
||||
assert.ok(!('error' in first));
|
||||
|
||||
const second = await handlePlanTask({
|
||||
...validParams(),
|
||||
description: 'Updated task handler description.',
|
||||
estimate: '1h',
|
||||
}, base);
|
||||
assert.ok(!('error' in second));
|
||||
|
||||
const task = getTask('M001', 'S02', 'T02');
|
||||
assert.equal(task?.description, 'Updated task handler description.');
|
||||
assert.equal(task?.estimate, '1h');
|
||||
|
||||
const parsed = parseTaskPlanFile(readFileSync(taskPlanPath, 'utf-8'));
|
||||
assert.equal(parsed.frontmatter.estimated_steps, 1);
|
||||
assert.match(readFileSync(taskPlanPath, 'utf-8'), /Updated task handler description\./);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
189
src/resources/extensions/gsd/tools/plan-slice.ts
Normal file
189
src/resources/extensions/gsd/tools/plan-slice.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { clearParseCache } from "../files.js";
|
||||
import {
|
||||
transaction,
|
||||
getSlice,
|
||||
insertTask,
|
||||
upsertSlicePlanning,
|
||||
upsertTaskPlanning,
|
||||
} from "../gsd-db.js";
|
||||
import { invalidateStateCache } from "../state.js";
|
||||
import { renderPlanFromDb } from "../markdown-renderer.js";
|
||||
|
||||
export interface PlanSliceTaskInput {
|
||||
taskId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
estimate: string;
|
||||
files: string[];
|
||||
verify: string;
|
||||
inputs: string[];
|
||||
expectedOutput: string[];
|
||||
observabilityImpact?: string;
|
||||
}
|
||||
|
||||
export interface PlanSliceParams {
|
||||
milestoneId: string;
|
||||
sliceId: string;
|
||||
goal: string;
|
||||
successCriteria: string;
|
||||
proofLevel: string;
|
||||
integrationClosure: string;
|
||||
observabilityImpact: string;
|
||||
tasks: PlanSliceTaskInput[];
|
||||
}
|
||||
|
||||
export interface PlanSliceResult {
|
||||
milestoneId: string;
|
||||
sliceId: string;
|
||||
planPath: string;
|
||||
taskPlanPaths: string[];
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function validateStringArray(value: unknown, field: string): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`${field} must be an array`);
|
||||
}
|
||||
if (value.some((item) => !isNonEmptyString(item))) {
|
||||
throw new Error(`${field} must contain only non-empty strings`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateTasks(value: unknown): PlanSliceTaskInput[] {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new Error("tasks must be a non-empty array");
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return value.map((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
throw new Error(`tasks[${index}] must be an object`);
|
||||
}
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const taskId = obj.taskId;
|
||||
const title = obj.title;
|
||||
const description = obj.description;
|
||||
const estimate = obj.estimate;
|
||||
const files = obj.files;
|
||||
const verify = obj.verify;
|
||||
const inputs = obj.inputs;
|
||||
const expectedOutput = obj.expectedOutput;
|
||||
const observabilityImpact = obj.observabilityImpact;
|
||||
|
||||
if (!isNonEmptyString(taskId)) throw new Error(`tasks[${index}].taskId must be a non-empty string`);
|
||||
if (seen.has(taskId)) throw new Error(`tasks[${index}].taskId must be unique`);
|
||||
seen.add(taskId);
|
||||
if (!isNonEmptyString(title)) throw new Error(`tasks[${index}].title must be a non-empty string`);
|
||||
if (!isNonEmptyString(description)) throw new Error(`tasks[${index}].description must be a non-empty string`);
|
||||
if (!isNonEmptyString(estimate)) throw new Error(`tasks[${index}].estimate must be a non-empty string`);
|
||||
if (!Array.isArray(files) || files.some((item) => !isNonEmptyString(item))) {
|
||||
throw new Error(`tasks[${index}].files must be an array of non-empty strings`);
|
||||
}
|
||||
if (!isNonEmptyString(verify)) throw new Error(`tasks[${index}].verify must be a non-empty string`);
|
||||
if (!Array.isArray(inputs) || inputs.some((item) => !isNonEmptyString(item))) {
|
||||
throw new Error(`tasks[${index}].inputs must be an array of non-empty strings`);
|
||||
}
|
||||
if (!Array.isArray(expectedOutput) || expectedOutput.some((item) => !isNonEmptyString(item))) {
|
||||
throw new Error(`tasks[${index}].expectedOutput must be an array of non-empty strings`);
|
||||
}
|
||||
if (observabilityImpact !== undefined && !isNonEmptyString(observabilityImpact)) {
|
||||
throw new Error(`tasks[${index}].observabilityImpact must be a non-empty string when provided`);
|
||||
}
|
||||
|
||||
return {
|
||||
taskId,
|
||||
title,
|
||||
description,
|
||||
estimate,
|
||||
files,
|
||||
verify,
|
||||
inputs,
|
||||
expectedOutput,
|
||||
observabilityImpact: typeof observabilityImpact === "string" ? observabilityImpact : "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateParams(params: PlanSliceParams): PlanSliceParams {
|
||||
if (!isNonEmptyString(params?.milestoneId)) throw new Error("milestoneId is required");
|
||||
if (!isNonEmptyString(params?.sliceId)) throw new Error("sliceId is required");
|
||||
if (!isNonEmptyString(params?.goal)) throw new Error("goal is required");
|
||||
if (!isNonEmptyString(params?.successCriteria)) throw new Error("successCriteria is required");
|
||||
if (!isNonEmptyString(params?.proofLevel)) throw new Error("proofLevel is required");
|
||||
if (!isNonEmptyString(params?.integrationClosure)) throw new Error("integrationClosure is required");
|
||||
if (!isNonEmptyString(params?.observabilityImpact)) throw new Error("observabilityImpact is required");
|
||||
|
||||
return {
|
||||
...params,
|
||||
tasks: validateTasks(params.tasks),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handlePlanSlice(
|
||||
rawParams: PlanSliceParams,
|
||||
basePath: string,
|
||||
): Promise<PlanSliceResult | { error: string }> {
|
||||
let params: PlanSliceParams;
|
||||
try {
|
||||
params = validateParams(rawParams);
|
||||
} catch (err) {
|
||||
return { error: `validation failed: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
const parentSlice = getSlice(params.milestoneId, params.sliceId);
|
||||
if (!parentSlice) {
|
||||
return { error: `missing parent slice: ${params.milestoneId}/${params.sliceId}` };
|
||||
}
|
||||
|
||||
try {
|
||||
transaction(() => {
|
||||
upsertSlicePlanning(params.milestoneId, params.sliceId, {
|
||||
goal: params.goal,
|
||||
successCriteria: params.successCriteria,
|
||||
proofLevel: params.proofLevel,
|
||||
integrationClosure: params.integrationClosure,
|
||||
observabilityImpact: params.observabilityImpact,
|
||||
});
|
||||
|
||||
for (const task of params.tasks) {
|
||||
insertTask({
|
||||
id: task.taskId,
|
||||
sliceId: params.sliceId,
|
||||
milestoneId: params.milestoneId,
|
||||
title: task.title,
|
||||
status: "pending",
|
||||
});
|
||||
upsertTaskPlanning(params.milestoneId, params.sliceId, task.taskId, {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
estimate: task.estimate,
|
||||
files: task.files,
|
||||
verify: task.verify,
|
||||
inputs: task.inputs,
|
||||
expectedOutput: task.expectedOutput,
|
||||
observabilityImpact: task.observabilityImpact ?? "",
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
return { error: `db write failed: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const renderResult = await renderPlanFromDb(basePath, params.milestoneId, params.sliceId);
|
||||
invalidateStateCache();
|
||||
clearParseCache();
|
||||
return {
|
||||
milestoneId: params.milestoneId,
|
||||
sliceId: params.sliceId,
|
||||
planPath: renderResult.planPath,
|
||||
taskPlanPaths: renderResult.taskPlanPaths,
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: `render failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
114
src/resources/extensions/gsd/tools/plan-task.ts
Normal file
114
src/resources/extensions/gsd/tools/plan-task.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { clearParseCache } from "../files.js";
|
||||
import { getSlice, getTask, insertTask, upsertTaskPlanning } from "../gsd-db.js";
|
||||
import { invalidateStateCache } from "../state.js";
|
||||
import { renderTaskPlanFromDb } from "../markdown-renderer.js";
|
||||
|
||||
export interface PlanTaskParams {
|
||||
milestoneId: string;
|
||||
sliceId: string;
|
||||
taskId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
estimate: string;
|
||||
files: string[];
|
||||
verify: string;
|
||||
inputs: string[];
|
||||
expectedOutput: string[];
|
||||
observabilityImpact?: string;
|
||||
}
|
||||
|
||||
export interface PlanTaskResult {
|
||||
milestoneId: string;
|
||||
sliceId: string;
|
||||
taskId: string;
|
||||
taskPlanPath: string;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function validateStringArray(value: unknown, field: string): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`${field} must be an array`);
|
||||
}
|
||||
if (value.some((item) => !isNonEmptyString(item))) {
|
||||
throw new Error(`${field} must contain only non-empty strings`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateParams(params: PlanTaskParams): PlanTaskParams {
|
||||
if (!isNonEmptyString(params?.milestoneId)) throw new Error("milestoneId is required");
|
||||
if (!isNonEmptyString(params?.sliceId)) throw new Error("sliceId is required");
|
||||
if (!isNonEmptyString(params?.taskId)) throw new Error("taskId is required");
|
||||
if (!isNonEmptyString(params?.title)) throw new Error("title is required");
|
||||
if (!isNonEmptyString(params?.description)) throw new Error("description is required");
|
||||
if (!isNonEmptyString(params?.estimate)) throw new Error("estimate is required");
|
||||
if (!isNonEmptyString(params?.verify)) throw new Error("verify is required");
|
||||
if (params.observabilityImpact !== undefined && !isNonEmptyString(params.observabilityImpact)) {
|
||||
throw new Error("observabilityImpact must be a non-empty string when provided");
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
files: validateStringArray(params.files, "files"),
|
||||
inputs: validateStringArray(params.inputs, "inputs"),
|
||||
expectedOutput: validateStringArray(params.expectedOutput, "expectedOutput"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handlePlanTask(
|
||||
rawParams: PlanTaskParams,
|
||||
basePath: string,
|
||||
): Promise<PlanTaskResult | { error: string }> {
|
||||
let params: PlanTaskParams;
|
||||
try {
|
||||
params = validateParams(rawParams);
|
||||
} catch (err) {
|
||||
return { error: `validation failed: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
const parentSlice = getSlice(params.milestoneId, params.sliceId);
|
||||
if (!parentSlice) {
|
||||
return { error: `missing parent slice: ${params.milestoneId}/${params.sliceId}` };
|
||||
}
|
||||
|
||||
try {
|
||||
if (!getTask(params.milestoneId, params.sliceId, params.taskId)) {
|
||||
insertTask({
|
||||
id: params.taskId,
|
||||
sliceId: params.sliceId,
|
||||
milestoneId: params.milestoneId,
|
||||
title: params.title,
|
||||
status: "pending",
|
||||
});
|
||||
}
|
||||
upsertTaskPlanning(params.milestoneId, params.sliceId, params.taskId, {
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
estimate: params.estimate,
|
||||
files: params.files,
|
||||
verify: params.verify,
|
||||
inputs: params.inputs,
|
||||
expectedOutput: params.expectedOutput,
|
||||
observabilityImpact: params.observabilityImpact ?? "",
|
||||
});
|
||||
} catch (err) {
|
||||
return { error: `db write failed: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const renderResult = await renderTaskPlanFromDb(basePath, params.milestoneId, params.sliceId, params.taskId);
|
||||
invalidateStateCache();
|
||||
clearParseCache();
|
||||
return {
|
||||
milestoneId: params.milestoneId,
|
||||
sliceId: params.sliceId,
|
||||
taskId: params.taskId,
|
||||
taskPlanPath: renderResult.taskPlanPath,
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: `render failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue