@aion0/forge 0.9.0 → 0.9.2

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 (70) hide show
  1. package/RELEASE_NOTES.md +60 -7
  2. package/app/api/agents/[id]/test/route.ts +150 -0
  3. package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
  4. package/app/api/connectors/tool-test/route.ts +70 -0
  5. package/app/api/jobs/[id]/cancel/route.ts +50 -0
  6. package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
  7. package/app/api/jobs/[id]/run/route.ts +22 -2
  8. package/app/api/jobs/route.ts +11 -1
  9. package/app/api/pipelines/[id]/schema/route.ts +53 -0
  10. package/app/api/pipelines/bulk-delete/route.ts +39 -0
  11. package/app/api/pipelines/gc/route.ts +27 -0
  12. package/app/api/schedules/[id]/cancel/route.ts +27 -0
  13. package/app/api/schedules/[id]/route.ts +173 -0
  14. package/app/api/schedules/[id]/run/route.ts +45 -0
  15. package/app/api/schedules/[id]/runs/route.ts +22 -0
  16. package/app/api/schedules/[id]/stop/route.ts +33 -0
  17. package/app/api/schedules/route.ts +175 -0
  18. package/app/api/tasks/bulk-delete/route.ts +47 -0
  19. package/bin/forge-server.mjs +22 -1
  20. package/cli/mw.mjs +186 -7657
  21. package/cli/mw.ts +26 -0
  22. package/components/ConnectorsPanel.tsx +46 -0
  23. package/components/Dashboard.tsx +23 -10
  24. package/components/JobsView.tsx +245 -6
  25. package/components/PipelineEditor.tsx +38 -1
  26. package/components/PipelineView.tsx +325 -4
  27. package/components/ScheduleCreateModal.tsx +1507 -0
  28. package/components/SchedulesView.tsx +605 -0
  29. package/components/SettingsModal.tsx +116 -7
  30. package/docs/Team-Workflow-Integration.md +487 -0
  31. package/docs/UI-Design-Brief-SidePanel.md +278 -0
  32. package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
  33. package/lib/__tests__/foreach-before.test.ts +201 -0
  34. package/lib/__tests__/foreach-parse.test.ts +114 -0
  35. package/lib/__tests__/foreach-snapshot.test.ts +112 -0
  36. package/lib/__tests__/foreach-source.test.ts +105 -0
  37. package/lib/__tests__/foreach-template.test.ts +112 -0
  38. package/lib/chat/agent-loop.ts +3 -3
  39. package/lib/chat-standalone.ts +26 -1
  40. package/lib/claude-process.ts +8 -5
  41. package/lib/connectors/sync.ts +8 -2
  42. package/lib/crypto.ts +1 -1
  43. package/lib/dirs.ts +22 -7
  44. package/lib/help-docs/05-pipelines.md +171 -0
  45. package/lib/help-docs/13-schedules.md +165 -0
  46. package/lib/help-docs/23-automation-states.md +148 -0
  47. package/lib/help-docs/CLAUDE.md +6 -6
  48. package/lib/init.ts +25 -6
  49. package/lib/jobs/recipes.ts +3 -2
  50. package/lib/jobs/scheduler.ts +215 -11
  51. package/lib/jobs/store.ts +79 -3
  52. package/lib/jobs/types.ts +31 -0
  53. package/lib/logger.ts +1 -1
  54. package/lib/notify.ts +13 -6
  55. package/lib/pipeline-gc.ts +105 -0
  56. package/lib/pipeline-scheduler.ts +29 -0
  57. package/lib/pipeline.ts +811 -330
  58. package/lib/schedules/action-runner.ts +257 -0
  59. package/lib/schedules/scheduler.ts +422 -0
  60. package/lib/schedules/state.ts +41 -0
  61. package/lib/schedules/store.ts +618 -0
  62. package/lib/schedules/types.ts +117 -0
  63. package/lib/settings.ts +35 -0
  64. package/lib/task-manager.ts +56 -13
  65. package/lib/workflow-marketplace.ts +7 -1
  66. package/lib/workspace/skill-installer.ts +7 -6
  67. package/package.json +3 -1
  68. package/lib/help-docs/19-jobs.md +0 -145
  69. package/lib/help-docs/20-mantis-bug-fix.md +0 -115
  70. package/lib/help-docs/22-recipes.md +0 -124
@@ -0,0 +1,278 @@
1
+
2
+ # Forge 浏览器扩展 UI 设计 Brief —— 给 AI / 设计师用
3
+
4
+ > **如何使用**:把这份文档完整粘到一次新 Claude / 设计 AI 对话里,或直接交给设计师。它包含了产品上下文、当前 UI 现状、要新增的屏幕、视觉约束。**不要让 AI 自己脑补 Forge 是什么** —— 这份就是 ground truth。
5
+ >
6
+ > 配套文档(可选,深入需求时再给):
7
+ > - `forge-team-workflow.md` —— 完整工作流(详细)
8
+
9
+
10
+ ## 1. 一句话产品定位
11
+
12
+ **Forge 浏览器扩展是 Chrome MV3 侧边栏,一个 AI chat 入口,让公司 Design / Dev / QA 三个团队的日常协作在一个对话框里完成。它复用用户已登录的公司系统(Mantis / GitLab / PMDB / Teams)session,通过浏览器 DOM 提取拿数据,不需要 API token。**
13
+
14
+
15
+ ## 2. 用户角色与高频任务
16
+
17
+ | 角色 | 高频任务(按频次) |
18
+ |---|---|
19
+ | **Design** | 1. 看 PMDB inbox 新需求 2. 起 PD 设计文档草稿 3. 整理评审反馈 4. 同类历史 epic 检索 |
20
+ | **Dev (技术负责人)** | 1. Review PD 提反馈 2. 起技术设计文档 3. 拆 GitLab 子 issue 4. 看周进度简报 |
21
+ | **Dev (模块负责人)** | 1. 看分给我的 issue 2. MR 摘要 3. 实现期写决策记录 |
22
+ | **QA** | 1. 起测试用例草稿 2. 起自动化脚本 3. 创建 / 跟踪 Mantis bug |
23
+ | **Manager** | (Phase 1 不专门设计,看 Dev 视图即可)Release dashboard 后期再做 |
24
+
25
+ **所有人共同的高频任务**:在 chat 里输入问题 / 指令,看 chat 输出,接受/编辑后落地到 pd-docs git 仓库。
26
+
27
+
28
+ ## 3. 当前 UI 现状(必读)
29
+
30
+ ### 已实现 —— Direction A 侧边栏 4 个 Tab
31
+
32
+ ```
33
+ ┌─────────────────────────────────────┐
34
+ │ ⚙ Forge [⚙] │ ← Header(logo + 设置图标)
35
+ ├─────────────────────────────────────┤
36
+ │ Chat | Jobs | Connectors | Settings │ ← Tab Bar(badge 可挂未读)
37
+ ├─────────────────────────────────────┤
38
+ │ │
39
+ │ │
40
+ │ (Tab 内容区) │
41
+ │ │
42
+ │ │
43
+ └─────────────────────────────────────┘
44
+ ```
45
+
46
+ 宽度:约 400-500 px(Chrome 侧边栏典型尺寸),高度全屏。
47
+
48
+ ### 当前 4 个 Tab 的现状
49
+
50
+ | Tab | 现状 |
51
+ |---|---|
52
+ | **Chat** | 类 ChatGPT 风格,消息列表 + 输入框;支持多会话切换 / fork / 删除;消息支持 markdown 渲染 |
53
+ | **Jobs** | 列定时 / 一次性 / cron 任务,基本 CRUD;skill picker 可挑技能;**已实现但可深化** |
54
+ | **Connectors** | 列已安装的连接器,每个有"批准脚本"按钮(SHA-256 校验);**已实现** |
55
+ | **Settings** | Forge URL / 登录 / 多 LLM provider 配置 / 保存测试按钮;**已实现** |
56
+
57
+ ### 视觉系统约束(必须遵守)
58
+
59
+ - 设计语言定义在 `docs/design/`(repo 内的"Smith Extension"设计包,Direction A side-panel)
60
+ - **颜色 / 字体 / 间距 token 已定**,新屏幕沿用,**不引入新色板**
61
+ - 主背景 `var(--paper)`(米白色基底)
62
+ - 风格关键词:**轻量、低对比、文字优先、克制动效**
63
+ - **不要拟物 / 渐变 / 大圆角**;参考 Notion / Linear 的克制感
64
+
65
+
66
+ ## 4. Phase 1 要新增 / 改造的 UI
67
+
68
+ 下面是**本期要做的 UI 工作**。每条都注明优先级和范围。
69
+
70
+ ### 4.1 ⭐ P0 —— Chat Tab 增强:"绑定 Epic 上下文"
71
+
72
+ **问题**:目前 chat 不知道你在哪个 epic 上下文里。Design 起 PD 时 chat 要能拉对应 epic 的 inbox 文件。
73
+
74
+ **新增 UI 元素**:
75
+ - **顶部"Epic 选择器"** —— Chat tab 上方一条窄条,左边显示当前绑定的 epic(如 `📌 EPIC-9981 SAML-SSO · 2026-Q3`),点击弹下拉切换 / 解绑 / 新建 epic
76
+ - **未绑定状态**:显示 `📂 No epic context · select one` 提示色弱化
77
+ - 切换 epic 时,chat 自动注入系统消息说明"上下文已切到 X"
78
+
79
+ **视觉**:窄条高度 ~32px,跟 TabBar 同一级别但**视觉重量更轻**(灰色文字)。
80
+
81
+ ### 4.2 ⭐ P0 —— Chat 输入框:Quick Actions
82
+
83
+ **问题**:Design / Dev / QA 各有几个常用动作,每次手打太重。
84
+
85
+ **新增 UI 元素**:
86
+ - **输入框上方一行 chip 按钮**,根据当前角色 / 当前 epic stage 上下文显示 3-5 个 Quick Action:
87
+ - Design + 已绑 epic + 无 02-design.md → `[起 PD 草稿]` `[起会议纪要]` `[找历史 epic]`
88
+ - Dev + 已绑 epic + 有 02-design.md → `[Review PD]` `[起技术设计]` `[拆子 issue]`
89
+ - QA + 已绑 epic + 02 / 04 都齐 → `[起测试用例]` `[起自动化脚本]` `[报 bug]`
90
+ - 点 chip = 把对应 prompt 模板填进输入框,**用户可继续编辑后发出**(不直接发,避免误操作)
91
+ - chip 视觉:细边框 / 灰色背景 / 小字号 / hover 微高亮
92
+
93
+ ### 4.3 ⭐ P0 —— 新 Tab: **Epic Workspace**(替换 / 升级现有 Connectors tab 上面?或独立 tab)
94
+
95
+ **问题**:Forge chat 是入口,但用户也需要看到"当前 epic 有哪些文档、什么阶段、关联了什么"。今天的 4 个 tab 不承载这个视图。
96
+
97
+ **建议**:**在 Chat 和 Jobs 中间插一个新 Tab,叫 `Epic`(或 `Work`)**。Tab Bar 变成 5 个:
98
+ ```
99
+ Chat | Epic | Jobs | Connectors | Settings
100
+ ```
101
+
102
+ **Epic Tab 的内容**:
103
+
104
+ ```
105
+ ┌────────────────────────────────────────┐
106
+ │ [搜索框] [+ New Epic] │
107
+ ├────────────────────────────────────────┤
108
+ │ ▾ 2026-Q3 │
109
+ │ 📌 EPIC-9981 SAML-SSO [Stage: Dev]│
110
+ │ pd-docs / Mantis / GitLab Epic │
111
+ │ 🟢 12 issues / 3 open / 0 blocker │
112
+ │ │
113
+ │ ⚪ EPIC-9985 Audit Log [Stage: Design]│
114
+ │ │
115
+ │ ▾ 未规划 │
116
+ │ ⚪ EPIC-TBD 双因素登录 [Stage: Design]│
117
+ │ │
118
+ │ ▾ Inbox (4 条新需求) │
119
+ │ 📥 PMDB-7821 ACME SAML · 3 天前 │
120
+ │ 📥 PMDB-7822 ... · 1 天前 │
121
+ │ ... │
122
+ └────────────────────────────────────────┘
123
+ ```
124
+
125
+ 要点:
126
+ - **按 release 分组**,折叠
127
+ - 每个 epic 卡片显示:**ID 名字、当前 stage、外链(pd-docs / Mantis / GitLab)、状态摘要**
128
+ - **Inbox 区**单独一组,新需求倒序
129
+ - 点 epic 卡片 → 跳到 chat tab 并自动绑定该 epic 上下文
130
+ - 点 inbox 项 → 弹出"基于这条建 epic"对话框,确认后 chat 触发 Forge job
131
+
132
+ ### 4.4 ⭐ P1 —— Epic 详情面板(从 Epic Tab 点入)
133
+
134
+ 点击 Epic 卡片如果不希望直接跳 chat,而是**先看详情**,提供一个详情面板(也可以是 modal / 下推):
135
+
136
+ ```
137
+ ┌────────────────────────────────────────┐
138
+ │ ← 返回 │
139
+ │ │
140
+ │ EPIC-9981 SAML-SSO │
141
+ │ 2026-Q3 · Stage: Implementation │
142
+ │ │
143
+ │ Links: │
144
+ │ 📄 pd-docs/.../EPIC-9981/ │
145
+ │ 🐞 Mantis MA-9981 │
146
+ │ 🦊 GitLab Epic !9981 │
147
+ │ │
148
+ │ Documents in this Epic: │
149
+ │ ✅ 00-intake.md │
150
+ │ ✅ 01-discovery/ (3) │
151
+ │ ✅ 02-design.md (v1.0) │
152
+ │ ✅ 03-review/ (2 rounds) │
153
+ │ 🔄 04-tech-design/ (3 modules) │
154
+ │ ⏳ 06-test/ (not yet) │
155
+ │ │
156
+ │ [💬 Open Chat in This Epic] │
157
+ │ [🔧 Run Job: Refresh Status] │
158
+ └────────────────────────────────────────┘
159
+ ```
160
+
161
+ 要点:
162
+ - 列出 pd-docs 里这个 epic 目录下所有文件,**用 ✅ / 🔄 / ⏳ 表示当前状态**
163
+ - 点文件名能预览 markdown(可后期做)
164
+ - 底部主 CTA:"在这个 epic 上下文里开 chat"
165
+ - 次要 CTA:运行相关 job(刷新 GitLab 状态、生成周报等)
166
+
167
+ ### 4.5 P1 —— Connectors Tab 增加视觉状态
168
+
169
+ **问题**:已有审批流(approved / pending / updated),但缺一些 affordance。
170
+
171
+ **改造**:
172
+ - 每个连接器卡片增加最后一次成功调用的时间戳(`✓ 5 min ago · list_my_bugs`)
173
+ - 出错时显示红色 pill(`⚠ last call failed: timeout`)
174
+ - 增加"测试连接"按钮,跑一个 read-only tool 看是否 healthy
175
+
176
+ ### 4.6 P1 —— Jobs Tab 增加"上次运行结果摘要"
177
+
178
+ **问题**:已有 jobs 列表,但用户不知道每个 job 上次跑得怎么样。
179
+
180
+ **改造**:
181
+ - 每个 job 卡片增加上次运行的时间戳 + 简短结果摘要(`✓ 10 min ago · synced 3 new items`)
182
+ - 失败时红色,点开看 stderr / 错误
183
+
184
+ ### 4.7 P2 —— 起 PD / 起测试用例的"分步引导"对话
185
+
186
+ **问题**:Quick Action chip 点一下 prompt 模板就填到输入框是够用的,但**起 PD 这种长流程**可以做得更引导。
187
+
188
+ **可选改造**(只在用户反馈不够好时做):
189
+ - 点 `[起 PD 草稿]` chip 弹一个轻量 wizard:
190
+ 1. "基于哪些 discovery 文件?"(多选已有的)
191
+ 2. "参考哪份历史 PD 的结构?"(下拉)
192
+ 3. "重点强调哪些维度?"(可选 chip:架构 / API / 性能 / 安全 / 兼容性)
193
+ - 走完 wizard 生成 prompt 给 chat 处理
194
+
195
+ **Phase 1 默认不做,只在 4.2 的 chip 不够好时再做。**
196
+
197
+
198
+ ## 5. 你不需要设计的部分(Out of scope)
199
+
200
+ - 完整的 Forge 后台管理界面(管理员看 jobs / connectors / 用户管理)—— 不在浏览器扩展里
201
+ - Release dashboard(Manager 视角)—— Phase 1 不做
202
+ - 移动端 / 不同浏览器 —— 只支持 Chrome 桌面版
203
+ - 文件预览的高级渲染(Mermaid / 代码高亮)—— Phase 1 列表展示就够
204
+ - 协作多人光标 / 实时同步 —— 离线文档协作走 git MR,不在 chat 里
205
+
206
+
207
+ ## 6. 给 AI 设计师的具体输出要求
208
+
209
+ 请按以下顺序产出(每个屏幕都要):
210
+
211
+ 1. **低保真线框图**(ASCII / markdown 表格 / 文字描述都行,Claude 输出的话用 ASCII)
212
+ 2. **交互说明**:每个可点元素点了之后发生什么
213
+ 3. **状态枚举**:loading / empty / error / success 各状态的视觉
214
+ 4. **响应式考虑**:侧边栏宽度可变(400-600px),怎么 graceful
215
+ 5. **可访问性**:键盘导航顺序,ARIA label 建议
216
+
217
+ **优先做 §4.1 / 4.2 / 4.3 / 4.4 这四个 P0 / P1**。
218
+
219
+ 如果时间允许,可以再给 §4.5 / 4.6 一些建议。
220
+
221
+
222
+ ## 7. 我可能会被问到的几个问题(提前回答)
223
+
224
+ **Q: 你为什么要把 Epic 视图做成独立 Tab 而不是放在 Chat 里?**
225
+ A: Chat 是"做事的地方",Epic 视图是"导航 / 状态总览的地方"。两种心智模型不一样。但**点 Epic 后跳回 Chat 自动绑定**这个动作是把两边粘合的关键,请重点设计这个转场。
226
+
227
+ **Q: Phase 1 是否要做 Manager 视角?**
228
+ A: 不要。Manager 看 Dev 视角的 Epic Tab 已经够。Release dashboard 留给后期。
229
+
230
+ **Q: 设计稿要不要细化到颜色 hex 值?**
231
+ A: 不需要。沿用现有 `docs/design/` 的 token。给布局 / 信息密度 / 交互流就够了。
232
+
233
+ **Q: 用户最常用的是不是 chat?其他 tab 会不会很少进?**
234
+ A: 是的。Chat 占 70%+ 时间。但 Epic Tab(§4.3)是**进入 chat 的入口** —— 用户每天开始工作时先看 Epic Tab,决定今天进哪个上下文。所以这两个 tab 的连接要顺。
235
+
236
+
237
+ ## 8. 输出哪里好
238
+
239
+ 你可以直接在对话里贴 ASCII 线框 + 说明。
240
+
241
+ 如果你要给我多版方案(强烈推荐),用 markdown 标题分隔:`## 方案 A` / `## 方案 B`,我会逐个 review。
242
+
243
+
244
+ # 附录:产品截图描述(没有图但可以脑补)
245
+
246
+ 当前 Chat tab 大致长这样(类 ChatGPT):
247
+
248
+ ```
249
+ ┌───────────────────────────────────┐
250
+ │ ⚙ Forge [⚙] │
251
+ ├───────────────────────────────────┤
252
+ │ Chat | Jobs | Connectors | Set │
253
+ ├───────────────────────────────────┤
254
+ │ [< Session: New Conversation v ] │
255
+ ├───────────────────────────────────┤
256
+ │ 👤 帮我看下 EPIC-9981 的 PD 进展 │
257
+ │ │
258
+ │ 🤖 EPIC-9981 (SAML-SSO) 的 PD │
259
+ │ 目前在 v0.3 版本,3 轮评审已经… │
260
+ │ [详情列表] │
261
+ │ │
262
+ │ 👤 帮我起一份技术设计… │
263
+ │ │
264
+ │ 🤖 [Tool: claude_code] │
265
+ │ 正在 fortinac/ cwd 跑分析... │
266
+ │ ✓ 生成 04-tech-design/ │
267
+ │ overview.md (3.2 KB) │
268
+ │ │
269
+ ├───────────────────────────────────┤
270
+ │ [_____________________________] │ ← 输入框
271
+ │ [发送] │
272
+ └───────────────────────────────────┘
273
+ ```
274
+
275
+ **新版本主要改动是**:
276
+ 1. 输入框上加一行 Quick Action chips
277
+ 2. 顶部 session selector 之上 / 之下加 Epic 选择器
278
+ 3. 增加 Epic tab(在 Chat 右边)
@@ -0,0 +1,33 @@
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);
@@ -0,0 +1,201 @@
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);
@@ -0,0 +1,114 @@
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);