@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,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `atomic workflow inputs <name> -a <agent>` — print a workflow's
|
|
3
|
+
* declared input schema so an orchestrating agent can build a valid
|
|
4
|
+
* `atomic workflow -n <name> -a <agent> --<field>=<value>` invocation
|
|
5
|
+
* without having to read the workflow source.
|
|
6
|
+
*
|
|
7
|
+
* Output formats:
|
|
8
|
+
* --format json (default) — machine-parseable JSON
|
|
9
|
+
* --format text — human-friendly text table
|
|
10
|
+
*
|
|
11
|
+
* Free-form workflows (no declared inputs) report a single synthetic
|
|
12
|
+
* `prompt` field so callers can treat both shapes uniformly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { COLORS, createPainter } from "../../theme/colors.ts";
|
|
16
|
+
import { AGENT_CONFIG, type AgentKey } from "../../services/config/index.ts";
|
|
17
|
+
import {
|
|
18
|
+
findWorkflow as _findWorkflow,
|
|
19
|
+
WorkflowLoader,
|
|
20
|
+
} from "../../sdk/workflows/index.ts";
|
|
21
|
+
import type { WorkflowInput } from "../../sdk/workflows/index.ts";
|
|
22
|
+
|
|
23
|
+
export type WorkflowInputsFormat = "json" | "text";
|
|
24
|
+
|
|
25
|
+
export interface WorkflowInputsResult {
|
|
26
|
+
workflow: string;
|
|
27
|
+
agent: string;
|
|
28
|
+
description: string;
|
|
29
|
+
freeform: boolean;
|
|
30
|
+
inputs: WorkflowInput[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the JSON payload returned to the agent. Free-form workflows
|
|
35
|
+
* synthesise a single optional `prompt` field so consumers don't have
|
|
36
|
+
* to special-case them — the same call shape works for both kinds.
|
|
37
|
+
*/
|
|
38
|
+
export function buildInputsPayload(
|
|
39
|
+
workflowName: string,
|
|
40
|
+
agent: string,
|
|
41
|
+
description: string,
|
|
42
|
+
inputs: readonly WorkflowInput[],
|
|
43
|
+
): WorkflowInputsResult {
|
|
44
|
+
const freeform = inputs.length === 0;
|
|
45
|
+
const declared: WorkflowInput[] = freeform
|
|
46
|
+
? [
|
|
47
|
+
{
|
|
48
|
+
name: "prompt",
|
|
49
|
+
type: "text",
|
|
50
|
+
required: false,
|
|
51
|
+
description:
|
|
52
|
+
"Free-form prompt — pass as a positional arg to `atomic workflow -n <name> -a <agent> \"<prompt>\"`.",
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
: inputs.map((i) => ({ ...i }));
|
|
56
|
+
return {
|
|
57
|
+
workflow: workflowName,
|
|
58
|
+
agent,
|
|
59
|
+
description,
|
|
60
|
+
freeform,
|
|
61
|
+
inputs: declared,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Render the payload as a human-friendly text block. */
|
|
66
|
+
export function renderInputsText(payload: WorkflowInputsResult): string {
|
|
67
|
+
const paint = createPainter();
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
lines.push("");
|
|
70
|
+
lines.push(
|
|
71
|
+
" " +
|
|
72
|
+
paint("text", payload.workflow, { bold: true }) +
|
|
73
|
+
paint("dim", " (") +
|
|
74
|
+
paint("accent", payload.agent) +
|
|
75
|
+
paint("dim", ")"),
|
|
76
|
+
);
|
|
77
|
+
if (payload.description) {
|
|
78
|
+
lines.push(" " + paint("dim", payload.description));
|
|
79
|
+
}
|
|
80
|
+
lines.push("");
|
|
81
|
+
if (payload.freeform) {
|
|
82
|
+
lines.push(" " + paint("dim", "free-form workflow — single positional prompt"));
|
|
83
|
+
lines.push("");
|
|
84
|
+
lines.push(
|
|
85
|
+
" " +
|
|
86
|
+
paint("dim", "run: ") +
|
|
87
|
+
paint(
|
|
88
|
+
"accent",
|
|
89
|
+
`atomic workflow -n ${payload.workflow} -a ${payload.agent} "<prompt>"`,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
lines.push("");
|
|
93
|
+
return lines.join("\n") + "\n";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const field of payload.inputs) {
|
|
97
|
+
const requiredLabel = field.required ? paint("warning", " (required)") : "";
|
|
98
|
+
const typeLabel = paint("dim", ` [${field.type}]`);
|
|
99
|
+
lines.push(
|
|
100
|
+
" " +
|
|
101
|
+
paint("accent", `--${field.name}`) +
|
|
102
|
+
typeLabel +
|
|
103
|
+
requiredLabel,
|
|
104
|
+
);
|
|
105
|
+
if (field.description) {
|
|
106
|
+
lines.push(" " + paint("text", field.description));
|
|
107
|
+
}
|
|
108
|
+
if (field.type === "enum" && field.values && field.values.length > 0) {
|
|
109
|
+
lines.push(
|
|
110
|
+
" " +
|
|
111
|
+
paint("dim", "values: ") +
|
|
112
|
+
paint("text", field.values.join(", ")),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (field.default !== undefined) {
|
|
116
|
+
lines.push(
|
|
117
|
+
" " + paint("dim", "default: ") + paint("text", field.default),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (field.placeholder) {
|
|
121
|
+
lines.push(
|
|
122
|
+
" " +
|
|
123
|
+
paint("dim", "placeholder: ") +
|
|
124
|
+
paint("text", field.placeholder),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
lines.push("");
|
|
130
|
+
const flagExample = payload.inputs
|
|
131
|
+
.map((i) => `--${i.name}=<${i.type}>`)
|
|
132
|
+
.join(" ");
|
|
133
|
+
lines.push(
|
|
134
|
+
" " +
|
|
135
|
+
paint("dim", "run: ") +
|
|
136
|
+
paint(
|
|
137
|
+
"accent",
|
|
138
|
+
`atomic workflow -n ${payload.workflow} -a ${payload.agent} ${flagExample}`,
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
lines.push("");
|
|
142
|
+
return lines.join("\n") + "\n";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface WorkflowInputsOptions {
|
|
146
|
+
name: string;
|
|
147
|
+
agent: string;
|
|
148
|
+
format?: WorkflowInputsFormat;
|
|
149
|
+
cwd?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Deps for `workflowInputsCommand`. Injected so tests can drive every
|
|
154
|
+
* branch (unknown agent / missing workflow / load failure / success)
|
|
155
|
+
* without the SDK's real filesystem-dependent discovery.
|
|
156
|
+
*/
|
|
157
|
+
export interface WorkflowInputsDeps {
|
|
158
|
+
findWorkflow: typeof _findWorkflow;
|
|
159
|
+
loadWorkflow: typeof WorkflowLoader.loadWorkflow;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const defaultDeps: WorkflowInputsDeps = {
|
|
163
|
+
findWorkflow: _findWorkflow,
|
|
164
|
+
loadWorkflow: WorkflowLoader.loadWorkflow,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the workflow, then either print its input schema (success)
|
|
169
|
+
* or print an error and return a non-zero exit code. The json branch
|
|
170
|
+
* also writes errors as JSON so an agent can parse a single envelope
|
|
171
|
+
* regardless of outcome.
|
|
172
|
+
*/
|
|
173
|
+
export async function workflowInputsCommand(
|
|
174
|
+
options: WorkflowInputsOptions,
|
|
175
|
+
deps: WorkflowInputsDeps = defaultDeps,
|
|
176
|
+
): Promise<number> {
|
|
177
|
+
const format: WorkflowInputsFormat = options.format ?? "json";
|
|
178
|
+
|
|
179
|
+
const validAgents = Object.keys(AGENT_CONFIG);
|
|
180
|
+
if (!validAgents.includes(options.agent)) {
|
|
181
|
+
return reportError(
|
|
182
|
+
format,
|
|
183
|
+
`Unknown agent '${options.agent}'. Valid agents: ${validAgents.join(", ")}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const agent = options.agent as AgentKey;
|
|
187
|
+
|
|
188
|
+
const discovered = await deps.findWorkflow(options.name, agent, options.cwd);
|
|
189
|
+
if (!discovered) {
|
|
190
|
+
return reportError(
|
|
191
|
+
format,
|
|
192
|
+
`Workflow '${options.name}' not found for agent '${agent}'.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const loaded = await deps.loadWorkflow(discovered);
|
|
197
|
+
if (!loaded.ok) {
|
|
198
|
+
return reportError(format, loaded.message);
|
|
199
|
+
}
|
|
200
|
+
const def = loaded.value.definition;
|
|
201
|
+
|
|
202
|
+
const payload = buildInputsPayload(def.name, agent, def.description, def.inputs);
|
|
203
|
+
|
|
204
|
+
if (format === "json") {
|
|
205
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
206
|
+
} else {
|
|
207
|
+
process.stdout.write(renderInputsText(payload));
|
|
208
|
+
}
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function reportError(format: WorkflowInputsFormat, message: string): number {
|
|
213
|
+
if (format === "json") {
|
|
214
|
+
process.stdout.write(JSON.stringify({ error: message }, null, 2) + "\n");
|
|
215
|
+
} else {
|
|
216
|
+
process.stderr.write(`${COLORS.red}Error: ${message}${COLORS.reset}\n`);
|
|
217
|
+
}
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `atomic workflow status` — covers the dependency-injected
|
|
3
|
+
* command shape end-to-end with no real tmux server, no real
|
|
4
|
+
* filesystem outside a temp dir, and JSON output capture so assertions
|
|
5
|
+
* can run on parsed objects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
|
|
9
|
+
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { workflowStatusCommand, type StatusDeps } from "./workflow-status.ts";
|
|
13
|
+
import {
|
|
14
|
+
buildSnapshot,
|
|
15
|
+
writeSnapshot,
|
|
16
|
+
type WorkflowStatusSnapshot,
|
|
17
|
+
} from "../../sdk/runtime/status-writer.ts";
|
|
18
|
+
import type { TmuxSession } from "../../sdk/runtime/tmux.ts";
|
|
19
|
+
import type { SessionData } from "../../sdk/components/orchestrator-panel-types.ts";
|
|
20
|
+
|
|
21
|
+
// ─── output capture ────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function captureStdout(): { read: () => string; restore: () => void } {
|
|
24
|
+
const chunks: string[] = [];
|
|
25
|
+
const orig = process.stdout.write;
|
|
26
|
+
process.stdout.write = ((c: string | Uint8Array) => {
|
|
27
|
+
chunks.push(typeof c === "string" ? c : new TextDecoder().decode(c));
|
|
28
|
+
return true;
|
|
29
|
+
}) as typeof process.stdout.write;
|
|
30
|
+
return {
|
|
31
|
+
read: () => chunks.join(""),
|
|
32
|
+
restore: () => {
|
|
33
|
+
process.stdout.write = orig;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let originalNoColor: string | undefined;
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
originalNoColor = process.env.NO_COLOR;
|
|
41
|
+
process.env.NO_COLOR = "1";
|
|
42
|
+
});
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
if (originalNoColor === undefined) delete process.env.NO_COLOR;
|
|
45
|
+
else process.env.NO_COLOR = originalNoColor;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function tmuxSession(name: string): TmuxSession {
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
windows: 1,
|
|
52
|
+
created: new Date().toISOString(),
|
|
53
|
+
attached: false,
|
|
54
|
+
type: "workflow",
|
|
55
|
+
agent: "claude",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function panelSession(
|
|
60
|
+
name: string,
|
|
61
|
+
status: SessionData["status"],
|
|
62
|
+
extra: Partial<SessionData> = {},
|
|
63
|
+
): SessionData {
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
status,
|
|
67
|
+
parents: [],
|
|
68
|
+
startedAt: 1000,
|
|
69
|
+
endedAt: null,
|
|
70
|
+
...extra,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function snapshotOf(
|
|
75
|
+
workflowName: string,
|
|
76
|
+
agent: string,
|
|
77
|
+
sessions: SessionData[],
|
|
78
|
+
opts: { fatalError?: string | null; completionReached?: boolean } = {},
|
|
79
|
+
): WorkflowStatusSnapshot {
|
|
80
|
+
return buildSnapshot({
|
|
81
|
+
workflowRunId: "abcd1234",
|
|
82
|
+
tmuxSession: `atomic-wf-${agent}-${workflowName}-abcd1234`,
|
|
83
|
+
workflowName,
|
|
84
|
+
agent,
|
|
85
|
+
prompt: "",
|
|
86
|
+
fatalError: opts.fatalError ?? null,
|
|
87
|
+
completionReached: opts.completionReached ?? false,
|
|
88
|
+
sessions,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Mocks {
|
|
93
|
+
isTmuxInstalled: ReturnType<typeof mock>;
|
|
94
|
+
sessionExists: ReturnType<typeof mock>;
|
|
95
|
+
listSessions: ReturnType<typeof mock>;
|
|
96
|
+
readSnapshot: ReturnType<typeof mock>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeDeps(sessionsBaseDir: string): { deps: StatusDeps; mocks: Mocks } {
|
|
100
|
+
const mocks: Mocks = {
|
|
101
|
+
isTmuxInstalled: mock(() => true),
|
|
102
|
+
sessionExists: mock(() => true),
|
|
103
|
+
listSessions: mock<() => TmuxSession[]>(() => []),
|
|
104
|
+
readSnapshot: mock(async () => null),
|
|
105
|
+
};
|
|
106
|
+
const deps: StatusDeps = {
|
|
107
|
+
isTmuxInstalled: mocks.isTmuxInstalled,
|
|
108
|
+
sessionExists: mocks.sessionExists,
|
|
109
|
+
listSessions: mocks.listSessions as unknown as StatusDeps["listSessions"],
|
|
110
|
+
readSnapshot: mocks.readSnapshot as unknown as StatusDeps["readSnapshot"],
|
|
111
|
+
sessionsBaseDir,
|
|
112
|
+
};
|
|
113
|
+
return { deps, mocks };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── tests ─────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("workflowStatusCommand", () => {
|
|
119
|
+
let tmpDir = "";
|
|
120
|
+
beforeEach(async () => {
|
|
121
|
+
tmpDir = await mkdtemp(join(tmpdir(), "atomic-status-cmd-"));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("prints empty list when no workflow sessions are running", async () => {
|
|
125
|
+
const { deps } = makeDeps(tmpDir);
|
|
126
|
+
const cap = captureStdout();
|
|
127
|
+
try {
|
|
128
|
+
const code = await workflowStatusCommand({ format: "json" }, deps);
|
|
129
|
+
expect(code).toBe(0);
|
|
130
|
+
const parsed = JSON.parse(cap.read());
|
|
131
|
+
expect(parsed).toEqual({ workflows: [] });
|
|
132
|
+
} finally {
|
|
133
|
+
cap.restore();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("derives 'in_progress' for an alive workflow with a running stage", async () => {
|
|
138
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
139
|
+
mocks.listSessions.mockReturnValue([
|
|
140
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
141
|
+
]);
|
|
142
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
143
|
+
snapshotOf("ralph", "claude", [panelSession("orchestrator", "running")]),
|
|
144
|
+
);
|
|
145
|
+
const cap = captureStdout();
|
|
146
|
+
try {
|
|
147
|
+
const code = await workflowStatusCommand({ format: "json" }, deps);
|
|
148
|
+
expect(code).toBe(0);
|
|
149
|
+
const parsed = JSON.parse(cap.read());
|
|
150
|
+
expect(parsed.workflows).toHaveLength(1);
|
|
151
|
+
expect(parsed.workflows[0].overall).toBe("in_progress");
|
|
152
|
+
expect(parsed.workflows[0].alive).toBe(true);
|
|
153
|
+
} finally {
|
|
154
|
+
cap.restore();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("returns 'needs_review' when any stage is awaiting input (HIL)", async () => {
|
|
159
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
160
|
+
mocks.listSessions.mockReturnValue([
|
|
161
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
162
|
+
]);
|
|
163
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
164
|
+
snapshotOf("ralph", "claude", [
|
|
165
|
+
panelSession("orchestrator", "running"),
|
|
166
|
+
panelSession("loop", "awaiting_input"),
|
|
167
|
+
]),
|
|
168
|
+
);
|
|
169
|
+
const cap = captureStdout();
|
|
170
|
+
try {
|
|
171
|
+
const code = await workflowStatusCommand(
|
|
172
|
+
{ format: "json", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
173
|
+
deps,
|
|
174
|
+
);
|
|
175
|
+
expect(code).toBe(0);
|
|
176
|
+
const parsed = JSON.parse(cap.read());
|
|
177
|
+
expect(parsed.overall).toBe("needs_review");
|
|
178
|
+
} finally {
|
|
179
|
+
cap.restore();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("returns 'completed' when completionReached and no errors", async () => {
|
|
184
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
185
|
+
mocks.listSessions.mockReturnValue([
|
|
186
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
187
|
+
]);
|
|
188
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
189
|
+
snapshotOf(
|
|
190
|
+
"ralph",
|
|
191
|
+
"claude",
|
|
192
|
+
[panelSession("orchestrator", "complete")],
|
|
193
|
+
{ completionReached: true },
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
const cap = captureStdout();
|
|
197
|
+
try {
|
|
198
|
+
const code = await workflowStatusCommand(
|
|
199
|
+
{ format: "json", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
200
|
+
deps,
|
|
201
|
+
);
|
|
202
|
+
expect(code).toBe(0);
|
|
203
|
+
const parsed = JSON.parse(cap.read());
|
|
204
|
+
expect(parsed.overall).toBe("completed");
|
|
205
|
+
} finally {
|
|
206
|
+
cap.restore();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("returns 'error' when fatalError is present in the snapshot", async () => {
|
|
211
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
212
|
+
mocks.listSessions.mockReturnValue([
|
|
213
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
214
|
+
]);
|
|
215
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
216
|
+
snapshotOf("ralph", "claude", [panelSession("orchestrator", "running")], {
|
|
217
|
+
fatalError: "boom",
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
const cap = captureStdout();
|
|
221
|
+
try {
|
|
222
|
+
const code = await workflowStatusCommand(
|
|
223
|
+
{ format: "json", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
224
|
+
deps,
|
|
225
|
+
);
|
|
226
|
+
expect(code).toBe(0);
|
|
227
|
+
const parsed = JSON.parse(cap.read());
|
|
228
|
+
expect(parsed.overall).toBe("error");
|
|
229
|
+
expect(parsed.fatalError).toBe("boom");
|
|
230
|
+
} finally {
|
|
231
|
+
cap.restore();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("returns 1 with a JSON error envelope when the requested id is unknown", async () => {
|
|
236
|
+
const { deps } = makeDeps(tmpDir);
|
|
237
|
+
const cap = captureStdout();
|
|
238
|
+
try {
|
|
239
|
+
const code = await workflowStatusCommand(
|
|
240
|
+
{ format: "json", id: "atomic-wf-claude-ralph-deadbeef" },
|
|
241
|
+
deps,
|
|
242
|
+
);
|
|
243
|
+
expect(code).toBe(1);
|
|
244
|
+
const parsed = JSON.parse(cap.read());
|
|
245
|
+
expect(parsed.error).toContain("not found");
|
|
246
|
+
} finally {
|
|
247
|
+
cap.restore();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("falls back to a minimal report when the orchestrator hasn't written a snapshot yet", async () => {
|
|
252
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
253
|
+
mocks.listSessions.mockReturnValue([
|
|
254
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
255
|
+
]);
|
|
256
|
+
mocks.readSnapshot.mockResolvedValue(null);
|
|
257
|
+
const cap = captureStdout();
|
|
258
|
+
try {
|
|
259
|
+
const code = await workflowStatusCommand({ format: "json" }, deps);
|
|
260
|
+
expect(code).toBe(0);
|
|
261
|
+
const parsed = JSON.parse(cap.read());
|
|
262
|
+
expect(parsed.workflows).toHaveLength(1);
|
|
263
|
+
expect(parsed.workflows[0].overall).toBe("in_progress");
|
|
264
|
+
expect(parsed.workflows[0].workflowName).toBe("");
|
|
265
|
+
} finally {
|
|
266
|
+
cap.restore();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("recognises a stale snapshot as 'error' when the tmux session is gone", async () => {
|
|
271
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
272
|
+
mocks.listSessions.mockReturnValue([]);
|
|
273
|
+
// Place a real snapshot on disk so the dead-session post-mortem
|
|
274
|
+
// path can read it.
|
|
275
|
+
const sessionDir = join(tmpDir, "abcd1234");
|
|
276
|
+
await mkdir(sessionDir, { recursive: true });
|
|
277
|
+
await writeSnapshot(
|
|
278
|
+
sessionDir,
|
|
279
|
+
snapshotOf("ralph", "claude", [panelSession("orchestrator", "running")]),
|
|
280
|
+
);
|
|
281
|
+
// Use the real reader for this test so the dead-session lookup
|
|
282
|
+
// hits the file we just wrote.
|
|
283
|
+
deps.readSnapshot = (await import(
|
|
284
|
+
"../../sdk/runtime/status-writer.ts"
|
|
285
|
+
)).readSnapshot;
|
|
286
|
+
const cap = captureStdout();
|
|
287
|
+
try {
|
|
288
|
+
const code = await workflowStatusCommand(
|
|
289
|
+
{ format: "json", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
290
|
+
deps,
|
|
291
|
+
);
|
|
292
|
+
expect(code).toBe(0);
|
|
293
|
+
const parsed = JSON.parse(cap.read());
|
|
294
|
+
expect(parsed.alive).toBe(false);
|
|
295
|
+
expect(parsed.overall).toBe("error");
|
|
296
|
+
} finally {
|
|
297
|
+
cap.restore();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("text format: empty list prints the 'no workflows running' hint", async () => {
|
|
302
|
+
const { deps } = makeDeps(tmpDir);
|
|
303
|
+
const cap = captureStdout();
|
|
304
|
+
try {
|
|
305
|
+
const code = await workflowStatusCommand({ format: "text" }, deps);
|
|
306
|
+
expect(code).toBe(0);
|
|
307
|
+
expect(cap.read()).toContain("no workflows running");
|
|
308
|
+
} finally {
|
|
309
|
+
cap.restore();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("text format: renders a workflow list with indicator, id, and status", async () => {
|
|
314
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
315
|
+
mocks.listSessions.mockReturnValue([
|
|
316
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
317
|
+
]);
|
|
318
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
319
|
+
snapshotOf("ralph", "claude", [panelSession("orchestrator", "running")]),
|
|
320
|
+
);
|
|
321
|
+
const cap = captureStdout();
|
|
322
|
+
try {
|
|
323
|
+
const code = await workflowStatusCommand({ format: "text" }, deps);
|
|
324
|
+
expect(code).toBe(0);
|
|
325
|
+
const out = cap.read();
|
|
326
|
+
expect(out).toContain("atomic-wf-claude-ralph-abcd1234");
|
|
327
|
+
expect(out).toContain("in_progress");
|
|
328
|
+
expect(out).toContain("ralph");
|
|
329
|
+
// singular noun when list has exactly one workflow
|
|
330
|
+
expect(out).toContain("1");
|
|
331
|
+
} finally {
|
|
332
|
+
cap.restore();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("text format: list uses '(no snapshot)' placeholder when workflowName is empty", async () => {
|
|
337
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
338
|
+
mocks.listSessions.mockReturnValue([
|
|
339
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
340
|
+
]);
|
|
341
|
+
mocks.readSnapshot.mockResolvedValue(null);
|
|
342
|
+
const cap = captureStdout();
|
|
343
|
+
try {
|
|
344
|
+
const code = await workflowStatusCommand({ format: "text" }, deps);
|
|
345
|
+
expect(code).toBe(0);
|
|
346
|
+
expect(cap.read()).toContain("(no snapshot)");
|
|
347
|
+
} finally {
|
|
348
|
+
cap.restore();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("text format: single-report render includes workflow, stages, fatal error, and updatedAt", async () => {
|
|
353
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
354
|
+
mocks.listSessions.mockReturnValue([
|
|
355
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
356
|
+
]);
|
|
357
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
358
|
+
snapshotOf(
|
|
359
|
+
"ralph",
|
|
360
|
+
"claude",
|
|
361
|
+
[
|
|
362
|
+
panelSession("orchestrator", "error", { error: "stage boom" }),
|
|
363
|
+
],
|
|
364
|
+
{ fatalError: "the whole thing" },
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
const cap = captureStdout();
|
|
368
|
+
try {
|
|
369
|
+
const code = await workflowStatusCommand(
|
|
370
|
+
{ format: "text", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
371
|
+
deps,
|
|
372
|
+
);
|
|
373
|
+
expect(code).toBe(0);
|
|
374
|
+
const out = cap.read();
|
|
375
|
+
expect(out).toContain("atomic-wf-claude-ralph-abcd1234");
|
|
376
|
+
expect(out).toContain("workflow:");
|
|
377
|
+
expect(out).toContain("ralph");
|
|
378
|
+
expect(out).toContain("stages:");
|
|
379
|
+
expect(out).toContain("orchestrator");
|
|
380
|
+
expect(out).toContain("stage boom");
|
|
381
|
+
expect(out).toContain("the whole thing");
|
|
382
|
+
expect(out).toContain("updated:");
|
|
383
|
+
} finally {
|
|
384
|
+
cap.restore();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("text format: unknown id writes 'not found' to stderr and returns 1", async () => {
|
|
389
|
+
const { deps } = makeDeps(tmpDir);
|
|
390
|
+
const errChunks: string[] = [];
|
|
391
|
+
const origErr = process.stderr.write;
|
|
392
|
+
process.stderr.write = ((c: string | Uint8Array) => {
|
|
393
|
+
errChunks.push(typeof c === "string" ? c : new TextDecoder().decode(c));
|
|
394
|
+
return true;
|
|
395
|
+
}) as typeof process.stderr.write;
|
|
396
|
+
try {
|
|
397
|
+
const code = await workflowStatusCommand(
|
|
398
|
+
{ format: "text", id: "atomic-wf-claude-ralph-deadbeef" },
|
|
399
|
+
deps,
|
|
400
|
+
);
|
|
401
|
+
expect(code).toBe(1);
|
|
402
|
+
expect(errChunks.join("")).toContain("not found");
|
|
403
|
+
} finally {
|
|
404
|
+
process.stderr.write = origErr;
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("reports zero workflows when tmux is not installed", async () => {
|
|
409
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
410
|
+
mocks.isTmuxInstalled.mockReturnValue(false);
|
|
411
|
+
const cap = captureStdout();
|
|
412
|
+
try {
|
|
413
|
+
const code = await workflowStatusCommand({ format: "json" }, deps);
|
|
414
|
+
expect(code).toBe(0);
|
|
415
|
+
const parsed = JSON.parse(cap.read());
|
|
416
|
+
expect(parsed).toEqual({ workflows: [] });
|
|
417
|
+
} finally {
|
|
418
|
+
cap.restore();
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("text format: tmux not installed prints the 'no sessions running' hint", async () => {
|
|
423
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
424
|
+
mocks.isTmuxInstalled.mockReturnValue(false);
|
|
425
|
+
const cap = captureStdout();
|
|
426
|
+
try {
|
|
427
|
+
const code = await workflowStatusCommand({ format: "text" }, deps);
|
|
428
|
+
expect(code).toBe(0);
|
|
429
|
+
expect(cap.read()).toContain("tmux is not installed");
|
|
430
|
+
} finally {
|
|
431
|
+
cap.restore();
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("defaults format to 'json' when omitted", async () => {
|
|
436
|
+
const { deps } = makeDeps(tmpDir);
|
|
437
|
+
const cap = captureStdout();
|
|
438
|
+
try {
|
|
439
|
+
const code = await workflowStatusCommand({}, deps);
|
|
440
|
+
expect(code).toBe(0);
|
|
441
|
+
// JSON parses cleanly
|
|
442
|
+
JSON.parse(cap.read());
|
|
443
|
+
} finally {
|
|
444
|
+
cap.restore();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
afterAll(async () => {
|
|
449
|
+
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
|
450
|
+
});
|
|
451
|
+
});
|