@aion0/forge 0.10.6 → 0.10.17

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 (41) hide show
  1. package/RELEASE_NOTES.md +5 -5
  2. package/app/api/public-info/[resource]/route.ts +40 -0
  3. package/app/api/skills/install-local/route.ts +2 -1
  4. package/cli/mw.mjs +11 -21
  5. package/components/SettingsModal.tsx +42 -33
  6. package/components/WorkspaceView.tsx +5 -3
  7. package/lib/agents/index.ts +8 -9
  8. package/lib/agents/known-models.ts +75 -0
  9. package/lib/agents/migrate.ts +14 -3
  10. package/lib/dirs.ts +6 -26
  11. package/lib/public-info/fetch.ts +116 -0
  12. package/lib/public-info/types.ts +38 -0
  13. package/lib/public-info/use-models-registry.ts +66 -0
  14. package/lib/settings.ts +34 -4
  15. package/lib/skills.ts +2 -2
  16. package/lib/workspace/watch-manager.ts +5 -1
  17. package/package.json +1 -1
  18. package/lib/__tests__/foreach-batch-yaml.test.ts +0 -33
  19. package/lib/__tests__/foreach-before.test.ts +0 -201
  20. package/lib/__tests__/foreach-parse.test.ts +0 -114
  21. package/lib/__tests__/foreach-snapshot.test.ts +0 -112
  22. package/lib/__tests__/foreach-source.test.ts +0 -105
  23. package/lib/__tests__/foreach-template.test.ts +0 -112
  24. package/lib/workspace/__tests__/state-machine.test.ts +0 -388
  25. package/lib/workspace/__tests__/workspace.test.ts +0 -311
  26. package/scripts/bench/README.md +0 -66
  27. package/scripts/bench/results/.gitignore +0 -2
  28. package/scripts/bench/run.ts +0 -635
  29. package/scripts/bench/tasks/01-text-utils/task.md +0 -26
  30. package/scripts/bench/tasks/01-text-utils/validator.sh +0 -46
  31. package/scripts/bench/tasks/02-pagination/setup.sh +0 -19
  32. package/scripts/bench/tasks/02-pagination/task.md +0 -48
  33. package/scripts/bench/tasks/02-pagination/validator.sh +0 -69
  34. package/scripts/bench/tasks/03-bug-fix/setup.sh +0 -82
  35. package/scripts/bench/tasks/03-bug-fix/task.md +0 -30
  36. package/scripts/bench/tasks/03-bug-fix/validator.sh +0 -29
  37. package/scripts/test-agents-migrate.ts +0 -149
  38. package/scripts/test-mantis.ts +0 -223
  39. package/scripts/test-memory-local.ts +0 -139
  40. package/scripts/test-memory-upsert.ts +0 -106
  41. package/scripts/verify-usage.ts +0 -178
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shapes of JSON resources served from the public-info repo
3
+ * (https://github.com/aiwatching/forge-public-info, by default).
4
+ *
5
+ * Each resource lives under `<repoUrl>/<resource-dir>/<file>.json` so new
6
+ * resource types can be added without touching settings. Forge fetches
7
+ * them via `lib/public-info/fetch.ts` (24h cache + built-in fallback).
8
+ */
9
+
10
+ export interface ModelInfo {
11
+ id: string;
12
+ label: string;
13
+ /** Rough quality/cost band — used by UI to sort/group. */
14
+ tier?: 'premium' | 'standard' | 'fast';
15
+ default?: boolean;
16
+ }
17
+
18
+ export interface ModelAlias {
19
+ id: string;
20
+ label: string;
21
+ }
22
+
23
+ export interface AgentModels {
24
+ displayName: string;
25
+ default: string;
26
+ aliases: ModelAlias[];
27
+ models: ModelInfo[];
28
+ }
29
+
30
+ export interface ModelsRegistry {
31
+ version: number;
32
+ updatedAt: string;
33
+ note?: string;
34
+ /** Keyed by CLI type (e.g. `claude-code`, `codex`, `aider`). */
35
+ agents: Record<string, AgentModels>;
36
+ /** Keyed by API provider id (e.g. `anthropic`, `openai`, `grok`, `google`, `deepseek`, `litellm`). */
37
+ providers?: Record<string, AgentModels>;
38
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Client-side hook for the model registry. Lazy-fetches from
3
+ * `GET /api/public-info/models`, caches in module-level state so
4
+ * multiple components sharing the registry hit the network once per
5
+ * page load. Returns the bundled fallback shape until the network
6
+ * round-trip completes so callers never see `undefined`.
7
+ */
8
+
9
+ 'use client';
10
+
11
+ import { useEffect, useState } from 'react';
12
+ import { KNOWN_MODELS_FALLBACK } from '../agents/known-models';
13
+ import type { ModelsRegistry, AgentModels } from './types';
14
+
15
+ let cached: ModelsRegistry | null = null;
16
+ let inflight: Promise<ModelsRegistry> | null = null;
17
+
18
+ async function load(refresh = false): Promise<ModelsRegistry> {
19
+ if (cached && !refresh) return cached;
20
+ if (inflight && !refresh) return inflight;
21
+ inflight = (async () => {
22
+ try {
23
+ const res = await fetch(`/api/public-info/models${refresh ? '?refresh=1' : ''}`);
24
+ if (res.ok) {
25
+ const data = await res.json();
26
+ cached = data as ModelsRegistry;
27
+ return cached;
28
+ }
29
+ } catch {}
30
+ cached = KNOWN_MODELS_FALLBACK;
31
+ return cached;
32
+ })();
33
+ try { return await inflight; } finally { inflight = null; }
34
+ }
35
+
36
+ export function useModelsRegistry(): {
37
+ registry: ModelsRegistry;
38
+ refresh: () => Promise<void>;
39
+ loading: boolean;
40
+ } {
41
+ const [registry, setRegistry] = useState<ModelsRegistry>(cached ?? KNOWN_MODELS_FALLBACK);
42
+ const [loading, setLoading] = useState(!cached);
43
+
44
+ useEffect(() => {
45
+ if (cached) return;
46
+ setLoading(true);
47
+ load().then(r => { setRegistry(r); setLoading(false); });
48
+ }, []);
49
+
50
+ async function refresh() {
51
+ setLoading(true);
52
+ const r = await load(true);
53
+ setRegistry(r);
54
+ setLoading(false);
55
+ }
56
+
57
+ return { registry, refresh, loading };
58
+ }
59
+
60
+ /** Get the model option list for a specific agent CLI type. Returns the
61
+ * flat list of `[...aliases, ...models]` as the picker expects them. */
62
+ export function pickerOptions(registry: ModelsRegistry, cliType: string): string[] {
63
+ const agent: AgentModels | undefined = registry.agents[cliType];
64
+ if (!agent) return ['default'];
65
+ return [...agent.aliases.map(a => a.id), ...agent.models.map(m => m.id)];
66
+ }
package/lib/settings.ts CHANGED
@@ -27,10 +27,11 @@ export interface AgentEntry {
27
27
  models?: { terminal?: string; task?: string; telegram?: string; help?: string; mobile?: string };
28
28
  skipPermissionsFlag?: string;
29
29
  requiresTTY?: boolean;
30
- model?: string; // flat model override
31
30
  env?: Record<string, string>; // environment variables injected when spawning CLI
32
31
 
33
32
  // === Deprecated (kept for migration compat; removed in a later release) ===
33
+ /** @deprecated 顶层 model 被 models.terminal/task/... 子表替代,migrate.ts 自动清掉 */
34
+ model?: string;
34
35
  /** @deprecated migrated to `tool` */
35
36
  base?: string;
36
37
  /** @deprecated migrated to `tool` */
@@ -84,7 +85,9 @@ export interface Settings {
84
85
  projectRoots: string[];
85
86
  docRoots: string[];
86
87
  claudePath: string;
87
- claudeHome: string;
88
+ // claudeHome removed in 0.10.10 — was a never-UI-exposed override that
89
+ // mostly served to leak `claudeHome: ""` into every yaml. Set the
90
+ // CLAUDE_HOME env var if you need to point at a non-default ~/.claude.
88
91
  telegramBotToken: string;
89
92
  telegramChatId: string;
90
93
  notifyOnComplete: boolean;
@@ -109,6 +112,14 @@ export interface Settings {
109
112
  * shape as connectorsRepoUrl. Default: `aiwatching/forge-workflow`.
110
113
  */
111
114
  workflowRepoUrl: string;
115
+ /**
116
+ * Base URL for the `forge-public-info` repo. Houses model lists and
117
+ * other small JSON files that the maintainer updates between npm
118
+ * releases — push a JSON commit, all users pick it up within 24h via
119
+ * the cache. Sub-paths (e.g. `models/registry.json`) are appended by
120
+ * `lib/public-info/fetch.ts`. Default: `aiwatching/forge-public-info`.
121
+ */
122
+ publicInfoRepoUrl: string;
112
123
  /**
113
124
  * Maximum concurrent pipeline runs (running + pending). When a Job's
114
125
  * scheduler tick would push the total above this, additional items
@@ -184,7 +195,11 @@ const defaults: Settings = {
184
195
  projectRoots: [],
185
196
  docRoots: [],
186
197
  claudePath: '',
187
- claudeHome: '',
198
+ // claudeHome intentionally omitted — it's an optional override (most users
199
+ // have claude at ~/.claude). Keeping it as a default empty string used to
200
+ // leak `claudeHome: ""` into every saved settings.yaml, which the dirs.ts
201
+ // regex then mis-parsed as the value `"`. See loadSettings normalizer +
202
+ // dirs.ts:getClaudeDir for the read-side handling.
188
203
  telegramBotToken: '',
189
204
  telegramChatId: '',
190
205
  notifyOnComplete: true,
@@ -200,6 +215,7 @@ const defaults: Settings = {
200
215
  skillsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-skills/main',
201
216
  connectorsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-connectors/main',
202
217
  workflowRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-workflow/main',
218
+ publicInfoRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-public-info/main',
203
219
  maxConcurrentPipelines: 5,
204
220
  displayName: 'Forge',
205
221
  displayEmail: '',
@@ -279,7 +295,21 @@ export function loadSettings(): Settings {
279
295
  if (!existsSync(SETTINGS_FILE)) return { ...defaults };
280
296
  try {
281
297
  const raw = readFileSync(SETTINGS_FILE, 'utf-8');
282
- const parsed = { ...defaults, ...YAML.parse(raw) };
298
+ const parsed: any = { ...defaults, ...YAML.parse(raw) };
299
+ // claudeHome was removed from the schema in 0.10.10. Drop any leftover
300
+ // value so it stops round-tripping through saveSettings. If a user had
301
+ // set a real override (rare — UI never exposed the field), surface a
302
+ // one-time warning pointing them at the CLAUDE_HOME env var.
303
+ if ('claudeHome' in parsed) {
304
+ const old = parsed.claudeHome;
305
+ delete parsed.claudeHome;
306
+ if (old && old !== '') {
307
+ console.warn(
308
+ `[settings] claudeHome="${old}" is no longer read. ` +
309
+ `If you need to override the claude config dir, set CLAUDE_HOME env var instead.`,
310
+ );
311
+ }
312
+ }
283
313
  // Decrypt top-level secret fields
284
314
  for (const field of SECRET_FIELDS) {
285
315
  if (parsed[field] && isEncrypted(parsed[field])) {
package/lib/skills.ts CHANGED
@@ -8,6 +8,7 @@ import { homedir } from 'node:os';
8
8
  import { getDb } from '../src/core/db/database';
9
9
  import { getDbPath } from '../src/config';
10
10
  import { loadSettings } from './settings';
11
+ import { getClaudeDir } from './dirs';
11
12
 
12
13
  type ItemType = 'skill' | 'command';
13
14
 
@@ -271,8 +272,7 @@ async function downloadFile(url: string): Promise<string> {
271
272
  // ─── Install ─────────────────────────────────────────────────
272
273
 
273
274
  function getClaudeHome(): string {
274
- const settings = loadSettings();
275
- return settings.claudeHome || join(homedir(), '.claude');
275
+ return getClaudeDir();
276
276
  }
277
277
 
278
278
  function getSkillDir(name: string, type: ItemType, projectPath?: string): string {
@@ -13,6 +13,7 @@ import { homedir } from 'node:os';
13
13
  import { execSync } from 'node:child_process';
14
14
  import type { WorkspaceAgentConfig, WatchTarget, WatchConfig } from './types';
15
15
  import { appendAgentLog } from './persistence';
16
+ import { getClaudeDir } from '../dirs';
16
17
 
17
18
  // ─── Snapshot types ──────────────────────────────────────
18
19
 
@@ -219,7 +220,10 @@ function detectAgentLogChanges(workspaceId: string, targetAgentId: string, patte
219
220
  const lastSessionFile = new Map<string, string>();
220
221
 
221
222
  function detectSessionChanges(projectPath: string, pattern: string | undefined, prevLineCount: number, contextChars = 500, sessionId?: string): { changes: WatchChange | null; lineCount: number } {
222
- const claudeHome = join(homedir(), '.claude', 'projects');
223
+ // Honor CLAUDE_HOME env var so watch-manager looks in the same place as
224
+ // session detection, skills installer, etc. Was previously hardcoded to
225
+ // ~/.claude, which silently broke for users with a custom claude dir.
226
+ const claudeHome = join(getClaudeDir(), 'projects');
223
227
  const encoded = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
224
228
  const sessionDir = join(claudeHome, encoded);
225
229
  if (!existsSync(sessionDir)) return { changes: null, lineCount: prevLineCount };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.6",
3
+ "version": "0.10.17",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,33 +0,0 @@
1
- /**
2
- * Smoke test: fortinet-mr-review-batch v0.3.0 parses end-to-end through
3
- * parseWorkflow with the new for_each.before extension.
4
- */
5
- import { parseWorkflow } from '../pipeline';
6
- import { readFileSync } from 'fs';
7
-
8
- const txt = readFileSync('/Users/zliu/.forge/data/flows/fortinet-mr-review-batch.yaml', 'utf8');
9
- const wf = parseWorkflow(txt);
10
-
11
- let ok = true;
12
- function eq(actual: any, expected: any, label: string) {
13
- if (JSON.stringify(actual) === JSON.stringify(expected)) {
14
- console.log(` ✓ ${label}`);
15
- } else {
16
- console.log(` ✗ ${label}: got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}`);
17
- ok = false;
18
- }
19
- }
20
-
21
- console.log('fortinet-mr-review-batch v0.3.0 — parseWorkflow integration');
22
- eq(wf.name, 'fortinet-mr-review-batch', 'workflow name');
23
- eq(wf.for_each?.source, '{{nodes.list-iids.outputs.iids}}', 'for_each.source');
24
- eq(wf.for_each?.as, 'mr_iid', 'for_each.as');
25
- eq(wf.for_each?.on_failure, 'continue', 'for_each.on_failure');
26
- eq(wf.for_each?.before, ['list-iids'], 'for_each.before');
27
- eq(Object.keys(wf.nodes), ['list-iids', 'ingest', 'triage', 'fix', 'reply', 'cleanup'], 'all 6 nodes parsed');
28
- eq(wf.nodes['list-iids']?.mode, 'shell', 'list-iids mode = shell');
29
- eq(wf.nodes['list-iids']?.worktree, false, 'list-iids worktree = false');
30
- eq(wf.nodes['list-iids']?.outputs?.[0]?.name, 'iids', 'list-iids outputs.iids name');
31
- eq(wf.nodes['list-iids']?.outputs?.[0]?.extract, 'stdout', 'list-iids outputs.iids extract');
32
-
33
- process.exit(ok ? 0 : 1);
@@ -1,201 +0,0 @@
1
- /**
2
- * for_each.before: tests — covers:
3
- * - parseWorkflow validates `before:` field shape + node existence
4
- * - parseForEach passes through `before:` correctly
5
- * - resolveForEachSource accepts node outputs (`{{nodes.X.outputs.Y}}`)
6
- *
7
- * Setup-phase orchestration (startPipeline scheduling, checkPipelineCompletion
8
- * transition) needs running tasks + file system + scheduler — not unit-testable
9
- * here. Verified end-to-end in fortinet-mr-review-batch v0.3.0.
10
- *
11
- * Run: npx tsx lib/__tests__/foreach-before.test.ts
12
- */
13
-
14
- import { parseWorkflow, resolveForEachSource } from '../pipeline';
15
-
16
- let passed = 0, failed = 0;
17
- function check(name: string, fn: () => void) {
18
- try { fn(); console.log(` ✓ ${name}`); passed++; }
19
- catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
20
- }
21
- function expectThrow(fn: () => void, msgIncludes: string) {
22
- try { fn(); throw new Error(`expected throw containing "${msgIncludes}"`); }
23
- catch (e) {
24
- const m = (e as Error).message;
25
- if (!m.includes(msgIncludes)) throw new Error(`got "${m}", expected to include "${msgIncludes}"`);
26
- }
27
- }
28
- function eqArr(a: unknown[], b: unknown[], label = '') {
29
- if (a.length !== b.length || a.some((v, i) => v !== b[i])) {
30
- throw new Error(`${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
31
- }
32
- }
33
-
34
- console.log('for_each.before parsing + node-output source resolution tests');
35
-
36
- // ── parseForEach / parseWorkflow: shape + validation ──
37
-
38
- check('parseWorkflow accepts valid before: [<node>]', () => {
39
- const wf = parseWorkflow(`
40
- name: test
41
- for_each:
42
- source: "{{nodes.list-ids.outputs.ids}}"
43
- as: id
44
- before: [list-ids]
45
- nodes:
46
- list-ids:
47
- project: x
48
- prompt: "echo 1,2,3"
49
- body:
50
- project: x
51
- prompt: "consume {{id}}"
52
- depends_on: [list-ids]
53
- `);
54
- if (!wf.for_each) throw new Error('for_each missing');
55
- if (!wf.for_each.before) throw new Error('before missing');
56
- eqArr(wf.for_each.before, ['list-ids']);
57
- });
58
-
59
- check('parseWorkflow accepts multi-node before: [a, b]', () => {
60
- const wf = parseWorkflow(`
61
- name: test
62
- for_each:
63
- source: "{{nodes.b.outputs.list}}"
64
- as: x
65
- before: [a, b]
66
- nodes:
67
- a: { project: x, prompt: "1" }
68
- b: { project: x, prompt: "2", depends_on: [a] }
69
- body: { project: x, prompt: "{{x}}" }
70
- `);
71
- eqArr(wf.for_each!.before!, ['a', 'b']);
72
- });
73
-
74
- check('parseWorkflow with NO before: → before undefined (back-compat)', () => {
75
- const wf = parseWorkflow(`
76
- name: test
77
- for_each:
78
- source: "{{input.ids}}"
79
- as: id
80
- nodes:
81
- body: { project: x, prompt: "{{id}}" }
82
- `);
83
- if (wf.for_each!.before !== undefined) throw new Error(`expected undefined, got ${JSON.stringify(wf.for_each!.before)}`);
84
- });
85
-
86
- check('parseWorkflow throws on before referencing unknown node', () => {
87
- expectThrow(() => parseWorkflow(`
88
- name: test
89
- for_each:
90
- source: "{{nodes.missing.outputs.x}}"
91
- as: id
92
- before: [missing]
93
- nodes:
94
- body: { project: x, prompt: "1" }
95
- `), "references unknown node id 'missing'");
96
- });
97
-
98
- check('parseWorkflow throws on before: not-an-array', () => {
99
- expectThrow(() => parseWorkflow(`
100
- name: test
101
- for_each:
102
- source: "{{nodes.list-ids.outputs.x}}"
103
- as: id
104
- before: "list-ids"
105
- nodes:
106
- list-ids: { project: x, prompt: "1" }
107
- body: { project: x, prompt: "{{id}}" }
108
- `), 'must be an array of node id strings');
109
- });
110
-
111
- check('parseWorkflow throws on before: [empty-string]', () => {
112
- expectThrow(() => parseWorkflow(`
113
- name: test
114
- for_each:
115
- source: "{{nodes.x.outputs.x}}"
116
- as: id
117
- before: [""]
118
- nodes:
119
- x: { project: x, prompt: "1" }
120
- body: { project: x, prompt: "{{id}}" }
121
- `), 'must be an array of node id strings');
122
- });
123
-
124
- check('parseWorkflow throws on before: [non-string]', () => {
125
- expectThrow(() => parseWorkflow(`
126
- name: test
127
- for_each:
128
- source: "{{nodes.x.outputs.x}}"
129
- as: id
130
- before: [123]
131
- nodes:
132
- x: { project: x, prompt: "1" }
133
- body: { project: x, prompt: "{{id}}" }
134
- `), 'must be an array of node id strings');
135
- });
136
-
137
- // ── resolveForEachSource: ctx now includes nodes ──
138
-
139
- check('resolveForEachSource reads {{nodes.X.outputs.Y}} from pipeline.nodes', () => {
140
- const nodes = {
141
- 'list-ids': { status: 'done' as const, outputs: { ids: '7,8,9' }, iterations: 0 },
142
- };
143
- const items = resolveForEachSource(
144
- { source: '{{nodes.list-ids.outputs.ids}}' },
145
- {},
146
- {},
147
- nodes,
148
- );
149
- eqArr(items, ['7', '8', '9']);
150
- });
151
-
152
- check('resolveForEachSource trims whitespace from node-output split items', () => {
153
- const nodes = {
154
- 'list-ids': { status: 'done' as const, outputs: { ids: ' 1, 2 ,3 ' }, iterations: 0 },
155
- };
156
- const items = resolveForEachSource(
157
- { source: '{{nodes.list-ids.outputs.ids}}' },
158
- {},
159
- {},
160
- nodes,
161
- );
162
- eqArr(items, ['1', '2', '3']);
163
- });
164
-
165
- check('resolveForEachSource empty node output → empty array', () => {
166
- const nodes = {
167
- 'list-ids': { status: 'done' as const, outputs: { ids: '' }, iterations: 0 },
168
- };
169
- const items = resolveForEachSource(
170
- { source: '{{nodes.list-ids.outputs.ids}}' },
171
- {},
172
- {},
173
- nodes,
174
- );
175
- eqArr(items, []);
176
- });
177
-
178
- check('resolveForEachSource with custom split + node output', () => {
179
- const nodes = {
180
- 'list-ids': { status: 'done' as const, outputs: { ids: 'a|b|c' }, iterations: 0 },
181
- };
182
- const items = resolveForEachSource(
183
- { source: '{{nodes.list-ids.outputs.ids}}', split: '|' },
184
- {},
185
- {},
186
- nodes,
187
- );
188
- eqArr(items, ['a', 'b', 'c']);
189
- });
190
-
191
- check('resolveForEachSource without nodes param (back-compat) still works', () => {
192
- const items = resolveForEachSource(
193
- { source: '{{input.ids}}' },
194
- { ids: '1,2' },
195
- {},
196
- );
197
- eqArr(items, ['1', '2']);
198
- });
199
-
200
- console.log(`\n${passed} passed, ${failed} failed`);
201
- process.exit(failed === 0 ? 0 : 1);
@@ -1,114 +0,0 @@
1
- /**
2
- * Step-2 verification: parseWorkflow handles `for_each:` correctly.
3
- *
4
- * No test framework — just a tsx script that throws on first failure.
5
- * Run: npx tsx lib/__tests__/foreach-parse.test.ts
6
- */
7
-
8
- import { parseWorkflow } from '../pipeline';
9
-
10
- let passed = 0;
11
- let failed = 0;
12
-
13
- function check(name: string, fn: () => void) {
14
- try { fn(); console.log(` ✓ ${name}`); passed++; }
15
- catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
16
- }
17
-
18
- function expectThrow(yaml: string, expectedSubstring: string) {
19
- try {
20
- parseWorkflow(yaml);
21
- throw new Error(`expected throw containing "${expectedSubstring}", got none`);
22
- } catch (e) {
23
- const msg = (e as Error).message;
24
- if (!msg.includes(expectedSubstring)) {
25
- throw new Error(`expected throw containing "${expectedSubstring}", got: ${msg}`);
26
- }
27
- }
28
- }
29
-
30
- console.log('parseWorkflow + for_each: parser tests');
31
-
32
- check('no for_each → workflow.for_each is undefined', () => {
33
- const w = parseWorkflow('name: foo\nnodes:\n a: { project: x, prompt: "echo hi" }\n');
34
- if (w.for_each !== undefined) throw new Error(`expected undefined, got ${JSON.stringify(w.for_each)}`);
35
- });
36
-
37
- check('valid for_each with string source', () => {
38
- const w = parseWorkflow(`
39
- name: foo
40
- for_each:
41
- source: "{{input.ids}}"
42
- as: bug_id
43
- nodes:
44
- a: { project: x, prompt: "echo {{bug_id}}" }
45
- `);
46
- if (!w.for_each) throw new Error('for_each missing');
47
- if (w.for_each.source !== '{{input.ids}}') throw new Error('source wrong');
48
- if (w.for_each.as !== 'bug_id') throw new Error('as wrong');
49
- if (w.for_each.on_failure !== 'continue') throw new Error(`on_failure default should be continue, got ${w.for_each.on_failure}`);
50
- });
51
-
52
- check('default as = "item"', () => {
53
- const w = parseWorkflow('name: foo\nfor_each:\n source: "{{input.x}}"\nnodes:\n a: { project: x, prompt: "" }\n');
54
- if (w.for_each!.as !== 'item') throw new Error('default as should be item');
55
- });
56
-
57
- check('on_failure: stop accepted', () => {
58
- const w = parseWorkflow('name: foo\nfor_each:\n source: x\n on_failure: stop\nnodes:\n a: { project: x, prompt: "" }\n');
59
- if (w.for_each!.on_failure !== 'stop') throw new Error('on_failure not preserved');
60
- });
61
-
62
- check('array source literal', () => {
63
- const w = parseWorkflow('name: foo\nfor_each:\n source: [1, 2, 3]\nnodes:\n a: { project: x, prompt: "" }\n');
64
- if (!Array.isArray(w.for_each!.source)) throw new Error('array literal not preserved');
65
- if ((w.for_each!.source as any[]).length !== 3) throw new Error('array length wrong');
66
- });
67
-
68
- check('split: ";" accepted', () => {
69
- const w = parseWorkflow('name: foo\nfor_each:\n source: x\n split: ";"\nnodes:\n a: { project: x, prompt: "" }\n');
70
- if (w.for_each!.split !== ';') throw new Error('split not preserved');
71
- });
72
-
73
- console.log('\nError-path tests:');
74
-
75
- check('throws on missing source', () => {
76
- expectThrow('name: foo\nfor_each:\n as: bug\nnodes:\n a: { project: x, prompt: "" }\n', 'source is required');
77
- });
78
-
79
- check('throws on invalid as identifier', () => {
80
- expectThrow('name: foo\nfor_each:\n source: x\n as: "bad-name"\nnodes:\n a: { project: x, prompt: "" }\n', 'must be a valid identifier');
81
- });
82
-
83
- check('throws on reserved as: input', () => {
84
- expectThrow('name: foo\nfor_each:\n source: x\n as: input\nnodes:\n a: { project: x, prompt: "" }\n', 'reserved template namespace');
85
- });
86
-
87
- check('throws on reserved as: run', () => {
88
- expectThrow('name: foo\nfor_each:\n source: x\n as: run\nnodes:\n a: { project: x, prompt: "" }\n', 'reserved template namespace');
89
- });
90
-
91
- check('throws on bad on_failure value', () => {
92
- expectThrow('name: foo\nfor_each:\n source: x\n on_failure: maybe\nnodes:\n a: { project: x, prompt: "" }\n', "must be 'continue' or 'stop'");
93
- });
94
-
95
- check('throws on for_each on conversation type', () => {
96
- expectThrow(`
97
- name: foo
98
- type: conversation
99
- for_each:
100
- source: x
101
- agents:
102
- - id: a
103
- agent: claude
104
- initial_prompt: hi
105
- nodes: {}
106
- `, "only supported on type='dag'");
107
- });
108
-
109
- check('throws on for_each scalar (not object)', () => {
110
- expectThrow('name: foo\nfor_each: "{{input.ids}}"\nnodes:\n a: { project: x, prompt: "" }\n', 'must be an object');
111
- });
112
-
113
- console.log(`\n${passed} passed, ${failed} failed`);
114
- process.exit(failed === 0 ? 0 : 1);