// ---- Interfaces ---- export interface TodoProgress { text: string; createdAt: string; } export interface TodoTask { id: string; text: string; createdAt: string; progress: TodoProgress[]; completedAt?: string; cancelledAt?: string; } // ---- Event union ---- export type TodoEvent = | { type: "created"; id: string; text: string; createdAt: string } | { type: "progress_added"; id: string; text: string; createdAt: string } | { type: "completed"; id: string; completedAt: string } | { type: "cancelled"; id: string; cancelledAt: string }; // ---- Event details (wrapper stored in tool result details) ---- export interface TodoEventDetails { version: 1; kind: "todo-event"; event: TodoEvent; } // ---- Factory / reader ---- export function makeTodoEventDetails(event: TodoEvent): TodoEventDetails { return { version: 1, kind: "todo-event", event }; } /** * Defensively validate and read a TodoEvent from an unknown value. * Returns the event on success, or `undefined` for any malformed input. * Never throws. */ export function readTodoEventDetails(value: unknown): TodoEvent | undefined { if (typeof value !== "object" || value === null) return undefined; const obj = value as Record; if (obj.version !== 1) return undefined; if (obj.kind !== "todo-event") return undefined; const event = obj.event; if (typeof event !== "object" || event === null) return undefined; const ev = event as Record; if (typeof ev.type !== "string") return undefined; switch (ev.type) { case "created": { if (typeof ev.id !== "string" || typeof ev.text !== "string" || typeof ev.createdAt !== "string") { return undefined; } return { type: "created", id: ev.id, text: ev.text, createdAt: ev.createdAt }; } case "progress_added": { if (typeof ev.id !== "string" || typeof ev.text !== "string" || typeof ev.createdAt !== "string") { return undefined; } return { type: "progress_added", id: ev.id, text: ev.text, createdAt: ev.createdAt }; } case "completed": { if (typeof ev.id !== "string" || typeof ev.completedAt !== "string") { return undefined; } return { type: "completed", id: ev.id, completedAt: ev.completedAt }; } case "cancelled": { if (typeof ev.id !== "string" || typeof ev.cancelledAt !== "string") { return undefined; } return { type: "cancelled", id: ev.id, cancelledAt: ev.cancelledAt }; } default: return undefined; } } // ---- Helper ---- /** * Return the effective "last updated" timestamp: * – completedAt or cancelledAt if present (terminal states win), * – otherwise the latest progress entry's createdAt, * – otherwise the task's own createdAt. */ export function getTaskUpdatedAt(task: TodoTask): string { if (task.completedAt !== undefined) return task.completedAt; if (task.cancelledAt !== undefined) return task.cancelledAt; if (task.progress.length > 0) { return task.progress[task.progress.length - 1].createdAt; } return task.createdAt; } // ---- Internal helpers ---- function deepCloneTask(task: TodoTask): TodoTask { return { id: task.id, text: task.text, createdAt: task.createdAt, progress: task.progress.map((p) => ({ text: p.text, createdAt: p.createdAt })), completedAt: task.completedAt, cancelledAt: task.cancelledAt, }; } // ---- State class ---- export class TodoState { private _tasks: Map = new Map(); /** Insertion order of task IDs */ private _order: string[] = []; // --------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------- reset(): void { this._tasks.clear(); this._order = []; } /** * Replace the entire state by replaying a sequence of events. * Equivalent to reset() followed by apply() for each event. */ replay(events: readonly TodoEvent[]): void { this.reset(); for (const event of events) { this.apply(event); } } // --------------------------------------------------------------- // Resilient event application (for persisted history) // --------------------------------------------------------------- /** * Apply a single event to the current state. * Rules (all silent / never throw): * – "created": first create wins; duplicate IDs are ignored. * – "progress_added": unknown IDs are ignored; completed or cancelled tasks ignore further progress. * – "completed": unknown IDs are ignored; repeat completions keep the first completedAt; * cancelled tasks ignore completion (first terminal state wins). * – "cancelled": unknown IDs are ignored; repeat cancellations keep the first cancelledAt; * completed tasks ignore cancellation (first terminal state wins). */ apply(event: TodoEvent): void { switch (event.type) { case "created": { if (this._tasks.has(event.id)) return; const task: TodoTask = { id: event.id, text: event.text, createdAt: event.createdAt, progress: [], }; this._tasks.set(event.id, task); this._order.push(event.id); break; } case "progress_added": { const task = this._tasks.get(event.id); if (!task) return; if (task.completedAt !== undefined || task.cancelledAt !== undefined) return; task.progress.push({ text: event.text, createdAt: event.createdAt }); break; } case "completed": { const task = this._tasks.get(event.id); if (!task) return; if (task.completedAt !== undefined || task.cancelledAt !== undefined) return; task.completedAt = event.completedAt; break; } case "cancelled": { const task = this._tasks.get(event.id); if (!task) return; if (task.completedAt !== undefined || task.cancelledAt !== undefined) return; task.cancelledAt = event.cancelledAt; break; } } } // --------------------------------------------------------------- // Public command methods (validate, then apply + return deep copy) // --------------------------------------------------------------- create(id: string, text: string, createdAt: string): { task: TodoTask; event: TodoEvent } { if (id === "") throw new Error("id must not be empty"); if (text === "") throw new Error("text must not be empty"); if (this._tasks.has(id)) throw new Error(`task with id "${id}" already exists`); const event: TodoEvent = { type: "created", id, text, createdAt }; this.apply(event); return { task: deepCloneTask(this._tasks.get(id)!), event }; } add(id: string, text: string, createdAt: string): { task: TodoTask; event: TodoEvent } { if (id === "") throw new Error("id must not be empty"); if (text === "") throw new Error("text must not be empty"); const task = this._tasks.get(id); if (!task) throw new Error(`task with id "${id}" not found`); if (task.completedAt !== undefined) throw new Error(`task "${id}" is already completed`); if (task.cancelledAt !== undefined) throw new Error(`task "${id}" is cancelled`); const event: TodoEvent = { type: "progress_added", id, text, createdAt }; this.apply(event); return { task: deepCloneTask(this._tasks.get(id)!), event }; } complete(id: string, completedAt: string): { task: TodoTask; event?: TodoEvent; alreadyCompleted: boolean } { if (id === "") throw new Error("id must not be empty"); const task = this._tasks.get(id); if (!task) throw new Error(`task with id "${id}" not found`); if (task.completedAt !== undefined) { // Idempotent: return the current state without creating a new event return { task: deepCloneTask(task), event: undefined, alreadyCompleted: true }; } const event: TodoEvent = { type: "completed", id, completedAt }; this.apply(event); return { task: deepCloneTask(this._tasks.get(id)!), event, alreadyCompleted: false }; } cancel(id: string, cancelledAt: string): { task: TodoTask; event?: TodoEvent; alreadyCancelled: boolean } { if (id === "") throw new Error("id must not be empty"); const task = this._tasks.get(id); if (!task) throw new Error(`task with id "${id}" not found`); if (task.completedAt !== undefined) throw new Error(`task "${id}" is already completed`); if (task.cancelledAt !== undefined) { // Idempotent: return the current state without creating a new event return { task: deepCloneTask(task), event: undefined, alreadyCancelled: true }; } const event: TodoEvent = { type: "cancelled", id, cancelledAt }; this.apply(event); return { task: deepCloneTask(this._tasks.get(id)!), event, alreadyCancelled: false }; } // --------------------------------------------------------------- // Queries (always return deep copies) // --------------------------------------------------------------- get(id: string): TodoTask | undefined { const task = this._tasks.get(id); return task ? deepCloneTask(task) : undefined; } all(): TodoTask[] { return this._order.map((id) => deepCloneTask(this._tasks.get(id)!)); } active(): TodoTask[] { return this._order .filter((id) => { const task = this._tasks.get(id)!; return task.completedAt === undefined && task.cancelledAt === undefined; }) .map((id) => deepCloneTask(this._tasks.get(id)!)); } completed(): TodoTask[] { return this._order .filter((id) => this._tasks.get(id)!.completedAt !== undefined) .map((id) => deepCloneTask(this._tasks.get(id)!)); } cancelled(): TodoTask[] { return this._order .filter((id) => this._tasks.get(id)!.cancelledAt !== undefined) .map((id) => deepCloneTask(this._tasks.get(id)!)); } }