From e4acd6cb7afa5142840664d877e0dffd95bc6ab9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 16:23:38 +0800 Subject: [PATCH] refactor(server): extract task document persistence (#883) * refactor(server): extract task document persistence * test(cli): wait for loop list visibility --- packages/cli/tests/31-loop-schedule.test.ts | 27 +++- packages/server/src/tasks/task-document.ts | 134 +++++++++++++++++++ packages/server/src/tasks/task-store.ts | 136 +------------------- 3 files changed, 161 insertions(+), 136 deletions(-) create mode 100644 packages/server/src/tasks/task-document.ts diff --git a/packages/cli/tests/31-loop-schedule.test.ts b/packages/cli/tests/31-loop-schedule.test.ts index 75504c53f..f5513bdcd 100644 --- a/packages/cli/tests/31-loop-schedule.test.ts +++ b/packages/cli/tests/31-loop-schedule.test.ts @@ -8,6 +8,26 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForLoopInList( + ctx: Awaited>, + id: string, +) { + for (let attempt = 0; attempt < 20; attempt++) { + const listed = await ctx.paseo(["loop", "ls", "--json"]); + assert.strictEqual(listed.exitCode, 0, listed.stderr); + const listedJson = JSON.parse(listed.stdout); + assert(Array.isArray(listedJson), listed.stdout); + if (listedJson.some((item: { id: string }) => item.id === id)) { + return listedJson; + } + await sleep(250); + } + + const listed = await ctx.paseo(["loop", "ls", "--json"]); + assert.strictEqual(listed.exitCode, 0, listed.stderr); + return JSON.parse(listed.stdout); +} + console.log("=== Loop And Schedule Command Tests ===\n"); const ctx = await createE2ETestContext({ timeout: 30000 }); @@ -143,13 +163,10 @@ try { const runJson = JSON.parse(run.stdout); assert.strictEqual(runJson.name, "smoke-loop"); - const listed = await ctx.paseo(["loop", "ls", "--json"]); - assert.strictEqual(listed.exitCode, 0, listed.stderr); - const listedJson = JSON.parse(listed.stdout); - assert(Array.isArray(listedJson), listed.stdout); + const listedJson = await waitForLoopInList(ctx, runJson.id); assert( listedJson.some((item: { id: string }) => item.id === runJson.id), - listed.stdout, + JSON.stringify(listedJson), ); async function pollStatus(attempt: number): Promise { diff --git a/packages/server/src/tasks/task-document.ts b/packages/server/src/tasks/task-document.ts new file mode 100644 index 000000000..50e324e3c --- /dev/null +++ b/packages/server/src/tasks/task-document.ts @@ -0,0 +1,134 @@ +import { readFile, writeFile } from "node:fs/promises"; +import type { Task, TaskStatus } from "./types.js"; + +function serializeTask(task: Task): string { + const frontmatterLines = [ + "---", + `id: ${task.id}`, + `title: ${task.title}`, + `status: ${task.status}`, + `deps: [${task.deps.join(", ")}]`, + `created: ${task.created}`, + ]; + + if (task.parentId) { + frontmatterLines.push(`parentId: ${task.parentId}`); + } + + if (task.assignee) { + frontmatterLines.push(`assignee: ${task.assignee}`); + } + + if (task.priority !== undefined) { + frontmatterLines.push(`priority: ${task.priority}`); + } + + frontmatterLines.push("---"); + + const frontmatter = frontmatterLines.join("\n"); + + let content = ""; + if (task.body) { + content += task.body + "\n"; + } + + if (task.acceptanceCriteria.length > 0) { + content += "\n## Acceptance Criteria\n\n"; + for (const criterion of task.acceptanceCriteria) { + content += `- [ ] ${criterion}\n`; + } + } + + if (task.notes.length > 0) { + content += "\n## Notes\n"; + for (const note of task.notes) { + content += `\n**${note.timestamp}**\n\n${note.content}\n`; + } + } + + return frontmatter + "\n\n" + content; +} + +function parseTask(content: string): Task { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + if (!frontmatterMatch) { + throw new Error("Invalid task file: missing frontmatter"); + } + + const frontmatter = frontmatterMatch[1]; + const fileBody = content.slice(frontmatterMatch[0].length); + + const getValue = (key: string): string => { + const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m")); + return match ? match[1] : ""; + }; + + const depsStr = getValue("deps"); + const depsMatch = depsStr.match(/\[(.*)\]/); + const deps = + depsMatch && depsMatch[1].trim() + ? depsMatch[1] + .split(",") + .map((d) => d.trim()) + .filter(Boolean) + : []; + + const notes: Task["notes"] = []; + const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/); + if (notesSection) { + const noteMatches = notesSection[1].matchAll( + /\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g, + ); + for (const match of noteMatches) { + notes.push({ + timestamp: match[1], + content: match[2].trim(), + }); + } + } + + const acceptanceCriteria: string[] = []; + const criteriaSection = fileBody.match(/## Acceptance Criteria\n\n([\s\S]*?)(?=\n## Notes|$)/); + if (criteriaSection) { + const criteriaMatches = criteriaSection[1].matchAll(/- \[[ x]\] (.+)$/gm); + for (const match of criteriaMatches) { + acceptanceCriteria.push(match[1].trim()); + } + } + + let taskBody = fileBody; + const firstSection = fileBody.match(/\n## (Acceptance Criteria|Notes)\n/); + if (firstSection) { + taskBody = fileBody.slice(0, firstSection.index).trim(); + } + taskBody = taskBody.trim(); + + const assignee = getValue("assignee"); + const parentId = getValue("parentId"); + const priorityStr = getValue("priority"); + const priority = priorityStr ? parseInt(priorityStr, 10) : undefined; + + return { + id: getValue("id"), + title: getValue("title"), + status: getValue("status") as TaskStatus, + deps, + parentId: parentId || undefined, + body: taskBody, + acceptanceCriteria, + notes, + created: getValue("created") || new Date().toISOString(), + assignee: assignee || undefined, + priority, + raw: content, + }; +} + +export async function readTaskDocument(filePath: string): Promise { + const content = await readFile(filePath, "utf-8"); + return parseTask(content); +} + +export async function writeTaskDocument(filePath: string, task: Task): Promise { + await writeFile(filePath, serializeTask(task), "utf-8"); +} diff --git a/packages/server/src/tasks/task-store.ts b/packages/server/src/tasks/task-store.ts index ed12021e2..c31f388f2 100644 --- a/packages/server/src/tasks/task-store.ts +++ b/packages/server/src/tasks/task-store.ts @@ -1,144 +1,19 @@ -import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises"; +import { readdir, mkdir, unlink } from "node:fs/promises"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; -import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js"; +import type { Task, TaskStore, CreateTaskOptions } from "./types.js"; import { isBlockedTask, isReadyTask, loadScopedTaskGraph, sortByPriorityThenCreated, } from "./task-graph.js"; +import { readTaskDocument, writeTaskDocument } from "./task-document.js"; function generateId(): string { return randomBytes(4).toString("hex"); } -function serializeTask(task: Task): string { - const frontmatterLines = [ - "---", - `id: ${task.id}`, - `title: ${task.title}`, - `status: ${task.status}`, - `deps: [${task.deps.join(", ")}]`, - `created: ${task.created}`, - ]; - - if (task.parentId) { - frontmatterLines.push(`parentId: ${task.parentId}`); - } - - if (task.assignee) { - frontmatterLines.push(`assignee: ${task.assignee}`); - } - - if (task.priority !== undefined) { - frontmatterLines.push(`priority: ${task.priority}`); - } - - frontmatterLines.push("---"); - - const frontmatter = frontmatterLines.join("\n"); - - let content = ""; - if (task.body) { - content += task.body + "\n"; - } - - if (task.acceptanceCriteria.length > 0) { - content += "\n## Acceptance Criteria\n\n"; - for (const criterion of task.acceptanceCriteria) { - content += `- [ ] ${criterion}\n`; - } - } - - if (task.notes.length > 0) { - content += "\n## Notes\n"; - for (const note of task.notes) { - content += `\n**${note.timestamp}**\n\n${note.content}\n`; - } - } - - return frontmatter + "\n\n" + content; -} - -function parseTask(content: string): Task { - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/); - if (!frontmatterMatch) { - throw new Error("Invalid task file: missing frontmatter"); - } - - const frontmatter = frontmatterMatch[1]; - const fileBody = content.slice(frontmatterMatch[0].length); - - const getValue = (key: string): string => { - const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m")); - return match ? match[1] : ""; - }; - - const depsStr = getValue("deps"); - const depsMatch = depsStr.match(/\[(.*)\]/); - const deps = - depsMatch && depsMatch[1].trim() - ? depsMatch[1] - .split(",") - .map((d) => d.trim()) - .filter(Boolean) - : []; - - // Parse notes from body - const notes: Task["notes"] = []; - const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/); - if (notesSection) { - const noteMatches = notesSection[1].matchAll( - /\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g, - ); - for (const match of noteMatches) { - notes.push({ - timestamp: match[1], - content: match[2].trim(), - }); - } - } - - // Parse acceptance criteria - const acceptanceCriteria: string[] = []; - const criteriaSection = fileBody.match(/## Acceptance Criteria\n\n([\s\S]*?)(?=\n## Notes|$)/); - if (criteriaSection) { - const criteriaMatches = criteriaSection[1].matchAll(/- \[[ x]\] (.+)$/gm); - for (const match of criteriaMatches) { - acceptanceCriteria.push(match[1].trim()); - } - } - - // Body is everything before ## Acceptance Criteria or ## Notes - let taskBody = fileBody; - const firstSection = fileBody.match(/\n## (Acceptance Criteria|Notes)\n/); - if (firstSection) { - taskBody = fileBody.slice(0, firstSection.index).trim(); - } - taskBody = taskBody.trim(); - - const assignee = getValue("assignee"); - const parentId = getValue("parentId"); - const priorityStr = getValue("priority"); - const priority = priorityStr ? parseInt(priorityStr, 10) : undefined; - - return { - id: getValue("id"), - title: getValue("title"), - status: getValue("status") as TaskStatus, - deps, - parentId: parentId || undefined, - body: taskBody, - acceptanceCriteria, - notes, - created: getValue("created") || new Date().toISOString(), - assignee: assignee || undefined, - priority, - raw: content, - }; -} - export class FileTaskStore implements TaskStore { constructor(private readonly dir: string) {} @@ -152,8 +27,7 @@ export class FileTaskStore implements TaskStore { private async readTask(id: string): Promise { try { - const content = await readFile(this.taskPath(id), "utf-8"); - return parseTask(content); + return await readTaskDocument(this.taskPath(id)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return null; @@ -164,7 +38,7 @@ export class FileTaskStore implements TaskStore { private async writeTask(task: Task): Promise { await this.ensureDir(); - await writeFile(this.taskPath(task.id), serializeTask(task), "utf-8"); + await writeTaskDocument(this.taskPath(task.id), task); } async list(): Promise {