@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.
- package/.atomic/workflows/hello/claude/index.ts +22 -25
- package/.atomic/workflows/hello/copilot/index.ts +41 -31
- package/.atomic/workflows/hello/opencode/index.ts +40 -40
- package/.atomic/workflows/hello-parallel/claude/index.ts +54 -54
- package/.atomic/workflows/hello-parallel/copilot/index.ts +89 -70
- package/.atomic/workflows/hello-parallel/opencode/index.ts +77 -77
- package/.atomic/workflows/ralph/claude/index.ts +128 -93
- package/.atomic/workflows/ralph/copilot/index.ts +212 -112
- package/.atomic/workflows/ralph/helpers/prompts.ts +45 -2
- package/.atomic/workflows/ralph/opencode/index.ts +174 -111
- package/README.md +138 -59
- package/package.json +1 -1
- package/src/cli.ts +0 -2
- package/src/commands/cli/chat/index.ts +28 -8
- package/src/commands/cli/init/index.ts +7 -10
- package/src/commands/cli/init/scm.ts +27 -10
- package/src/sdk/components/connectors.test.ts +45 -0
- package/src/sdk/components/layout.test.ts +321 -0
- package/src/sdk/components/layout.ts +51 -15
- package/src/sdk/components/orchestrator-panel-contexts.ts +13 -4
- package/src/sdk/components/orchestrator-panel-store.test.ts +156 -0
- package/src/sdk/components/orchestrator-panel-store.ts +24 -0
- package/src/sdk/components/orchestrator-panel.tsx +21 -0
- package/src/sdk/components/session-graph-panel.tsx +8 -15
- package/src/sdk/components/statusline.tsx +4 -6
- package/src/sdk/define-workflow.test.ts +71 -0
- package/src/sdk/define-workflow.ts +42 -39
- package/src/sdk/errors.ts +1 -1
- package/src/sdk/index.ts +4 -1
- package/src/sdk/providers/claude.ts +1 -1
- package/src/sdk/providers/copilot.ts +5 -3
- package/src/sdk/providers/opencode.ts +5 -3
- package/src/sdk/runtime/executor.ts +512 -301
- package/src/sdk/runtime/loader.ts +2 -2
- package/src/sdk/runtime/tmux.ts +31 -2
- package/src/sdk/types.ts +93 -20
- package/src/sdk/workflows.ts +7 -4
- package/src/services/config/definitions.ts +39 -2
- package/src/services/config/settings.ts +0 -6
- package/src/services/system/skills.ts +3 -7
- package/.atomic/workflows/package-lock.json +0 -31
- 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
|
-
//
|
|
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
|
-
|
|
126
|
+
const ep = effective.get(s.name) ?? [];
|
|
127
|
+
if (ep.length > 1) {
|
|
98
128
|
mergeNodes.push(map[s.name]!);
|
|
99
|
-
} else if (
|
|
100
|
-
map[
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
const storeVersion = useStoreVersion(store);
|
|
55
55
|
|
|
56
56
|
// Compute layout from current session data
|
|
57
|
-
const layout = useMemo(() => computeLayout(store.sessions), [
|
|
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
|
-
}, [
|
|
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
|
-
}, [
|
|
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
|
|
184
|
-
if (key.ctrl && key.name === "c") {
|
|
185
|
-
store.
|
|
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 {
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 —
|
|
2
|
+
* Workflow Builder — defines a workflow with a single `.run()` entry point.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* defineWorkflow({ name: "
|
|
6
|
-
* .
|
|
7
|
-
*
|
|
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,
|
|
13
|
+
import type { WorkflowOptions, WorkflowContext, WorkflowDefinition } from "./types.ts";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
|
-
* Chainable workflow builder. Records
|
|
15
|
-
* then .compile() seals
|
|
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
|
|
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
|
-
*
|
|
30
|
+
* Set the workflow's entry point.
|
|
30
31
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* .
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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.
|
|
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(),
|
|
61
|
-
*
|
|
51
|
+
* After calling compile(), the returned object is consumed by the
|
|
52
|
+
* Atomic CLI runtime.
|
|
62
53
|
*/
|
|
63
54
|
compile(): WorkflowDefinition {
|
|
64
|
-
if (this.
|
|
65
|
-
throw new Error(
|
|
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
|
-
|
|
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: "
|
|
86
|
-
* description: "
|
|
81
|
+
* name: "hello",
|
|
82
|
+
* description: "Two-session demo",
|
|
87
83
|
* })
|
|
88
|
-
* .
|
|
89
|
-
*
|
|
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
|
-
` .
|
|
23
|
+
` .run(async (ctx) => { ... })\n` +
|
|
24
24
|
` .compile();`,
|
|
25
25
|
);
|
|
26
26
|
this.name = "WorkflowNotCompiledError";
|