Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/task-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,25 @@ function isProcessRunning(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}

/**
* Fill defaults for tasks persisted by older versions. Task files written
* before the blocking feature have no `blockedBy`/`blocks`/`metadata`, so
* consumers that read those fields unguarded (e.g. `task.blockedBy.length`)
* would throw. Normalizing at the load boundary lets every consumer trust the
* shape. Also guards against wrong types in hand-edited files.
*/
function normalizeTask(t: Task): Task {
const now = Date.now();
return {
...t,
metadata: t.metadata && typeof t.metadata === "object" && !Array.isArray(t.metadata) ? t.metadata : {},
blocks: Array.isArray(t.blocks) ? t.blocks : [],
blockedBy: Array.isArray(t.blockedBy) ? t.blockedBy : [],
createdAt: typeof t.createdAt === "number" ? t.createdAt : now,
updatedAt: typeof t.updatedAt === "number" ? t.updatedAt : now,
};
}

export class TaskStore {
private filePath: string | undefined;
private lockPath: string | undefined;
Expand Down Expand Up @@ -99,7 +118,7 @@ export class TaskStore {
this.nextId = data.nextId;
this.tasks.clear();
for (const t of data.tasks) {
this.tasks.set(t.id, t);
this.tasks.set(t.id, normalizeTask(t));
}
} catch { /* corrupt file — start fresh */ }
}
Expand Down
13 changes: 12 additions & 1 deletion src/ui/task-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,19 @@ export class TaskWidget {
}
}

/** Build widget lines from current live state. Called from the render callback. */
/** Render callback entry point. Guarded so a render error can never escape to
* the TUI timer and crash the whole host process — worst case the widget is
* empty for one frame. */
private renderWidget(tui: any, theme: Theme): string[] {
try {
return this.buildWidgetLines(tui, theme);
} catch {
return [];
}
}

/** Build widget lines from current live state. */
private buildWidgetLines(tui: any, theme: Theme): string[] {
const sortOrder = this.config.sortOrder ?? "id";
const tasks = this.store.list(sortOrder);
const w = tui.terminal.columns;
Expand Down
28 changes: 27 additions & 1 deletion test/task-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -475,6 +475,32 @@ describe("TaskStore (absolute path)", () => {
rmSync(parentDir, { recursive: true, force: true });
});

it("normalizes legacy task records missing blockedBy/blocks/metadata on load", () => {
const dir = join(tmpdir(), `pi-tasks-legacy-${process.pid}-${Date.now()}`);
const filePath = join(dir, "tasks.json");
mkdirSync(dir, { recursive: true });
try {
// A task file written before the blocking feature — no blockedBy/blocks/metadata.
writeFileSync(filePath, JSON.stringify({
nextId: 2,
tasks: [{
id: "1",
subject: "Legacy task",
description: "From an older version",
status: "pending",
}],
}));

const task = new TaskStore(filePath).get("1")!;
expect(task.blockedBy).toEqual([]);
expect(task.blocks).toEqual([]);
expect(task.metadata).toEqual({});
expect(task.subject).toBe("Legacy task"); // existing fields preserved
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("creates the backing directory lazily — not on construction, but on first write", () => {
const parentDir = join(tmpdir(), `pi-tasks-lazy-${Date.now()}`);
const filePath = join(parentDir, "tasks.json");
Expand Down
14 changes: 14 additions & 0 deletions test/task-widget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ describe("TaskWidget", () => {
expect(blockedLine).not.toContain("blocked by");
});

it("does not crash the host when a task is missing legacy fields", () => {
// Simulate a record persisted before the blocking feature — no blockedBy.
// A throw here would escape to the TUI timer and kill the whole pi process,
// so the render must never throw (the guard returns a safe fallback).
store.create("Legacy pending", "Desc");
const raw = store.get("1") as any;
delete raw.blockedBy;
delete raw.blocks;
delete raw.metadata;
widget.update();

expect(() => renderWidget(ui.state)).not.toThrow();
});

it("shows status summary in header", () => {
store.create("Task A", "Desc");
store.create("Task B", "Desc");
Expand Down
Loading