Skip to content

Commit 7c2aa86

Browse files
dcramerclaude
andcommitted
feat(cli): Add archive command for completed tasks
Add 'dex archive <task-id>' command to archive completed tasks and their descendants to reduce storage size. Archived tasks are compacted and moved to archive.jsonl while preserving essential fields. Features: - Validates task and all descendants are completed - Validates no incomplete ancestors exist - Compacts tasks (drops blockedBy, blocks, children, timestamps, priority) - Preserves id, parent_id, name, description, result, completed_at, metadata - Cleans up blocking references in remaining active tasks - Shows size reduction percentage on completion Also updates ArchivedTask schema to preserve description field instead of dropping it during compaction. Refs #69 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 056f8ce commit 7c2aa86

10 files changed

Lines changed: 490 additions & 8 deletions

File tree

docs/src/pages/cli.astro

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,20 @@ dex edit abc123 --remove-blocker xyz789`}
156156
</Terminal>
157157
</div>
158158

159+
<div class="command-card">
160+
<h3>dex archive</h3>
161+
<div class="synopsis">dex archive &lt;id&gt;</div>
162+
<p>Archive a completed task and its descendants to reduce storage size. Archived tasks are compacted and moved to <code>archive.jsonl</code>.</p>
163+
<p><strong>Requirements:</strong></p>
164+
<ul>
165+
<li>Task and all descendants must be completed</li>
166+
<li>Task must not have any incomplete ancestors</li>
167+
</ul>
168+
<Terminal title="Terminal">
169+
<Code code="dex archive abc123" lang="bash" theme="vitesse-black" />
170+
</Terminal>
171+
</div>
172+
159173
<div class="command-card">
160174
<h3>dex plan</h3>
161175
<div class="synopsis">dex plan &lt;markdown-file&gt; [options]</div>

src/cli/archive.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import * as path from "node:path";
3+
import { runCli } from "./index.js";
4+
import {
5+
createCliTestFixture,
6+
createTaskAndGetId,
7+
CliTestFixture,
8+
} from "./test-helpers.js";
9+
import { ArchiveStorage } from "../core/storage/archive-storage.js";
10+
11+
describe("archive command", () => {
12+
let fixture: CliTestFixture;
13+
14+
beforeEach(() => {
15+
fixture = createCliTestFixture();
16+
});
17+
18+
afterEach(() => {
19+
fixture.cleanup();
20+
});
21+
22+
it("shows help with --help flag", async () => {
23+
await runCli(["archive", "--help"], { storage: fixture.storage });
24+
25+
const out = fixture.output.stdout.join("\n");
26+
expect(out).toContain("dex archive");
27+
expect(out).toContain("Archive a completed task");
28+
expect(out).toContain("REQUIREMENTS");
29+
});
30+
31+
it("requires task ID", async () => {
32+
await expect(
33+
runCli(["archive"], { storage: fixture.storage }),
34+
).rejects.toThrow("process.exit");
35+
expect(fixture.output.stderr.join("\n")).toContain("Task ID is required");
36+
});
37+
38+
it("fails for nonexistent task", async () => {
39+
await expect(
40+
runCli(["archive", "nonexist"], { storage: fixture.storage }),
41+
).rejects.toThrow("process.exit");
42+
expect(fixture.output.stderr.join("\n")).toContain("not found");
43+
});
44+
45+
it("fails for pending task", async () => {
46+
const taskId = await createTaskAndGetId(fixture, "Pending task");
47+
48+
await expect(
49+
runCli(["archive", taskId], { storage: fixture.storage }),
50+
).rejects.toThrow("process.exit");
51+
52+
const stderr = fixture.output.stderr.join("\n");
53+
expect(stderr).toContain("not completed");
54+
expect(stderr).toContain("dex complete");
55+
});
56+
57+
it("archives a completed task", async () => {
58+
const taskId = await createTaskAndGetId(fixture, "Task to archive");
59+
await runCli(["complete", taskId, "--result", "Done"], {
60+
storage: fixture.storage,
61+
});
62+
fixture.output.stdout.length = 0;
63+
64+
await runCli(["archive", taskId], { storage: fixture.storage });
65+
66+
const out = fixture.output.stdout.join("\n");
67+
expect(out).toContain("Archived");
68+
expect(out).toContain("1");
69+
expect(out).toContain("Size reduction");
70+
71+
// Verify task removed from active store
72+
const tasks = await fixture.storage.readAsync();
73+
expect(tasks.tasks).toHaveLength(0);
74+
75+
// Verify task added to archive
76+
const archiveStorage = new ArchiveStorage({
77+
path: fixture.storage.getIdentifier(),
78+
});
79+
const archive = archiveStorage.readArchive();
80+
expect(archive.tasks).toHaveLength(1);
81+
expect(archive.tasks[0].id).toBe(taskId);
82+
});
83+
84+
it("archives task with completed subtasks", async () => {
85+
const parentId = await createTaskAndGetId(fixture, "Parent task");
86+
const child1Id = await createTaskAndGetId(fixture, "Child 1", {
87+
parent: parentId,
88+
});
89+
const child2Id = await createTaskAndGetId(fixture, "Child 2", {
90+
parent: parentId,
91+
});
92+
93+
// Complete all tasks
94+
await runCli(["complete", child1Id, "--result", "Done"], {
95+
storage: fixture.storage,
96+
});
97+
await runCli(["complete", child2Id, "--result", "Done"], {
98+
storage: fixture.storage,
99+
});
100+
await runCli(["complete", parentId, "--result", "All done"], {
101+
storage: fixture.storage,
102+
});
103+
fixture.output.stdout.length = 0;
104+
105+
await runCli(["archive", parentId], { storage: fixture.storage });
106+
107+
const out = fixture.output.stdout.join("\n");
108+
expect(out).toContain("Archived");
109+
expect(out).toContain("3");
110+
expect(out).toContain("Subtasks: 2");
111+
112+
// Verify all tasks removed from active store
113+
const tasks = await fixture.storage.readAsync();
114+
expect(tasks.tasks).toHaveLength(0);
115+
116+
// Verify all tasks added to archive
117+
const archiveStorage = new ArchiveStorage({
118+
path: fixture.storage.getIdentifier(),
119+
});
120+
const archive = archiveStorage.readArchive();
121+
expect(archive.tasks).toHaveLength(3);
122+
});
123+
124+
it("fails when subtask is not completed", async () => {
125+
const parentId = await createTaskAndGetId(fixture, "Parent task");
126+
await createTaskAndGetId(fixture, "Incomplete child", { parent: parentId });
127+
128+
// Mark parent as completed directly in storage (bypassing validation)
129+
const store = await fixture.storage.readAsync();
130+
const parent = store.tasks.find((t) => t.id === parentId);
131+
if (parent) {
132+
parent.completed = true;
133+
parent.completed_at = new Date().toISOString();
134+
parent.result = "Done";
135+
}
136+
await fixture.storage.writeAsync(store);
137+
fixture.output.stderr.length = 0;
138+
139+
await expect(
140+
runCli(["archive", parentId], { storage: fixture.storage }),
141+
).rejects.toThrow("process.exit");
142+
143+
const stderr = fixture.output.stderr.join("\n");
144+
expect(stderr).toContain("incomplete");
145+
expect(stderr).toContain("subtask");
146+
});
147+
148+
it("fails when ancestor is not completed", async () => {
149+
const parentId = await createTaskAndGetId(fixture, "Parent task");
150+
const childId = await createTaskAndGetId(fixture, "Child task", {
151+
parent: parentId,
152+
});
153+
154+
// Complete only the child
155+
await runCli(["complete", childId, "--result", "Done"], {
156+
storage: fixture.storage,
157+
});
158+
fixture.output.stderr.length = 0;
159+
160+
await expect(
161+
runCli(["archive", childId], { storage: fixture.storage }),
162+
).rejects.toThrow("process.exit");
163+
164+
const stderr = fixture.output.stderr.join("\n");
165+
expect(stderr).toContain("incomplete");
166+
expect(stderr).toContain("ancestor");
167+
});
168+
169+
it("cleans up blocking references when archiving", async () => {
170+
const blockerId = await createTaskAndGetId(fixture, "Blocker task");
171+
const blockedId = await createTaskAndGetId(fixture, "Blocked task", {
172+
blockedBy: blockerId,
173+
});
174+
175+
// Complete the blocker
176+
await runCli(["complete", blockerId, "--result", "Done"], {
177+
storage: fixture.storage,
178+
});
179+
fixture.output.stdout.length = 0;
180+
181+
// Archive the blocker
182+
await runCli(["archive", blockerId], { storage: fixture.storage });
183+
184+
// Verify blocked task's blockedBy is cleaned up
185+
const tasks = await fixture.storage.readAsync();
186+
const blocked = tasks.tasks.find((t) => t.id === blockedId);
187+
expect(blocked?.blockedBy).toEqual([]);
188+
});
189+
190+
it("preserves sibling tasks when archiving", async () => {
191+
const task1Id = await createTaskAndGetId(fixture, "Task 1");
192+
const task2Id = await createTaskAndGetId(fixture, "Task 2");
193+
194+
// Complete and archive task1
195+
await runCli(["complete", task1Id, "--result", "Done"], {
196+
storage: fixture.storage,
197+
});
198+
await runCli(["archive", task1Id], { storage: fixture.storage });
199+
200+
// Verify task2 still exists
201+
const tasks = await fixture.storage.readAsync();
202+
expect(tasks.tasks).toHaveLength(1);
203+
expect(tasks.tasks[0].id).toBe(task2Id);
204+
});
205+
206+
it("preserves description when archiving", async () => {
207+
const taskId = await createTaskAndGetId(fixture, "Task with description", {
208+
description: "This is a description that should be preserved",
209+
});
210+
await runCli(["complete", taskId, "--result", "Done"], {
211+
storage: fixture.storage,
212+
});
213+
await runCli(["archive", taskId], { storage: fixture.storage });
214+
215+
// Verify archived task has description preserved
216+
const archiveStorage = new ArchiveStorage({
217+
path: fixture.storage.getIdentifier(),
218+
});
219+
const archive = archiveStorage.readArchive();
220+
expect(archive.tasks[0].description).toBe(
221+
"This is a description that should be preserved",
222+
);
223+
expect(archive.tasks[0].result).toBe("Done");
224+
});
225+
});

0 commit comments

Comments
 (0)