Initial release

This commit is contained in:
lesetong
2026-07-22 23:35:04 +08:00
commit 281f3c1615
17 changed files with 5001 additions and 0 deletions
+343
View File
@@ -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.
}
}
}
}