clawspec 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +6 -0
  2. package/README.zh-CN.md +6 -0
  3. package/package.json +1 -2
  4. package/src/bootstrap/state.ts +128 -0
  5. package/src/dependencies/acpx.ts +6 -0
  6. package/src/dependencies/openspec.ts +5 -0
  7. package/src/index.ts +125 -43
  8. package/src/watchers/manager.ts +69 -1
  9. package/test/acp-client.test.ts +0 -309
  10. package/test/acpx-dependency.test.ts +0 -133
  11. package/test/assistant-journal.test.ts +0 -203
  12. package/test/command-surface.test.ts +0 -24
  13. package/test/config.test.ts +0 -77
  14. package/test/detach-attach.test.ts +0 -98
  15. package/test/doctor.test.ts +0 -142
  16. package/test/file-lock.test.ts +0 -88
  17. package/test/fs-utils.test.ts +0 -22
  18. package/test/helpers/harness.ts +0 -305
  19. package/test/helpers.test.ts +0 -108
  20. package/test/keywords.test.ts +0 -92
  21. package/test/notifier.test.ts +0 -29
  22. package/test/openspec-dependency.test.ts +0 -68
  23. package/test/paths-utils.test.ts +0 -30
  24. package/test/pause-cancel.test.ts +0 -55
  25. package/test/planning-journal.test.ts +0 -155
  26. package/test/plugin-registration.test.ts +0 -35
  27. package/test/project-memory.test.ts +0 -42
  28. package/test/proposal.test.ts +0 -24
  29. package/test/queue-planning.test.ts +0 -322
  30. package/test/queue-work.test.ts +0 -220
  31. package/test/recovery.test.ts +0 -603
  32. package/test/service-archive.test.ts +0 -87
  33. package/test/shell-command.test.ts +0 -48
  34. package/test/state-store.test.ts +0 -74
  35. package/test/tasks-and-checkpoint.test.ts +0 -60
  36. package/test/use-project.test.ts +0 -67
  37. package/test/watcher-planning.test.ts +0 -533
  38. package/test/watcher-work.test.ts +0 -1771
  39. package/test/worker-command.test.ts +0 -66
  40. package/test/worker-io-helper.test.ts +0 -97
  41. package/test/worker-skills.test.ts +0 -12
@@ -1,603 +0,0 @@
1
- import test, { type TestContext } from "node:test";
2
- import assert from "node:assert/strict";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { mkdtemp, mkdir } from "node:fs/promises";
6
- import { pathExists, readJsonFile, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
7
- import { getRepoStatePaths } from "../src/utils/paths.ts";
8
- import { RollbackStore } from "../src/rollback/store.ts";
9
- import { ProjectStateStore } from "../src/state/store.ts";
10
- import { WatcherManager } from "../src/watchers/manager.ts";
11
- import { createLogger, waitFor } from "./helpers/harness.ts";
12
-
13
- const TEST_WATCHER_POLL_INTERVAL_MS = 250;
14
- const TEST_WAIT_TIMEOUT_MS = 15_000;
15
-
16
- function createWorkOpenSpec(changeDir: string, tasksPath: string) {
17
- return {
18
- instructionsApply: async (cwd: string, cn: string) => {
19
- const content = await readUtf8(tasksPath);
20
- const task1Done = content.includes("- [x] 1.1");
21
- const task2Done = content.includes("- [x] 1.2");
22
- const complete = (task1Done ? 1 : 0) + (task2Done ? 1 : 0);
23
- const total = content.includes("1.2") ? 2 : 1;
24
- return {
25
- command: `openspec instructions apply --change ${cn} --json`,
26
- cwd, stdout: "{}", stderr: "", durationMs: 1,
27
- parsed: {
28
- changeName: cn, changeDir, schemaName: "spec-driven",
29
- contextFiles: { tasks: tasksPath },
30
- progress: { total, complete, remaining: total - complete },
31
- tasks: total === 2
32
- ? [
33
- { id: "1.1", description: "First task", done: task1Done },
34
- { id: "1.2", description: "Second task", done: task2Done },
35
- ]
36
- : [{ id: "1.1", description: "Build the demo endpoint", done: task1Done }],
37
- state: complete === total ? "all_done" : "ready",
38
- instruction: "Implement the remaining task.",
39
- },
40
- };
41
- },
42
- } as any;
43
- }
44
-
45
- function registerManagerCleanup(t: TestContext, manager: WatcherManager): () => Promise<void> {
46
- let stopped = false;
47
- const stop = async () => {
48
- if (stopped) {
49
- return;
50
- }
51
- stopped = true;
52
- await manager.stop();
53
- };
54
- t.after(stop);
55
- return stop;
56
- }
57
-
58
- async function waitForProjectStatus(repoPath: string, status: "done", timeoutMs = TEST_WAIT_TIMEOUT_MS): Promise<void> {
59
- const stateFile = getRepoStatePaths(repoPath, "archives").stateFile;
60
- await waitFor(async () => (await readJsonFile<any>(stateFile, null))?.status === status, timeoutMs);
61
- }
62
-
63
- test("recovery from orphaned state (armed, no execution field)", async (t) => {
64
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-orphan-"));
65
- const workspacePath = path.join(tempRoot, "workspace");
66
- const repoPath = path.join(workspacePath, "demo-app");
67
- const changeName = "orphan-recovery";
68
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
69
- const tasksPath = path.join(changeDir, "tasks.md");
70
- const outputPath = path.join(repoPath, "src", "demo.txt");
71
- await mkdir(path.join(repoPath, "src"), { recursive: true });
72
- await mkdir(changeDir, { recursive: true });
73
- await writeUtf8(tasksPath, "- [ ] 1.1 Build the demo endpoint\n");
74
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
75
-
76
- const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
77
- await rollbackStore.initializeBaseline();
78
-
79
- const stateStore = new ProjectStateStore(tempRoot, "archives");
80
- await stateStore.initialize();
81
- const notifierMessages: string[] = [];
82
-
83
- const fakeAcpClient = {
84
- agentId: "codex",
85
- runTurn: async () => {
86
- await writeUtf8(tasksPath, "- [x] 1.1 Build the demo endpoint\n");
87
- await writeUtf8(outputPath, "demo\n");
88
- await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
89
- version: 1, changeName, mode: "apply", status: "done",
90
- timestamp: new Date().toISOString(), summary: "Completed task 1.1.",
91
- progressMade: true, completedTask: "1.1 Build the demo endpoint",
92
- changedFiles: ["src/demo.txt"], notes: ["Task completed"],
93
- taskCounts: { total: 1, complete: 1, remaining: 0 }, remainingTasks: 0,
94
- });
95
- },
96
- cancelSession: async () => undefined,
97
- closeSession: async () => undefined,
98
- };
99
-
100
- const channelKey = "discord:orphan-recovery:default:main";
101
- await stateStore.createProject(channelKey);
102
- await stateStore.updateProject(channelKey, (current) => ({
103
- ...current,
104
- workspacePath, repoPath, projectName: "demo-app", projectTitle: "Demo App",
105
- changeName, changeDir, status: "armed", phase: "implementing",
106
- workerAgentId: "codex",
107
- currentTask: "1.1 Build the demo endpoint",
108
- taskCounts: { total: 1, complete: 0, remaining: 1 },
109
- planningJournal: { dirty: false, entryCount: 0 },
110
- rollback: {
111
- baselineRoot: rollbackStore.baselineRoot,
112
- manifestPath: rollbackStore.manifestPath,
113
- snapshotReady: true, touchedFileCount: 0,
114
- },
115
- // execution is intentionally NOT set — simulates orphaned state
116
- }));
117
-
118
- // Create stale tmp file to verify cleanup
119
- const clawspecDir = path.join(repoPath, ".openclaw", "clawspec");
120
- await writeUtf8(path.join(clawspecDir, "state.json.12345.999999.tmp"), "stale");
121
-
122
- const manager = new WatcherManager({
123
- stateStore,
124
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
125
- archiveDirName: "archives",
126
- logger: createLogger(),
127
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
128
- acpClient: fakeAcpClient as any,
129
- pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
130
- });
131
- const stopManager = registerManagerCleanup(t, manager);
132
-
133
- await manager.start();
134
- await waitForProjectStatus(repoPath, "done");
135
- await stopManager();
136
-
137
- const project = await stateStore.getActiveProject(channelKey);
138
- assert.equal(project?.status, "done");
139
- assert.equal(project?.phase, "validating");
140
- assert.equal(notifierMessages.some((m) => m.includes("Gateway restarted")), true);
141
- assert.equal(notifierMessages.some((m) => m.includes("All tasks complete")), true);
142
- assert.equal(await pathExists(path.join(clawspecDir, "state.json.12345.999999.tmp")), false);
143
- });
144
-
145
- test("recovery from mid-crash running state", async (t) => {
146
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-midcrash-"));
147
- const workspacePath = path.join(tempRoot, "workspace");
148
- const repoPath = path.join(workspacePath, "demo-app");
149
- const changeName = "midcrash-recovery";
150
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
151
- const tasksPath = path.join(changeDir, "tasks.md");
152
- await mkdir(path.join(repoPath, "src"), { recursive: true });
153
- await mkdir(changeDir, { recursive: true });
154
- await writeUtf8(tasksPath, "- [x] 1.1 First task\n- [ ] 1.2 Second task\n");
155
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
156
-
157
- const stateStore = new ProjectStateStore(tempRoot, "archives");
158
- await stateStore.initialize();
159
- const notifierMessages: string[] = [];
160
-
161
- const fakeAcpClient = {
162
- agentId: "codex",
163
- runTurn: async () => {
164
- await writeUtf8(tasksPath, "- [x] 1.1 First task\n- [x] 1.2 Second task\n");
165
- await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
166
- version: 1, changeName, mode: "apply", status: "done",
167
- timestamp: new Date().toISOString(), summary: "Completed task 1.2.",
168
- progressMade: true, completedTask: "1.2 Second task",
169
- changedFiles: [], notes: ["Task completed"],
170
- taskCounts: { total: 2, complete: 2, remaining: 0 }, remainingTasks: 0,
171
- });
172
- },
173
- cancelSession: async () => undefined,
174
- closeSession: async () => undefined,
175
- };
176
-
177
- const channelKey = "discord:midcrash-recovery:default:main";
178
- await stateStore.createProject(channelKey);
179
- await stateStore.updateProject(channelKey, (current) => ({
180
- ...current,
181
- workspacePath, repoPath, projectName: "demo-app", projectTitle: "Demo App",
182
- changeName, changeDir, status: "running", phase: "implementing",
183
- workerAgentId: "codex",
184
- currentTask: "1.2 Second task",
185
- taskCounts: { total: 2, complete: 1, remaining: 1 },
186
- planningJournal: { dirty: false, entryCount: 0 },
187
- execution: {
188
- mode: "apply", action: "work", state: "running",
189
- workerAgentId: "codex", workerSlot: "primary",
190
- armedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
191
- sessionKey: "clawspec:dead-session",
192
- lastHeartbeatAt: new Date(Date.now() - 120_000).toISOString(),
193
- },
194
- }));
195
-
196
- const manager = new WatcherManager({
197
- stateStore,
198
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
199
- archiveDirName: "archives",
200
- logger: createLogger(),
201
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
202
- acpClient: fakeAcpClient as any,
203
- pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
204
- });
205
- const stopManager = registerManagerCleanup(t, manager);
206
-
207
- await manager.start();
208
- await waitForProjectStatus(repoPath, "done");
209
- await stopManager();
210
-
211
- const project = await stateStore.getActiveProject(channelKey);
212
- assert.equal(project?.status, "done");
213
- assert.equal(notifierMessages.some((m) => m.includes("Gateway restarted")), true);
214
- });
215
-
216
- test("startup recovery adopts a live implementation session instead of spawning a new worker", async (t) => {
217
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-adopt-live-"));
218
- const workspacePath = path.join(tempRoot, "workspace");
219
- const repoPath = path.join(workspacePath, "demo-app");
220
- const changeName = "adopt-live-recovery";
221
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
222
- const tasksPath = path.join(changeDir, "tasks.md");
223
- const repoStatePaths = getRepoStatePaths(repoPath, "archives");
224
- await mkdir(changeDir, { recursive: true });
225
- await writeUtf8(tasksPath, "- [x] 1.1 First task\n- [ ] 1.2 Second task\n");
226
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
227
-
228
- const stateStore = new ProjectStateStore(tempRoot, "archives");
229
- await stateStore.initialize();
230
- const notifierMessages: string[] = [];
231
- let runTurnCount = 0;
232
- let cancelCount = 0;
233
-
234
- const existingProgress = `${JSON.stringify({
235
- version: 1,
236
- timestamp: new Date(Date.now() - 60_000).toISOString(),
237
- kind: "task_start",
238
- current: 2,
239
- total: 2,
240
- taskId: "1.2",
241
- message: "Start 1.2: second task already resumed before gateway crash. Next: finish this task.",
242
- })}\n`;
243
- await writeUtf8(repoStatePaths.workerProgressFile, existingProgress);
244
-
245
- const fakeAcpClient = {
246
- agentId: "codex",
247
- runTurn: async () => {
248
- runTurnCount += 1;
249
- },
250
- getSessionStatus: async () => ({
251
- summary: "status=alive pid=4242",
252
- details: {
253
- status: "alive",
254
- pid: 4242,
255
- },
256
- }),
257
- cancelSession: async () => {
258
- cancelCount += 1;
259
- },
260
- closeSession: async () => undefined,
261
- };
262
-
263
- const channelKey = "discord:adopt-live-recovery:default:main";
264
- await stateStore.createProject(channelKey);
265
- await stateStore.updateProject(channelKey, (current) => ({
266
- ...current,
267
- workspacePath,
268
- repoPath,
269
- projectName: "demo-app",
270
- projectTitle: "Demo App",
271
- changeName,
272
- changeDir,
273
- status: "running",
274
- phase: "implementing",
275
- workerAgentId: "codex",
276
- currentTask: "1.2 Second task",
277
- taskCounts: { total: 2, complete: 1, remaining: 1 },
278
- planningJournal: { dirty: false, entryCount: 0 },
279
- execution: {
280
- mode: "apply",
281
- action: "work",
282
- state: "running",
283
- workerAgentId: "codex",
284
- workerSlot: "primary",
285
- armedAt: new Date(Date.now() - 120_000).toISOString(),
286
- startedAt: new Date(Date.now() - 90_000).toISOString(),
287
- sessionKey: "clawspec:live-session",
288
- lastHeartbeatAt: new Date(Date.now() - 15_000).toISOString(),
289
- progressOffset: existingProgress.length,
290
- },
291
- }));
292
-
293
- const manager = new WatcherManager({
294
- stateStore,
295
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
296
- archiveDirName: "archives",
297
- logger: createLogger(),
298
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
299
- acpClient: fakeAcpClient as any,
300
- pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
301
- });
302
- const stopManager = registerManagerCleanup(t, manager);
303
-
304
- setTimeout(() => {
305
- void (async () => {
306
- const doneEvent = JSON.stringify({
307
- version: 1,
308
- timestamp: new Date().toISOString(),
309
- kind: "task_done",
310
- current: 2,
311
- total: 2,
312
- taskId: "1.2",
313
- message: "Done 1.2: finished after gateway recovery. Changed 1 files: openspec/changes/adopt-live-recovery/tasks.md. Next: done.",
314
- });
315
- await writeUtf8(repoStatePaths.workerProgressFile, `${existingProgress}${doneEvent}\n`);
316
- await writeUtf8(tasksPath, "- [x] 1.1 First task\n- [x] 1.2 Second task\n");
317
- await writeJsonFile(repoStatePaths.executionResultFile, {
318
- version: 1,
319
- changeName,
320
- mode: "apply",
321
- status: "done",
322
- timestamp: new Date().toISOString(),
323
- summary: "Completed task 1.2.",
324
- progressMade: true,
325
- completedTask: "1.2 Second task",
326
- changedFiles: ["openspec/changes/adopt-live-recovery/tasks.md"],
327
- notes: ["Task completed after live-session recovery."],
328
- taskCounts: { total: 2, complete: 2, remaining: 0 },
329
- remainingTasks: 0,
330
- });
331
- })();
332
- }, 120);
333
-
334
- await manager.start();
335
- await waitForProjectStatus(repoPath, "done");
336
- await stopManager();
337
-
338
- const project = await stateStore.getActiveProject(channelKey);
339
- assert.equal(project?.status, "done");
340
- assert.equal(project?.phase, "validating");
341
- assert.equal(runTurnCount, 0);
342
- assert.equal(cancelCount >= 1, true);
343
- assert.equal(
344
- notifierMessages.some((m) =>
345
- m.includes("Gateway restarted")
346
- && (m.includes("Reattached") || m.includes("Waiting for the next worker update"))
347
- ),
348
- true,
349
- );
350
- assert.equal(notifierMessages.some((m) => m.includes("Done 1.2: finished after gateway recovery")), true);
351
- assert.equal(notifierMessages.some((m) => m.includes("Start 1.2: second task already resumed before gateway crash")), false);
352
- assert.equal(notifierMessages.some((m) => m.includes("Watcher active. Starting codex worker")), false);
353
- });
354
-
355
- test("recovery from recoverable blocked implementation state", async (t) => {
356
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-blocked-"));
357
- const workspacePath = path.join(tempRoot, "workspace");
358
- const repoPath = path.join(workspacePath, "demo-app");
359
- const changeName = "blocked-recovery";
360
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
361
- const tasksPath = path.join(changeDir, "tasks.md");
362
- const outputPath = path.join(repoPath, "src", "recovered.txt");
363
- await mkdir(path.join(repoPath, "src"), { recursive: true });
364
- await mkdir(changeDir, { recursive: true });
365
- await writeUtf8(tasksPath, "- [ ] 1.1 Resume after backend startup race\n");
366
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
367
-
368
- const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
369
- await rollbackStore.initializeBaseline();
370
-
371
- const stateStore = new ProjectStateStore(tempRoot, "archives");
372
- await stateStore.initialize();
373
- const notifierMessages: string[] = [];
374
-
375
- const fakeAcpClient = {
376
- agentId: "codex",
377
- runTurn: async () => {
378
- await writeUtf8(tasksPath, "- [x] 1.1 Resume after backend startup race\n");
379
- await writeUtf8(outputPath, "recovered\n");
380
- await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
381
- version: 1,
382
- changeName,
383
- mode: "apply",
384
- status: "done",
385
- timestamp: new Date().toISOString(),
386
- summary: "Recovered from blocked startup state.",
387
- progressMade: true,
388
- completedTask: "1.1 Resume after backend startup race",
389
- changedFiles: ["src/recovered.txt"],
390
- notes: ["Recovered after ACP runtime backend became ready."],
391
- taskCounts: { total: 1, complete: 1, remaining: 0 },
392
- remainingTasks: 0,
393
- });
394
- },
395
- cancelSession: async () => undefined,
396
- closeSession: async () => undefined,
397
- };
398
-
399
- const channelKey = "discord:blocked-recovery:default:main";
400
- await stateStore.createProject(channelKey);
401
- await stateStore.updateProject(channelKey, (current) => ({
402
- ...current,
403
- workspacePath,
404
- repoPath,
405
- projectName: "demo-app",
406
- projectTitle: "Demo App",
407
- changeName,
408
- changeDir,
409
- status: "blocked",
410
- phase: "implementing",
411
- workerAgentId: "codex",
412
- currentTask: "1.1 Resume after backend startup race",
413
- taskCounts: { total: 1, complete: 0, remaining: 1 },
414
- latestSummary: "Execution failed: ACP runtime backend is currently unavailable. Try again in a moment.",
415
- blockedReason: "ACP runtime backend is currently unavailable. Try again in a moment.",
416
- planningJournal: { dirty: false, entryCount: 0 },
417
- rollback: {
418
- baselineRoot: rollbackStore.baselineRoot,
419
- manifestPath: rollbackStore.manifestPath,
420
- snapshotReady: true,
421
- touchedFileCount: 0,
422
- },
423
- execution: undefined,
424
- lastExecution: {
425
- version: 1,
426
- changeName,
427
- mode: "apply",
428
- status: "blocked",
429
- timestamp: new Date().toISOString(),
430
- summary: "Execution failed: ACP runtime backend is currently unavailable. Try again in a moment.",
431
- progressMade: false,
432
- changedFiles: [],
433
- notes: ["ACP runtime backend is currently unavailable. Try again in a moment."],
434
- blocker: "ACP runtime backend is currently unavailable. Try again in a moment.",
435
- taskCounts: { total: 1, complete: 0, remaining: 1 },
436
- remainingTasks: 1,
437
- },
438
- lastExecutionAt: new Date().toISOString(),
439
- }));
440
-
441
- const manager = new WatcherManager({
442
- stateStore,
443
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
444
- archiveDirName: "archives",
445
- logger: createLogger(),
446
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
447
- acpClient: fakeAcpClient as any,
448
- pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
449
- });
450
- const stopManager = registerManagerCleanup(t, manager);
451
-
452
- await manager.start();
453
- await waitForProjectStatus(repoPath, "done");
454
- await stopManager();
455
-
456
- const project = await stateStore.getActiveProject(channelKey);
457
- assert.equal(project?.status, "done");
458
- assert.equal(project?.phase, "validating");
459
- assert.equal(notifierMessages.some((m) => m.includes("Gateway restarted")), true);
460
- assert.equal(notifierMessages.some((m) => m.includes("All tasks complete")), true);
461
- });
462
-
463
- test("rearm never drops execution field (batch mode)", async (t) => {
464
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-rearm-safe-"));
465
- const workspacePath = path.join(tempRoot, "workspace");
466
- const repoPath = path.join(workspacePath, "demo-app");
467
- const changeName = "rearm-safe";
468
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
469
- const tasksPath = path.join(changeDir, "tasks.md");
470
- await mkdir(path.join(repoPath, "src"), { recursive: true });
471
- await mkdir(changeDir, { recursive: true });
472
- await writeUtf8(tasksPath, "- [ ] 1.1 Build it\n- [ ] 1.2 Test it\n");
473
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
474
-
475
- const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
476
- await rollbackStore.initializeBaseline();
477
-
478
- const stateStore = new ProjectStateStore(tempRoot, "archives");
479
- await stateStore.initialize();
480
- let turnCount = 0;
481
-
482
- const fakeAcpClient = {
483
- agentId: "codex",
484
- runTurn: async () => {
485
- turnCount += 1;
486
- await writeUtf8(tasksPath, "- [x] 1.1 Build it\n- [x] 1.2 Test it\n");
487
- await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
488
- version: 1, changeName, mode: "apply", status: "done",
489
- timestamp: new Date().toISOString(), summary: "Completed 2 tasks.",
490
- progressMade: true, completedTask: "1.2 Test it",
491
- changedFiles: [], notes: ["Completed all tasks in batch."],
492
- taskCounts: { total: 2, complete: 2, remaining: 0 },
493
- });
494
- },
495
- cancelSession: async () => undefined,
496
- closeSession: async () => undefined,
497
- };
498
-
499
- const channelKey = "discord:rearm-safe:default:main";
500
- await stateStore.createProject(channelKey);
501
- await stateStore.updateProject(channelKey, (current) => ({
502
- ...current,
503
- workspacePath, repoPath, projectName: "demo-app", projectTitle: "Demo App",
504
- changeName, changeDir, status: "armed", phase: "implementing",
505
- currentTask: "1.1 Build it",
506
- taskCounts: { total: 2, complete: 0, remaining: 2 },
507
- planningJournal: { dirty: false, entryCount: 0 },
508
- rollback: {
509
- baselineRoot: rollbackStore.baselineRoot,
510
- manifestPath: rollbackStore.manifestPath,
511
- snapshotReady: true, touchedFileCount: 0,
512
- },
513
- execution: {
514
- mode: "apply", action: "work", state: "armed", armedAt: new Date().toISOString(),
515
- },
516
- }));
517
-
518
- const manager = new WatcherManager({
519
- stateStore,
520
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
521
- archiveDirName: "archives",
522
- logger: createLogger(),
523
- notifier: { send: async () => undefined } as any,
524
- acpClient: fakeAcpClient as any,
525
- pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
526
- });
527
- const stopManager = registerManagerCleanup(t, manager);
528
-
529
- await manager.start();
530
- await manager.wake(channelKey);
531
- await waitForProjectStatus(repoPath, "done");
532
- await stopManager();
533
-
534
- const project = await stateStore.getActiveProject(channelKey);
535
- assert.equal(project?.status, "done");
536
- assert.equal(project?.phase, "validating");
537
- assert.equal(turnCount, 1);
538
- });
539
-
540
- test("startup recovery does not spawn a worker for visible chat planning", async (t) => {
541
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-visible-plan-"));
542
- const workspacePath = path.join(tempRoot, "workspace");
543
- const repoPath = path.join(workspacePath, "demo-app");
544
- const changeName = "visible-plan";
545
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
546
- await mkdir(changeDir, { recursive: true });
547
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
548
-
549
- const stateStore = new ProjectStateStore(tempRoot, "archives");
550
- await stateStore.initialize();
551
- const notifierMessages: string[] = [];
552
- let runCount = 0;
553
-
554
- const fakeAcpClient = {
555
- agentId: "codex",
556
- runTurn: async () => {
557
- runCount += 1;
558
- },
559
- cancelSession: async () => undefined,
560
- closeSession: async () => undefined,
561
- };
562
-
563
- const channelKey = "discord:visible-plan:default:main";
564
- await stateStore.createProject(channelKey);
565
- await stateStore.updateProject(channelKey, (current) => ({
566
- ...current,
567
- workspacePath,
568
- repoPath,
569
- projectName: "demo-app",
570
- projectTitle: "Demo App",
571
- changeName,
572
- changeDir,
573
- status: "planning",
574
- phase: "planning_sync",
575
- planningJournal: {
576
- dirty: true,
577
- entryCount: 1,
578
- lastEntryAt: new Date(Date.now() - 60_000).toISOString(),
579
- },
580
- execution: undefined,
581
- }));
582
-
583
- const manager = new WatcherManager({
584
- stateStore,
585
- openSpec: createWorkOpenSpec(changeDir, path.join(changeDir, "tasks.md")),
586
- archiveDirName: "archives",
587
- logger: createLogger(),
588
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
589
- acpClient: fakeAcpClient as any,
590
- pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
591
- });
592
- const stopManager = registerManagerCleanup(t, manager);
593
-
594
- await manager.start();
595
- await new Promise((resolve) => setTimeout(resolve, 150));
596
- await stopManager();
597
-
598
- const project = await stateStore.getActiveProject(channelKey);
599
- assert.equal(runCount, 0);
600
- assert.equal(project?.status, "planning");
601
- assert.equal(project?.phase, "planning_sync");
602
- assert.equal(notifierMessages.length, 0);
603
- });
@@ -1,87 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { mkdtemp, mkdir } from "node:fs/promises";
6
- import { ProjectMemoryStore } from "../src/memory/store.ts";
7
- import { ClawSpecService } from "../src/orchestrator/service.ts";
8
- import { ProjectStateStore } from "../src/state/store.ts";
9
- import { pathExists, writeUtf8 } from "../src/utils/fs.ts";
10
- import { WorkspaceStore } from "../src/workspace/store.ts";
11
-
12
- test("service writes archive bundles from visible-execution state", async () => {
13
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-service-"));
14
- const repoPath = path.join(tempRoot, "repo");
15
- const workspacePath = path.join(tempRoot, "workspace");
16
- const changeName = "demo-change";
17
- await mkdir(path.join(repoPath, "openspec", "changes", changeName), { recursive: true });
18
- await mkdir(workspacePath, { recursive: true });
19
-
20
- await writeUtf8(
21
- path.join(repoPath, "openspec", "changes", changeName, "tasks.md"),
22
- [
23
- "## 1. Setup",
24
- "",
25
- "- [x] 1.1 Create plugin",
26
- "- [ ] 1.2 Add lifecycle store",
27
- "",
28
- ].join("\n"),
29
- );
30
-
31
- const stateStore = new ProjectStateStore(tempRoot, "archives");
32
- const memoryStore = new ProjectMemoryStore(path.join(tempRoot, "memory.json"));
33
- const workspaceStore = new WorkspaceStore(path.join(tempRoot, "workspace-state.json"), workspacePath);
34
- await stateStore.initialize();
35
- await memoryStore.initialize();
36
- await workspaceStore.initialize();
37
-
38
- let project = await stateStore.createProject("channel:demo");
39
- project = await stateStore.updateProject("channel:demo", (current) => ({
40
- ...current,
41
- workspacePath,
42
- repoPath,
43
- projectName: "repo",
44
- changeName,
45
- changeDir: path.join(repoPath, "openspec", "changes", changeName),
46
- latestSummary: "Latest summary text",
47
- }));
48
-
49
- const fakeApi = {
50
- logger: {
51
- info: () => undefined,
52
- warn: () => undefined,
53
- error: () => undefined,
54
- debug: () => undefined,
55
- },
56
- } as any;
57
-
58
- const service = new ClawSpecService({
59
- api: fakeApi,
60
- config: {
61
- acp: {
62
- backend: "acpx",
63
- defaultAgent: "codex",
64
- },
65
- } as any,
66
- logger: fakeApi.logger,
67
- stateStore,
68
- memoryStore,
69
- openSpec: {} as any,
70
- archiveDirName: "archives",
71
- defaultWorkspace: workspacePath,
72
- defaultWorkerAgentId: undefined,
73
- workspaceStore,
74
- });
75
-
76
- await (service as any).ensureProjectSupportFiles(project);
77
- const counts = await (service as any).loadTaskCounts(project);
78
- assert.equal(counts.complete, 1);
79
- assert.equal(counts.remaining, 1);
80
-
81
- const archivePath = await (service as any).writeArchiveBundle(project, counts);
82
- assert.equal(await pathExists(path.join(archivePath, "resume-context.md")), true);
83
- assert.equal(await pathExists(path.join(archivePath, "session-summary.md")), true);
84
- assert.equal(await pathExists(path.join(archivePath, "changed-files.md")), true);
85
- assert.equal(await pathExists(path.join(archivePath, "decision-log.md")), true);
86
- assert.equal(await pathExists(path.join(archivePath, "run-metadata.json")), true);
87
- });