feat: strengthen task workflow and compaction recovery

This commit is contained in:
lesetong
2026-07-23 01:23:10 +08:00
parent fe05b278a4
commit 7d876735b2
13 changed files with 380 additions and 81 deletions
+30 -5
View File
@@ -10,13 +10,13 @@ import type {
} from "./types.ts";
import { TaskValidationError } from "./types.ts";
const MUTATION_TOOLS = new Set(["add_task", "update_task", "rm_task"]);
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "rm_task"]);
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
interface TaskRecord {
id: number;
name: string;
description: string;
description?: string;
status: TaskStatus;
parentId: number | null;
}
@@ -63,7 +63,9 @@ function validateGraph(records: TaskRecord[]): string[] {
ids.add(task.id);
if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`);
if (/[\r\n]/.test(task.name)) errors.push(`task ${task.id}: name must be a single line`);
if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`);
if (task.description !== undefined && !task.description.trim()) {
errors.push(`task ${task.id}: description must not be empty`);
}
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
if (task.parentId !== null && !byId.has(task.parentId)) {
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
@@ -191,7 +193,7 @@ export class TaskState {
}
if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`);
if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`);
if (typeof draft.description !== "string" || !draft.description.trim()) {
if (draft.description !== undefined && (typeof draft.description !== "string" || !draft.description.trim())) {
errors.push(`${path}.description must not be empty`);
}
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
@@ -214,7 +216,7 @@ export class TaskState {
working.push({
id,
name: typeof draft.name === "string" ? draft.name.trim() : "",
description: typeof draft.description === "string" ? draft.description.trim() : "",
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
status: draft.status ?? "pending",
parentId,
});
@@ -233,6 +235,29 @@ export class TaskState {
return this.get(addedIds);
}
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
if (mode === "append") {
if (force !== undefined) throw new TaskValidationError(["force is allowed only in replace mode"]);
return this.add(drafts);
}
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]);
if (!Array.isArray(drafts)) throw new TaskValidationError(["tasks must be an array"]);
const errors: string[] = [];
if (this.records.some((task) => task.status !== "done") && force !== true) {
errors.push("replace requires force when unfinished tasks exist");
}
drafts.forEach((draft, index) => {
if (draft?.parentId !== undefined) errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
});
if (errors.length > 0) throw new TaskValidationError(errors);
const replacement = new TaskState();
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
this.restore(replacement.snapshot());
return affected;
}
update(patches: TaskPatch[]): Task[] {
const errors: string[] = [];
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);