@bastani/atomic 0.5.0-1

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 (68) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +956 -0
  3. package/assets/settings.schema.json +52 -0
  4. package/package.json +68 -0
  5. package/src/cli.ts +197 -0
  6. package/src/commands/cli/chat/client.ts +18 -0
  7. package/src/commands/cli/chat/index.ts +247 -0
  8. package/src/commands/cli/chat.ts +8 -0
  9. package/src/commands/cli/config.ts +55 -0
  10. package/src/commands/cli/init/index.ts +452 -0
  11. package/src/commands/cli/init/onboarding.ts +45 -0
  12. package/src/commands/cli/init/scm.ts +190 -0
  13. package/src/commands/cli/init.ts +8 -0
  14. package/src/commands/cli/update.ts +46 -0
  15. package/src/commands/cli/workflow.ts +164 -0
  16. package/src/lib/merge.ts +65 -0
  17. package/src/lib/path-root-guard.ts +38 -0
  18. package/src/lib/spawn.ts +467 -0
  19. package/src/scripts/bump-version.ts +94 -0
  20. package/src/scripts/constants-base.ts +14 -0
  21. package/src/scripts/constants.ts +34 -0
  22. package/src/sdk/components/color-utils.ts +20 -0
  23. package/src/sdk/components/connectors.test.ts +661 -0
  24. package/src/sdk/components/connectors.ts +156 -0
  25. package/src/sdk/components/edge.tsx +11 -0
  26. package/src/sdk/components/error-boundary.tsx +38 -0
  27. package/src/sdk/components/graph-theme.ts +36 -0
  28. package/src/sdk/components/header.tsx +60 -0
  29. package/src/sdk/components/layout.test.ts +924 -0
  30. package/src/sdk/components/layout.ts +186 -0
  31. package/src/sdk/components/node-card.tsx +68 -0
  32. package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
  33. package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
  34. package/src/sdk/components/orchestrator-panel-store.ts +118 -0
  35. package/src/sdk/components/orchestrator-panel-types.ts +21 -0
  36. package/src/sdk/components/orchestrator-panel.tsx +143 -0
  37. package/src/sdk/components/session-graph-panel.tsx +364 -0
  38. package/src/sdk/components/status-helpers.ts +32 -0
  39. package/src/sdk/components/statusline.tsx +63 -0
  40. package/src/sdk/define-workflow.ts +98 -0
  41. package/src/sdk/errors.ts +39 -0
  42. package/src/sdk/index.ts +38 -0
  43. package/src/sdk/providers/claude.ts +316 -0
  44. package/src/sdk/providers/copilot.ts +43 -0
  45. package/src/sdk/providers/opencode.ts +43 -0
  46. package/src/sdk/runtime/discovery.ts +172 -0
  47. package/src/sdk/runtime/executor.test.ts +415 -0
  48. package/src/sdk/runtime/executor.ts +695 -0
  49. package/src/sdk/runtime/loader.ts +372 -0
  50. package/src/sdk/runtime/panel.tsx +9 -0
  51. package/src/sdk/runtime/theme.ts +76 -0
  52. package/src/sdk/runtime/tmux.ts +542 -0
  53. package/src/sdk/types.ts +114 -0
  54. package/src/sdk/workflows.ts +85 -0
  55. package/src/services/config/atomic-config.ts +124 -0
  56. package/src/services/config/atomic-global-config.ts +361 -0
  57. package/src/services/config/config-path.ts +19 -0
  58. package/src/services/config/definitions.ts +176 -0
  59. package/src/services/config/index.ts +7 -0
  60. package/src/services/config/settings-schema.ts +2 -0
  61. package/src/services/config/settings.ts +149 -0
  62. package/src/services/system/copy.ts +381 -0
  63. package/src/services/system/detect.ts +161 -0
  64. package/src/services/system/download.ts +325 -0
  65. package/src/services/system/file-lock.ts +289 -0
  66. package/src/services/system/skills.ts +67 -0
  67. package/src/theme/colors.ts +25 -0
  68. package/src/version.ts +7 -0
@@ -0,0 +1,561 @@
1
+ import { test, expect, describe, mock, beforeEach } from "bun:test";
2
+ import { PanelStore } from "./orchestrator-panel-store.ts";
3
+
4
+ describe("PanelStore", () => {
5
+ let store: PanelStore;
6
+
7
+ beforeEach(() => {
8
+ store = new PanelStore();
9
+ });
10
+
11
+ // ── 1. Initial state ────────────────────────────────────────────────────────
12
+
13
+ describe("initial state", () => {
14
+ test("version starts at 0", () => {
15
+ expect(store.version).toBe(0);
16
+ });
17
+
18
+ test("workflowName starts as empty string", () => {
19
+ expect(store.workflowName).toBe("");
20
+ });
21
+
22
+ test("agent starts as empty string", () => {
23
+ expect(store.agent).toBe("");
24
+ });
25
+
26
+ test("prompt starts as empty string", () => {
27
+ expect(store.prompt).toBe("");
28
+ });
29
+
30
+ test("sessions starts as empty array", () => {
31
+ expect(store.sessions).toEqual([]);
32
+ });
33
+
34
+ test("completionInfo starts as null", () => {
35
+ expect(store.completionInfo).toBeNull();
36
+ });
37
+
38
+ test("fatalError starts as null", () => {
39
+ expect(store.fatalError).toBeNull();
40
+ });
41
+
42
+ test("completionReached starts as false", () => {
43
+ expect(store.completionReached).toBe(false);
44
+ });
45
+
46
+ test("exitResolve starts as null", () => {
47
+ expect(store.exitResolve).toBeNull();
48
+ });
49
+ });
50
+
51
+ // ── 2. setWorkflowInfo ──────────────────────────────────────────────────────
52
+
53
+ describe("setWorkflowInfo", () => {
54
+ test("sets workflowName, agent, and prompt", () => {
55
+ store.setWorkflowInfo("my-workflow", "claude", [], "do something");
56
+ expect(store.workflowName).toBe("my-workflow");
57
+ expect(store.agent).toBe("claude");
58
+ expect(store.prompt).toBe("do something");
59
+ });
60
+
61
+ test("creates orchestrator session as first session with running status", () => {
62
+ store.setWorkflowInfo("wf", "claude", [], "prompt");
63
+ const orch = store.sessions[0]!;
64
+ expect(orch.name).toBe("orchestrator");
65
+ expect(orch.status).toBe("running");
66
+ expect(orch.parents).toEqual([]);
67
+ expect(orch.startedAt).toBeGreaterThan(0);
68
+ expect(orch.endedAt).toBeNull();
69
+ });
70
+
71
+ test("creates pending sessions for provided panel sessions", () => {
72
+ store.setWorkflowInfo("wf", "claude", [
73
+ { name: "task-a", parents: ["p1"] },
74
+ { name: "task-b", parents: ["p1", "p2"] },
75
+ ], "prompt");
76
+
77
+ expect(store.sessions).toHaveLength(3);
78
+
79
+ const taskA = store.sessions[1]!;
80
+ expect(taskA.name).toBe("task-a");
81
+ expect(taskA.status).toBe("pending");
82
+ expect(taskA.parents).toEqual(["p1"]);
83
+ expect(taskA.startedAt).toBeNull();
84
+ expect(taskA.endedAt).toBeNull();
85
+
86
+ const taskB = store.sessions[2]!;
87
+ expect(taskB.name).toBe("task-b");
88
+ expect(taskB.status).toBe("pending");
89
+ expect(taskB.parents).toEqual(["p1", "p2"]);
90
+ });
91
+
92
+ test("increments version by exactly 1", () => {
93
+ expect(store.version).toBe(0);
94
+ store.setWorkflowInfo("wf", "claude", [], "prompt");
95
+ expect(store.version).toBe(1);
96
+ });
97
+
98
+ test("calls subscribed listener", () => {
99
+ const listener = mock(() => {});
100
+ store.subscribe(listener);
101
+ store.setWorkflowInfo("wf", "claude", [], "prompt");
102
+ expect(listener).toHaveBeenCalledTimes(1);
103
+ });
104
+ });
105
+
106
+ // ── 3. setWorkflowInfo with empty parents ───────────────────────────────────
107
+
108
+ describe("setWorkflowInfo with empty parents", () => {
109
+ test("sessions with empty parents array default to ['orchestrator']", () => {
110
+ store.setWorkflowInfo("wf", "claude", [
111
+ { name: "s1", parents: [] },
112
+ { name: "s2", parents: [] },
113
+ ], "prompt");
114
+
115
+ expect(store.sessions[1]!.parents).toEqual(["orchestrator"]);
116
+ expect(store.sessions[2]!.parents).toEqual(["orchestrator"]);
117
+ });
118
+
119
+ test("sessions with explicit parents are not overridden", () => {
120
+ store.setWorkflowInfo("wf", "claude", [
121
+ { name: "s1", parents: ["other"] },
122
+ ], "prompt");
123
+
124
+ expect(store.sessions[1]!.parents).toEqual(["other"]);
125
+ });
126
+ });
127
+
128
+ // ── 4. startSession ─────────────────────────────────────────────────────────
129
+
130
+ describe("startSession", () => {
131
+ beforeEach(() => {
132
+ store.setWorkflowInfo("wf", "claude", [{ name: "worker", parents: [] }], "prompt");
133
+ });
134
+
135
+ test("changes session status to running", () => {
136
+ store.startSession("worker");
137
+ const s = store.sessions.find((s) => s.name === "worker")!;
138
+ expect(s.status).toBe("running");
139
+ });
140
+
141
+ test("sets startedAt to a positive timestamp", () => {
142
+ store.startSession("worker");
143
+ const s = store.sessions.find((s) => s.name === "worker")!;
144
+ expect(s.startedAt).toBeGreaterThan(0);
145
+ });
146
+
147
+ test("bumps version by exactly 1", () => {
148
+ const before = store.version;
149
+ store.startSession("worker");
150
+ expect(store.version).toBe(before + 1);
151
+ });
152
+ });
153
+
154
+ // ── 5. startSession with unknown name ───────────────────────────────────────
155
+
156
+ describe("startSession with unknown name", () => {
157
+ beforeEach(() => {
158
+ store.setWorkflowInfo("wf", "claude", [{ name: "worker", parents: [] }], "prompt");
159
+ });
160
+
161
+ test("does not emit (version unchanged) when session not found", () => {
162
+ const before = store.version;
163
+ store.startSession("nonexistent");
164
+ expect(store.version).toBe(before);
165
+ });
166
+
167
+ test("does not mutate existing sessions", () => {
168
+ const before = store.sessions.map((s) => ({ ...s }));
169
+ store.startSession("nonexistent");
170
+ store.sessions.forEach((s, i) => {
171
+ expect(s.name).toBe(before[i]!.name);
172
+ expect(s.status).toBe(before[i]!.status);
173
+ });
174
+ });
175
+
176
+ test("does not notify listeners when session not found", () => {
177
+ const listener = mock(() => {});
178
+ store.subscribe(listener);
179
+ store.startSession("nonexistent");
180
+ expect(listener).toHaveBeenCalledTimes(0);
181
+ });
182
+ });
183
+
184
+ // ── 6. completeSession ──────────────────────────────────────────────────────
185
+
186
+ describe("completeSession", () => {
187
+ beforeEach(() => {
188
+ store.setWorkflowInfo("wf", "claude", [{ name: "worker", parents: [] }], "prompt");
189
+ store.startSession("worker");
190
+ });
191
+
192
+ test("changes status to complete", () => {
193
+ store.completeSession("worker");
194
+ const s = store.sessions.find((s) => s.name === "worker")!;
195
+ expect(s.status).toBe("complete");
196
+ });
197
+
198
+ test("sets endedAt to a positive timestamp", () => {
199
+ store.completeSession("worker");
200
+ const s = store.sessions.find((s) => s.name === "worker")!;
201
+ expect(s.endedAt).toBeGreaterThan(0);
202
+ });
203
+
204
+ test("bumps version by exactly 1", () => {
205
+ const before = store.version;
206
+ store.completeSession("worker");
207
+ expect(store.version).toBe(before + 1);
208
+ });
209
+
210
+ test("does not emit when session not found", () => {
211
+ const before = store.version;
212
+ store.completeSession("nonexistent");
213
+ expect(store.version).toBe(before);
214
+ });
215
+ });
216
+
217
+ // ── 7. failSession ──────────────────────────────────────────────────────────
218
+
219
+ describe("failSession", () => {
220
+ beforeEach(() => {
221
+ store.setWorkflowInfo("wf", "claude", [{ name: "worker", parents: [] }], "prompt");
222
+ store.startSession("worker");
223
+ });
224
+
225
+ test("changes status to error", () => {
226
+ store.failSession("worker", "connection timeout");
227
+ const s = store.sessions.find((s) => s.name === "worker")!;
228
+ expect(s.status).toBe("error");
229
+ });
230
+
231
+ test("stores the error message on the session", () => {
232
+ store.failSession("worker", "connection timeout");
233
+ const s = store.sessions.find((s) => s.name === "worker")!;
234
+ expect(s.error).toBe("connection timeout");
235
+ });
236
+
237
+ test("sets endedAt to a positive timestamp", () => {
238
+ store.failSession("worker", "err");
239
+ const s = store.sessions.find((s) => s.name === "worker")!;
240
+ expect(s.endedAt).toBeGreaterThan(0);
241
+ });
242
+
243
+ test("bumps version by exactly 1", () => {
244
+ const before = store.version;
245
+ store.failSession("worker", "err");
246
+ expect(store.version).toBe(before + 1);
247
+ });
248
+ });
249
+
250
+ // ── 8. setCompletion ────────────────────────────────────────────────────────
251
+
252
+ describe("setCompletion", () => {
253
+ beforeEach(() => {
254
+ store.setWorkflowInfo("wf", "claude", [], "prompt");
255
+ });
256
+
257
+ test("stores completionInfo with workflowName and transcriptsPath", () => {
258
+ store.setCompletion("my-wf", "/tmp/transcripts");
259
+ expect(store.completionInfo).toEqual({
260
+ workflowName: "my-wf",
261
+ transcriptsPath: "/tmp/transcripts",
262
+ });
263
+ });
264
+
265
+ test("marks orchestrator session as complete", () => {
266
+ store.setCompletion("my-wf", "/tmp/transcripts");
267
+ const orch = store.sessions.find((s) => s.name === "orchestrator")!;
268
+ expect(orch.status).toBe("complete");
269
+ });
270
+
271
+ test("sets orchestrator endedAt to a positive timestamp", () => {
272
+ store.setCompletion("my-wf", "/tmp/transcripts");
273
+ const orch = store.sessions.find((s) => s.name === "orchestrator")!;
274
+ expect(orch.endedAt).toBeGreaterThan(0);
275
+ });
276
+
277
+ test("bumps version by exactly 1", () => {
278
+ const before = store.version;
279
+ store.setCompletion("my-wf", "/tmp/transcripts");
280
+ expect(store.version).toBe(before + 1);
281
+ });
282
+ });
283
+
284
+ // ── 9. setFatalError ────────────────────────────────────────────────────────
285
+
286
+ describe("setFatalError", () => {
287
+ beforeEach(() => {
288
+ store.setWorkflowInfo("wf", "claude", [], "prompt");
289
+ });
290
+
291
+ test("stores the fatalError message", () => {
292
+ store.setFatalError("disk full");
293
+ expect(store.fatalError).toBe("disk full");
294
+ });
295
+
296
+ test("sets completionReached to true", () => {
297
+ store.setFatalError("disk full");
298
+ expect(store.completionReached).toBe(true);
299
+ });
300
+
301
+ test("marks orchestrator session as error", () => {
302
+ store.setFatalError("disk full");
303
+ const orch = store.sessions.find((s) => s.name === "orchestrator")!;
304
+ expect(orch.status).toBe("error");
305
+ });
306
+
307
+ test("sets orchestrator endedAt to a positive timestamp", () => {
308
+ store.setFatalError("disk full");
309
+ const orch = store.sessions.find((s) => s.name === "orchestrator")!;
310
+ expect(orch.endedAt).toBeGreaterThan(0);
311
+ });
312
+
313
+ test("bumps version by exactly 1", () => {
314
+ const before = store.version;
315
+ store.setFatalError("disk full");
316
+ expect(store.version).toBe(before + 1);
317
+ });
318
+ });
319
+
320
+ // ── 10. subscribe / unsubscribe ─────────────────────────────────────────────
321
+
322
+ describe("subscribe / unsubscribe", () => {
323
+ test("listener is called when an emit occurs", () => {
324
+ const listener = mock(() => {});
325
+ store.subscribe(listener);
326
+ store.markCompletionReached();
327
+ expect(listener).toHaveBeenCalledTimes(1);
328
+ });
329
+
330
+ test("listener is not called after unsubscribing", () => {
331
+ const listener = mock(() => {});
332
+ const unsubscribe = store.subscribe(listener);
333
+ unsubscribe();
334
+ store.markCompletionReached();
335
+ expect(listener).toHaveBeenCalledTimes(0);
336
+ });
337
+
338
+ test("multiple unsubscribes are safe (idempotent)", () => {
339
+ const listener = mock(() => {});
340
+ const unsubscribe = store.subscribe(listener);
341
+ unsubscribe();
342
+ unsubscribe(); // second call should not throw
343
+ store.markCompletionReached();
344
+ expect(listener).toHaveBeenCalledTimes(0);
345
+ });
346
+
347
+ test("multiple listeners all receive the emit", () => {
348
+ const a = mock(() => {});
349
+ const b = mock(() => {});
350
+ store.subscribe(a);
351
+ store.subscribe(b);
352
+ store.markCompletionReached();
353
+ expect(a).toHaveBeenCalledTimes(1);
354
+ expect(b).toHaveBeenCalledTimes(1);
355
+ });
356
+
357
+ test("unsubscribing one listener does not affect others", () => {
358
+ const a = mock(() => {});
359
+ const b = mock(() => {});
360
+ const unsubA = store.subscribe(a);
361
+ store.subscribe(b);
362
+ unsubA();
363
+ store.markCompletionReached();
364
+ expect(a).toHaveBeenCalledTimes(0);
365
+ expect(b).toHaveBeenCalledTimes(1);
366
+ });
367
+ });
368
+
369
+ // ── 11. version increments ──────────────────────────────────────────────────
370
+
371
+ describe("version increments", () => {
372
+ test("each setWorkflowInfo call increments version by exactly 1", () => {
373
+ store.setWorkflowInfo("wf", "claude", [], "p");
374
+ expect(store.version).toBe(1);
375
+ store.setWorkflowInfo("wf2", "copilot", [], "p2");
376
+ expect(store.version).toBe(2);
377
+ });
378
+
379
+ test("startSession increments version by exactly 1", () => {
380
+ store.setWorkflowInfo("wf", "claude", [{ name: "s1", parents: [] }], "p");
381
+ const before = store.version;
382
+ store.startSession("s1");
383
+ expect(store.version).toBe(before + 1);
384
+ });
385
+
386
+ test("completeSession increments version by exactly 1", () => {
387
+ store.setWorkflowInfo("wf", "claude", [{ name: "s1", parents: [] }], "p");
388
+ store.startSession("s1");
389
+ const before = store.version;
390
+ store.completeSession("s1");
391
+ expect(store.version).toBe(before + 1);
392
+ });
393
+
394
+ test("failSession increments version by exactly 1", () => {
395
+ store.setWorkflowInfo("wf", "claude", [{ name: "s1", parents: [] }], "p");
396
+ store.startSession("s1");
397
+ const before = store.version;
398
+ store.failSession("s1", "err");
399
+ expect(store.version).toBe(before + 1);
400
+ });
401
+
402
+ test("setCompletion increments version by exactly 1", () => {
403
+ store.setWorkflowInfo("wf", "claude", [], "p");
404
+ const before = store.version;
405
+ store.setCompletion("wf", "/tmp");
406
+ expect(store.version).toBe(before + 1);
407
+ });
408
+
409
+ test("setFatalError increments version by exactly 1", () => {
410
+ store.setWorkflowInfo("wf", "claude", [], "p");
411
+ const before = store.version;
412
+ store.setFatalError("err");
413
+ expect(store.version).toBe(before + 1);
414
+ });
415
+
416
+ test("markCompletionReached increments version by exactly 1", () => {
417
+ const before = store.version;
418
+ store.markCompletionReached();
419
+ expect(store.version).toBe(before + 1);
420
+ });
421
+
422
+ test("resolveExit does not increment version (no emit)", () => {
423
+ store.exitResolve = () => {};
424
+ const before = store.version;
425
+ store.resolveExit();
426
+ expect(store.version).toBe(before);
427
+ });
428
+ });
429
+
430
+ // ── 12. resolveExit ─────────────────────────────────────────────────────────
431
+
432
+ describe("resolveExit", () => {
433
+ test("calls the exitResolve callback", () => {
434
+ const resolver = mock(() => {});
435
+ store.exitResolve = resolver;
436
+ store.resolveExit();
437
+ expect(resolver).toHaveBeenCalledTimes(1);
438
+ });
439
+
440
+ test("sets exitResolve to null after calling it", () => {
441
+ store.exitResolve = () => {};
442
+ store.resolveExit();
443
+ expect(store.exitResolve).toBeNull();
444
+ });
445
+ });
446
+
447
+ // ── 13. resolveExit idempotency ─────────────────────────────────────────────
448
+
449
+ describe("resolveExit idempotency", () => {
450
+ test("calling resolveExit twice only invokes callback once", () => {
451
+ const resolver = mock(() => {});
452
+ store.exitResolve = resolver;
453
+ store.resolveExit();
454
+ store.resolveExit(); // second call: exitResolve is now null, so callback should NOT fire again
455
+ expect(resolver).toHaveBeenCalledTimes(1);
456
+ });
457
+ });
458
+
459
+ // ── 14. resolveExit with no callback ────────────────────────────────────────
460
+
461
+ describe("resolveExit with no callback", () => {
462
+ test("is a no-op when exitResolve is null", () => {
463
+ expect(store.exitResolve).toBeNull();
464
+ expect(() => store.resolveExit()).not.toThrow();
465
+ });
466
+
467
+ test("does not change any state when exitResolve is null", () => {
468
+ const versionBefore = store.version;
469
+ store.resolveExit();
470
+ expect(store.version).toBe(versionBefore);
471
+ expect(store.exitResolve).toBeNull();
472
+ });
473
+ });
474
+
475
+ // ── 15. markCompletionReached ───────────────────────────────────────────────
476
+
477
+ describe("markCompletionReached", () => {
478
+ test("sets completionReached to true", () => {
479
+ expect(store.completionReached).toBe(false);
480
+ store.markCompletionReached();
481
+ expect(store.completionReached).toBe(true);
482
+ });
483
+
484
+ test("emits (version increments)", () => {
485
+ const before = store.version;
486
+ store.markCompletionReached();
487
+ expect(store.version).toBe(before + 1);
488
+ });
489
+
490
+ test("notifies subscribed listeners", () => {
491
+ const listener = mock(() => {});
492
+ store.subscribe(listener);
493
+ store.markCompletionReached();
494
+ expect(listener).toHaveBeenCalledTimes(1);
495
+ });
496
+
497
+ test("is idempotent for the completionReached flag", () => {
498
+ store.markCompletionReached();
499
+ store.markCompletionReached();
500
+ expect(store.completionReached).toBe(true);
501
+ });
502
+ });
503
+
504
+ // ── 16. Full lifecycle ──────────────────────────────────────────────────────
505
+
506
+ describe("full lifecycle", () => {
507
+ test("setWorkflowInfo → startSession → completeSession → setCompletion → markCompletionReached → resolveExit", () => {
508
+ // Track all emits throughout
509
+ const emitCount = { value: 0 };
510
+ store.subscribe(() => { emitCount.value++; });
511
+
512
+ // Step 1: set up workflow
513
+ store.setWorkflowInfo("pipeline", "claude", [
514
+ { name: "step-1", parents: [] },
515
+ { name: "step-2", parents: ["step-1"] },
516
+ ], "run the pipeline");
517
+
518
+ expect(store.workflowName).toBe("pipeline");
519
+ expect(store.sessions).toHaveLength(3);
520
+ expect(store.sessions[0]!.status).toBe("running"); // orchestrator
521
+ expect(store.sessions[1]!.status).toBe("pending"); // step-1
522
+ expect(store.sessions[2]!.status).toBe("pending"); // step-2
523
+
524
+ // Step 2: start step-1
525
+ store.startSession("step-1");
526
+ expect(store.sessions.find((s) => s.name === "step-1")!.status).toBe("running");
527
+
528
+ // Step 3: complete step-1
529
+ store.completeSession("step-1");
530
+ expect(store.sessions.find((s) => s.name === "step-1")!.status).toBe("complete");
531
+
532
+ // Start and complete step-2
533
+ store.startSession("step-2");
534
+ store.completeSession("step-2");
535
+ expect(store.sessions.find((s) => s.name === "step-2")!.status).toBe("complete");
536
+
537
+ // Step 4: set completion
538
+ store.setCompletion("pipeline", "/transcripts/pipeline");
539
+ expect(store.completionInfo).toEqual({
540
+ workflowName: "pipeline",
541
+ transcriptsPath: "/transcripts/pipeline",
542
+ });
543
+ expect(store.sessions.find((s) => s.name === "orchestrator")!.status).toBe("complete");
544
+
545
+ // Step 5: mark completion reached
546
+ store.markCompletionReached();
547
+ expect(store.completionReached).toBe(true);
548
+
549
+ // Step 6: resolveExit
550
+ const exitCallback = mock(() => {});
551
+ store.exitResolve = exitCallback;
552
+ store.resolveExit();
553
+ expect(exitCallback).toHaveBeenCalledTimes(1);
554
+ expect(store.exitResolve).toBeNull();
555
+
556
+ // Verify total emit count: setWorkflowInfo(1) + start(1) + complete(1) + start(1) + complete(1) + setCompletion(1) + markCompletionReached(1) = 7
557
+ // resolveExit does NOT emit
558
+ expect(emitCount.value).toBe(7);
559
+ });
560
+ });
561
+ });
@@ -0,0 +1,118 @@
1
+ // ─── State Store ──────────────────────────────────
2
+ // Bridges the imperative OrchestratorPanel API with the React component tree.
3
+
4
+ import type { SessionData, SessionStatus, PanelSession } from "./orchestrator-panel-types.ts";
5
+
6
+ type Listener = () => void;
7
+
8
+ export class PanelStore {
9
+ version = 0;
10
+ workflowName = "";
11
+ agent = "";
12
+ prompt = "";
13
+ sessions: SessionData[] = [];
14
+ completionInfo: { workflowName: string; transcriptsPath: string } | null = null;
15
+ fatalError: string | null = null;
16
+ completionReached = false;
17
+ exitResolve: (() => void) | null = null;
18
+
19
+ private listeners = new Set<Listener>();
20
+
21
+ subscribe = (fn: Listener): (() => void) => {
22
+ this.listeners.add(fn);
23
+ return () => this.listeners.delete(fn);
24
+ };
25
+
26
+ private emit(): void {
27
+ this.version++;
28
+ for (const fn of this.listeners) fn();
29
+ }
30
+
31
+ setWorkflowInfo(
32
+ name: string,
33
+ agent: string,
34
+ sessions: PanelSession[],
35
+ prompt: string,
36
+ ): void {
37
+ this.workflowName = name;
38
+ this.agent = agent;
39
+ this.prompt = prompt;
40
+ this.sessions = [
41
+ {
42
+ name: "orchestrator",
43
+ status: "running",
44
+ parents: [],
45
+ startedAt: Date.now(),
46
+ endedAt: null,
47
+ },
48
+ ...sessions.map((s) => ({
49
+ name: s.name,
50
+ status: "pending" as SessionStatus,
51
+ parents: s.parents.length > 0 ? s.parents : ["orchestrator"],
52
+ startedAt: null,
53
+ endedAt: null,
54
+ })),
55
+ ];
56
+ this.emit();
57
+ }
58
+
59
+ startSession(name: string): void {
60
+ const session = this.sessions.find((s) => s.name === name);
61
+ if (!session) return;
62
+ session.status = "running";
63
+ session.startedAt = Date.now();
64
+ this.emit();
65
+ }
66
+
67
+ completeSession(name: string): void {
68
+ const session = this.sessions.find((s) => s.name === name);
69
+ if (!session) return;
70
+ session.status = "complete";
71
+ session.endedAt = Date.now();
72
+ this.emit();
73
+ }
74
+
75
+ failSession(name: string, error: string): void {
76
+ const session = this.sessions.find((s) => s.name === name);
77
+ if (!session) return;
78
+ session.status = "error";
79
+ session.error = error;
80
+ session.endedAt = Date.now();
81
+ this.emit();
82
+ }
83
+
84
+ setCompletion(workflowName: string, transcriptsPath: string): void {
85
+ this.completionInfo = { workflowName, transcriptsPath };
86
+ const orch = this.sessions.find((s) => s.name === "orchestrator");
87
+ if (orch) {
88
+ orch.status = "complete";
89
+ orch.endedAt = Date.now();
90
+ }
91
+ this.emit();
92
+ }
93
+
94
+ setFatalError(message: string): void {
95
+ this.fatalError = message;
96
+ this.completionReached = true;
97
+ const orch = this.sessions.find((s) => s.name === "orchestrator");
98
+ if (orch) {
99
+ orch.status = "error";
100
+ orch.endedAt = Date.now();
101
+ }
102
+ this.emit();
103
+ }
104
+
105
+ /** Safely invoke exitResolve at most once, guarding against rapid repeated calls. */
106
+ resolveExit(): void {
107
+ if (this.exitResolve) {
108
+ const resolve = this.exitResolve;
109
+ this.exitResolve = null;
110
+ resolve();
111
+ }
112
+ }
113
+
114
+ markCompletionReached(): void {
115
+ this.completionReached = true;
116
+ this.emit();
117
+ }
118
+ }
@@ -0,0 +1,21 @@
1
+ // ─── Orchestrator Panel Types ─────────────────────
2
+
3
+ export type SessionStatus = "pending" | "running" | "complete" | "error";
4
+
5
+ export interface PanelSession {
6
+ name: string;
7
+ parents: string[];
8
+ }
9
+
10
+ export interface PanelOptions {
11
+ tmuxSession: string;
12
+ }
13
+
14
+ export interface SessionData {
15
+ name: string;
16
+ status: SessionStatus;
17
+ parents: string[];
18
+ error?: string;
19
+ startedAt: number | null;
20
+ endedAt: number | null;
21
+ }