diff --git a/internal/tasktree/activities.ts b/internal/tasktree/activities.ts index 5999bbb8..92580499 100644 --- a/internal/tasktree/activities.ts +++ b/internal/tasktree/activities.ts @@ -6,7 +6,6 @@ export function createTaskTreeActivities(store: TaskTreeStore) { const task = await store.getTask(input.taskId); if (!task) throw Object.assign(new Error("task was not found"), { code: "task_not_found" }); const { report } = await store.writeReport({ taskId: task.id, title: "Temporal execution report", body: `Task ${task.title} completed by Temporal worker.`, status: "succeeded" }); - await store.completeTask(task.id); return { taskId: task.id, reportId: report.id }; } }; diff --git a/internal/tasktree/store.ts b/internal/tasktree/store.ts index dd0e120a..b5e385f3 100644 --- a/internal/tasktree/store.ts +++ b/internal/tasktree/store.ts @@ -57,6 +57,17 @@ const schemaMigrations: SchemaMigration[] = [ error_code TEXT NOT NULL DEFAULT '', error_message TEXT NOT NULL DEFAULT '', updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`, ] }, + { id: "tasktree-20260721-v4-report-completes-task", statements: [ + `UPDATE tasktree_groups g SET updated_at=now() + WHERE EXISTS ( + SELECT 1 FROM tasktree_tasks t + JOIN tasktree_execution_reports r ON r.task_id=t.id + WHERE t.group_id=g.id AND t.status <> 'completed' + )`, + `UPDATE tasktree_tasks t SET status='completed',updated_at=now() + WHERE t.status <> 'completed' + AND EXISTS (SELECT 1 FROM tasktree_execution_reports r WHERE r.task_id=t.id)`, + ] }, ]; const schemaMigrationIds = schemaMigrations.map((migration) => migration.id); const schemaAdvisoryLockKeys = [0x48574c42, 0x54545245] as const; @@ -346,14 +357,14 @@ export class TaskTreeStore { const client = await this.pool.connect(); try { await client.query("BEGIN"); + const task = await client.query("SELECT group_id FROM tasktree_tasks WHERE id=$1 FOR UPDATE", [input.taskId]); + if (!task.rows[0]) throw domainError("task_not_found", "task was not found"); const result = await client.query( "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5) RETURNING *", [`tr_${randomUUID()}`, input.taskId, input.title, input.body, input.status ?? "succeeded"] ); - await client.query( - "UPDATE tasktree_groups g SET updated_at=now() FROM tasktree_tasks t WHERE t.id=$1 AND g.id=t.group_id", - [input.taskId] - ); + await client.query("UPDATE tasktree_tasks SET status='completed',updated_at=now() WHERE id=$1", [input.taskId]); + await client.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [task.rows[0].group_id]); await client.query("COMMIT"); return reportRow(result.rows[0]); } catch (error) { @@ -376,29 +387,55 @@ export class TaskTreeStore { return result.rows[0] ? reportRow(result.rows[0]) : null; } - async writeReport(input: { taskId: string; title: string; body: string; status?: string }): Promise<{ report: ExecutionReport; mutation: boolean }> { + async writeReport(input: { taskId: string; title: string; body: string; status?: string }): Promise<{ + report: ExecutionReport; + mutation: boolean; + task: TaskItem; + taskMutation: boolean; + }> { await this.ensureSchema(); - const task = await this.getTask(input.taskId); - if (!task) throw domainError("task_not_found", "task was not found"); - const current = await this.pool.query( - "SELECT * FROM tasktree_execution_reports WHERE task_id=$1 AND title=$2 ORDER BY created_at DESC,id LIMIT 1", - [input.taskId, input.title] - ); - const status = input.status ?? "succeeded"; - if (current.rows[0] && current.rows[0].body === input.body && current.rows[0].status === status) { - return { report: reportRow(current.rows[0]), mutation: false }; - } - const result = current.rows[0] - ? await this.pool.query( - "UPDATE tasktree_execution_reports SET body=$2,status=$3,created_at=now() WHERE id=$1 RETURNING *", - [current.rows[0].id, input.body, status] - ) - : await this.pool.query( - "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5) RETURNING *", - [`tr_${randomUUID()}`, input.taskId, input.title, input.body, status] + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const taskResult = await client.query("SELECT * FROM tasktree_tasks WHERE id=$1 FOR UPDATE", [input.taskId]); + if (!taskResult.rows[0]) throw domainError("task_not_found", "task was not found"); + const current = await client.query( + "SELECT * FROM tasktree_execution_reports WHERE task_id=$1 AND title=$2 ORDER BY created_at DESC,id LIMIT 1", + [input.taskId, input.title] ); - await this.pool.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [task.groupId]); - return { report: reportRow(result.rows[0]), mutation: true }; + const status = input.status ?? "succeeded"; + const mutation = !current.rows[0] || current.rows[0].body !== input.body || current.rows[0].status !== status; + const reportResult = !mutation + ? current + : current.rows[0] + ? await client.query( + "UPDATE tasktree_execution_reports SET body=$2,status=$3,created_at=now() WHERE id=$1 RETURNING *", + [current.rows[0].id, input.body, status] + ) + : await client.query( + "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5) RETURNING *", + [`tr_${randomUUID()}`, input.taskId, input.title, input.body, status] + ); + const taskMutation = taskResult.rows[0].status !== "completed"; + const completedTask = taskMutation + ? await client.query("UPDATE tasktree_tasks SET status='completed',updated_at=now() WHERE id=$1 RETURNING *", [input.taskId]) + : taskResult; + if (mutation || taskMutation) { + await client.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [taskResult.rows[0].group_id]); + } + await client.query("COMMIT"); + return { + report: reportRow(reportResult.rows[0]), + mutation, + task: taskRow(completedTask.rows[0]), + taskMutation, + }; + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } } async importMdtodo(plan: MdtodoImportPlan) { @@ -422,7 +459,8 @@ export class TaskTreeStore { await client.query( `INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,start_at,due_at,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, - [taskId, groupId, parentId, kind, task.title, task.description, task.status, task.startAt, task.dueAt, index] + [taskId, groupId, parentId, kind, task.title, task.description, + task.reports.length > 0 ? "completed" : task.status, task.startAt, task.dueAt, index] ); for (const report of task.reports) { await client.query( diff --git a/internal/tasktree/tasktree.test.ts b/internal/tasktree/tasktree.test.ts index c2108156..99f4b7d0 100644 --- a/internal/tasktree/tasktree.test.ts +++ b/internal/tasktree/tasktree.test.ts @@ -166,11 +166,20 @@ test("native PostgreSQL store exposes overview and three task levels", async () (error: any) => error?.code === "execution_report_required" ); const firstReport = await store.writeReport({ taskId: task.id, title: "Execution", body: "passed" }); + assert.equal(firstReport.task.status, "completed"); + assert.equal(firstReport.taskMutation, true); const repeatedReport = await store.writeReport({ taskId: task.id, title: "Execution", body: "passed" }); + assert.equal(repeatedReport.taskMutation, false); + await store.updateTask(task.id, { status: "blocked" }); + const correctedReport = await store.writeReport({ taskId: task.id, title: "Execution", body: "passed" }); + assert.equal(correctedReport.mutation, false); + assert.equal(correctedReport.taskMutation, true); + assert.equal(correctedReport.task.status, "completed"); const changedReport = await store.writeReport({ taskId: task.id, title: "Execution", body: "passed with evidence" }); assert.equal(firstReport.mutation, true); assert.equal(repeatedReport.mutation, false); assert.equal(changedReport.mutation, true); + assert.equal(changedReport.taskMutation, false); assert.equal(firstReport.report.id, repeatedReport.report.id); assert.equal(firstReport.report.id, changedReport.report.id); assert.equal((await store.completeTask(task.id))?.status, "completed");