fix(KB-602): harden file operations for database-backed storage

- Create task directories on-demand when file operations need them\n- Handle missing directories gracefully for writes (PROMPT.md, task.json, agent.log, attachments)\n- Handle missing files gracefully for reads (empty string returns)\n- Add comprehensive tests for storage resilience (161 new assertions)\n- Add changeset for the patch release
This commit is contained in:
gsxdsm
2026-03-31 17:30:36 -07:00
parent 789cc519da
commit 0982ab74e3
3 changed files with 175 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { TaskStore } from "./store.js";
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
import { readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -58,6 +58,12 @@ describe("TaskStore", () => {
return task;
}
async function deleteTaskDir(taskId: string): Promise<string> {
const dir = join(rootDir, ".kb", "tasks", taskId);
await rm(dir, { recursive: true, force: true });
return dir;
}
// ── Prompt generation (no duplicate description) ───────────────
describe("prompt generation", () => {
@@ -804,6 +810,123 @@ describe("TaskStore", () => {
});
});
describe("SQLite-first reads when task blobs are missing", () => {
it("getTask returns metadata from SQLite with an empty prompt when the task directory is missing", async () => {
const task = await createTestTask();
await deleteTaskDir(task.id);
const fetched = await store.getTask(task.id);
expect(fetched.id).toBe(task.id);
expect(fetched.description).toBe(task.description);
expect(fetched.prompt).toBe("");
});
});
describe("directory recreation for file-backed blobs", () => {
it("pauseTask recreates missing task directory before writing task.json", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
const paused = await store.pauseTask(task.id, true);
expect(paused.paused).toBe(true);
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "task.json"))).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.paused).toBe(true);
});
it("updateStep recreates missing task directory and persists regenerated task.json", async () => {
const task = await createTaskWithSteps();
const promptDir = join(rootDir, ".kb", "tasks", task.id);
const prompt = await readFile(join(promptDir, "PROMPT.md"), "utf-8");
const dir = await deleteTaskDir(task.id);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), prompt);
const updated = await store.updateStep(task.id, 0, "in-progress");
expect(updated.steps[0].status).toBe("in-progress");
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "task.json"))).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.steps[0].status).toBe("in-progress");
});
it("addSteeringComment recreates missing task directory before persisting metadata", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
const updated = await store.addSteeringComment(task.id, "Please recover from missing directory");
expect(updated.steeringComments).toHaveLength(1);
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "task.json"))).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.steeringComments).toHaveLength(1);
});
it("appendAgentLog recreates missing task directory before writing agent.log", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
await store.appendAgentLog(task.id, "Recovered log", "text");
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "agent.log"))).toBe(true);
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].text).toBe("Recovered log");
});
it("addAttachment recreates missing task directory and attachment directory", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
const attachment = await store.addAttachment(task.id, "note.txt", Buffer.from("hello"), "text/plain");
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "attachments", attachment.filename))).toBe(true);
expect(existsSync(join(dir, "task.json"))).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.attachments).toHaveLength(1);
});
it("updateTask recreates missing task directory before rewriting PROMPT.md", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
const prompt = "# KB-001\n\nRecovered prompt\n";
const updated = await store.updateTask(task.id, { title: "Recovered", prompt });
expect(updated.title).toBe("Recovered");
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "PROMPT.md"))).toBe(true);
expect(await readFile(join(dir, "PROMPT.md"), "utf-8")).toBe(prompt);
const fetched = await store.getTask(task.id);
expect(fetched.title).toBe("Recovered");
expect(fetched.prompt).toBe(prompt);
});
it("duplicateTask recreates the new task directory before copying PROMPT.md", async () => {
const task = await createTestTask();
const duplicate = await store.duplicateTask(task.id);
const duplicateDir = join(rootDir, ".kb", "tasks", duplicate.id);
expect(existsSync(duplicateDir)).toBe(true);
expect(existsSync(join(duplicateDir, "PROMPT.md"))).toBe(true);
expect(await readFile(join(duplicateDir, "PROMPT.md"), "utf-8")).toContain(task.description);
});
});
describe("pauseTask", () => {
it("sets paused flag to true and adds log entry", async () => {
const task = await createTestTask();
@@ -998,6 +1121,14 @@ describe("TaskStore", () => {
expect(logs).toEqual([]);
});
it("getAgentLogs returns empty array when the task directory is missing", async () => {
const task = await createTestTask();
await deleteTaskDir(task.id);
const logs = await store.getAgentLogs(task.id);
expect(logs).toEqual([]);
});
it("appendAgentLog emits agent:log event", async () => {
const task = await createTestTask();
const events: any[] = [];
@@ -1587,6 +1718,16 @@ describe("TaskStore", () => {
});
});
describe("parseStepsFromPrompt", () => {
it("returns empty array when task directory is missing", async () => {
const task = await createTaskWithSteps();
await deleteTaskDir(task.id);
const steps = await store.parseStepsFromPrompt(task.id);
expect(steps).toEqual([]);
});
});
describe("parseDependenciesFromPrompt", () => {
it("returns single dependency from PROMPT.md", async () => {
const task = await store.createTask({ description: "Task with dep" });
@@ -1678,12 +1819,19 @@ describe("TaskStore", () => {
const task = await store.createTask({ description: "No prompt" });
const dir = join(rootDir, ".kb", "tasks", task.id);
// Delete the PROMPT.md that createTask generates
const { unlink } = await import("node:fs/promises");
await unlink(join(dir, "PROMPT.md"));
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual([]);
});
it("returns empty array when task directory is missing", async () => {
const task = await store.createTask({ description: "No directory" });
await deleteTaskDir(task.id);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual([]);
});
});
describe("parseFileScopeFromPrompt", () => {
@@ -1764,13 +1912,20 @@ describe("TaskStore", () => {
it("returns empty array when PROMPT.md does not exist", async () => {
const task = await store.createTask({ description: "No prompt" });
const dir = join(rootDir, ".kb", "tasks", task.id);
const { unlink } = await import("node:fs/promises");
await unlink(join(dir, "PROMPT.md"));
const paths = await store.parseFileScopeFromPrompt(task.id);
expect(paths).toEqual([]);
});
it("returns empty array when task directory is missing", async () => {
const task = await store.createTask({ description: "No prompt directory" });
await deleteTaskDir(task.id);
const paths = await store.parseFileScopeFromPrompt(task.id);
expect(paths).toEqual([]);
});
it("handles glob patterns in backtick-quoted paths", async () => {
const task = await store.createTask({ description: "Glob scope" });
const dir = join(rootDir, ".kb", "tasks", task.id);

View File

@@ -20,6 +20,12 @@ export interface TaskStoreEvents {
}
export class TaskStore extends EventEmitter<TaskStoreEvents> {
/**
* Hybrid storage note: task metadata lives in SQLite, while blob files remain on disk.
* Any write to `.kb/tasks/{id}` must recreate the directory on demand, and any read from
* optional blob files must tolerate missing files/directories because cleanup, migration,
* or manual filesystem changes can remove them independently of the database row.
*/
private kbDir: string;
private tasksDir: string;
private configPath: string;
@@ -616,6 +622,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const prompt = task.column === "triage"
? `# ${heading}\n\n${task.description}\n`
: this.generateSpecifiedPrompt(task);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), prompt);
this.emit("task:created", task);
@@ -659,6 +666,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Copy source PROMPT.md content (the AI will re-specify it in triage)
const sourcePrompt = sourceTask.prompt;
await mkdir(newDir, { recursive: true });
await writeFile(join(newDir, "PROMPT.md"), sourcePrompt);
// Update cache if watcher is active
@@ -717,6 +725,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Create a PROMPT.md for the refinement
const heading = newTask.title;
const prompt = `# ${heading}\n\n${newTask.description}\n`;
await mkdir(newDir, { recursive: true });
await writeFile(join(newDir, "PROMPT.md"), prompt);
// Copy attachments from source if any
@@ -932,6 +941,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (this.watcher) this.taskCache.set(id, { ...task });
if (updates.prompt !== undefined) {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), updates.prompt);
}
@@ -1878,6 +1888,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
};
const dir = this.taskDir(taskId);
const logPath = join(dir, "agent.log");
await mkdir(dir, { recursive: true });
await appendFile(logPath, JSON.stringify(entry) + "\n");
this.emit("agent:log", entry);
}
@@ -2326,6 +2337,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Generate PROMPT.md with preserved steps
const prompt = this.generatePromptFromArchiveEntry(entry);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), prompt);
// Create empty attachments directory if attachments existed