@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.
- package/.agents/skills/workflow-creator/SKILL.md +56 -8
- package/dist/sdk/components/orchestrator-panel.d.ts +23 -1
- package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/runtime/status-writer.d.ts +101 -0
- package/dist/sdk/runtime/status-writer.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +57 -3
- package/src/commands/cli/session.test.ts +43 -0
- package/src/commands/cli/session.ts +18 -8
- package/src/commands/cli/workflow-inputs.test.ts +321 -0
- package/src/commands/cli/workflow-inputs.ts +219 -0
- package/src/commands/cli/workflow-status.test.ts +451 -0
- package/src/commands/cli/workflow-status.ts +330 -0
- package/src/sdk/components/orchestrator-panel.tsx +36 -1
- package/src/sdk/runtime/executor.ts +37 -0
- package/src/sdk/runtime/status-writer.test.ts +245 -0
- package/src/sdk/runtime/status-writer.ts +201 -0
|
@@ -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
|
+
});
|