feat(KB-156): implement archive cleanup with restoration support
- Add ArchiveEntry type and archive.jsonl storage format for compact task archives - Implement archiveTask() with optional cleanup and archiveTaskAndCleanup() convenience method - Add restoreFromArchive() to reconstruct tasks from archive entries when directory is missing - Implement cleanupArchivedTasks() for bulk cleanup of all archived task directories - Add comprehensive tests for archive, cleanup, and restore operations (385+ lines) - Add readArchiveLog(), findInArchive(), and unarchiveTask() API methods - Document archive storage pattern and restoration behavior in AGENTS.md - Include changeset for patch release noting archive cleanup feature
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
export {
|
||||
|
||||
@@ -2284,4 +2284,389 @@ describe("TaskStore", () => {
|
||||
expect(detail.prompt).toMatch(/^# KB-001: Build the authentication system/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Archive Cleanup Tests ────────────────────────────────────────
|
||||
|
||||
describe("cleanupArchivedTasks", () => {
|
||||
it("writes compact entry to archive.jsonl without agent log", async () => {
|
||||
// Create and archive a task
|
||||
const task = await store.createTask({ description: "Test cleanup", title: "Cleanup Task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
// Add an agent log entry (should not be in archive)
|
||||
await store.appendAgentLog(task.id, "Test agent log", "text");
|
||||
|
||||
// Cleanup archived tasks
|
||||
const cleaned = await store.cleanupArchivedTasks();
|
||||
expect(cleaned).toContain(task.id);
|
||||
|
||||
// Read archive.jsonl
|
||||
const archivePath = join(rootDir, ".kb", "archive.jsonl");
|
||||
const content = await readFile(archivePath, "utf-8");
|
||||
const entry = JSON.parse(content.trim()) as import("./types.js").ArchivedTaskEntry;
|
||||
|
||||
expect(entry.id).toBe(task.id);
|
||||
expect(entry.title).toBe("Cleanup Task");
|
||||
expect(entry.description).toBe("Test cleanup");
|
||||
expect(entry.column).toBe("archived");
|
||||
// Agent log should NOT be in the archive entry
|
||||
expect(entry).not.toHaveProperty("agentLog");
|
||||
});
|
||||
|
||||
it("removes task directory after archiving", async () => {
|
||||
const task = await store.createTask({ description: "Test dir removal" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("skips already-cleaned-up tasks (idempotent)", async () => {
|
||||
const task = await store.createTask({ description: "Test idempotent" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
// First cleanup
|
||||
const cleaned1 = await store.cleanupArchivedTasks();
|
||||
expect(cleaned1).toContain(task.id);
|
||||
|
||||
// Second cleanup should skip
|
||||
const cleaned2 = await store.cleanupArchivedTasks();
|
||||
expect(cleaned2).not.toContain(task.id);
|
||||
expect(cleaned2).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves task metadata in archive entry", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Test metadata",
|
||||
title: "Metadata Task",
|
||||
});
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
// Add some metadata via updateTask
|
||||
await store.updateTask(task.id, {
|
||||
reviewLevel: 2,
|
||||
size: "M",
|
||||
});
|
||||
|
||||
// Add an attachment (metadata only, no content)
|
||||
await store.addAttachment(task.id, "test.txt", Buffer.from("test"), "text/plain");
|
||||
|
||||
await store.archiveTask(task.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
const archivePath = join(rootDir, ".kb", "archive.jsonl");
|
||||
const content = await readFile(archivePath, "utf-8");
|
||||
const entry = JSON.parse(content.trim()) as import("./types.js").ArchivedTaskEntry;
|
||||
|
||||
expect(entry.id).toBe(task.id);
|
||||
expect(entry.title).toBe("Metadata Task");
|
||||
expect(entry.size).toBe("M");
|
||||
expect(entry.reviewLevel).toBe(2);
|
||||
expect(entry.attachments).toHaveLength(1);
|
||||
expect(entry.attachments![0].originalName).toBe("test.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readArchiveLog", () => {
|
||||
it("returns empty array when archive.jsonl does not exist", async () => {
|
||||
const entries = await store.readArchiveLog();
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns parsed entries from archive.jsonl", async () => {
|
||||
const task = await store.createTask({ description: "Test read" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
const entries = await store.readArchiveLog();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].id).toBe(task.id);
|
||||
expect(entries[0].description).toBe("Test read");
|
||||
});
|
||||
|
||||
it("handles multiple entries in archive.jsonl", async () => {
|
||||
// Archive and cleanup task 1
|
||||
const task1 = await store.createTask({ description: "Task 1" });
|
||||
await store.moveTask(task1.id, "todo");
|
||||
await store.moveTask(task1.id, "in-progress");
|
||||
await store.moveTask(task1.id, "in-review");
|
||||
await store.moveTask(task1.id, "done");
|
||||
await store.archiveTask(task1.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
// Archive and cleanup task 2
|
||||
const task2 = await store.createTask({ description: "Task 2" });
|
||||
await store.moveTask(task2.id, "todo");
|
||||
await store.moveTask(task2.id, "in-progress");
|
||||
await store.moveTask(task2.id, "in-review");
|
||||
await store.moveTask(task2.id, "done");
|
||||
await store.archiveTask(task2.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
const entries = await store.readArchiveLog();
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries.map((e) => e.id).sort()).toEqual([task1.id, task2.id].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("findInArchive", () => {
|
||||
it("returns undefined when task not in archive", async () => {
|
||||
const entry = await store.findInArchive("KB-999");
|
||||
expect(entry).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns archive entry for specific task", async () => {
|
||||
const task = await store.createTask({ description: "Test find" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
const entry = await store.findInArchive(task.id);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.id).toBe(task.id);
|
||||
expect(entry!.description).toBe("Test find");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchiveTask with restore", () => {
|
||||
it("restores missing task from archive.jsonl", async () => {
|
||||
const task = await store.createTask({ description: "Test restore" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
|
||||
// Unarchive should restore from archive
|
||||
const unarchived = await store.unarchiveTask(task.id);
|
||||
expect(unarchived.column).toBe("done");
|
||||
expect(unarchived.description).toBe("Test restore");
|
||||
|
||||
// Directory should be recreated
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("works normally when task directory exists", async () => {
|
||||
const task = await store.createTask({ description: "Test normal" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
// Note: NOT calling cleanupArchivedTasks, so directory exists
|
||||
|
||||
const unarchived = await store.unarchiveTask(task.id);
|
||||
expect(unarchived.column).toBe("done");
|
||||
});
|
||||
|
||||
it("restored task has correct column (done) and preserved metadata", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Test metadata preserve",
|
||||
title: "Preserved Task",
|
||||
});
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
// Set metadata via updateTask
|
||||
await store.updateTask(task.id, { size: "L", reviewLevel: 2 });
|
||||
await store.archiveTask(task.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
const unarchived = await store.unarchiveTask(task.id);
|
||||
expect(unarchived.column).toBe("done");
|
||||
expect(unarchived.title).toBe("Preserved Task");
|
||||
expect(unarchived.size).toBe("L");
|
||||
expect(unarchived.reviewLevel).toBe(2);
|
||||
expect(unarchived.description).toBe("Test metadata preserve");
|
||||
});
|
||||
|
||||
it("throws error when task directory missing and not in archive", async () => {
|
||||
// Create a fake archived task by manually moving column
|
||||
const task = await store.createTask({ description: "Not in archive" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
// Delete directory without archiving
|
||||
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||
const { rm } = await import("node:fs/promises");
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
|
||||
await expect(store.unarchiveTask(task.id)).rejects.toThrow("not found in archive");
|
||||
});
|
||||
|
||||
it("adds log entry for restore action", async () => {
|
||||
const task = await store.createTask({ description: "Test restore log" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
const unarchived = await store.unarchiveTask(task.id);
|
||||
expect(unarchived.log.some((l) => l.action === "Task restored from archive")).toBe(true);
|
||||
expect(unarchived.log.some((l) => l.action === "Task unarchived")).toBe(true);
|
||||
});
|
||||
|
||||
it("recreates PROMPT.md after restore", async () => {
|
||||
const task = await store.createTask({ description: "Test prompt restore" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
await store.unarchiveTask(task.id);
|
||||
|
||||
// Verify PROMPT.md was recreated
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.prompt).toContain(task.id);
|
||||
expect(detail.prompt).toContain("Test prompt restore");
|
||||
});
|
||||
|
||||
it("recreates attachments directory (empty) after restore", async () => {
|
||||
const task = await store.createTask({ description: "Test attach restore" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
// Add an attachment
|
||||
await store.addAttachment(task.id, "test.txt", Buffer.from("test"), "text/plain");
|
||||
|
||||
await store.archiveTask(task.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
|
||||
await store.unarchiveTask(task.id);
|
||||
|
||||
// Directory should exist with empty attachments folder
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
expect(existsSync(join(dir, "attachments"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveTask with cleanup", () => {
|
||||
it("archiveTask(true) archives and cleans up immediately", async () => {
|
||||
const task = await store.createTask({ description: "Immediate cleanup" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const archived = await store.archiveTask(task.id, true);
|
||||
expect(archived.column).toBe("archived");
|
||||
|
||||
// Directory should be gone immediately
|
||||
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
|
||||
// Should be in archive.jsonl
|
||||
const entry = await store.findInArchive(task.id);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.description).toBe("Immediate cleanup");
|
||||
});
|
||||
|
||||
it("archiveTaskAndCleanup is convenience method", async () => {
|
||||
const task = await store.createTask({ description: "Convenience method" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const archived = await store.archiveTaskAndCleanup(task.id);
|
||||
expect(archived.column).toBe("archived");
|
||||
|
||||
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("archiveTask(false) preserves directory (backward compatibility)", async () => {
|
||||
const task = await store.createTask({ description: "No cleanup" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const archived = await store.archiveTask(task.id, false);
|
||||
expect(archived.column).toBe("archived");
|
||||
|
||||
// Directory should still exist
|
||||
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("default cleanup parameter is false", async () => {
|
||||
const task = await store.createTask({ description: "Default cleanup" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const archived = await store.archiveTask(task.id); // No cleanup param
|
||||
expect(archived.column).toBe("archived");
|
||||
|
||||
// Directory should still exist (default is false)
|
||||
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive log persistence", () => {
|
||||
it("archive log survives TaskStore reinitialization", async () => {
|
||||
const task = await store.createTask({ description: "Survival test" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
await store.cleanupArchivedTasks();
|
||||
|
||||
// Create new store instance
|
||||
const newStore = new TaskStore(rootDir);
|
||||
await newStore.init();
|
||||
|
||||
const entries = await newStore.readArchiveLog();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].id).toBe(task.id);
|
||||
expect(entries[0].description).toBe("Survival test");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private kbDir: string;
|
||||
private tasksDir: string;
|
||||
private configPath: string;
|
||||
private archiveLogPath: string;
|
||||
|
||||
/** File-system watcher instance */
|
||||
private watcher: FSWatcher | null = null;
|
||||
@@ -42,6 +43,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.kbDir = join(rootDir, ".kb");
|
||||
this.tasksDir = join(this.kbDir, "tasks");
|
||||
this.configPath = join(this.kbDir, "config.json");
|
||||
this.archiveLogPath = join(this.kbDir, "archive.jsonl");
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -867,8 +869,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
/**
|
||||
* Archive a done task (move from done → archived).
|
||||
* Logs the action and emits `task:moved` event.
|
||||
* @param cleanup - If true, immediately cleans up the task directory after archiving
|
||||
* by writing a compact entry to archive.jsonl and removing files.
|
||||
* Default: false for backward compatibility.
|
||||
*/
|
||||
async archiveTask(id: string): Promise<Task> {
|
||||
async archiveTask(id: string, cleanup: boolean = false): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
@@ -892,23 +897,93 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
action: "Task archived",
|
||||
});
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
// If cleanup requested, write archive entry BEFORE removing directory
|
||||
if (cleanup) {
|
||||
const entry: import("./types.js").ArchivedTaskEntry = {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
column: "archived",
|
||||
dependencies: task.dependencies,
|
||||
steps: task.steps,
|
||||
currentStep: task.currentStep,
|
||||
size: task.size,
|
||||
reviewLevel: task.reviewLevel,
|
||||
prInfo: task.prInfo,
|
||||
issueInfo: task.issueInfo,
|
||||
attachments: task.attachments,
|
||||
log: task.log,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
columnMovedAt: task.columnMovedAt,
|
||||
archivedAt: task.columnMovedAt,
|
||||
modelProvider: task.modelProvider,
|
||||
modelId: task.modelId,
|
||||
validatorModelProvider: task.validatorModelProvider,
|
||||
validatorModelId: task.validatorModelId,
|
||||
breakIntoSubtasks: task.breakIntoSubtasks,
|
||||
paused: task.paused,
|
||||
baseBranch: task.baseBranch,
|
||||
mergeRetries: task.mergeRetries,
|
||||
error: task.error,
|
||||
};
|
||||
|
||||
// Update cache if watcher is active
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
// Write to archive.jsonl atomically (append only)
|
||||
await appendFile(this.archiveLogPath, JSON.stringify(entry) + "\n");
|
||||
|
||||
// Remove task directory recursively
|
||||
const { rm } = await import("node:fs/promises");
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
|
||||
// Remove from cache if watcher is active
|
||||
if (this.watcher) {
|
||||
this.taskCache.delete(id);
|
||||
}
|
||||
} else {
|
||||
// Normal archive - just write task.json
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
|
||||
// Update cache if watcher is active
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
}
|
||||
|
||||
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column });
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a task and immediately clean up its directory.
|
||||
* Convenience method equivalent to `archiveTask(id, true)`.
|
||||
*/
|
||||
async archiveTaskAndCleanup(id: string): Promise<Task> {
|
||||
return this.archiveTask(id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchive an archived task (move from archived → done).
|
||||
* If the task directory was cleaned up, restores from archive.jsonl first.
|
||||
* Logs the action and emits `task:moved` event.
|
||||
*/
|
||||
async unarchiveTask(id: string): Promise<Task> {
|
||||
const dir = this.taskDir(id);
|
||||
|
||||
// Check if directory exists BEFORE acquiring lock
|
||||
if (!existsSync(dir)) {
|
||||
// Task was cleaned up - restore from archive
|
||||
const entry = await this.findInArchive(id);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Cannot unarchive ${id}: task directory missing and not found in archive`,
|
||||
);
|
||||
}
|
||||
|
||||
// Restore the task directory first
|
||||
await this.restoreFromArchive(entry);
|
||||
}
|
||||
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
// Re-read task.json (either existing or freshly restored)
|
||||
const task = await this.readTaskJson(dir);
|
||||
|
||||
// Initialize log array if missing (for legacy tasks)
|
||||
@@ -1447,6 +1522,204 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── Archive Cleanup Methods ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read and parse the archive log file (archive.jsonl).
|
||||
* Returns empty array if archive file doesn't exist.
|
||||
*/
|
||||
async readArchiveLog(): Promise<import("./types.js").ArchivedTaskEntry[]> {
|
||||
if (!existsSync(this.archiveLogPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = await readFile(this.archiveLogPath, "utf-8");
|
||||
const entries: import("./types.js").ArchivedTaskEntry[] = [];
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
entries.push(JSON.parse(line) as import("./types.js").ArchivedTaskEntry);
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific task in the archive log by ID.
|
||||
* Returns undefined if not found or archive doesn't exist.
|
||||
*/
|
||||
async findInArchive(id: string): Promise<import("./types.js").ArchivedTaskEntry | undefined> {
|
||||
const entries = await this.readArchiveLog();
|
||||
return entries.find((e) => e.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup archived tasks by condensing them into compact archive entries.
|
||||
* For each archived task with an existing directory:
|
||||
* - Creates a compact archive entry (metadata only, no agent logs)
|
||||
* - Appends entry to archive.jsonl atomically
|
||||
* - Removes the entire task directory
|
||||
* Skips tasks already cleaned up (directory already gone).
|
||||
*/
|
||||
async cleanupArchivedTasks(): Promise<string[]> {
|
||||
const archivedTasks = await this.listTasks().then((tasks) =>
|
||||
tasks.filter((t) => t.column === "archived"),
|
||||
);
|
||||
|
||||
const cleanedUpIds: string[] = [];
|
||||
|
||||
for (const task of archivedTasks) {
|
||||
const dir = this.taskDir(task.id);
|
||||
|
||||
// Skip if directory already cleaned up
|
||||
if (!existsSync(dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create compact archive entry (exclude agent logs)
|
||||
const entry: import("./types.js").ArchivedTaskEntry = {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
column: "archived",
|
||||
dependencies: task.dependencies,
|
||||
steps: task.steps,
|
||||
currentStep: task.currentStep,
|
||||
size: task.size,
|
||||
reviewLevel: task.reviewLevel,
|
||||
prInfo: task.prInfo,
|
||||
issueInfo: task.issueInfo,
|
||||
attachments: task.attachments,
|
||||
log: task.log,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
columnMovedAt: task.columnMovedAt,
|
||||
archivedAt: new Date().toISOString(),
|
||||
modelProvider: task.modelProvider,
|
||||
modelId: task.modelId,
|
||||
validatorModelProvider: task.validatorModelProvider,
|
||||
validatorModelId: task.validatorModelId,
|
||||
breakIntoSubtasks: task.breakIntoSubtasks,
|
||||
paused: task.paused,
|
||||
baseBranch: task.baseBranch,
|
||||
mergeRetries: task.mergeRetries,
|
||||
error: task.error,
|
||||
};
|
||||
|
||||
// Atomic append to archive.jsonl
|
||||
await appendFile(this.archiveLogPath, JSON.stringify(entry) + "\n");
|
||||
|
||||
// Remove task directory recursively
|
||||
const { rm } = await import("node:fs/promises");
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
|
||||
// Remove from cache if watcher is active
|
||||
if (this.watcher) {
|
||||
this.taskCache.delete(task.id);
|
||||
}
|
||||
|
||||
cleanedUpIds.push(task.id);
|
||||
}
|
||||
|
||||
return cleanedUpIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a task from an archive entry.
|
||||
* Recreates task directory with task.json and PROMPT.md.
|
||||
* Clears transient execution state (worktree, status, blockedBy, etc.).
|
||||
* Does NOT recreate agent.log (intentionally lost during archive).
|
||||
*/
|
||||
private async restoreFromArchive(entry: import("./types.js").ArchivedTaskEntry): Promise<Task> {
|
||||
const dir = this.taskDir(entry.id);
|
||||
|
||||
// Create task directory
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
// Build restored task (clear transient fields)
|
||||
const restoredTask: Task = {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
description: entry.description,
|
||||
column: "archived", // Will be changed to "done" by unarchiveTask
|
||||
dependencies: entry.dependencies,
|
||||
steps: entry.steps,
|
||||
currentStep: entry.currentStep,
|
||||
size: entry.size,
|
||||
reviewLevel: entry.reviewLevel,
|
||||
prInfo: entry.prInfo,
|
||||
issueInfo: entry.issueInfo,
|
||||
attachments: entry.attachments,
|
||||
log: [...entry.log, { timestamp: new Date().toISOString(), action: "Task restored from archive" }],
|
||||
createdAt: entry.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
columnMovedAt: entry.columnMovedAt,
|
||||
modelProvider: entry.modelProvider,
|
||||
modelId: entry.modelId,
|
||||
validatorModelProvider: entry.validatorModelProvider,
|
||||
validatorModelId: entry.validatorModelId,
|
||||
breakIntoSubtasks: entry.breakIntoSubtasks,
|
||||
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, error, steeringComments
|
||||
};
|
||||
|
||||
// Write task.json
|
||||
await this.atomicWriteTaskJson(dir, restoredTask);
|
||||
|
||||
// Generate PROMPT.md with preserved steps
|
||||
const prompt = this.generatePromptFromArchiveEntry(entry);
|
||||
await writeFile(join(dir, "PROMPT.md"), prompt);
|
||||
|
||||
// Create empty attachments directory if attachments existed
|
||||
if (entry.attachments && entry.attachments.length > 0) {
|
||||
await mkdir(join(dir, "attachments"), { recursive: true });
|
||||
}
|
||||
|
||||
return restoredTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PROMPT.md from an archive entry, preserving the original step structure.
|
||||
*/
|
||||
private generatePromptFromArchiveEntry(entry: import("./types.js").ArchivedTaskEntry): string {
|
||||
const deps =
|
||||
entry.dependencies.length > 0
|
||||
? entry.dependencies.map((d) => `- **Task:** ${d}`).join("\n")
|
||||
: "- **None**";
|
||||
|
||||
const heading = entry.title ? `${entry.id}: ${entry.title}` : entry.id;
|
||||
|
||||
// Build steps section from preserved steps
|
||||
let stepsSection = "## Steps\n\n";
|
||||
if (entry.steps && entry.steps.length > 0) {
|
||||
for (let i = 0; i < entry.steps.length; i++) {
|
||||
const step = entry.steps[i];
|
||||
const status = step.status === "done" ? "[x]" : "[ ]";
|
||||
stepsSection += `### Step ${i}: ${step.name}\n\n- ${status} ${step.name}\n\n`;
|
||||
}
|
||||
} else {
|
||||
stepsSection += "### Step 0: Preflight\n\n- [ ] Review and verify\n\n";
|
||||
}
|
||||
|
||||
return `# ${heading}
|
||||
|
||||
**Created:** ${entry.createdAt.split("T")[0]}
|
||||
${entry.size ? `**Size:** ${entry.size}` : "**Size:** M"}
|
||||
|
||||
## Mission
|
||||
|
||||
${entry.description}
|
||||
|
||||
## Dependencies
|
||||
|
||||
${deps}
|
||||
|
||||
${stepsSection}`;
|
||||
}
|
||||
|
||||
getRootDir(): string {
|
||||
return this.rootDir;
|
||||
}
|
||||
|
||||
@@ -366,6 +366,42 @@ export const VALID_TRANSITIONS: Record<Column, Column[]> = {
|
||||
|
||||
// ── Planning Mode Types ────────────────────────────────────────────────────
|
||||
|
||||
/** Entry in the archive log (archive.jsonl) representing a compact,
|
||||
* restorable snapshot of an archived task without agent log content.
|
||||
*/
|
||||
export interface ArchivedTaskEntry {
|
||||
id: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
column: "archived"; // Always archived when in the log
|
||||
dependencies: string[];
|
||||
steps: TaskStep[];
|
||||
currentStep: number;
|
||||
size?: "S" | "M" | "L";
|
||||
reviewLevel?: number;
|
||||
prInfo?: PrInfo;
|
||||
issueInfo?: IssueInfo;
|
||||
/** Attachment metadata (filenames, mime types, etc.) without file content */
|
||||
attachments?: TaskAttachment[];
|
||||
log: TaskLogEntry[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
columnMovedAt?: string;
|
||||
/** Timestamp when the task was archived to the log */
|
||||
archivedAt: string;
|
||||
/** Optional: model override fields for executor and validator */
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
validatorModelProvider?: string;
|
||||
validatorModelId?: string;
|
||||
/** Optional: other metadata to preserve */
|
||||
breakIntoSubtasks?: boolean;
|
||||
paused?: boolean;
|
||||
baseBranch?: string;
|
||||
mergeRetries?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Type of planning question presented to the user */
|
||||
export type PlanningQuestionType = "text" | "single_select" | "multi_select" | "confirm";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user