Initial release
This commit is contained in:
+343
@@ -0,0 +1,343 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
AddTaskDraft,
|
||||
Task,
|
||||
TaskPatch,
|
||||
TaskSnapshot,
|
||||
TaskStats,
|
||||
TaskStatus,
|
||||
TaskToolDetails,
|
||||
} from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
|
||||
const MUTATION_TOOLS = new Set(["add_task", "update_task", "rm_task"]);
|
||||
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
|
||||
|
||||
interface TaskRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
}
|
||||
|
||||
function cloneRecords(records: TaskRecord[]): TaskRecord[] {
|
||||
return records.map((task) => ({ ...task }));
|
||||
}
|
||||
|
||||
function indexRecords(records: TaskRecord[]): Map<number, TaskRecord> {
|
||||
return new Map(records.map((task) => [task.id, task]));
|
||||
}
|
||||
|
||||
function childIds(records: TaskRecord[], parentId: number | null): number[] {
|
||||
return records.filter((task) => task.parentId === parentId).map((task) => task.id);
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[]): Task[] {
|
||||
return records.map((task) => ({
|
||||
...task,
|
||||
childrenIds: childIds(records, task.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
|
||||
const result: number[] = [];
|
||||
const visit = (id: number) => {
|
||||
for (const childId of childIds(records, id)) {
|
||||
result.push(childId);
|
||||
visit(childId);
|
||||
}
|
||||
};
|
||||
visit(rootId);
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateGraph(records: TaskRecord[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const byId = indexRecords(records);
|
||||
const ids = new Set<number>();
|
||||
|
||||
for (const task of records) {
|
||||
if (!Number.isInteger(task.id) || task.id < 1) errors.push(`task ${task.id}: id must be a positive integer`);
|
||||
if (ids.has(task.id)) errors.push(`task ${task.id}: duplicate id`);
|
||||
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 (!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`);
|
||||
}
|
||||
}
|
||||
|
||||
const visiting = new Set<number>();
|
||||
const visited = new Set<number>();
|
||||
const visit = (id: number): boolean => {
|
||||
if (visiting.has(id)) return true;
|
||||
if (visited.has(id)) return false;
|
||||
visiting.add(id);
|
||||
const parentId = byId.get(id)?.parentId;
|
||||
if (parentId !== null && parentId !== undefined && visit(parentId)) return true;
|
||||
visiting.delete(id);
|
||||
visited.add(id);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const task of records) {
|
||||
if (visit(task.id)) {
|
||||
errors.push(`task ${task.id}: parentId creates a cycle`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) return errors;
|
||||
|
||||
for (const task of records) {
|
||||
if (task.status !== "done") continue;
|
||||
const unfinished = collectDescendantIds(records, task.id).filter((id) => byId.get(id)?.status !== "done");
|
||||
if (unfinished.length > 0) {
|
||||
errors.push(`task ${task.id}: done task has unfinished descendants ${unfinished.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function insertAtSiblingPosition(records: TaskRecord[], id: number, position: number): string | undefined {
|
||||
const byId = indexRecords(records);
|
||||
const task = byId.get(id);
|
||||
if (!task) return `task ${id}: does not exist`;
|
||||
|
||||
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
|
||||
if (!Number.isInteger(position) || position < 0 || position > siblings.length) {
|
||||
return `task ${id}: position must be between 0 and ${siblings.length}`;
|
||||
}
|
||||
|
||||
const recordIndex = records.findIndex((record) => record.id === id);
|
||||
const [record] = records.splice(recordIndex, 1);
|
||||
if (!record) return `task ${id}: does not exist`;
|
||||
|
||||
if (position < siblings.length) {
|
||||
const targetIndex = records.findIndex((candidate) => candidate.id === siblings[position]);
|
||||
records.splice(targetIndex, 0, record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (siblings.length === 0) {
|
||||
records.push(record);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
|
||||
records.splice(lastSiblingIndex + 1, 0, record);
|
||||
}
|
||||
|
||||
function moveToSiblingEnd(records: TaskRecord[], id: number): void {
|
||||
const task = records.find((candidate) => candidate.id === id);
|
||||
if (!task) return;
|
||||
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
|
||||
const currentIndex = records.findIndex((candidate) => candidate.id === id);
|
||||
const [record] = records.splice(currentIndex, 1);
|
||||
if (!record) return;
|
||||
if (siblings.length === 0) {
|
||||
records.push(record);
|
||||
return;
|
||||
}
|
||||
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
|
||||
records.splice(lastSiblingIndex + 1, 0, record);
|
||||
}
|
||||
|
||||
export class TaskState {
|
||||
private records: TaskRecord[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
list(): Task[] {
|
||||
return publicTasks(this.records);
|
||||
}
|
||||
|
||||
get(ids: number[]): Task[] {
|
||||
const byId = new Map(this.list().map((task) => [task.id, task]));
|
||||
return ids.map((id) => byId.get(id)).filter((task): task is Task => task !== undefined);
|
||||
}
|
||||
|
||||
snapshot(): TaskSnapshot {
|
||||
return { tasks: this.list(), nextId: this.nextId };
|
||||
}
|
||||
|
||||
stats(): TaskStats {
|
||||
const tasks = this.records;
|
||||
return {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter((task) => task.status === "pending").length,
|
||||
inProgress: tasks.filter((task) => task.status === "in-progress").length,
|
||||
blocked: tasks.filter((task) => task.status === "blocked").length,
|
||||
done: tasks.filter((task) => task.status === "done").length,
|
||||
};
|
||||
}
|
||||
|
||||
add(drafts: AddTaskDraft[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(drafts) || drafts.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const existingIds = new Set(working.map((task) => task.id));
|
||||
let nextId = this.nextId;
|
||||
const addedIds: number[] = [];
|
||||
|
||||
const append = (draft: AddTaskDraft, implicitParentId: number | null, path: string, nested: boolean) => {
|
||||
if (!draft || typeof draft !== "object") {
|
||||
errors.push(`${path}: task must be an object`);
|
||||
return;
|
||||
}
|
||||
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()) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
}
|
||||
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
|
||||
errors.push(`${path}.status is invalid`);
|
||||
}
|
||||
if (nested && draft.parentId !== undefined) errors.push(`${path}.parentId is not allowed inside children`);
|
||||
if (draft.children !== undefined && !Array.isArray(draft.children)) errors.push(`${path}.children must be an array`);
|
||||
|
||||
let parentId = implicitParentId;
|
||||
if (!nested && draft.parentId !== undefined) {
|
||||
if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
|
||||
errors.push(`${path}.parentId ${draft.parentId} does not exist`);
|
||||
} else {
|
||||
parentId = draft.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
const id = nextId++;
|
||||
addedIds.push(id);
|
||||
working.push({
|
||||
id,
|
||||
name: typeof draft.name === "string" ? draft.name.trim() : "",
|
||||
description: typeof draft.description === "string" ? draft.description.trim() : "",
|
||||
status: draft.status ?? "pending",
|
||||
parentId,
|
||||
});
|
||||
|
||||
for (const [index, child] of (draft.children ?? []).entries()) {
|
||||
append(child, id, `${path}.children[${index}]`, true);
|
||||
}
|
||||
};
|
||||
|
||||
drafts.forEach((draft, index) => append(draft, null, `tasks[${index}]`, false));
|
||||
errors.push(...validateGraph(working));
|
||||
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
|
||||
|
||||
this.records = working;
|
||||
this.nextId = nextId;
|
||||
return this.get(addedIds);
|
||||
}
|
||||
|
||||
update(patches: TaskPatch[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
|
||||
const duplicateIds = patches
|
||||
.map((patch) => patch.id)
|
||||
.filter((id, index, ids) => ids.indexOf(id) !== index);
|
||||
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
for (const [index, patch] of patches.entries()) {
|
||||
const path = `tasks[${index}]`;
|
||||
const task = working.find((candidate) => candidate.id === patch.id);
|
||||
if (!task) {
|
||||
errors.push(`${path}.id ${patch.id} does not exist`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasChange = ["name", "description", "status", "parentId", "position"].some((key) =>
|
||||
Object.prototype.hasOwnProperty.call(patch, key),
|
||||
);
|
||||
if (!hasChange) errors.push(`${path} must include at least one field to update`);
|
||||
if (patch.name !== undefined && (typeof patch.name !== "string" || !patch.name.trim())) {
|
||||
errors.push(`${path}.name must not be empty`);
|
||||
}
|
||||
if (typeof patch.name === "string" && /[\r\n]/.test(patch.name)) errors.push(`${path}.name must be a single line`);
|
||||
if (patch.description !== undefined && (typeof patch.description !== "string" || !patch.description.trim())) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
}
|
||||
if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) errors.push(`${path}.status is invalid`);
|
||||
if (patch.parentId !== undefined && patch.parentId !== null) {
|
||||
if (!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId)) {
|
||||
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) task.name = patch.name.trim();
|
||||
if (patch.description !== undefined) task.description = patch.description.trim();
|
||||
if (patch.status !== undefined) task.status = patch.status;
|
||||
if (patch.parentId !== undefined && (patch.parentId === null || working.some((item) => item.id === patch.parentId))) {
|
||||
task.parentId = patch.parentId;
|
||||
moveToSiblingEnd(working, task.id);
|
||||
}
|
||||
if (patch.position !== undefined) {
|
||||
const positionError = insertAtSiblingPosition(working, task.id, patch.position);
|
||||
if (positionError) errors.push(`${path}.position: ${positionError}`);
|
||||
}
|
||||
}
|
||||
|
||||
errors.push(...validateGraph(working));
|
||||
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
|
||||
|
||||
this.records = working;
|
||||
return this.get(patches.map((patch) => patch.id));
|
||||
}
|
||||
|
||||
remove(ids: number[]): Task[] {
|
||||
if (!Array.isArray(ids) || ids.length === 0) throw new TaskValidationError(["ids must not be empty"]);
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const missing = uniqueIds.filter((id) => !this.records.some((task) => task.id === id));
|
||||
if (missing.length > 0) throw new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]);
|
||||
|
||||
const removedIds = new Set<number>();
|
||||
for (const id of uniqueIds) {
|
||||
removedIds.add(id);
|
||||
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId);
|
||||
}
|
||||
const removed = this.list().filter((task) => removedIds.has(task.id));
|
||||
this.records = this.records.filter((task) => !removedIds.has(task.id));
|
||||
return removed;
|
||||
}
|
||||
|
||||
restore(snapshot: TaskSnapshot): void {
|
||||
if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) {
|
||||
throw new TaskValidationError(["invalid task snapshot"]);
|
||||
}
|
||||
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
parentId,
|
||||
}));
|
||||
const errors = validateGraph(records);
|
||||
const maxId = records.reduce((max, task) => Math.max(max, task.id), 0);
|
||||
if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`);
|
||||
if (errors.length > 0) throw new TaskValidationError(errors);
|
||||
this.records = cloneRecords(records);
|
||||
this.nextId = snapshot.nextId;
|
||||
}
|
||||
|
||||
loadFromSession(ctx: ExtensionContext): void {
|
||||
this.records = [];
|
||||
this.nextId = 1;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const message = entry.message;
|
||||
if (message.role !== "toolResult" || !MUTATION_TOOLS.has(message.toolName)) continue;
|
||||
const details = message.details as TaskToolDetails | undefined;
|
||||
if (!details?.snapshot) continue;
|
||||
try {
|
||||
this.restore(details.snapshot);
|
||||
} catch {
|
||||
// Ignore corrupt historical snapshots and keep the latest valid state.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user