@bastani/atomic 0.5.0-2 → 0.5.0-4
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 +62 -53
- package/package.json +3 -2
- package/src/commands/cli/chat/index.ts +28 -8
- package/src/commands/cli/init/index.ts +6 -4
- 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-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 +3 -9
- 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/tsconfig.json +34 -0
|
@@ -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;
|
|
@@ -180,15 +180,9 @@ export function SessionGraphPanel() {
|
|
|
180
180
|
|
|
181
181
|
// Keyboard handling
|
|
182
182
|
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();
|
|
183
|
+
// Ctrl+C or q: quit the workflow (abort if running, exit if completed)
|
|
184
|
+
if ((key.ctrl && key.name === "c") || key.name === "q") {
|
|
185
|
+
store.requestQuit();
|
|
192
186
|
return;
|
|
193
187
|
}
|
|
194
188
|
|
|
@@ -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";
|
package/src/sdk/index.ts
CHANGED
|
@@ -307,7 +307,7 @@ export function validateClaudeWorkflow(source: string): ClaudeValidationWarning[
|
|
|
307
307
|
rule: "claude/create-session",
|
|
308
308
|
message:
|
|
309
309
|
"Could not verify that createClaudeSession is called before claudeQuery(). " +
|
|
310
|
-
"Call createClaudeSession({ paneId:
|
|
310
|
+
"Call createClaudeSession({ paneId: s.paneId }) to start the Claude CLI before sending queries.",
|
|
311
311
|
});
|
|
312
312
|
}
|
|
313
313
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Copilot workflow source validation.
|
|
3
3
|
*
|
|
4
4
|
* Checks that Copilot workflow source files follow required patterns:
|
|
5
|
-
* - `cliUrl` is wired to
|
|
5
|
+
* - `cliUrl` is wired to the session context's `serverUrl`
|
|
6
6
|
* - `setForegroundSessionId` is called after creating a session
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -18,11 +18,13 @@ export function validateCopilotWorkflow(source: string): CopilotValidationWarnin
|
|
|
18
18
|
const warnings: CopilotValidationWarning[] = [];
|
|
19
19
|
|
|
20
20
|
if (/\bCopilotClient\b/.test(source)) {
|
|
21
|
-
|
|
21
|
+
// Accept any identifier before .serverUrl (e.g., s.serverUrl, ctx.serverUrl)
|
|
22
|
+
// or a destructured `serverUrl` variable
|
|
23
|
+
if (!/cliUrl\s*:\s*(?:\w+\.serverUrl|serverUrl)/.test(source)) {
|
|
22
24
|
warnings.push({
|
|
23
25
|
rule: "copilot/cli-url",
|
|
24
26
|
message:
|
|
25
|
-
"Could not verify that CopilotClient is created with { cliUrl:
|
|
27
|
+
"Could not verify that CopilotClient is created with { cliUrl: s.serverUrl }. " +
|
|
26
28
|
"This is required to connect to the workflow's agent pane.",
|
|
27
29
|
});
|
|
28
30
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* OpenCode workflow source validation.
|
|
3
3
|
*
|
|
4
4
|
* Checks that OpenCode workflow source files follow required patterns:
|
|
5
|
-
* - `baseUrl` is wired to
|
|
5
|
+
* - `baseUrl` is wired to the session context's `serverUrl`
|
|
6
6
|
* - `tui.selectSession` is called after creating a session
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -18,11 +18,13 @@ export function validateOpenCodeWorkflow(source: string): OpenCodeValidationWarn
|
|
|
18
18
|
const warnings: OpenCodeValidationWarning[] = [];
|
|
19
19
|
|
|
20
20
|
if (/\bcreateOpencodeClient\b/.test(source)) {
|
|
21
|
-
|
|
21
|
+
// Accept any identifier before .serverUrl (e.g., s.serverUrl, ctx.serverUrl)
|
|
22
|
+
// or a destructured `serverUrl` variable
|
|
23
|
+
if (!/baseUrl\s*:\s*(?:\w+\.serverUrl|serverUrl)/.test(source)) {
|
|
22
24
|
warnings.push({
|
|
23
25
|
rule: "opencode/base-url",
|
|
24
26
|
message:
|
|
25
|
-
"Could not verify that createOpencodeClient is called with { baseUrl:
|
|
27
|
+
"Could not verify that createOpencodeClient is called with { baseUrl: s.serverUrl }. " +
|
|
26
28
|
"This is required to connect to the workflow's agent pane.",
|
|
27
29
|
});
|
|
28
30
|
}
|