@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,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
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow status snapshot — bridges the in-process panel state with
|
|
3
|
+
* out-of-process consumers (e.g. `atomic workflow status`).
|
|
4
|
+
*
|
|
5
|
+
* The orchestrator subscribes to its `PanelStore` and writes a fresh
|
|
6
|
+
* snapshot to `~/.atomic/sessions/<workflowRunId>/status.json` every
|
|
7
|
+
* time the store mutates. Consumers read that file to derive the
|
|
8
|
+
* overall workflow state without needing IPC into the orchestrator.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { SessionData, SessionStatus } from "../components/orchestrator-panel-types.ts";
|
|
13
|
+
|
|
14
|
+
/** File name used for the status snapshot inside each workflow's session directory. */
|
|
15
|
+
export const STATUS_FILE_NAME = "status.json";
|
|
16
|
+
|
|
17
|
+
/** High-level workflow state surfaced to the agent / CLI consumer. */
|
|
18
|
+
export type WorkflowOverallStatus =
|
|
19
|
+
| "in_progress"
|
|
20
|
+
| "error"
|
|
21
|
+
| "completed"
|
|
22
|
+
| "needs_review";
|
|
23
|
+
|
|
24
|
+
/** Per-session entry mirrored from the orchestrator's panel store. */
|
|
25
|
+
export interface WorkflowStatusSession {
|
|
26
|
+
name: string;
|
|
27
|
+
status: SessionStatus;
|
|
28
|
+
parents: string[];
|
|
29
|
+
error?: string;
|
|
30
|
+
startedAt: number | null;
|
|
31
|
+
endedAt: number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Snapshot persisted to disk for `atomic workflow status` to read.
|
|
36
|
+
* Schema is versioned so future readers can stay backwards-compatible.
|
|
37
|
+
*/
|
|
38
|
+
export interface WorkflowStatusSnapshot {
|
|
39
|
+
schemaVersion: 1;
|
|
40
|
+
workflowRunId: string;
|
|
41
|
+
tmuxSession: string;
|
|
42
|
+
workflowName: string;
|
|
43
|
+
agent: string;
|
|
44
|
+
prompt: string;
|
|
45
|
+
/** Overall state derived from per-session status + completion flags. */
|
|
46
|
+
overall: WorkflowOverallStatus;
|
|
47
|
+
/** True when the orchestrator has shown its completion banner. */
|
|
48
|
+
completionReached: boolean;
|
|
49
|
+
/** Fatal-error message set via `panel.showFatalError`, if any. */
|
|
50
|
+
fatalError: string | null;
|
|
51
|
+
/** Wall-clock time of the snapshot in ISO-8601 format. */
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
sessions: WorkflowStatusSession[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Inputs the writer needs to render a snapshot — a strict subset of
|
|
58
|
+
* `PanelStore` so the writer doesn't depend on the renderer module.
|
|
59
|
+
*/
|
|
60
|
+
export interface StatusWriterInputs {
|
|
61
|
+
workflowRunId: string;
|
|
62
|
+
tmuxSession: string;
|
|
63
|
+
workflowName: string;
|
|
64
|
+
agent: string;
|
|
65
|
+
prompt: string;
|
|
66
|
+
fatalError: string | null;
|
|
67
|
+
completionReached: boolean;
|
|
68
|
+
sessions: readonly SessionData[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Derive the overall workflow state from per-session statuses + the
|
|
73
|
+
* orchestrator-level completion / fatal-error flags.
|
|
74
|
+
*
|
|
75
|
+
* Precedence (highest first):
|
|
76
|
+
* 1. `error` — fatal error or any session ended in error
|
|
77
|
+
* 2. `needs_review` — at least one session is awaiting human input (HIL)
|
|
78
|
+
* 3. `completed` — completion banner reached and nothing errored
|
|
79
|
+
* 4. `in_progress` — default
|
|
80
|
+
*
|
|
81
|
+
* `needs_review` outranks `completed` so an agent that pauses for HIL
|
|
82
|
+
* right at the end is never reported as done while still waiting.
|
|
83
|
+
*/
|
|
84
|
+
export function deriveOverallStatus(input: {
|
|
85
|
+
sessions: readonly SessionData[];
|
|
86
|
+
completionReached: boolean;
|
|
87
|
+
fatalError: string | null;
|
|
88
|
+
}): WorkflowOverallStatus {
|
|
89
|
+
if (input.fatalError !== null) return "error";
|
|
90
|
+
if (input.sessions.some((s) => s.status === "error")) return "error";
|
|
91
|
+
if (input.sessions.some((s) => s.status === "awaiting_input")) {
|
|
92
|
+
return "needs_review";
|
|
93
|
+
}
|
|
94
|
+
if (input.completionReached) return "completed";
|
|
95
|
+
return "in_progress";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Build a snapshot from the writer inputs (pure — exported for tests). */
|
|
99
|
+
export function buildSnapshot(
|
|
100
|
+
input: StatusWriterInputs,
|
|
101
|
+
now: () => Date = () => new Date(),
|
|
102
|
+
): WorkflowStatusSnapshot {
|
|
103
|
+
return {
|
|
104
|
+
schemaVersion: 1,
|
|
105
|
+
workflowRunId: input.workflowRunId,
|
|
106
|
+
tmuxSession: input.tmuxSession,
|
|
107
|
+
workflowName: input.workflowName,
|
|
108
|
+
agent: input.agent,
|
|
109
|
+
prompt: input.prompt,
|
|
110
|
+
overall: deriveOverallStatus({
|
|
111
|
+
sessions: input.sessions,
|
|
112
|
+
completionReached: input.completionReached,
|
|
113
|
+
fatalError: input.fatalError,
|
|
114
|
+
}),
|
|
115
|
+
completionReached: input.completionReached,
|
|
116
|
+
fatalError: input.fatalError,
|
|
117
|
+
updatedAt: now().toISOString(),
|
|
118
|
+
sessions: input.sessions.map((s) => ({
|
|
119
|
+
name: s.name,
|
|
120
|
+
status: s.status,
|
|
121
|
+
parents: [...s.parents],
|
|
122
|
+
...(s.error !== undefined ? { error: s.error } : {}),
|
|
123
|
+
startedAt: s.startedAt,
|
|
124
|
+
endedAt: s.endedAt,
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Absolute path of the status file for a given workflow run directory. */
|
|
130
|
+
export function statusFilePath(sessionDir: string): string {
|
|
131
|
+
return join(sessionDir, STATUS_FILE_NAME);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Write a snapshot to `<sessionDir>/status.json`. Uses an atomic
|
|
136
|
+
* write-then-rename so concurrent readers never see partial JSON.
|
|
137
|
+
* Errors are swallowed — the orchestrator must keep running even if
|
|
138
|
+
* the status file can't be persisted.
|
|
139
|
+
*/
|
|
140
|
+
export async function writeSnapshot(
|
|
141
|
+
sessionDir: string,
|
|
142
|
+
snapshot: WorkflowStatusSnapshot,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const finalPath = statusFilePath(sessionDir);
|
|
145
|
+
const tmpPath = `${finalPath}.tmp-${process.pid}`;
|
|
146
|
+
try {
|
|
147
|
+
await Bun.write(tmpPath, JSON.stringify(snapshot, null, 2));
|
|
148
|
+
const { rename } = await import("node:fs/promises");
|
|
149
|
+
await rename(tmpPath, finalPath);
|
|
150
|
+
} catch {
|
|
151
|
+
// Best-effort — never fail the workflow because of a status write.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read a snapshot from disk. Returns `null` when the file doesn't
|
|
157
|
+
* exist or fails to parse — callers fall back to deriving status from
|
|
158
|
+
* the live tmux session list.
|
|
159
|
+
*/
|
|
160
|
+
export async function readSnapshot(
|
|
161
|
+
sessionDir: string,
|
|
162
|
+
): Promise<WorkflowStatusSnapshot | null> {
|
|
163
|
+
try {
|
|
164
|
+
const file = Bun.file(statusFilePath(sessionDir));
|
|
165
|
+
if (!(await file.exists())) return null;
|
|
166
|
+
const parsed: unknown = JSON.parse(await file.text());
|
|
167
|
+
if (!isSnapshot(parsed)) return null;
|
|
168
|
+
return parsed;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Runtime guard for deserialised snapshots — keeps the reader type-safe. */
|
|
175
|
+
function isSnapshot(value: unknown): value is WorkflowStatusSnapshot {
|
|
176
|
+
if (!value || typeof value !== "object") return false;
|
|
177
|
+
const v = value as Record<string, unknown>;
|
|
178
|
+
return (
|
|
179
|
+
v.schemaVersion === 1 &&
|
|
180
|
+
typeof v.workflowRunId === "string" &&
|
|
181
|
+
typeof v.tmuxSession === "string" &&
|
|
182
|
+
typeof v.overall === "string" &&
|
|
183
|
+
Array.isArray(v.sessions)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Extract the `workflowRunId` (the trailing 8-hex segment) from a
|
|
189
|
+
* tmux session name shaped `atomic-wf-<agent>-<name>-<id>`. Returns
|
|
190
|
+
* `null` for non-workflow sessions or names that don't end in a
|
|
191
|
+
* UUID-style suffix.
|
|
192
|
+
*/
|
|
193
|
+
export function workflowRunIdFromTmuxName(name: string): string | null {
|
|
194
|
+
if (!name.startsWith("atomic-wf-")) return null;
|
|
195
|
+
const lastDash = name.lastIndexOf("-");
|
|
196
|
+
if (lastDash < 0) return null;
|
|
197
|
+
const candidate = name.slice(lastDash + 1);
|
|
198
|
+
// generateId() produces an 8-char hex slice from crypto.randomUUID.
|
|
199
|
+
if (!/^[0-9a-f]{8}$/i.test(candidate)) return null;
|
|
200
|
+
return candidate;
|
|
201
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny semver comparator used to check a workflow's declared
|
|
3
|
+
* `minSDKVersion` against the bundled CLI {@link VERSION}.
|
|
4
|
+
*
|
|
5
|
+
* Accepts the subset of semver we actually ship: `MAJOR.MINOR.PATCH`
|
|
6
|
+
* with an optional numeric prerelease (e.g. `0.5.21`, `0.5.21-0`).
|
|
7
|
+
* Anything more exotic (build metadata, alpha tags) is treated as a
|
|
8
|
+
* plain string and compared lexicographically on the prerelease tail,
|
|
9
|
+
* which is good enough for "is the installed CLI new enough?".
|
|
10
|
+
*
|
|
11
|
+
* Isolated from the `semver` npm package so the workflow loader stays
|
|
12
|
+
* dependency-free — this check runs for every discovered workflow on
|
|
13
|
+
* every CLI launch.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface ParsedVersion {
|
|
17
|
+
major: number;
|
|
18
|
+
minor: number;
|
|
19
|
+
patch: number;
|
|
20
|
+
prerelease: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseVersion(v: string): ParsedVersion | null {
|
|
24
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
|
|
25
|
+
if (!match) return null;
|
|
26
|
+
return {
|
|
27
|
+
major: Number(match[1]),
|
|
28
|
+
minor: Number(match[2]),
|
|
29
|
+
patch: Number(match[3]),
|
|
30
|
+
prerelease: match[4] ?? "",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return a negative number if `a < b`, positive if `a > b`, 0 if equal.
|
|
36
|
+
* Unparseable inputs compare as equal so we never block a workflow over
|
|
37
|
+
* a typo in its `minSDKVersion` — the visible load error is friendlier
|
|
38
|
+
* than a hard refusal.
|
|
39
|
+
*/
|
|
40
|
+
export function compareVersions(a: string, b: string): number {
|
|
41
|
+
const pa = parseVersion(a);
|
|
42
|
+
const pb = parseVersion(b);
|
|
43
|
+
if (!pa || !pb) return 0;
|
|
44
|
+
|
|
45
|
+
if (pa.major !== pb.major) return pa.major - pb.major;
|
|
46
|
+
if (pa.minor !== pb.minor) return pa.minor - pb.minor;
|
|
47
|
+
if (pa.patch !== pb.patch) return pa.patch - pb.patch;
|
|
48
|
+
|
|
49
|
+
// Per semver, a version without a prerelease outranks one with a
|
|
50
|
+
// prerelease at the same MAJOR.MINOR.PATCH (1.0.0 > 1.0.0-0).
|
|
51
|
+
if (pa.prerelease === "" && pb.prerelease !== "") return 1;
|
|
52
|
+
if (pa.prerelease !== "" && pb.prerelease === "") return -1;
|
|
53
|
+
if (pa.prerelease === pb.prerelease) return 0;
|
|
54
|
+
return pa.prerelease < pb.prerelease ? -1 : 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* True when the current CLI is new enough to run a workflow that
|
|
59
|
+
* declared `minRequired`. A null/undefined requirement always
|
|
60
|
+
* satisfies — workflows that don't opt in are treated as compatible.
|
|
61
|
+
*/
|
|
62
|
+
export function satisfiesMinVersion(
|
|
63
|
+
current: string,
|
|
64
|
+
minRequired: string | null | undefined,
|
|
65
|
+
): boolean {
|
|
66
|
+
if (!minRequired) return true;
|
|
67
|
+
return compareVersions(current, minRequired) >= 0;
|
|
68
|
+
}
|
package/src/sdk/types.ts
CHANGED
|
@@ -366,6 +366,22 @@ export interface WorkflowOptions<
|
|
|
366
366
|
* and enforce them on `ctx.inputs`.
|
|
367
367
|
*/
|
|
368
368
|
inputs?: I;
|
|
369
|
+
/**
|
|
370
|
+
* Minimum Atomic CLI version this workflow is known to work with.
|
|
371
|
+
*
|
|
372
|
+
* When set, the CLI refuses to load the workflow on an older install
|
|
373
|
+
* and surfaces an actionable "update required" entry in the picker
|
|
374
|
+
* and `atomic workflow -l` output instead of silently dropping it.
|
|
375
|
+
*
|
|
376
|
+
* Leave unset (the default) to opt out entirely — the workflow will
|
|
377
|
+
* be treated as compatible with every CLI version. Use this when you
|
|
378
|
+
* consume a new SDK feature (new provider API, a new field on the
|
|
379
|
+
* stage options, etc.) that older installs can't honour.
|
|
380
|
+
*
|
|
381
|
+
* Accepts `MAJOR.MINOR.PATCH` with an optional numeric prerelease
|
|
382
|
+
* (`0.6.0`, `0.6.0-0`). Invalid strings are ignored.
|
|
383
|
+
*/
|
|
384
|
+
minSDKVersion?: string;
|
|
369
385
|
}
|
|
370
386
|
|
|
371
387
|
/**
|
|
@@ -377,6 +393,11 @@ export interface WorkflowDefinition<A extends AgentType = AgentType, N extends s
|
|
|
377
393
|
readonly description: string;
|
|
378
394
|
/** Declared input schema — empty array for free-form workflows. */
|
|
379
395
|
readonly inputs: readonly WorkflowInput[];
|
|
396
|
+
/**
|
|
397
|
+
* Minimum Atomic SDK version required. `null` when the workflow
|
|
398
|
+
* declared no requirement — treated as compatible with every CLI.
|
|
399
|
+
*/
|
|
400
|
+
readonly minSDKVersion: string | null;
|
|
380
401
|
/** The workflow's entry point. Called by the executor with a WorkflowContext. */
|
|
381
402
|
readonly run: (ctx: WorkflowContext<A, N>) => Promise<void>;
|
|
382
403
|
}
|