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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user