@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.
- package/RELEASE_NOTES.md +5 -5
- package/app/api/public-info/[resource]/route.ts +40 -0
- package/app/api/skills/install-local/route.ts +2 -1
- package/cli/mw.mjs +11 -21
- package/components/SettingsModal.tsx +42 -33
- package/components/WorkspaceView.tsx +5 -3
- package/lib/agents/index.ts +8 -9
- package/lib/agents/known-models.ts +75 -0
- package/lib/agents/migrate.ts +14 -3
- package/lib/dirs.ts +6 -26
- package/lib/public-info/fetch.ts +116 -0
- package/lib/public-info/types.ts +38 -0
- package/lib/public-info/use-models-registry.ts +66 -0
- package/lib/settings.ts +34 -4
- package/lib/skills.ts +2 -2
- package/lib/workspace/watch-manager.ts +5 -1
- package/package.json +1 -1
- package/lib/__tests__/foreach-batch-yaml.test.ts +0 -33
- package/lib/__tests__/foreach-before.test.ts +0 -201
- package/lib/__tests__/foreach-parse.test.ts +0 -114
- package/lib/__tests__/foreach-snapshot.test.ts +0 -112
- package/lib/__tests__/foreach-source.test.ts +0 -105
- package/lib/__tests__/foreach-template.test.ts +0 -112
- package/lib/workspace/__tests__/state-machine.test.ts +0 -388
- package/lib/workspace/__tests__/workspace.test.ts +0 -311
- package/scripts/bench/README.md +0 -66
- package/scripts/bench/results/.gitignore +0 -2
- package/scripts/bench/run.ts +0 -635
- package/scripts/bench/tasks/01-text-utils/task.md +0 -26
- package/scripts/bench/tasks/01-text-utils/validator.sh +0 -46
- package/scripts/bench/tasks/02-pagination/setup.sh +0 -19
- package/scripts/bench/tasks/02-pagination/task.md +0 -48
- package/scripts/bench/tasks/02-pagination/validator.sh +0 -69
- package/scripts/bench/tasks/03-bug-fix/setup.sh +0 -82
- package/scripts/bench/tasks/03-bug-fix/task.md +0 -30
- package/scripts/bench/tasks/03-bug-fix/validator.sh +0 -29
- package/scripts/test-agents-migrate.ts +0 -149
- package/scripts/test-mantis.ts +0 -223
- package/scripts/test-memory-local.ts +0 -139
- package/scripts/test-memory-upsert.ts +0 -106
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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,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);
|