Files
harness-tools/extensions/todo-herdr/src/state.ts
T

683 lines
27 KiB
TypeScript

import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
hasUnsafeTaskText,
MAX_TASK_DEPTH,
MAX_TASK_DESCRIPTION_LENGTH,
MAX_TASK_NAME_LENGTH,
MAX_TASKS,
MAX_TASKS_PER_MUTATION,
taskTextLength,
} from "./limits.ts";
import type {
AddTaskDraft,
Task,
TaskPatch,
TaskSnapshot,
TaskStats,
TaskStatus,
TaskTimingSnapshot,
TaskToolDetails,
} from "./types.ts";
import { TaskValidationError } from "./types.ts";
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "patch_tasks", "rm_task"]);
export const TASK_STATE_ENTRY_TYPE = "pi-todo-herdr-state";
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
const MAX_DATE_MS = 8_640_000_000_000_000;
interface TaskRecord {
id: number;
name: string;
description?: string;
status: TaskStatus;
parentId: number | null;
startedAt?: number;
activeStartedAt?: number;
stoppedAt?: number;
accumulatedMs: number;
}
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 snapshotTasks(records: TaskRecord[]): Task[] {
return records.map((task) => ({
id: task.id,
name: task.name,
...(task.description !== undefined ? { description: task.description } : {}),
status: task.status,
parentId: task.parentId,
childrenIds: childIds(records, task.id),
}));
}
function durationMs(task: TaskRecord, now: number): number {
const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt);
return Math.min(MAX_DATE_MS, task.accumulatedMs + activeMs);
}
function publicTasks(records: TaskRecord[], now: number): Task[] {
const byId = indexRecords(records);
return snapshotTasks(records).map((task) => {
const record = byId.get(task.id);
if (!record || record.startedAt === undefined) return task;
return {
...task,
startedAt: new Date(record.startedAt).toISOString(),
...(record.stoppedAt !== undefined ? { stoppedAt: new Date(record.stoppedAt).toISOString() } : {}),
durationMs: durationMs(record, now),
};
});
}
function timingSnapshots(records: TaskRecord[]): TaskTimingSnapshot[] {
const timings: TaskTimingSnapshot[] = [];
for (const task of records) {
if (task.startedAt === undefined) continue;
timings.push({
taskId: task.id,
startedAt: task.startedAt,
...(task.activeStartedAt !== undefined ? { activeStartedAt: task.activeStartedAt } : {}),
...(task.stoppedAt !== undefined ? { stoppedAt: task.stoppedAt } : {}),
accumulatedMs: task.accumulatedMs,
});
}
return timings;
}
function startTimer(task: TaskRecord, now: number): void {
const startedAt = Math.max(now, task.stoppedAt ?? task.startedAt ?? 0);
task.startedAt ??= startedAt;
task.activeStartedAt = startedAt;
task.stoppedAt = undefined;
}
function stopTimer(task: TaskRecord, now: number): void {
if (task.activeStartedAt === undefined) return;
const stoppedAt = Math.max(now, task.activeStartedAt);
task.accumulatedMs = Math.min(MAX_DATE_MS, task.accumulatedMs + stoppedAt - task.activeStartedAt);
task.activeStartedAt = undefined;
task.stoppedAt = stoppedAt;
}
function isNonNegativeSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function isValidTimestamp(value: unknown): value is number {
return isNonNegativeSafeInteger(value) && value <= MAX_DATE_MS;
}
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.isSafeInteger(task.id) || task.id < 1) {
errors.push(`task ${task.id}: id must be a positive safe 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 (hasUnsafeTaskText(task.name)) errors.push(`task ${task.id}: name contains an unsafe control character`);
if (taskTextLength(task.name) > MAX_TASK_NAME_LENGTH) {
errors.push(`task ${task.id}: name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
}
if (task.description !== undefined) {
if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`);
if (taskTextLength(task.description) > MAX_TASK_DESCRIPTION_LENGTH) {
errors.push(`task ${task.id}: description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
}
if (hasUnsafeTaskText(task.description)) {
errors.push(`task ${task.id}: description contains an unsafe control character`);
}
}
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
if (!isNonNegativeSafeInteger(task.accumulatedMs) || task.accumulatedMs > MAX_DATE_MS) {
errors.push(`task ${task.id}: accumulatedMs must be an integer between 0 and ${MAX_DATE_MS}`);
}
if (task.startedAt === undefined) {
if (task.activeStartedAt !== undefined || task.stoppedAt !== undefined || task.accumulatedMs !== 0) {
errors.push(`task ${task.id}: timer fields require startedAt`);
}
if (task.status === "in-progress") errors.push(`task ${task.id}: in-progress task is missing timer data`);
} else {
if (!isValidTimestamp(task.startedAt)) {
errors.push(`task ${task.id}: startedAt must be a valid non-negative timestamp`);
}
if (task.activeStartedAt !== undefined && !isValidTimestamp(task.activeStartedAt)) {
errors.push(`task ${task.id}: activeStartedAt must be a valid non-negative timestamp`);
}
if (task.stoppedAt !== undefined && !isValidTimestamp(task.stoppedAt)) {
errors.push(`task ${task.id}: stoppedAt must be a valid non-negative timestamp`);
}
if (task.activeStartedAt !== undefined && task.activeStartedAt < task.startedAt) {
errors.push(`task ${task.id}: activeStartedAt must not precede startedAt`);
}
if (task.stoppedAt !== undefined && task.stoppedAt < task.startedAt) {
errors.push(`task ${task.id}: stoppedAt must not precede startedAt`);
}
if (task.status === "in-progress") {
if (task.activeStartedAt === undefined) errors.push(`task ${task.id}: active timer is missing its segment start`);
if (task.stoppedAt !== undefined) errors.push(`task ${task.id}: active timer must not have stoppedAt`);
} else {
if (task.activeStartedAt !== undefined) errors.push(`task ${task.id}: inactive task has an active timer segment`);
if (task.stoppedAt === undefined) errors.push(`task ${task.id}: paused timer is missing stoppedAt`);
}
}
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;
const depths = new Map<number, number>();
const depthOf = (task: TaskRecord): number => {
const cached = depths.get(task.id);
if (cached !== undefined) return cached;
const parent = task.parentId === null ? undefined : byId.get(task.parentId);
const depth = parent === undefined ? 1 : depthOf(parent) + 1;
depths.set(task.id, depth);
return depth;
};
for (const task of records) {
if (depthOf(task) > MAX_TASK_DEPTH) {
errors.push(`task ${task.id}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
}
}
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;
private readonly now: () => number;
constructor(now: () => number = Date.now) {
this.now = now;
}
private currentTime(): number {
const now = Math.floor(this.now());
if (!isValidTimestamp(now)) throw new Error("task clock must return a valid non-negative timestamp");
return now;
}
list(): Task[] {
const now = this.records.some((task) => task.startedAt !== undefined) ? this.currentTime() : 0;
return publicTasks(this.records, now);
}
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 {
const timings = timingSnapshots(this.records);
return {
tasks: snapshotTasks(this.records),
nextId: this.nextId,
...(timings.length > 0 ? { timings } : {}),
};
}
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"]);
if (drafts.length > MAX_TASKS_PER_MUTATION) {
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`]);
}
const working = cloneRecords(this.records);
const existingIds = new Set(working.map((task) => task.id));
const now = this.currentTime();
let nextId = this.nextId;
const addedIds: number[] = [];
let mutationTaskCount = 0;
const append = (
draft: AddTaskDraft,
implicitParentId: number | null,
path: string,
nested: boolean,
depth: number,
) => {
mutationTaskCount += 1;
if (mutationTaskCount > MAX_TASKS_PER_MUTATION) {
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
return;
}
if (depth > MAX_TASK_DEPTH) {
errors.push(`${path}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
return;
}
if (working.length >= MAX_TASKS) {
errors.push(`task session is limited to ${MAX_TASKS} tasks`);
return;
}
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.name === "string" && hasUnsafeTaskText(draft.name)) {
errors.push(`${path}.name contains an unsafe control character`);
}
if (typeof draft.name === "string" && taskTextLength(draft.name.trim()) > MAX_TASK_NAME_LENGTH) {
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
}
if (draft.description !== undefined && draft.description !== null) {
if (typeof draft.description !== "string" || !draft.description.trim()) {
errors.push(`${path}.description must not be empty`);
} else {
if (hasUnsafeTaskText(draft.description)) {
errors.push(`${path}.description contains an unsafe control character`);
}
if (taskTextLength(draft.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
}
}
}
if (draft.status !== undefined && draft.status !== null && !VALID_STATUSES.has(draft.status)) {
errors.push(`${path}.status is invalid`);
}
if (nested && draft.parentId !== undefined && draft.parentId !== null) {
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 && draft.parentId !== null) {
if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
errors.push(`${path}.parentId ${draft.parentId} does not exist`);
} else {
parentId = draft.parentId;
}
}
if (nextId >= Number.MAX_SAFE_INTEGER) {
errors.push("no safe task IDs remain");
return;
}
const id = nextId++;
const status = draft.status ?? "pending";
addedIds.push(id);
working.push({
id,
name: typeof draft.name === "string" ? draft.name.trim() : "",
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
status,
parentId,
accumulatedMs: 0,
...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}),
});
const children = Array.isArray(draft.children) ? draft.children : [];
for (const [index, child] of children.entries()) {
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
break;
}
append(child, id, `${path}.children[${index}]`, true, depth + 1);
}
};
for (const [index, draft] of drafts.entries()) {
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
break;
}
append(draft, null, `tasks[${index}]`, false, 1);
}
errors.push(...validateGraph(working));
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
this.records = working;
this.nextId = nextId;
return this.get(addedIds);
}
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
if (mode === "append") {
if (force === true) throw new TaskValidationError(["force must be false in append 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 && draft.parentId !== null) {
errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
}
});
if (errors.length > 0) throw new TaskValidationError(errors);
const replacement = new TaskState(this.now);
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"]);
if (patches.length > MAX_TASKS_PER_MUTATION) {
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} patches`]);
}
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);
const now = this.currentTime();
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 hasNameChange = patch.name !== undefined && patch.name !== null;
const hasDescriptionChange = patch.description !== undefined && patch.description !== null;
const hasStatusChange = patch.status !== undefined && patch.status !== null;
const hasParentChange = patch.parentId !== undefined && patch.parentId !== -1;
const hasPositionChange = patch.position !== undefined && patch.position !== null && patch.position !== -1;
if (!hasNameChange && !hasDescriptionChange && !hasStatusChange && !hasParentChange && !hasPositionChange) {
errors.push(`${path} must include at least one field to update`);
}
if (hasNameChange && (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 (typeof patch.name === "string" && hasUnsafeTaskText(patch.name)) {
errors.push(`${path}.name contains an unsafe control character`);
}
if (typeof patch.name === "string" && taskTextLength(patch.name.trim()) > MAX_TASK_NAME_LENGTH) {
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
}
if (hasDescriptionChange && (typeof patch.description !== "string" || !patch.description.trim())) {
errors.push(`${path}.description must not be empty`);
}
if (typeof patch.description === "string") {
if (hasUnsafeTaskText(patch.description)) {
errors.push(`${path}.description contains an unsafe control character`);
}
if (taskTextLength(patch.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
}
}
if (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`);
if (
hasParentChange &&
patch.parentId !== null &&
patch.parentId !== 0 &&
(!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId))
) {
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
}
if (patch.name !== undefined && patch.name !== null) task.name = patch.name.trim();
if (patch.description !== undefined && patch.description !== null) task.description = patch.description.trim();
if (patch.status !== undefined && patch.status !== null && patch.status !== task.status) {
if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now);
task.status = patch.status;
}
if (
patch.parentId !== undefined &&
patch.parentId !== -1 &&
(patch.parentId === null ||
patch.parentId === 0 ||
working.some((item) => item.id === patch.parentId))
) {
task.parentId = patch.parentId === 0 ? null : patch.parentId;
moveToSiblingEnd(working, task.id);
}
if (patch.position !== undefined && patch.position !== null && patch.position !== -1) {
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"]);
if (ids.length > MAX_TASKS_PER_MUTATION) {
throw new TaskValidationError([`ids must contain at most ${MAX_TASKS_PER_MUTATION} entries`]);
}
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 now = this.currentTime();
for (const task of this.records) {
if (removedIds.has(task.id) && task.status === "in-progress") stopTimer(task, now);
}
const removed = publicTasks(this.records, now).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)) {
throw new TaskValidationError(["invalid task snapshot"]);
}
if (!Number.isSafeInteger(snapshot.nextId) || snapshot.nextId < 1) {
throw new TaskValidationError(["nextId must be a positive safe integer"]);
}
if (snapshot.timings !== undefined && !Array.isArray(snapshot.timings)) {
throw new TaskValidationError(["invalid task snapshot timings"]);
}
if (snapshot.tasks.length > MAX_TASKS) {
throw new TaskValidationError([`task session is limited to ${MAX_TASKS} tasks`]);
}
if ((snapshot.timings?.length ?? 0) > MAX_TASKS) {
throw new TaskValidationError([`task snapshot timings are limited to ${MAX_TASKS} entries`]);
}
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
id,
name,
description,
status,
parentId,
accumulatedMs: 0,
}));
const byId = indexRecords(records);
const timingErrors: string[] = [];
const timedIds = new Set<number>();
for (const [index, timing] of (snapshot.timings ?? []).entries()) {
const path = `timings[${index}]`;
if (!timing || typeof timing !== "object") {
timingErrors.push(`${path} must be an object`);
continue;
}
if (!Number.isSafeInteger(timing.taskId) || timing.taskId < 1) {
timingErrors.push(`${path}.taskId must be a positive safe integer`);
continue;
}
if (timedIds.has(timing.taskId)) {
timingErrors.push(`${path}.taskId ${timing.taskId} is duplicated`);
continue;
}
timedIds.add(timing.taskId);
const task = byId.get(timing.taskId);
if (!task) {
timingErrors.push(`${path}.taskId ${timing.taskId} does not exist`);
continue;
}
task.startedAt = timing.startedAt;
task.activeStartedAt = timing.activeStartedAt;
task.stoppedAt = timing.stoppedAt;
task.accumulatedMs = timing.accumulatedMs;
}
if (timingErrors.length > 0) throw new TaskValidationError(timingErrors);
if (snapshot.timings === undefined && records.some((task) => task.status === "in-progress")) {
const now = this.currentTime();
for (const task of records) {
if (task.status === "in-progress") startTimer(task, now);
}
}
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()) {
let snapshot: TaskSnapshot | undefined;
if (entry.type === "custom" && entry.customType === TASK_STATE_ENTRY_TYPE) {
snapshot = entry.data as TaskSnapshot | undefined;
} else if (entry.type === "message") {
const message = entry.message;
if (message.role === "toolResult" && MUTATION_TOOLS.has(message.toolName)) {
snapshot = (message.details as TaskToolDetails | undefined)?.snapshot;
}
}
if (!snapshot) continue;
try {
this.restore(snapshot);
} catch {
// Ignore corrupt historical snapshots and keep the latest valid state.
}
}
}
}