@bastani/atomic 0.5.20 → 0.5.21-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.
- package/.agents/skills/workflow-creator/SKILL.md +78 -8
- package/.agents/skills/workflow-creator/references/discovery-and-verification.md +75 -0
- package/dist/sdk/components/orchestrator-panel.d.ts +23 -1
- package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -1
- package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
- package/dist/sdk/define-workflow.d.ts.map +1 -1
- package/dist/sdk/errors.d.ts +12 -0
- package/dist/sdk/errors.d.ts.map +1 -1
- package/dist/sdk/runtime/discovery.d.ts +55 -12
- package/dist/sdk/runtime/discovery.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/runtime/loader.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/dist/sdk/runtime/version-compat.d.ts +28 -0
- package/dist/sdk/runtime/version-compat.d.ts.map +1 -0
- package/dist/sdk/types.d.ts +21 -0
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/workflows/index.d.ts +1 -1
- package/dist/sdk/workflows/index.d.ts.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.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-command.test.ts +10 -4
- package/src/commands/cli/workflow-inputs.test.ts +322 -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/commands/cli/workflow.test.ts +10 -3
- package/src/commands/cli/workflow.ts +57 -18
- package/src/sdk/components/orchestrator-panel.tsx +36 -1
- package/src/sdk/components/workflow-picker-panel.tsx +167 -18
- package/src/sdk/define-workflow.ts +1 -0
- package/src/sdk/errors.ts +20 -0
- package/src/sdk/runtime/discovery.ts +94 -20
- package/src/sdk/runtime/executor.ts +37 -0
- package/src/sdk/runtime/loader.ts +29 -2
- package/src/sdk/runtime/status-writer.test.ts +245 -0
- package/src/sdk/runtime/status-writer.ts +201 -0
- package/src/sdk/runtime/version-compat.ts +68 -0
- package/src/sdk/types.ts +21 -0
- package/src/sdk/workflows/index.ts +1 -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
|
+
}
|
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
renderWorkflowList,
|
|
7
7
|
} from "./workflow.ts";
|
|
8
8
|
import type { WorkflowInput } from "../../sdk/workflows/index.ts";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
DiscoveredWorkflow,
|
|
11
|
+
WorkflowWithMetadata,
|
|
12
|
+
} from "../../sdk/workflows/index.ts";
|
|
10
13
|
|
|
11
14
|
// ─── Colour handling ────────────────────────────────────────────────────────
|
|
12
15
|
// The renderer emits ANSI sequences when the host terminal claims truecolor
|
|
@@ -190,12 +193,16 @@ function wf(
|
|
|
190
193
|
name: string,
|
|
191
194
|
agent: DiscoveredWorkflow["agent"],
|
|
192
195
|
source: DiscoveredWorkflow["source"],
|
|
193
|
-
|
|
196
|
+
status: WorkflowWithMetadata["status"] = { kind: "ok" },
|
|
197
|
+
): WorkflowWithMetadata {
|
|
194
198
|
return {
|
|
195
199
|
name,
|
|
196
200
|
agent,
|
|
197
201
|
source,
|
|
198
202
|
path: `/tmp/fake/${source}/${name}/${agent}/index.ts`,
|
|
203
|
+
description: "",
|
|
204
|
+
inputs: [],
|
|
205
|
+
status,
|
|
199
206
|
};
|
|
200
207
|
}
|
|
201
208
|
|
|
@@ -216,7 +223,7 @@ describe("renderWorkflowList", () => {
|
|
|
216
223
|
});
|
|
217
224
|
|
|
218
225
|
test("groups entries by source → provider and sorts names", () => {
|
|
219
|
-
const workflows:
|
|
226
|
+
const workflows: WorkflowWithMetadata[] = [
|
|
220
227
|
wf("zebra", "claude", "local"),
|
|
221
228
|
wf("apple", "claude", "local"),
|
|
222
229
|
wf("middle", "opencode", "local"),
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
AgentType,
|
|
28
28
|
DiscoveredWorkflow,
|
|
29
29
|
WorkflowInput,
|
|
30
|
+
WorkflowMetadataStatus,
|
|
30
31
|
WorkflowWithMetadata,
|
|
31
32
|
} from "../../sdk/workflows/index.ts";
|
|
32
33
|
import { WorkflowPickerPanel } from "../../sdk/components/workflow-picker-panel.tsx";
|
|
@@ -227,8 +228,11 @@ export async function workflowCommand(options: {
|
|
|
227
228
|
options.agent as AgentType | undefined,
|
|
228
229
|
{ merge: false },
|
|
229
230
|
);
|
|
230
|
-
//
|
|
231
|
-
//
|
|
231
|
+
// Keep workflows that failed to load in the list — their status is
|
|
232
|
+
// surfaced inline as a "needs update" or "broken" tag so the user
|
|
233
|
+
// can see that a workflow still exists on disk even after an SDK
|
|
234
|
+
// bump invalidated it. Silent filtering was the original cause of
|
|
235
|
+
// the "workflow vanished after upgrade" report.
|
|
232
236
|
const workflows = await loadWorkflowsMetadata(discovered);
|
|
233
237
|
process.stdout.write(renderWorkflowList(workflows));
|
|
234
238
|
return 0;
|
|
@@ -477,9 +481,13 @@ async function runNamedMode(
|
|
|
477
481
|
` ~/.atomic/workflows/${name}/${agent}/index.ts ${COLORS.dim}(global)${COLORS.reset}`,
|
|
478
482
|
);
|
|
479
483
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
484
|
+
// Only suggest runnable alternatives — broken/incompatible workflows
|
|
485
|
+
// are visible via `atomic workflow -l` where their status is surfaced;
|
|
486
|
+
// listing them here would mask the real problem (the name the user
|
|
487
|
+
// typed does not exist) behind a dead-end suggestion.
|
|
488
|
+
const available = (
|
|
489
|
+
await loadWorkflowsMetadata(await discoverWorkflows(cwd, agent))
|
|
490
|
+
).filter((w) => w.status.kind === "ok");
|
|
483
491
|
if (available.length > 0) {
|
|
484
492
|
console.error(`\nAvailable ${agent} workflows:`);
|
|
485
493
|
for (const wf of available) {
|
|
@@ -608,6 +616,29 @@ const SOURCE_COLORS: Record<DiscoveredWorkflow["source"], PaletteKey> = {
|
|
|
608
616
|
builtin: "info", // sky — ships with atomic, foundational
|
|
609
617
|
};
|
|
610
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Per-row status badge shown in `atomic workflow -l` output. `ok` rows
|
|
621
|
+
* render with no badge (the list is already dense; flagging only
|
|
622
|
+
* non-ok rows keeps the happy path untouched). Incompatible rows
|
|
623
|
+
* include the required version so the user can compare at a glance;
|
|
624
|
+
* error rows stay terse and defer detail to `atomic workflow -n
|
|
625
|
+
* <name>` which surfaces the structured loader message.
|
|
626
|
+
*/
|
|
627
|
+
function renderStatusBadge(
|
|
628
|
+
paint: ReturnType<typeof createPainter>,
|
|
629
|
+
status: WorkflowMetadataStatus,
|
|
630
|
+
): string {
|
|
631
|
+
if (status.kind === "ok") return "";
|
|
632
|
+
if (status.kind === "incompatible") {
|
|
633
|
+
return (
|
|
634
|
+
" " +
|
|
635
|
+
paint("warning", "⚠ needs v" + status.requiredVersion) +
|
|
636
|
+
paint("dim", ` (installed v${status.currentVersion})`)
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return " " + paint("error", "✗ broken");
|
|
640
|
+
}
|
|
641
|
+
|
|
611
642
|
/**
|
|
612
643
|
* Render `atomic workflow --list` output as a printable string.
|
|
613
644
|
*
|
|
@@ -635,7 +666,7 @@ const SOURCE_COLORS: Record<DiscoveredWorkflow["source"], PaletteKey> = {
|
|
|
635
666
|
* Exported for testing — the pure-function shape makes coverage for the
|
|
636
667
|
* renderer trivial without spinning up a full CLI invocation.
|
|
637
668
|
*/
|
|
638
|
-
export function renderWorkflowList(workflows:
|
|
669
|
+
export function renderWorkflowList(workflows: WorkflowWithMetadata[]): string {
|
|
639
670
|
const paint = createPainter();
|
|
640
671
|
const lines: string[] = [];
|
|
641
672
|
|
|
@@ -653,9 +684,11 @@ export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
|
|
|
653
684
|
return lines.join("\n") + "\n";
|
|
654
685
|
}
|
|
655
686
|
|
|
656
|
-
// Group by source → agent → sorted
|
|
657
|
-
//
|
|
658
|
-
|
|
687
|
+
// Group by source → agent → sorted entries. Entries carry the full
|
|
688
|
+
// metadata (name + status) so the row renderer can append a status
|
|
689
|
+
// badge to non-ok rows without another lookup.
|
|
690
|
+
type EntrySummary = { name: string; status: WorkflowMetadataStatus };
|
|
691
|
+
type ByAgent = Map<AgentType, EntrySummary[]>;
|
|
659
692
|
const bySource = new Map<DiscoveredWorkflow["source"], ByAgent>();
|
|
660
693
|
for (const wf of workflows) {
|
|
661
694
|
let byAgent = bySource.get(wf.source);
|
|
@@ -663,13 +696,13 @@ export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
|
|
|
663
696
|
byAgent = new Map();
|
|
664
697
|
bySource.set(wf.source, byAgent);
|
|
665
698
|
}
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
byAgent.set(wf.agent,
|
|
699
|
+
const entries = byAgent.get(wf.agent) ?? [];
|
|
700
|
+
entries.push({ name: wf.name, status: wf.status });
|
|
701
|
+
byAgent.set(wf.agent, entries);
|
|
669
702
|
}
|
|
670
703
|
for (const byAgent of bySource.values()) {
|
|
671
|
-
for (const
|
|
672
|
-
|
|
704
|
+
for (const entries of byAgent.values()) {
|
|
705
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
673
706
|
}
|
|
674
707
|
}
|
|
675
708
|
|
|
@@ -702,8 +735,8 @@ export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
|
|
|
702
735
|
);
|
|
703
736
|
|
|
704
737
|
for (const agent of AGENT_ORDER) {
|
|
705
|
-
const
|
|
706
|
-
if (!
|
|
738
|
+
const entries = byAgent.get(agent);
|
|
739
|
+
if (!entries || entries.length === 0) continue;
|
|
707
740
|
|
|
708
741
|
// Provider heading: bold accent blue — a clearly different layer from
|
|
709
742
|
// both the semantic source heading above and the neutral entries below.
|
|
@@ -712,8 +745,14 @@ export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
|
|
|
712
745
|
" " + paint("accent", AGENT_DISPLAY_NAMES[agent], { bold: true }),
|
|
713
746
|
);
|
|
714
747
|
|
|
715
|
-
for (const
|
|
716
|
-
|
|
748
|
+
for (const entry of entries) {
|
|
749
|
+
// Dim the name on non-ok rows so the eye lands on the status
|
|
750
|
+
// badge rather than the workflow name — the badge is where the
|
|
751
|
+
// actionable info lives, and the name is already unrunnable.
|
|
752
|
+
const nameCol: PaletteKey = entry.status.kind === "ok" ? "text" : "dim";
|
|
753
|
+
lines.push(
|
|
754
|
+
" " + paint(nameCol, entry.name) + renderStatusBadge(paint, entry.status),
|
|
755
|
+
);
|
|
717
756
|
}
|
|
718
757
|
}
|
|
719
758
|
}
|
|
@@ -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
|
}
|