@bastani/atomic 0.5.20-0 → 0.5.21-0

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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * `atomic workflow status [<id>]` — query the current state of one or
3
+ * all running workflows so an orchestrating agent can decide whether
4
+ * to keep waiting, surface a HIL prompt to the user, or move on.
5
+ *
6
+ * Status sources, in priority order:
7
+ * 1. <sessionDir>/status.json — written by the orchestrator on every
8
+ * panel-store mutation. Provides per-stage detail and the
9
+ * derived overall state (in_progress | error | completed |
10
+ * needs_review).
11
+ * 2. tmux liveness fallback — when status.json is missing or stale
12
+ * we still report whether the tmux session is alive so
13
+ * script-driven workflows aren't blind during the brief window
14
+ * before the orchestrator first writes its snapshot.
15
+ */
16
+
17
+ import { join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { COLORS, createPainter, type PaletteKey } from "../../theme/colors.ts";
20
+ import {
21
+ isTmuxInstalled as _isTmuxInstalled,
22
+ listSessions as _listSessions,
23
+ sessionExists as _sessionExists,
24
+ } from "../../sdk/workflows/index.ts";
25
+ import {
26
+ readSnapshot,
27
+ workflowRunIdFromTmuxName,
28
+ type WorkflowOverallStatus,
29
+ type WorkflowStatusSnapshot,
30
+ } from "../../sdk/runtime/status-writer.ts";
31
+ import type { TmuxSession } from "../../sdk/runtime/tmux.ts";
32
+
33
+ export type StatusFormat = "json" | "text";
34
+
35
+ /** A single workflow's resolved status, as returned to the caller. */
36
+ export interface WorkflowStatusReport {
37
+ /** Tmux session name (e.g. `atomic-wf-claude-ralph-a1b2c3d4`). */
38
+ id: string;
39
+ /** Workflow run id (the trailing 8-hex segment of the tmux name). */
40
+ workflowRunId: string;
41
+ /** Workflow name pulled from the snapshot. Empty when no snapshot exists. */
42
+ workflowName: string;
43
+ /** Agent backend (claude / copilot / opencode). */
44
+ agent: string;
45
+ overall: WorkflowOverallStatus;
46
+ /** True when the tmux session is currently alive on the atomic socket. */
47
+ alive: boolean;
48
+ /** ISO timestamp of the last snapshot, or null when none exists. */
49
+ updatedAt: string | null;
50
+ /** Sessions/stages, mirrored from the snapshot. Empty when no snapshot exists. */
51
+ sessions: WorkflowStatusSnapshot["sessions"];
52
+ /** Fatal-error message, if any. */
53
+ fatalError: string | null;
54
+ }
55
+
56
+ export interface StatusDeps {
57
+ isTmuxInstalled: () => boolean;
58
+ sessionExists: (name: string) => boolean;
59
+ listSessions: () => TmuxSession[];
60
+ /**
61
+ * Read a snapshot from disk. Defaults to the real reader; tests
62
+ * inject a fake to control the snapshot data without touching the
63
+ * filesystem.
64
+ */
65
+ readSnapshot: typeof readSnapshot;
66
+ /** Base directory for session dirs. Defaults to `~/.atomic/sessions`. */
67
+ sessionsBaseDir: string;
68
+ }
69
+
70
+ const defaultDeps: StatusDeps = {
71
+ isTmuxInstalled: _isTmuxInstalled,
72
+ sessionExists: _sessionExists,
73
+ listSessions: _listSessions,
74
+ readSnapshot,
75
+ sessionsBaseDir: join(homedir(), ".atomic", "sessions"),
76
+ };
77
+
78
+ /**
79
+ * Build a report for a single workflow. When the on-disk snapshot is
80
+ * missing we still emit a minimal report so callers can distinguish
81
+ * "workflow exists but hasn't written a snapshot yet" from "workflow
82
+ * doesn't exist at all" (the latter returns null upstream).
83
+ */
84
+ async function buildReport(
85
+ tmuxName: string,
86
+ alive: boolean,
87
+ deps: StatusDeps,
88
+ ): Promise<WorkflowStatusReport | null> {
89
+ const workflowRunId = workflowRunIdFromTmuxName(tmuxName);
90
+ if (!workflowRunId) return null;
91
+
92
+ const sessionDir = join(deps.sessionsBaseDir, workflowRunId);
93
+ const snapshot = await deps.readSnapshot(sessionDir);
94
+
95
+ if (!snapshot) {
96
+ return {
97
+ id: tmuxName,
98
+ workflowRunId,
99
+ workflowName: "",
100
+ agent: "",
101
+ // Without a snapshot we can only say it's still running (or
102
+ // already gone) — never that it succeeded or errored.
103
+ overall: alive ? "in_progress" : "error",
104
+ alive,
105
+ updatedAt: null,
106
+ sessions: [],
107
+ fatalError: alive ? null : "orchestrator exited before writing status",
108
+ };
109
+ }
110
+
111
+ // If the orchestrator has shut down but the snapshot still says
112
+ // in_progress, downgrade to error — the process died without
113
+ // writing a terminal state.
114
+ const overall: WorkflowOverallStatus =
115
+ !alive && snapshot.overall === "in_progress" ? "error" : snapshot.overall;
116
+
117
+ return {
118
+ id: tmuxName,
119
+ workflowRunId,
120
+ workflowName: snapshot.workflowName,
121
+ agent: snapshot.agent,
122
+ overall,
123
+ alive,
124
+ updatedAt: snapshot.updatedAt,
125
+ sessions: snapshot.sessions,
126
+ fatalError:
127
+ overall === "error" && snapshot.fatalError === null && !alive
128
+ ? "orchestrator exited unexpectedly"
129
+ : snapshot.fatalError,
130
+ };
131
+ }
132
+
133
+ export interface WorkflowStatusOptions {
134
+ /** Filter to a specific workflow by tmux session name. */
135
+ id?: string;
136
+ format?: StatusFormat;
137
+ }
138
+
139
+ /**
140
+ * Top-level command. Prints either a single report (when `id` is
141
+ * provided) or the list of all workflow sessions on the atomic
142
+ * socket. Returns 1 when a requested id can't be found, 0 otherwise.
143
+ */
144
+ export async function workflowStatusCommand(
145
+ options: WorkflowStatusOptions,
146
+ deps: StatusDeps = defaultDeps,
147
+ ): Promise<number> {
148
+ const format: StatusFormat = options.format ?? "json";
149
+
150
+ if (!deps.isTmuxInstalled()) {
151
+ return emit(format, { workflows: [] }, "no sessions running (tmux is not installed)");
152
+ }
153
+
154
+ const allSessions = deps.listSessions();
155
+ const workflowSessions = allSessions.filter((s) => s.type === "workflow");
156
+
157
+ // ── Single-workflow query ────────────────────────────────────────
158
+ if (options.id !== undefined) {
159
+ const target = workflowSessions.find((s) => s.name === options.id);
160
+ // Honour the requested id even when the tmux session is gone but
161
+ // the on-disk snapshot might still be readable (best-effort
162
+ // post-mortem). When neither exists we report not found.
163
+ if (!target) {
164
+ const fallbackRunId = workflowRunIdFromTmuxName(options.id);
165
+ if (fallbackRunId) {
166
+ const report = await buildReport(options.id, false, deps);
167
+ if (report && report.workflowName !== "") {
168
+ return emitReport(format, report);
169
+ }
170
+ }
171
+ return reportError(
172
+ format,
173
+ `Workflow '${options.id}' not found.`,
174
+ );
175
+ }
176
+ const report = await buildReport(target.name, true, deps);
177
+ if (!report) {
178
+ return reportError(format, `Could not parse workflow id '${options.id}'.`);
179
+ }
180
+ return emitReport(format, report);
181
+ }
182
+
183
+ // ── All-workflow listing ─────────────────────────────────────────
184
+ const reports: WorkflowStatusReport[] = [];
185
+ for (const s of workflowSessions) {
186
+ const r = await buildReport(s.name, true, deps);
187
+ if (r) reports.push(r);
188
+ }
189
+
190
+ return emit(
191
+ format,
192
+ { workflows: reports },
193
+ "no workflows running",
194
+ reports,
195
+ );
196
+ }
197
+
198
+ // ─── Output helpers ─────────────────────────────────────────────────
199
+
200
+ function emit(
201
+ format: StatusFormat,
202
+ jsonPayload: { workflows: WorkflowStatusReport[] },
203
+ emptyMessage: string,
204
+ reports?: WorkflowStatusReport[],
205
+ ): number {
206
+ if (format === "json") {
207
+ process.stdout.write(JSON.stringify(jsonPayload, null, 2) + "\n");
208
+ return 0;
209
+ }
210
+ const list = reports ?? jsonPayload.workflows;
211
+ if (list.length === 0) {
212
+ const paint = createPainter();
213
+ process.stdout.write(
214
+ "\n " + paint("text", emptyMessage, { bold: true }) + "\n\n",
215
+ );
216
+ return 0;
217
+ }
218
+ process.stdout.write(renderListText(list));
219
+ return 0;
220
+ }
221
+
222
+ function emitReport(format: StatusFormat, report: WorkflowStatusReport): number {
223
+ if (format === "json") {
224
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
225
+ } else {
226
+ process.stdout.write(renderReportText(report));
227
+ }
228
+ return 0;
229
+ }
230
+
231
+ function reportError(format: StatusFormat, message: string): number {
232
+ if (format === "json") {
233
+ process.stdout.write(JSON.stringify({ error: message }, null, 2) + "\n");
234
+ } else {
235
+ process.stderr.write(`${COLORS.red}Error: ${message}${COLORS.reset}\n`);
236
+ }
237
+ return 1;
238
+ }
239
+
240
+ const OVERALL_COLORS: Record<WorkflowOverallStatus, PaletteKey> = {
241
+ in_progress: "accent",
242
+ needs_review: "warning",
243
+ completed: "success",
244
+ error: "error",
245
+ };
246
+
247
+ const OVERALL_INDICATOR: Record<WorkflowOverallStatus, string> = {
248
+ in_progress: "●",
249
+ needs_review: "!",
250
+ completed: "✓",
251
+ error: "✗",
252
+ };
253
+
254
+ function renderListText(reports: WorkflowStatusReport[]): string {
255
+ const paint = createPainter();
256
+ const lines: string[] = [];
257
+ const noun = reports.length === 1 ? "workflow" : "workflows";
258
+ lines.push("");
259
+ lines.push(
260
+ " " +
261
+ paint("text", String(reports.length), { bold: true }) +
262
+ " " +
263
+ paint("dim", noun),
264
+ );
265
+ lines.push("");
266
+ for (const r of reports) {
267
+ const color = OVERALL_COLORS[r.overall];
268
+ const indicator = OVERALL_INDICATOR[r.overall];
269
+ const label =
270
+ r.workflowName !== "" ? r.workflowName : "(no snapshot)";
271
+ lines.push(
272
+ " " +
273
+ paint(color, indicator) +
274
+ " " +
275
+ paint("text", r.id, { bold: true }) +
276
+ " " +
277
+ paint(color, r.overall) +
278
+ " " +
279
+ paint("dim", label),
280
+ );
281
+ }
282
+ lines.push("");
283
+ return lines.join("\n") + "\n";
284
+ }
285
+
286
+ function renderReportText(report: WorkflowStatusReport): string {
287
+ const paint = createPainter();
288
+ const color = OVERALL_COLORS[report.overall];
289
+ const lines: string[] = [];
290
+ lines.push("");
291
+ lines.push(
292
+ " " +
293
+ paint(color, OVERALL_INDICATOR[report.overall]) +
294
+ " " +
295
+ paint("text", report.id, { bold: true }) +
296
+ " " +
297
+ paint(color, report.overall),
298
+ );
299
+ if (report.workflowName !== "") {
300
+ lines.push(
301
+ " " +
302
+ paint("dim", "workflow: ") +
303
+ paint("text", report.workflowName) +
304
+ paint("dim", " · ") +
305
+ paint("accent", report.agent),
306
+ );
307
+ }
308
+ if (report.fatalError) {
309
+ lines.push(" " + paint("error", `error: ${report.fatalError}`));
310
+ }
311
+ if (report.sessions.length > 0) {
312
+ lines.push("");
313
+ lines.push(" " + paint("dim", "stages:"));
314
+ for (const s of report.sessions) {
315
+ lines.push(
316
+ " " +
317
+ paint("text", s.name) +
318
+ " " +
319
+ paint("dim", s.status) +
320
+ (s.error ? " " + paint("error", s.error) : ""),
321
+ );
322
+ }
323
+ }
324
+ if (report.updatedAt) {
325
+ lines.push("");
326
+ lines.push(" " + paint("dim", `updated: ${report.updatedAt}`));
327
+ }
328
+ lines.push("");
329
+ return lines.join("\n") + "\n";
330
+ }
@@ -11,7 +11,7 @@ import { deriveGraphTheme } from "./graph-theme.ts";
11
11
  import type { GraphTheme } from "./graph-theme.ts";
12
12
  import { PanelStore } from "./orchestrator-panel-store.ts";
13
13
  import { StoreContext, ThemeContext, TmuxSessionContext } from "./orchestrator-panel-contexts.ts";
14
- import type { PanelSession, PanelOptions } from "./orchestrator-panel-types.ts";
14
+ import type { PanelSession, PanelOptions, SessionData } from "./orchestrator-panel-types.ts";
15
15
  import { SessionGraphPanel } from "./session-graph-panel.tsx";
16
16
  import { ErrorBoundary } from "./error-boundary.tsx";
17
17
 
@@ -179,4 +179,39 @@ export class OrchestratorPanel {
179
179
  this.renderer.destroy();
180
180
  } catch {}
181
181
  }
182
+
183
+ /**
184
+ * Subscribe to store mutations. Returned function unsubscribes.
185
+ *
186
+ * Used by the orchestrator process to mirror the in-memory panel
187
+ * state to a `status.json` file on disk so out-of-process consumers
188
+ * (e.g. `atomic workflow status`) can read the live workflow state.
189
+ */
190
+ subscribe(fn: () => void): () => void {
191
+ return this.store.subscribe(fn);
192
+ }
193
+
194
+ /**
195
+ * Read-only snapshot of the fields needed by the on-disk status
196
+ * writer. Defined here (not in PanelStore) because the store keeps
197
+ * full mutable references; this projection drops the renderer-only
198
+ * promise resolvers and version counter.
199
+ */
200
+ getSnapshot(): {
201
+ workflowName: string;
202
+ agent: string;
203
+ prompt: string;
204
+ fatalError: string | null;
205
+ completionReached: boolean;
206
+ sessions: readonly SessionData[];
207
+ } {
208
+ return {
209
+ workflowName: this.store.workflowName,
210
+ agent: this.store.agent,
211
+ prompt: this.store.prompt,
212
+ fatalError: this.store.fatalError,
213
+ completionReached: this.store.completionReached,
214
+ sessions: this.store.sessions,
215
+ };
216
+ }
182
217
  }
@@ -55,6 +55,7 @@ import {
55
55
  } from "../providers/claude.ts";
56
56
  import { OrchestratorPanel } from "./panel.tsx";
57
57
  import { GraphFrontierTracker } from "./graph-inference.ts";
58
+ import { buildSnapshot, writeSnapshot } from "./status-writer.ts";
58
59
  import { errorMessage } from "../errors.ts";
59
60
  import { createPainter } from "../../theme/colors.ts";
60
61
 
@@ -1533,11 +1534,47 @@ export async function runOrchestrator(): Promise<void> {
1533
1534
  tmuxSession: tmuxSessionName,
1534
1535
  });
1535
1536
 
1537
+ // Mirror panel-store mutations to <sessionDir>/status.json so
1538
+ // out-of-process consumers (e.g. `atomic workflow status`) can read
1539
+ // the live workflow state without IPC into the orchestrator.
1540
+ // Writes are debounced via a "pending" flag so a burst of mutations
1541
+ // collapses into a single file write.
1542
+ let snapshotPending = false;
1543
+ const persistSnapshot = (): void => {
1544
+ if (snapshotPending) return;
1545
+ snapshotPending = true;
1546
+ queueMicrotask(() => {
1547
+ snapshotPending = false;
1548
+ const snap = panel.getSnapshot();
1549
+ void writeSnapshot(
1550
+ sessionsBaseDir,
1551
+ buildSnapshot({
1552
+ workflowRunId,
1553
+ tmuxSession: tmuxSessionName,
1554
+ ...snap,
1555
+ }),
1556
+ );
1557
+ });
1558
+ };
1559
+ const unsubscribePanel = panel.subscribe(persistSnapshot);
1560
+ // Seed an initial snapshot so the file exists before any session starts.
1561
+ persistSnapshot();
1562
+
1536
1563
  // Idempotent shutdown guard
1537
1564
  let shutdownCalled = false;
1538
1565
  const shutdown = (exitCode = 0) => {
1539
1566
  if (shutdownCalled) return;
1540
1567
  shutdownCalled = true;
1568
+ unsubscribePanel();
1569
+ // Final snapshot reflecting terminal state (completed/error/aborted).
1570
+ void writeSnapshot(
1571
+ sessionsBaseDir,
1572
+ buildSnapshot({
1573
+ workflowRunId,
1574
+ tmuxSession: tmuxSessionName,
1575
+ ...panel.getSnapshot(),
1576
+ }),
1577
+ );
1541
1578
  panel.destroy();
1542
1579
  try {
1543
1580
  tmux.killSession(tmuxSessionName);
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Unit tests for the workflow status writer.
3
+ *
4
+ * Covers the pure helpers (overall-status derivation, snapshot
5
+ * construction, tmux-name → run-id parsing) and the file I/O round
6
+ * trip against a real temp directory.
7
+ */
8
+
9
+ import { describe, test, expect } from "bun:test";
10
+ import { mkdtemp, rm } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import {
14
+ buildSnapshot,
15
+ deriveOverallStatus,
16
+ readSnapshot,
17
+ workflowRunIdFromTmuxName,
18
+ writeSnapshot,
19
+ type WorkflowStatusSnapshot,
20
+ } from "./status-writer.ts";
21
+ import type { SessionData } from "../components/orchestrator-panel-types.ts";
22
+
23
+ function session(
24
+ name: string,
25
+ status: SessionData["status"],
26
+ extra: Partial<SessionData> = {},
27
+ ): SessionData {
28
+ return {
29
+ name,
30
+ status,
31
+ parents: [],
32
+ startedAt: 1000,
33
+ endedAt: null,
34
+ ...extra,
35
+ };
36
+ }
37
+
38
+ // ─── deriveOverallStatus ────────────────────────────────────────────
39
+
40
+ describe("deriveOverallStatus", () => {
41
+ test("returns 'error' when fatalError is set, even if completion is reached", () => {
42
+ expect(
43
+ deriveOverallStatus({
44
+ sessions: [],
45
+ fatalError: "boom",
46
+ completionReached: true,
47
+ }),
48
+ ).toBe("error");
49
+ });
50
+
51
+ test("returns 'error' when any session ended in error", () => {
52
+ expect(
53
+ deriveOverallStatus({
54
+ sessions: [session("a", "complete"), session("b", "error")],
55
+ fatalError: null,
56
+ completionReached: false,
57
+ }),
58
+ ).toBe("error");
59
+ });
60
+
61
+ test("returns 'needs_review' when any session is awaiting_input", () => {
62
+ expect(
63
+ deriveOverallStatus({
64
+ sessions: [session("a", "running"), session("b", "awaiting_input")],
65
+ fatalError: null,
66
+ completionReached: false,
67
+ }),
68
+ ).toBe("needs_review");
69
+ });
70
+
71
+ test("'needs_review' wins over 'completed' so a HIL pause near the end isn't reported as done", () => {
72
+ expect(
73
+ deriveOverallStatus({
74
+ sessions: [session("a", "complete"), session("b", "awaiting_input")],
75
+ fatalError: null,
76
+ completionReached: true,
77
+ }),
78
+ ).toBe("needs_review");
79
+ });
80
+
81
+ test("returns 'completed' when completionReached and nothing errored or paused", () => {
82
+ expect(
83
+ deriveOverallStatus({
84
+ sessions: [session("a", "complete"), session("b", "complete")],
85
+ fatalError: null,
86
+ completionReached: true,
87
+ }),
88
+ ).toBe("completed");
89
+ });
90
+
91
+ test("returns 'in_progress' as the default", () => {
92
+ expect(
93
+ deriveOverallStatus({
94
+ sessions: [session("a", "running")],
95
+ fatalError: null,
96
+ completionReached: false,
97
+ }),
98
+ ).toBe("in_progress");
99
+ });
100
+ });
101
+
102
+ // ─── workflowRunIdFromTmuxName ──────────────────────────────────────
103
+
104
+ describe("workflowRunIdFromTmuxName", () => {
105
+ test("extracts the trailing 8-hex segment from a workflow session name", () => {
106
+ expect(workflowRunIdFromTmuxName("atomic-wf-claude-ralph-a1b2c3d4")).toBe(
107
+ "a1b2c3d4",
108
+ );
109
+ });
110
+
111
+ test("handles workflow names containing hyphens", () => {
112
+ expect(
113
+ workflowRunIdFromTmuxName("atomic-wf-claude-deep-research-12345678"),
114
+ ).toBe("12345678");
115
+ });
116
+
117
+ test("returns null for chat sessions", () => {
118
+ expect(workflowRunIdFromTmuxName("atomic-chat-claude-deadbeef")).toBeNull();
119
+ });
120
+
121
+ test("returns null when the suffix is not 8-char hex", () => {
122
+ expect(workflowRunIdFromTmuxName("atomic-wf-claude-ralph-not-hex")).toBeNull();
123
+ });
124
+
125
+ test("returns null for an unrelated session name", () => {
126
+ expect(workflowRunIdFromTmuxName("my-session")).toBeNull();
127
+ });
128
+ });
129
+
130
+ // ─── buildSnapshot ──────────────────────────────────────────────────
131
+
132
+ describe("buildSnapshot", () => {
133
+ test("populates schemaVersion + identifying fields and clones session arrays", () => {
134
+ const fixed = new Date("2026-01-01T00:00:00.000Z");
135
+ const sourceParents = ["orchestrator"];
136
+ const sourceSession = session("orchestrator", "running", { parents: sourceParents });
137
+ const snap = buildSnapshot(
138
+ {
139
+ workflowRunId: "abcd1234",
140
+ tmuxSession: "atomic-wf-claude-ralph-abcd1234",
141
+ workflowName: "ralph",
142
+ agent: "claude",
143
+ prompt: "hello",
144
+ fatalError: null,
145
+ completionReached: false,
146
+ sessions: [sourceSession],
147
+ },
148
+ () => fixed,
149
+ );
150
+
151
+ expect(snap.schemaVersion).toBe(1);
152
+ expect(snap.workflowRunId).toBe("abcd1234");
153
+ expect(snap.tmuxSession).toBe("atomic-wf-claude-ralph-abcd1234");
154
+ expect(snap.workflowName).toBe("ralph");
155
+ expect(snap.agent).toBe("claude");
156
+ expect(snap.prompt).toBe("hello");
157
+ expect(snap.overall).toBe("in_progress");
158
+ expect(snap.updatedAt).toBe("2026-01-01T00:00:00.000Z");
159
+ expect(snap.sessions).toHaveLength(1);
160
+ expect(snap.sessions[0]!.name).toBe("orchestrator");
161
+ // parents must be cloned, not aliased to the input array — otherwise
162
+ // a later panel-store mutation would silently rewrite a snapshot
163
+ // that we already handed to a consumer.
164
+ expect(snap.sessions[0]!.parents).not.toBe(sourceParents);
165
+ expect(snap.sessions[0]!.parents).toEqual(sourceParents);
166
+ });
167
+
168
+ test("propagates the derived overall status from the inputs", () => {
169
+ const snap = buildSnapshot({
170
+ workflowRunId: "abcd1234",
171
+ tmuxSession: "x",
172
+ workflowName: "ralph",
173
+ agent: "claude",
174
+ prompt: "",
175
+ fatalError: null,
176
+ completionReached: true,
177
+ sessions: [session("a", "complete")],
178
+ });
179
+ expect(snap.overall).toBe("completed");
180
+ });
181
+ });
182
+
183
+ // ─── write/read round trip ──────────────────────────────────────────
184
+
185
+ describe("writeSnapshot + readSnapshot", () => {
186
+ test("persists a snapshot and reads it back unchanged", async () => {
187
+ const dir = await mkdtemp(join(tmpdir(), "atomic-status-"));
188
+ try {
189
+ const snap: WorkflowStatusSnapshot = buildSnapshot({
190
+ workflowRunId: "abcd1234",
191
+ tmuxSession: "atomic-wf-claude-ralph-abcd1234",
192
+ workflowName: "ralph",
193
+ agent: "claude",
194
+ prompt: "hello",
195
+ fatalError: null,
196
+ completionReached: false,
197
+ sessions: [session("orchestrator", "running")],
198
+ });
199
+
200
+ await writeSnapshot(dir, snap);
201
+ const back = await readSnapshot(dir);
202
+ expect(back).not.toBeNull();
203
+ expect(back!.workflowRunId).toBe("abcd1234");
204
+ expect(back!.overall).toBe("in_progress");
205
+ expect(back!.sessions).toHaveLength(1);
206
+ } finally {
207
+ await rm(dir, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test("returns null when status.json does not exist", async () => {
212
+ const dir = await mkdtemp(join(tmpdir(), "atomic-status-"));
213
+ try {
214
+ const back = await readSnapshot(dir);
215
+ expect(back).toBeNull();
216
+ } finally {
217
+ await rm(dir, { recursive: true, force: true });
218
+ }
219
+ });
220
+
221
+ test("returns null when status.json is malformed JSON", async () => {
222
+ const dir = await mkdtemp(join(tmpdir(), "atomic-status-"));
223
+ try {
224
+ await Bun.write(join(dir, "status.json"), "not-json");
225
+ const back = await readSnapshot(dir);
226
+ expect(back).toBeNull();
227
+ } finally {
228
+ await rm(dir, { recursive: true, force: true });
229
+ }
230
+ });
231
+
232
+ test("returns null when status.json fails the snapshot shape guard", async () => {
233
+ const dir = await mkdtemp(join(tmpdir(), "atomic-status-"));
234
+ try {
235
+ await Bun.write(
236
+ join(dir, "status.json"),
237
+ JSON.stringify({ schemaVersion: 99, foo: "bar" }),
238
+ );
239
+ const back = await readSnapshot(dir);
240
+ expect(back).toBeNull();
241
+ } finally {
242
+ await rm(dir, { recursive: true, force: true });
243
+ }
244
+ });
245
+ });