@bastani/atomic 0.5.0-3 → 0.5.0-5

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.
Files changed (42) hide show
  1. package/.atomic/workflows/hello/claude/index.ts +22 -25
  2. package/.atomic/workflows/hello/copilot/index.ts +41 -31
  3. package/.atomic/workflows/hello/opencode/index.ts +40 -40
  4. package/.atomic/workflows/hello-parallel/claude/index.ts +54 -54
  5. package/.atomic/workflows/hello-parallel/copilot/index.ts +89 -70
  6. package/.atomic/workflows/hello-parallel/opencode/index.ts +77 -77
  7. package/.atomic/workflows/ralph/claude/index.ts +128 -93
  8. package/.atomic/workflows/ralph/copilot/index.ts +212 -112
  9. package/.atomic/workflows/ralph/helpers/prompts.ts +45 -2
  10. package/.atomic/workflows/ralph/opencode/index.ts +174 -111
  11. package/README.md +138 -59
  12. package/package.json +1 -1
  13. package/src/cli.ts +0 -2
  14. package/src/commands/cli/chat/index.ts +28 -8
  15. package/src/commands/cli/init/index.ts +7 -10
  16. package/src/commands/cli/init/scm.ts +27 -10
  17. package/src/sdk/components/connectors.test.ts +45 -0
  18. package/src/sdk/components/layout.test.ts +321 -0
  19. package/src/sdk/components/layout.ts +51 -15
  20. package/src/sdk/components/orchestrator-panel-contexts.ts +13 -4
  21. package/src/sdk/components/orchestrator-panel-store.test.ts +156 -0
  22. package/src/sdk/components/orchestrator-panel-store.ts +24 -0
  23. package/src/sdk/components/orchestrator-panel.tsx +21 -0
  24. package/src/sdk/components/session-graph-panel.tsx +8 -15
  25. package/src/sdk/components/statusline.tsx +4 -6
  26. package/src/sdk/define-workflow.test.ts +71 -0
  27. package/src/sdk/define-workflow.ts +42 -39
  28. package/src/sdk/errors.ts +1 -1
  29. package/src/sdk/index.ts +4 -1
  30. package/src/sdk/providers/claude.ts +1 -1
  31. package/src/sdk/providers/copilot.ts +5 -3
  32. package/src/sdk/providers/opencode.ts +5 -3
  33. package/src/sdk/runtime/executor.ts +512 -301
  34. package/src/sdk/runtime/loader.ts +2 -2
  35. package/src/sdk/runtime/tmux.ts +31 -2
  36. package/src/sdk/types.ts +93 -20
  37. package/src/sdk/workflows.ts +7 -4
  38. package/src/services/config/definitions.ts +39 -2
  39. package/src/services/config/settings.ts +0 -6
  40. package/src/services/system/skills.ts +3 -7
  41. package/.atomic/workflows/package-lock.json +0 -31
  42. package/.atomic/workflows/package.json +0 -8
@@ -72,6 +72,32 @@ function resolveOverlaps(map: Record<string, LayoutNode>): void {
72
72
 
73
73
  // ─── Layout Computation ───────────────────────────
74
74
 
75
+ /**
76
+ * Compute effective parents for each session by filtering out references
77
+ * to sessions that don't exist in the map and deduplicating. Orphaned
78
+ * sessions (all parents missing) fall back to the "orchestrator" node
79
+ * when one is present, instead of becoming disconnected roots.
80
+ */
81
+ function normalizeParents(
82
+ sessions: SessionData[],
83
+ map: Record<string, LayoutNode>,
84
+ ): Map<string, string[]> {
85
+ const hasOrchestrator = "orchestrator" in map;
86
+ const effective = new Map<string, string[]>();
87
+
88
+ for (const s of sessions) {
89
+ const valid = [...new Set(s.parents)].filter((p) => p in map);
90
+ if (valid.length > 0) {
91
+ effective.set(s.name, valid);
92
+ } else if (hasOrchestrator && s.name !== "orchestrator") {
93
+ effective.set(s.name, ["orchestrator"]);
94
+ } else {
95
+ effective.set(s.name, []);
96
+ }
97
+ }
98
+ return effective;
99
+ }
100
+
75
101
  export function computeLayout(sessions: SessionData[]): LayoutResult {
76
102
  const map: Record<string, LayoutNode> = {};
77
103
  const roots: LayoutNode[] = [];
@@ -92,27 +118,36 @@ export function computeLayout(sessions: SessionData[]): LayoutResult {
92
118
  };
93
119
  }
94
120
 
95
- // Classify: single-parent tree child, multi-parent → merge node, no parent → root
121
+ // Normalize parents: filter missing refs, dedupe, orchestrator fallback
122
+ const effective = normalizeParents(sessions, map);
123
+
124
+ // Classify using effective parents (preserves LayoutNode.parents as raw metadata)
96
125
  for (const s of sessions) {
97
- if (s.parents.length > 1) {
126
+ const ep = effective.get(s.name) ?? [];
127
+ if (ep.length > 1) {
98
128
  mergeNodes.push(map[s.name]!);
99
- } else if (s.parents.length === 1 && map[s.parents[0]!]) {
100
- map[s.parents[0]!]!.children.push(map[s.name]!);
129
+ } else if (ep.length === 1 && map[ep[0]!]) {
130
+ map[ep[0]!]!.children.push(map[s.name]!);
101
131
  } else {
102
132
  roots.push(map[s.name]!);
103
133
  }
104
134
  }
105
135
 
106
- function setDepth(n: LayoutNode, d: number) {
107
- n.depth = d;
108
- for (const c of n.children) setDepth(c, d + 1);
136
+ // Memoized depth resolution — handles tree children, merge nodes,
137
+ // indirect dependencies, and arbitrary session ordering.
138
+ const depthCache = new Map<string, number>();
139
+ function resolveDepth(name: string): number {
140
+ if (depthCache.has(name)) return depthCache.get(name)!;
141
+ depthCache.set(name, 0); // guard against cycles
142
+ const ep = effective.get(name) ?? [];
143
+ if (ep.length === 0) return 0;
144
+ const maxParentDepth = Math.max(...ep.map((p) => resolveDepth(p)));
145
+ const depth = maxParentDepth + 1;
146
+ depthCache.set(name, depth);
147
+ return depth;
109
148
  }
110
- for (const r of roots) setDepth(r, 0);
111
-
112
- // Merge nodes: depth = max(parent depths) + 1, then recurse into children
113
- for (const m of mergeNodes) {
114
- const maxParentDepth = Math.max(...m.parents.map((p) => map[p]?.depth ?? 0));
115
- setDepth(m, maxParentDepth + 1);
149
+ for (const s of sessions) {
150
+ map[s.name]!.depth = resolveDepth(s.name);
116
151
  }
117
152
 
118
153
  const rowH: Record<number, number> = {};
@@ -149,9 +184,10 @@ export function computeLayout(sessions: SessionData[]): LayoutResult {
149
184
  firstRoot = false;
150
185
  }
151
186
 
152
- // Place merge nodes centered under all parents (and their sub-trees)
187
+ // Place merge nodes centered under all effective parents (and their sub-trees)
153
188
  for (const m of mergeNodes) {
154
- const parentCenters = m.parents.map((p) => (map[p]?.x ?? 0) + Math.floor(NODE_W / 2));
189
+ const ep = effective.get(m.name) ?? [];
190
+ const parentCenters = ep.map((p) => (map[p]?.x ?? 0) + Math.floor(NODE_W / 2));
155
191
  const avgCenter = Math.round(parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length);
156
192
 
157
193
  if (m.children.length > 0) {
@@ -1,6 +1,6 @@
1
1
  // ─── React Contexts & Hooks ───────────────────────
2
2
 
3
- import { createContext, useContext, useState, useEffect } from "react";
3
+ import { createContext, useContext, useSyncExternalStore } from "react";
4
4
  import type { PanelStore } from "./orchestrator-panel-store.ts";
5
5
  import type { GraphTheme } from "./graph-theme.ts";
6
6
 
@@ -20,7 +20,16 @@ export function useGraphTheme(): GraphTheme {
20
20
  return ctx;
21
21
  }
22
22
 
23
- export function useStoreSubscription(store: PanelStore): void {
24
- const [, forceRender] = useState(0);
25
- useEffect(() => store.subscribe(() => forceRender((c) => c + 1)), [store]);
23
+ /**
24
+ * Subscribe to the store and return its current version.
25
+ *
26
+ * Uses `useSyncExternalStore` so the subscription is active from the
27
+ * very first render — no `useEffect` timing gap that could cause a
28
+ * missed `addSession` update.
29
+ */
30
+ export function useStoreVersion(store: PanelStore): number {
31
+ return useSyncExternalStore(
32
+ store.subscribe,
33
+ () => store.version,
34
+ );
26
35
  }
@@ -558,4 +558,160 @@ describe("PanelStore", () => {
558
558
  expect(emitCount.value).toBe(7);
559
559
  });
560
560
  });
561
+
562
+ // ── addSession ──────────────────────────────────────────────────────────────
563
+
564
+ describe("addSession", () => {
565
+ test("appends a new session to the sessions array", () => {
566
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
567
+ const beforeCount = store.sessions.length;
568
+
569
+ store.addSession({
570
+ name: "dynamic-1",
571
+ status: "running",
572
+ parents: ["orchestrator"],
573
+ startedAt: Date.now(),
574
+ endedAt: null,
575
+ });
576
+
577
+ expect(store.sessions.length).toBe(beforeCount + 1);
578
+ expect(store.sessions.at(-1)!.name).toBe("dynamic-1");
579
+ expect(store.sessions.at(-1)!.status).toBe("running");
580
+ });
581
+
582
+ test("increments version on add", () => {
583
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
584
+ const v = store.version;
585
+
586
+ store.addSession({
587
+ name: "dynamic-2",
588
+ status: "running",
589
+ parents: ["orchestrator"],
590
+ startedAt: Date.now(),
591
+ endedAt: null,
592
+ });
593
+
594
+ expect(store.version).toBe(v + 1);
595
+ });
596
+
597
+ test("notifies subscribers on add", () => {
598
+ const listener = mock(() => {});
599
+ store.subscribe(listener);
600
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
601
+ listener.mockClear();
602
+
603
+ store.addSession({
604
+ name: "dynamic-3",
605
+ status: "running",
606
+ parents: ["orchestrator"],
607
+ startedAt: Date.now(),
608
+ endedAt: null,
609
+ });
610
+
611
+ expect(listener).toHaveBeenCalledTimes(1);
612
+ });
613
+
614
+ test("dynamically added session can be started/completed", () => {
615
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
616
+
617
+ store.addSession({
618
+ name: "dynamic-4",
619
+ status: "running",
620
+ parents: ["orchestrator"],
621
+ startedAt: Date.now(),
622
+ endedAt: null,
623
+ });
624
+
625
+ store.completeSession("dynamic-4");
626
+ const session = store.sessions.find((s) => s.name === "dynamic-4");
627
+ expect(session!.status).toBe("complete");
628
+ expect(session!.endedAt).not.toBeNull();
629
+ });
630
+
631
+ test("stores session with non-existent parent reference", () => {
632
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
633
+
634
+ store.addSession({
635
+ name: "orphan",
636
+ status: "running",
637
+ parents: ["nonexistent"],
638
+ startedAt: Date.now(),
639
+ endedAt: null,
640
+ });
641
+
642
+ const session = store.sessions.find((s) => s.name === "orphan");
643
+ expect(session).toBeDefined();
644
+ expect(session!.parents).toEqual(["nonexistent"]);
645
+ });
646
+
647
+ test("stores session with empty parents array", () => {
648
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
649
+
650
+ store.addSession({
651
+ name: "no-parent",
652
+ status: "running",
653
+ parents: [],
654
+ startedAt: Date.now(),
655
+ endedAt: null,
656
+ });
657
+
658
+ const session = store.sessions.find((s) => s.name === "no-parent");
659
+ expect(session).toBeDefined();
660
+ expect(session!.parents).toEqual([]);
661
+ });
662
+
663
+ test("nested child added after parent via addSession", () => {
664
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
665
+
666
+ store.addSession({
667
+ name: "step-1",
668
+ status: "running",
669
+ parents: ["orchestrator"],
670
+ startedAt: Date.now(),
671
+ endedAt: null,
672
+ });
673
+
674
+ store.addSession({
675
+ name: "step-1-child",
676
+ status: "running",
677
+ parents: ["step-1"],
678
+ startedAt: Date.now(),
679
+ endedAt: null,
680
+ });
681
+
682
+ const child = store.sessions.find((s) => s.name === "step-1-child");
683
+ expect(child).toBeDefined();
684
+ expect(child!.parents).toEqual(["step-1"]);
685
+ });
686
+ });
687
+
688
+ // ── setWorkflowInfo edge cases ─────────────────────────────────────────────
689
+
690
+ describe("setWorkflowInfo edge cases", () => {
691
+ test("session with inter-session parent reference preserves parents", () => {
692
+ store.setWorkflowInfo("wf", "copilot", [
693
+ { name: "step-1", parents: [] },
694
+ { name: "step-2", parents: ["step-1"] },
695
+ ], "prompt");
696
+
697
+ const step1 = store.sessions.find((s) => s.name === "step-1");
698
+ const step2 = store.sessions.find((s) => s.name === "step-2");
699
+ // step-1 with empty parents gets ["orchestrator"]
700
+ expect(step1!.parents).toEqual(["orchestrator"]);
701
+ // step-2 with explicit parent keeps it
702
+ expect(step2!.parents).toEqual(["step-1"]);
703
+ });
704
+
705
+ test("deeply nested pre-defined sessions preserve hierarchy", () => {
706
+ store.setWorkflowInfo("wf", "copilot", [
707
+ { name: "s1", parents: [] },
708
+ { name: "s2", parents: ["s1"] },
709
+ { name: "s3", parents: ["s2"] },
710
+ ], "prompt");
711
+
712
+ expect(store.sessions.find((s) => s.name === "s1")!.parents).toEqual(["orchestrator"]);
713
+ expect(store.sessions.find((s) => s.name === "s2")!.parents).toEqual(["s1"]);
714
+ expect(store.sessions.find((s) => s.name === "s3")!.parents).toEqual(["s2"]);
715
+ });
716
+ });
561
717
  });
@@ -15,6 +15,7 @@ export class PanelStore {
15
15
  fatalError: string | null = null;
16
16
  completionReached = false;
17
17
  exitResolve: (() => void) | null = null;
18
+ abortResolve: (() => void) | null = null;
18
19
 
19
20
  private listeners = new Set<Listener>();
20
21
 
@@ -81,6 +82,11 @@ export class PanelStore {
81
82
  this.emit();
82
83
  }
83
84
 
85
+ addSession(session: SessionData): void {
86
+ this.sessions.push(session);
87
+ this.emit();
88
+ }
89
+
84
90
  setCompletion(workflowName: string, transcriptsPath: string): void {
85
91
  this.completionInfo = { workflowName, transcriptsPath };
86
92
  const orch = this.sessions.find((s) => s.name === "orchestrator");
@@ -111,6 +117,24 @@ export class PanelStore {
111
117
  }
112
118
  }
113
119
 
120
+ /** Safely invoke abortResolve at most once to signal mid-execution quit. */
121
+ resolveAbort(): void {
122
+ if (this.abortResolve) {
123
+ const resolve = this.abortResolve;
124
+ this.abortResolve = null;
125
+ resolve();
126
+ }
127
+ }
128
+
129
+ /** Quit the workflow — routes to the correct handler based on current phase. */
130
+ requestQuit(): void {
131
+ if (this.completionReached) {
132
+ this.resolveExit();
133
+ } else {
134
+ this.resolveAbort();
135
+ }
136
+ }
137
+
114
138
  markCompletionReached(): void {
115
139
  this.completionReached = true;
116
140
  this.emit();
@@ -111,6 +111,17 @@ export class OrchestratorPanel {
111
111
  this.store.failSession(name, message);
112
112
  }
113
113
 
114
+ /** Dynamically add a new session node to the graph UI. */
115
+ addSession(name: string, parents: string[]): void {
116
+ this.store.addSession({
117
+ name,
118
+ status: "running",
119
+ parents,
120
+ startedAt: Date.now(),
121
+ endedAt: null,
122
+ });
123
+ }
124
+
114
125
  /** Show the workflow-complete banner with a link to saved transcripts. */
115
126
  showCompletion(workflowName: string, transcriptsPath: string): void {
116
127
  this.store.setCompletion(workflowName, transcriptsPath);
@@ -132,6 +143,16 @@ export class OrchestratorPanel {
132
143
  });
133
144
  }
134
145
 
146
+ /**
147
+ * Returns a promise that resolves when the user requests a mid-execution quit
148
+ * (via `q` or `Ctrl+C`). Race this against the workflow run.
149
+ */
150
+ waitForAbort(): Promise<void> {
151
+ return new Promise<void>((resolve) => {
152
+ this.store.abortResolve = resolve;
153
+ });
154
+ }
155
+
135
156
  /** Tear down the terminal renderer and release resources. Idempotent. */
136
157
  destroy(): void {
137
158
  if (this.destroyed) return;
@@ -22,7 +22,7 @@ import { tmuxRun } from "../runtime/tmux.ts";
22
22
  import {
23
23
  useStore,
24
24
  useGraphTheme,
25
- useStoreSubscription,
25
+ useStoreVersion,
26
26
  TmuxSessionContext,
27
27
  } from "./orchestrator-panel-contexts.ts";
28
28
  import { computeLayout } from "./layout.ts";
@@ -51,10 +51,10 @@ export function SessionGraphPanel() {
51
51
  useRenderer();
52
52
  const { width: termW, height: termH } = useTerminalDimensions();
53
53
 
54
- useStoreSubscription(store);
54
+ const storeVersion = useStoreVersion(store);
55
55
 
56
56
  // Compute layout from current session data
57
- const layout = useMemo(() => computeLayout(store.sessions), [store.version]);
57
+ const layout = useMemo(() => computeLayout(store.sessions), [storeVersion]);
58
58
  const nodeList = useMemo(() => Object.values(layout.map), [layout]);
59
59
 
60
60
  const connectors = useMemo(() => {
@@ -82,7 +82,7 @@ export function SessionGraphPanel() {
82
82
  if (store.sessions.length > 0 && !layout.map[focusedId]) {
83
83
  setFocusedId(store.sessions[0]!.name);
84
84
  }
85
- }, [store.version]);
85
+ }, [storeVersion]);
86
86
 
87
87
  // Pulse animation for running nodes — paused when nothing is running
88
88
  const hasRunning = store.sessions.some((s) => s.status === "running");
@@ -99,11 +99,10 @@ export function SessionGraphPanel() {
99
99
  // Live timer refresh — re-render every second while any session is running
100
100
  const [, setTick] = useState(0);
101
101
  useEffect(() => {
102
- const hasRunning = store.sessions.some((s) => s.status === "running");
103
102
  if (!hasRunning) return;
104
103
  const id = setInterval(() => setTick((t) => t + 1), 1000);
105
104
  return () => clearInterval(id);
106
- }, [store.version]);
105
+ }, [hasRunning]);
107
106
 
108
107
  // Attach flash message
109
108
  const [attachMsg, setAttachMsg] = useState("");
@@ -180,15 +179,9 @@ export function SessionGraphPanel() {
180
179
 
181
180
  // Keyboard handling
182
181
  useKeyboard((key) => {
183
- // Ctrl+C always exits
184
- if (key.ctrl && key.name === "c") {
185
- store.resolveExit();
186
- return;
187
- }
188
-
189
- // After completion: only q exits (Enter is for attach, Escape is too easy to hit)
190
- if (store.completionReached && key.name === "q") {
191
- store.resolveExit();
182
+ // Ctrl+C or q: quit the workflow (abort if running, exit if completed)
183
+ if ((key.ctrl && key.name === "c") || key.name === "q") {
184
+ store.requestQuit();
192
185
  return;
193
186
  }
194
187
 
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource @opentui/react */
2
2
 
3
- import { useStore, useGraphTheme } from "./orchestrator-panel-contexts.ts";
3
+ import { useGraphTheme } from "./orchestrator-panel-contexts.ts";
4
4
  import { statusIcon, statusColor, statusLabel } from "./status-helpers.ts";
5
5
  import type { LayoutNode } from "./layout.ts";
6
6
 
@@ -11,11 +11,9 @@ export function Statusline({
11
11
  focusedNode: LayoutNode | undefined;
12
12
  attachMsg: string;
13
13
  }) {
14
- const store = useStore();
15
14
  const theme = useGraphTheme();
16
15
  const ni = focusedNode ? statusIcon(focusedNode.status) : "";
17
16
  const nc = focusedNode ? statusColor(focusedNode.status, theme) : theme.textDim;
18
- const canExit = store.completionReached;
19
17
 
20
18
  return (
21
19
  <box height={1} flexDirection="row" backgroundColor={theme.backgroundElement}>
@@ -52,9 +50,9 @@ export function Statusline({
52
50
  <span fg={theme.textDim}> {"\u00B7"} </span>
53
51
  <span fg={theme.text}>{"\u21B5"}</span>
54
52
  <span fg={theme.textMuted}> attach</span>
55
- {canExit ? <span fg={theme.textDim}> {"\u00B7"} </span> : null}
56
- {canExit ? <span fg={theme.text}>q</span> : null}
57
- {canExit ? <span fg={theme.textMuted}> quit</span> : null}
53
+ <span fg={theme.textDim}> {"\u00B7"} </span>
54
+ <span fg={theme.text}>q</span>
55
+ <span fg={theme.textMuted}> quit</span>
58
56
  </text>
59
57
  )}
60
58
  </box>
@@ -0,0 +1,71 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { defineWorkflow, WorkflowBuilder } from "./define-workflow.ts";
3
+
4
+ describe("defineWorkflow", () => {
5
+ test("returns a WorkflowBuilder", () => {
6
+ const builder = defineWorkflow({ name: "test" });
7
+ expect(builder).toBeInstanceOf(WorkflowBuilder);
8
+ expect(builder.__brand).toBe("WorkflowBuilder");
9
+ });
10
+
11
+ test("throws on empty name", () => {
12
+ expect(() => defineWorkflow({ name: "" })).toThrow("Workflow name is required");
13
+ });
14
+
15
+ test("throws on whitespace-only name", () => {
16
+ expect(() => defineWorkflow({ name: " " })).toThrow("Workflow name is required");
17
+ });
18
+ });
19
+
20
+ describe("WorkflowBuilder.run()", () => {
21
+ test("accepts a function and returns this for chaining", () => {
22
+ const builder = defineWorkflow({ name: "test" });
23
+ const result = builder.run(async () => {});
24
+ expect(result).toBe(builder);
25
+ });
26
+
27
+ test("throws if called twice", () => {
28
+ const builder = defineWorkflow({ name: "test" }).run(async () => {});
29
+ expect(() => builder.run(async () => {})).toThrow("run() can only be called once");
30
+ });
31
+
32
+ test("throws if argument is not a function", () => {
33
+ const builder = defineWorkflow({ name: "test" });
34
+ expect(() => builder.run("not a function" as never)).toThrow("run() requires a function");
35
+ });
36
+ });
37
+
38
+ describe("WorkflowBuilder.compile()", () => {
39
+ test("produces a WorkflowDefinition with correct brand", () => {
40
+ const def = defineWorkflow({ name: "test" })
41
+ .run(async () => {})
42
+ .compile();
43
+ expect(def.__brand).toBe("WorkflowDefinition");
44
+ });
45
+
46
+ test("preserves name and description", () => {
47
+ const def = defineWorkflow({ name: "my-wf", description: "A description" })
48
+ .run(async () => {})
49
+ .compile();
50
+ expect(def.name).toBe("my-wf");
51
+ expect(def.description).toBe("A description");
52
+ });
53
+
54
+ test("defaults description to empty string", () => {
55
+ const def = defineWorkflow({ name: "test" })
56
+ .run(async () => {})
57
+ .compile();
58
+ expect(def.description).toBe("");
59
+ });
60
+
61
+ test("stores the run function", () => {
62
+ const fn = async () => {};
63
+ const def = defineWorkflow({ name: "test" }).run(fn).compile();
64
+ expect(def.run).toBe(fn);
65
+ });
66
+
67
+ test("throws if no run callback was provided", () => {
68
+ const builder = defineWorkflow({ name: "test" });
69
+ expect(() => builder.compile()).toThrow("has no run callback");
70
+ });
71
+ });
@@ -1,75 +1,71 @@
1
1
  /**
2
- * Workflow Builder — chainable DSL for defining multi-session workflows.
2
+ * Workflow Builder — defines a workflow with a single `.run()` entry point.
3
3
  *
4
4
  * Usage:
5
- * defineWorkflow({ name: "ralph", description: "..." })
6
- * .session({ name: "research", run: async (ctx) => { ... } })
7
- * .session({ name: "plan", run: async (ctx) => { ... } })
5
+ * defineWorkflow({ name: "my-workflow", description: "..." })
6
+ * .run(async (ctx) => {
7
+ * await ctx.session({ name: "research" }, async (s) => { ... });
8
+ * await ctx.session({ name: "plan" }, async (s) => { ... });
9
+ * })
8
10
  * .compile()
9
11
  */
10
12
 
11
- import type { WorkflowOptions, SessionOptions, WorkflowDefinition } from "./types.ts";
13
+ import type { WorkflowOptions, WorkflowContext, WorkflowDefinition } from "./types.ts";
12
14
 
13
15
  /**
14
- * Chainable workflow builder. Records session definitions in order,
15
- * then .compile() seals them into a WorkflowDefinition.
16
+ * Chainable workflow builder. Records the run callback,
17
+ * then .compile() seals it into a WorkflowDefinition.
16
18
  */
17
19
  export class WorkflowBuilder {
18
20
  /** @internal Brand for detection across package boundaries */
19
21
  readonly __brand = "WorkflowBuilder" as const;
20
22
  private readonly options: WorkflowOptions;
21
- private readonly stepDefs: SessionOptions[][] = [];
22
- private readonly namesSeen = new Set<string>();
23
+ private runFn: ((ctx: WorkflowContext) => Promise<void>) | null = null;
23
24
 
24
25
  constructor(options: WorkflowOptions) {
25
26
  this.options = options;
26
27
  }
27
28
 
28
29
  /**
29
- * Add a session (or parallel group of sessions) to the workflow.
30
+ * Set the workflow's entry point.
30
31
  *
31
- * Pass a single SessionOptions for sequential execution.
32
- * Pass an array of SessionOptions for parallel execution —
33
- * all sessions in the array run concurrently, and the next
34
- * .session() call waits for the entire group to complete.
32
+ * The callback receives a {@link WorkflowContext} with `session()` for
33
+ * spawning agent sessions, and `transcript()` / `getMessages()` for
34
+ * reading completed session outputs. Use native TypeScript control flow
35
+ * (loops, conditionals, `Promise.all()`) for orchestration.
35
36
  */
36
- session(opts: SessionOptions | SessionOptions[]): this {
37
- const step = Array.isArray(opts) ? opts : [opts];
38
- if (step.length === 0) {
39
- throw new Error("session() requires at least one SessionOptions.");
37
+ run(fn: (ctx: WorkflowContext) => Promise<void>): this {
38
+ if (this.runFn) {
39
+ throw new Error("run() can only be called once per workflow.");
40
40
  }
41
- for (const s of step) {
42
- if (!s.name || s.name.trim() === "") {
43
- throw new Error("Session name is required.");
44
- }
45
- if (typeof s.run !== "function") {
46
- throw new Error(`Session "${s.name}": run must be a function, got ${typeof s.run}.`);
47
- }
48
- if (this.namesSeen.has(s.name)) {
49
- throw new Error(`Duplicate session name: "${s.name}"`);
50
- }
51
- this.namesSeen.add(s.name);
41
+ if (typeof fn !== "function") {
42
+ throw new Error(`run() requires a function, got ${typeof fn}.`);
52
43
  }
53
- this.stepDefs.push(step);
44
+ this.runFn = fn;
54
45
  return this;
55
46
  }
56
47
 
57
48
  /**
58
49
  * Compile the workflow into a sealed WorkflowDefinition.
59
50
  *
60
- * After calling compile(), no more sessions can be added.
61
- * The returned object is consumed by the Atomic CLI runtime.
51
+ * After calling compile(), the returned object is consumed by the
52
+ * Atomic CLI runtime.
62
53
  */
63
54
  compile(): WorkflowDefinition {
64
- if (this.stepDefs.length === 0) {
65
- throw new Error(`Workflow "${this.options.name}" has no sessions. Add at least one .session() call.`);
55
+ if (!this.runFn) {
56
+ throw new Error(
57
+ `Workflow "${this.options.name}" has no run callback. ` +
58
+ `Add a .run(async (ctx) => { ... }) call before .compile().`,
59
+ );
66
60
  }
67
61
 
62
+ const runFn = this.runFn;
63
+
68
64
  return {
69
65
  __brand: "WorkflowDefinition" as const,
70
66
  name: this.options.name,
71
67
  description: this.options.description ?? "",
72
- steps: Object.freeze(this.stepDefs.map((step) => Object.freeze([...step]))),
68
+ run: runFn,
73
69
  };
74
70
  }
75
71
  }
@@ -82,11 +78,18 @@ export class WorkflowBuilder {
82
78
  * import { defineWorkflow } from "@bastani/atomic/workflows";
83
79
  *
84
80
  * export default defineWorkflow({
85
- * name: "ralph",
86
- * description: "Research, plan, implement",
81
+ * name: "hello",
82
+ * description: "Two-session demo",
87
83
  * })
88
- * .session({ name: "research", run: async (ctx) => { ... } })
89
- * .session({ name: "plan", run: async (ctx) => { ... } })
84
+ * .run(async (ctx) => {
85
+ * const describe = await ctx.session({ name: "describe" }, async (s) => {
86
+ * // ... agent SDK code using s.serverUrl, s.paneId, s.save() ...
87
+ * });
88
+ * await ctx.session({ name: "summarize" }, async (s) => {
89
+ * const research = await s.transcript(describe);
90
+ * // ...
91
+ * });
92
+ * })
90
93
  * .compile();
91
94
  * ```
92
95
  */
package/src/sdk/errors.ts CHANGED
@@ -20,7 +20,7 @@ export class WorkflowNotCompiledError extends Error {
20
20
  `Workflow at ${path} was defined but not compiled.\n` +
21
21
  ` Add .compile() at the end of your defineWorkflow() chain:\n\n` +
22
22
  ` export default defineWorkflow({ ... })\n` +
23
- ` .session({ ... })\n` +
23
+ ` .run(async (ctx) => { ... })\n` +
24
24
  ` .compile();`,
25
25
  );
26
26
  this.name = "WorkflowNotCompiledError";