@aion0/forge 0.6.1 → 0.8.0

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 (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
package/lib/pipeline.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { randomUUID } from 'node:crypto';
9
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import YAML from 'yaml';
12
12
  import { createTask, getTask, onTaskEvent, taskModelOverrides } from './task-manager';
@@ -35,6 +35,9 @@ export interface WorkflowNode {
35
35
  agent?: string; // agent ID (default: from settings)
36
36
  branch?: string; // auto checkout this branch before running (supports templates)
37
37
  worktree?: boolean; // default: true. Set false to skip worktree isolation (run in project dir directly)
38
+ workdir?: string; // explicit cwd override (template). Wins over auto-worktree. Use when one node
39
+ // produces a worktree path that a later node needs to execute inside —
40
+ // e.g. worktree-setup → fix-code via `workdir: "{{nodes.worktree-setup.outputs.wt}}"`.
38
41
  // Plugin mode fields
39
42
  plugin?: string; // plugin ID (e.g., 'jenkins', 'docker')
40
43
  pluginAction?: string; // action name (e.g., 'trigger', 'build'), defaults to plugin's defaultAction
@@ -242,6 +245,597 @@ nodes:
242
245
  outputs:
243
246
  - name: result
244
247
  extract: stdout
248
+ `,
249
+ 'mantis-bug-fix-and-mr': `
250
+ name: mantis-bug-fix-and-mr
251
+ description: "Fetch Mantis bug context → worktree → fix code via headless Claude → push branch → open GitLab MR via glab CLI → notify assignee + reporter on Teams."
252
+ input:
253
+ bug_id: "Mantis bug id (number)"
254
+ project: "Forge project name"
255
+ base_branch: "Target branch the MR will be opened against (e.g. release/25.4). Required — set in the Job's input_template from the bug's product_version or target_branch field."
256
+ summary: "Bug summary (one line)"
257
+ description: "Full bug description (free text)"
258
+ priority: "Bug priority (optional)"
259
+ category: "Bug category (optional)"
260
+ reporter: "Mantis reporter username — used to notify them on Teams"
261
+ assignee: "Mantis assignee username — used to notify them on Teams"
262
+ extra_context: "Extra hints for Claude (optional)"
263
+ mr_title_template: "MR title template. Vars: {bug_id} {summary}. Default: 'Fix Mantis #{bug_id}: {summary}'"
264
+ mr_body_template: "MR body. Vars: {bug_id} {summary} {description} {claude_summary}. Default closes-reference + Claude summary."
265
+ teams_message_template: "Teams DM template. Vars: {bug_id} {summary} {mr_url} {role}. Default: '🤖 Mantis #{bug_id} fixed — review MR: {mr_url}'"
266
+ nodes:
267
+ resolve:
268
+ mode: shell
269
+ project: "{{input.project}}"
270
+ worktree: false
271
+ prompt: |
272
+ set -e
273
+ cd "$(git rev-parse --show-toplevel)"
274
+ command -v glab >/dev/null || { echo "ERROR: glab CLI not installed. brew install glab && glab auth login" >&2; exit 1; }
275
+ BUG_ID="{{input.bug_id}}"
276
+ BASE="{{input.base_branch}}"
277
+ [ -z "$BUG_ID" ] && { echo "ERROR: bug_id is required" >&2; exit 1; }
278
+ [ -z "$BASE" ] && { echo "ERROR: base_branch is required (set via Job input_template)" >&2; exit 1; }
279
+ REMOTE=$(git remote get-url origin)
280
+ RAW=$(echo "$REMOTE" | sed -E 's#^(https?://[^/]+/|git@[^:]+:)##; s#\\.git$##')
281
+ HOST=$(echo "$REMOTE" | sed -E 's#^https?://##; s#git@##; s#[:/].*##')
282
+ PROJECT_PATH="$RAW"
283
+ echo "HOST=$HOST"
284
+ echo "PROJECT_PATH=$PROJECT_PATH"
285
+ echo "BUG_ID=$BUG_ID"
286
+ echo "BASE=$BASE"
287
+ git fetch origin "$BASE" --quiet 2>/dev/null || true
288
+ outputs:
289
+ - name: info
290
+ extract: stdout
291
+ worktree-setup:
292
+ mode: shell
293
+ project: "{{input.project}}"
294
+ worktree: false
295
+ depends_on: [resolve]
296
+ prompt: |
297
+ set -e
298
+ INFO=$'{{nodes.resolve.outputs.info}}'
299
+ eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
300
+ # worktree: false above keeps us in the project root, so this is fine.
301
+ # Falls back to FORGE_PROJECT_ROOT just in case auto-worktree gets
302
+ # re-enabled in the future.
303
+ ROOT="\${FORGE_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
304
+ cd "$ROOT"
305
+ WORKTREE_DIR="$ROOT/.forge/worktrees/mantis-$BUG_ID"
306
+ BRANCH="fix/mantis-\${BUG_ID}"
307
+
308
+ # ALL the noisy git operations route to stderr — downstream nodes
309
+ # consume \`wt\` via \`eval "$(echo … | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"\` inside
310
+ # \`$'…'\` quoting. ANY apostrophe / paren / space-ID-paren in the
311
+ # captured stdout (e.g. "Deleted branch fix/X (was abc1234).",
312
+ # "Preparing worktree (resetting branch 'X')") breaks bash with
313
+ # "syntax error near unexpected token '('".
314
+ {
315
+ # 1. Remove the on-disk worktree for THIS bug (idempotent).
316
+ if [ -d "$WORKTREE_DIR" ]; then
317
+ git worktree remove --force "$WORKTREE_DIR" 2>/dev/null || rm -rf "$WORKTREE_DIR"
318
+ fi
319
+ # 2. Drop stale worktree registrations + any other worktree pinning
320
+ # our branch. Without (3), "fatal: <branch> already used by
321
+ # worktree at <path>" can fire on the next add.
322
+ git worktree prune
323
+ git worktree list --porcelain | awk -v b="refs/heads/$BRANCH" '
324
+ /^worktree /{w=$2}
325
+ $0=="branch "b{print w}
326
+ ' | while read w; do
327
+ [ -n "$w" ] && git worktree remove --force "$w" 2>/dev/null || true
328
+ done
329
+ git branch -D "$BRANCH" 2>/dev/null || true
330
+ mkdir -p "$ROOT/.forge/worktrees"
331
+ # -B (capital) = create-or-reset; --force lets us reuse an existing dir.
332
+ git worktree add --force -B "$BRANCH" "$WORKTREE_DIR" "origin/$BASE"
333
+ } 1>&2
334
+
335
+ # IMPORTANT: emit ONLY the worktree path on stdout — nothing else.
336
+ # fix-code uses \`workdir: {{nodes.worktree-setup.outputs.wt}}\` and the
337
+ # task runner needs a clean path. If we echo'd "WORKTREE=…\\nBRANCH=…"
338
+ # the multi-line string became an invalid workdir → Claude silently
339
+ # ran in the pipeline's parent worktree, made its changes there, and
340
+ # nothing showed up in the inner branch when push-and-mr cd'd in.
341
+ # Downstream nodes get the branch by asking git from inside the
342
+ # worktree (git symbolic-ref --short HEAD) — no out-of-band passing.
343
+ echo "$WORKTREE_DIR"
344
+ outputs:
345
+ - name: wt
346
+ extract: stdout
347
+ fetch-bug-details:
348
+ mode: shell
349
+ project: "{{input.project}}"
350
+ worktree: false
351
+ depends_on: [resolve]
352
+ prompt: |
353
+ # Pull the FULL mantis bug — summary, description, additional_information,
354
+ # reproducibility, all notes with author/date, history — via Forge's
355
+ # loopback connector-tool endpoint. search_bugs (when used as the Job
356
+ # source) only returns list-page columns; this node fills the gap so
357
+ # fix-code has every field it could possibly want.
358
+ set -e
359
+ INFO=$'{{nodes.resolve.outputs.info}}'
360
+ eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
361
+ PAYLOAD="{\\"plugin_id\\":\\"mantis\\",\\"tool\\":\\"get_bug\\",\\"input\\":{\\"id\\":$BUG_ID}}"
362
+ RESP=$(curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
363
+ -H 'content-type: application/json' --data "$PAYLOAD")
364
+ IS_ERR=$(echo "$RESP" | jq -r '.is_error // false')
365
+ if [ "$IS_ERR" = "true" ]; then
366
+ echo "WARN: mantis.get_bug failed: $(echo "$RESP" | jq -r '.content // .error // "(unknown)"' | head -c 300)" >&2
367
+ echo "BUG_ID=$BUG_ID"
368
+ echo "BUG_JSON_B64="
369
+ echo "SUMMARY="
370
+ echo "CATEGORY="
371
+ echo "DESCRIPTION_B64="
372
+ echo "ADDITIONAL_INFO_B64="
373
+ echo "NOTES_B64="
374
+ exit 0
375
+ fi
376
+ # .content is itself a JSON string (the bug object); decode once.
377
+ BUG=$(echo "$RESP" | jq -r '.content' | jq -c '.')
378
+
379
+ # Whole bug as base64 — gives fix-code an escape hatch to read any
380
+ # field we didn't bother to enumerate (history, _fields_raw, etc).
381
+ BUG_JSON_B64=$(echo -n "$BUG" | base64 | tr -d '\\n')
382
+
383
+ # Convenience extracts. Multi-line fields (description, notes,
384
+ # additional_information) → base64'd so newlines / quotes / shell
385
+ # metachars survive transport. Single-line stays plain for the
386
+ # KEY=value eval pattern downstream nodes already use.
387
+ SUMMARY=$(echo "$BUG" | jq -r '.summary // ""' | head -c 500 | tr -d '\\n')
388
+ CAT=$(echo "$BUG" | jq -r '.category // ""' | tr -d '\\n')
389
+ REPRO=$(echo "$BUG" | jq -r '.reproducibility // ""' | tr -d '\\n')
390
+ DESC_B64=$(echo "$BUG" | jq -r '.description // ""' | base64 | tr -d '\\n')
391
+ ADDL_B64=$(echo "$BUG" | jq -r '.additional_information // ""' | base64 | tr -d '\\n')
392
+ # Notes with author + date + body, separated by ─── for readability.
393
+ NOTES_B64=$(echo "$BUG" | jq -r '[.notes[]? | "[\\(.author // "?") @ \\(.date // "?")]\\n\\(.body // "")"] | join("\\n\\n─────────────\\n\\n")' 2>/dev/null | base64 | tr -d '\\n')
394
+
395
+ echo "BUG_ID=$BUG_ID"
396
+ echo "SUMMARY=$SUMMARY"
397
+ echo "CATEGORY=$CAT"
398
+ echo "REPRODUCIBILITY=$REPRO"
399
+ echo "DESCRIPTION_B64=$DESC_B64"
400
+ echo "ADDITIONAL_INFO_B64=$ADDL_B64"
401
+ echo "NOTES_B64=$NOTES_B64"
402
+ echo "BUG_JSON_B64=$BUG_JSON_B64"
403
+
404
+ # ── Human-readable preview to stderr ──
405
+ # Goes into the task log so the user can see what we actually got
406
+ # from Mantis without having to manually base64 -d. Truncated at
407
+ # 800 chars per field to keep the log tractable. Stdout above keeps
408
+ # the clean KEY=value pairs downstream nodes parse.
409
+ DESC_PLAIN=$(echo "$BUG" | jq -r '.description // ""')
410
+ ADDL_PLAIN=$(echo "$BUG" | jq -r '.additional_information // ""')
411
+ NOTES_COUNT=$(echo "$BUG" | jq -r '.notes | length // 0')
412
+ NOTES_FIRST=$(echo "$BUG" | jq -r '.notes[0]? | "[\\(.author // "?") @ \\(.date // "?")] \\(.body // "")"' 2>/dev/null | head -c 400)
413
+ NOTES_LAST=$(echo "$BUG" | jq -r '.notes[-1]? | "[\\(.author // "?") @ \\(.date // "?")] \\(.body // "")"' 2>/dev/null | head -c 400)
414
+ DESC_LEN=$(printf %s "$DESC_PLAIN" | wc -c | tr -d ' ')
415
+ ADDL_LEN=$(printf %s "$ADDL_PLAIN" | wc -c | tr -d ' ')
416
+ {
417
+ echo ""
418
+ echo "─── fetch-bug-details — what we got from Mantis #$BUG_ID ───"
419
+ echo "Summary: $SUMMARY"
420
+ echo "Category: $CAT"
421
+ echo "Reproducibility:$REPRO"
422
+ echo ""
423
+ echo "Description (\${DESC_LEN} chars, showing first 800):"
424
+ printf %s "$DESC_PLAIN" | head -c 800
425
+ [ "$DESC_LEN" -gt 800 ] && echo "… [truncated]"
426
+ echo ""
427
+ echo ""
428
+ echo "Additional Information (\${ADDL_LEN} chars, showing first 800):"
429
+ printf %s "$ADDL_PLAIN" | head -c 800
430
+ [ "$ADDL_LEN" -gt 800 ] && echo "… [truncated]"
431
+ echo ""
432
+ echo ""
433
+ echo "Notes: $NOTES_COUNT total"
434
+ if [ "$NOTES_COUNT" -gt 0 ]; then
435
+ echo " First: $NOTES_FIRST"
436
+ [ "$NOTES_COUNT" -gt 1 ] && echo " Last: $NOTES_LAST"
437
+ fi
438
+ echo "──────────────────────────────────────────────────────"
439
+ } >&2
440
+ outputs:
441
+ - name: details
442
+ extract: stdout
443
+ download-attachments:
444
+ mode: shell
445
+ project: "{{input.project}}"
446
+ worktree: false
447
+ depends_on: [resolve, worktree-setup]
448
+ prompt: |
449
+ # Download all Attached Files from the Mantis bug into the worktree's
450
+ # .attachments/ directory so fix-code (Claude with vision) can read
451
+ # screenshots, logs, and any other supporting material. Skips files
452
+ # larger than 5 MB (see download_attachment's max_size_bytes default).
453
+ set -e
454
+ INFO=$'{{nodes.resolve.outputs.info}}'
455
+ eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
456
+ WORKTREE_DIR=$'{{nodes.worktree-setup.outputs.wt}}'
457
+ WORKTREE_DIR=$(echo "$WORKTREE_DIR" | head -1 | tr -d '[:space:]')
458
+ ATTACH_DIR="$WORKTREE_DIR/.attachments"
459
+ mkdir -p "$ATTACH_DIR"
460
+
461
+ # 1. List attachments.
462
+ LIST=$(curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
463
+ -H 'content-type: application/json' \\
464
+ --data "{\\"plugin_id\\":\\"mantis\\",\\"tool\\":\\"list_attachments\\",\\"input\\":{\\"id\\":$BUG_ID,\\"bug_id\\":$BUG_ID}}" )
465
+ IS_ERR=$(echo "$LIST" | jq -r '.is_error // false')
466
+ if [ "$IS_ERR" = "true" ]; then
467
+ echo "ATTACH_COUNT=0"
468
+ echo "ATTACH_DIR=$ATTACH_DIR"
469
+ echo "WARN: list_attachments failed: $(echo "$LIST" | jq -r '.content' | head -c 200)" >&2
470
+ exit 0
471
+ fi
472
+ ATTACHMENTS=$(echo "$LIST" | jq -r '.content' | jq -c '.attachments // []')
473
+ TOTAL=$(echo "$ATTACHMENTS" | jq 'length')
474
+ echo "Found $TOTAL attachment(s) on bug $BUG_ID"
475
+
476
+ COUNT=0
477
+ SKIPPED=0
478
+ # 2. Iterate + download each. jq stream the ids/filenames.
479
+ echo "$ATTACHMENTS" | jq -c '.[]' | while read -r ATT; do
480
+ FID=$(echo "$ATT" | jq -r '.file_id')
481
+ FN=$(echo "$ATT" | jq -r '.filename')
482
+ SZ=$(echo "$ATT" | jq -r '.size')
483
+ # Sanitize filename to avoid path traversal.
484
+ SAFE=$(echo "$FN" | tr -d '\\\\/:*?"<>|' | head -c 200)
485
+ [ -z "$SAFE" ] && SAFE="file-$FID"
486
+ echo " → $SAFE ($SZ bytes)"
487
+ RESP=$(curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
488
+ -H 'content-type: application/json' \\
489
+ --data "{\\"plugin_id\\":\\"mantis\\",\\"tool\\":\\"download_attachment\\",\\"input\\":{\\"file_id\\":$FID}}")
490
+ if [ "$(echo "$RESP" | jq -r '.is_error // false')" = "true" ]; then
491
+ echo " WARN: download failed for $SAFE" >&2
492
+ continue
493
+ fi
494
+ BODY=$(echo "$RESP" | jq -r '.content')
495
+ SKIP=$(echo "$BODY" | jq -r '.skipped // false')
496
+ if [ "$SKIP" = "true" ]; then
497
+ REASON=$(echo "$BODY" | jq -r '.reason // ""')
498
+ echo " SKIPPED $SAFE — $REASON" >&2
499
+ SKIPPED=$((SKIPPED+1))
500
+ continue
501
+ fi
502
+ B64=$(echo "$BODY" | jq -r '.content_b64')
503
+ [ -z "$B64" ] && { echo " WARN: empty content_b64 for $SAFE" >&2; continue; }
504
+ echo "$B64" | base64 -d > "$ATTACH_DIR/$SAFE"
505
+ COUNT=$((COUNT+1))
506
+ done
507
+
508
+ echo "ATTACH_COUNT=$COUNT"
509
+ echo "ATTACH_SKIPPED=$SKIPPED"
510
+ echo "ATTACH_DIR=$ATTACH_DIR"
511
+
512
+ # Human-readable directory listing into stderr so the log shows
513
+ # what's actually on disk for the next node.
514
+ {
515
+ echo ""
516
+ echo "─── download-attachments — files in $ATTACH_DIR ───"
517
+ if [ -d "$ATTACH_DIR" ] && [ -n "$(ls -A "$ATTACH_DIR" 2>/dev/null)" ]; then
518
+ ls -lhS "$ATTACH_DIR" 2>/dev/null | tail -n +2
519
+ else
520
+ echo "(no files)"
521
+ fi
522
+ echo "──────────────────────────────────────────────────────"
523
+ } >&2
524
+ outputs:
525
+ - name: attach
526
+ extract: stdout
527
+ fix-code:
528
+ project: "{{input.project}}"
529
+ worktree: false
530
+ depends_on: [worktree-setup, fetch-bug-details, download-attachments]
531
+ workdir: "{{nodes.worktree-setup.outputs.wt}}"
532
+ prompt: |
533
+ A Mantis bug needs to be fixed in this worktree (already checked out
534
+ from the target base branch). You are running headless — make the fix
535
+ yourself, stage + commit.
536
+
537
+ ## Bug
538
+ ID: {{input.bug_id}}
539
+ Priority: {{input.priority}}
540
+ Category (from get_bug, full): see DETAILS below
541
+ Assignee: {{input.assignee}}
542
+ Reporter: {{input.reporter}}
543
+
544
+ ## Summary
545
+ {{input.summary}}
546
+
547
+ ## Full bug details (fetched via mantis.get_bug)
548
+ The fetch-bug-details node ran \`mantis.get_bug\` and exported these
549
+ key=value lines. _B64 fields are base64-encoded multi-line strings:
550
+
551
+ {{nodes.fetch-bug-details.outputs.details}}
552
+
553
+ Decode any _B64 field with:
554
+ \`echo "<value>" | base64 -d\`
555
+
556
+ Available fields (READ ALL of them before deciding on a fix):
557
+ SUMMARY one-line title (plain)
558
+ CATEGORY Mantis category (plain)
559
+ REPRODUCIBILITY always / sometimes / random / N/A (plain)
560
+ DESCRIPTION_B64 full bug description (base64; often the most
561
+ important field — repro steps live here)
562
+ ADDITIONAL_INFO_B64 "Additional Information" custom field —
563
+ FortiNAC bug reports usually put stack traces,
564
+ env details, log snippets here. Decode + read.
565
+ NOTES_B64 all comments concatenated, with author + date
566
+ headers ([author @ date]). Use to see what
567
+ QA + reporter already discussed.
568
+ BUG_JSON_B64 escape hatch — the whole bug object as JSON
569
+ (decoded, this has every field including
570
+ history[] and _fields_raw if you need it).
571
+
572
+ ## Description (search_bugs-supplied, may be empty)
573
+ {{input.description}}
574
+
575
+ ## Attached Files
576
+ {{nodes.download-attachments.outputs.attach}}
577
+
578
+ Files (if any) were saved under \`.attachments/\` in this worktree.
579
+ List them with \`ls -la .attachments/\`. For images, USE THE READ
580
+ TOOL on the file — your vision can analyze screenshots / error
581
+ dialogs / network diagrams directly. For text-like attachments
582
+ (log snippets, .txt, .json, small .pcap text exports) just open
583
+ them. Files larger than 5 MB were skipped — if a critical one is
584
+ missing, mention it in your fix notes; don't try to re-fetch.
585
+
586
+ ## Extra context
587
+ {{input.extra_context}}
588
+
589
+ ## HARD RULES — read before tool use
590
+ - **Never call mcp__* tools.** Pipeline tasks do not have interactive
591
+ auth; any MCP call that needs SSO/TOTP (mantis_auth, gitlab, pmdb,
592
+ …) will hang and fail the pipeline. The bug context above is the
593
+ only Mantis data you get.
594
+ - If a field above is empty or truncated, **work with what is given**.
595
+ Do NOT try to fetch more from Mantis. The pipeline owns Mantis access
596
+ upstream — re-running mantis lookup here is wasted effort and breaks
597
+ the design contract (Job → Pipeline data flow).
598
+ - If you ABSOLUTELY need supplemental data from another Forge connector
599
+ (rare), use the Forge HTTP API instead of MCP. POST
600
+ http://127.0.0.1:8403/api/connector-tool with JSON body
601
+ {"plugin_id":"<plugin>","tool":"<tool>","input":{...}}
602
+
603
+ ## Steps
604
+ 1. Read the bug carefully — identify the affected component.
605
+ 2. Find the relevant file(s) via git grep + reading the code.
606
+ 3. Implement a minimal fix.
607
+ 4. Add tests if the codebase has a test suite.
608
+ 5. Stage + commit with message: 'Fix Mantis #{{input.bug_id}}: <one-line>'.
609
+ **DO NOT add a "Co-Authored-By: Claude …" or "Generated with Claude
610
+ Code" trailer.** Plain commit message only — the MR is going into a
611
+ corporate repo and the AI co-authorship attribution is not wanted.
612
+
613
+ Do NOT push — the next pipeline node handles push + MR.
614
+ outputs:
615
+ - name: summary
616
+ extract: result
617
+ - name: diff
618
+ extract: git_diff
619
+ push-and-mr:
620
+ mode: shell
621
+ project: "{{input.project}}"
622
+ worktree: false
623
+ workdir: "{{nodes.worktree-setup.outputs.wt}}"
624
+ depends_on: [fix-code]
625
+ prompt: |
626
+ set -e
627
+ INFO=$'{{nodes.resolve.outputs.info}}'
628
+ eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
629
+ # workdir already cd'd us into the mantis worktree. Derive BRANCH.
630
+ BRANCH=$(git symbolic-ref --short HEAD)
631
+ # Bail if Claude didn't actually commit anything
632
+ AHEAD=$(git rev-list --count "origin/$BASE..HEAD" 2>/dev/null || echo 0)
633
+ if [ "$AHEAD" -lt 1 ]; then
634
+ echo "NO_CHANGES — Claude did not commit; aborting MR creation"
635
+ echo "MR_URL="
636
+ exit 0
637
+ fi
638
+ git push -u origin "$BRANCH" --force-with-lease 2>&1
639
+ # Heredoc keeps Claude's summary intact (apostrophes, parens, $, …).
640
+ SUMMARY=$(cat <<'FORGE_SUMMARY_EOF'
641
+ {{nodes.fix-code.outputs.summary}}
642
+ FORGE_SUMMARY_EOF
643
+ )
644
+ # ALL pipeline-templated values must enter via \`$'…'\` ANSI-C quoting,
645
+ # NOT plain "…". The pipeline engine ANSI-C-escapes substituted values
646
+ # (\\n / \\t / \\' etc.); inside "…" bash treats those as literal text
647
+ # and the MR description ends up showing "\\n" instead of real
648
+ # newlines, breaking the markdown.
649
+ TITLE_TPL=$'{{input.mr_title_template}}'
650
+ [ -z "$TITLE_TPL" ] && TITLE_TPL="Fix Mantis #{bug_id}: {summary}"
651
+ BODY_TPL=$'{{input.mr_body_template}}'
652
+ [ -z "$BODY_TPL" ] && BODY_TPL="Auto-fix for Mantis bug #{bug_id}.
653
+
654
+ ## Summary
655
+ {summary}
656
+
657
+ ## Original description
658
+ {description}
659
+
660
+ ## Claude's fix notes
661
+ {claude_summary}
662
+
663
+ ## Files changed
664
+ {diff_stat}
665
+
666
+ _Opened by Forge mantis-bug-fix-and-mr pipeline._"
667
+ BUG_ID_VAR=$'{{input.bug_id}}'
668
+ SUMMARY_VAR=$'{{input.summary}}'
669
+ DESCRIPTION_VAR=$'{{input.description}}'
670
+ # Compact list of files touched. --stat gives "N files changed, X+/Y-"
671
+ # which is small but tells the reviewer what's in the MR at a glance.
672
+ DIFF_STAT=$(git diff --stat "origin/$BASE..HEAD" 2>/dev/null || echo "")
673
+ MR_TITLE=$(python3 - "$TITLE_TPL" "$BUG_ID_VAR" "$SUMMARY_VAR" <<'PY'
674
+ import sys
675
+ tpl, bug_id, summary = sys.argv[1], sys.argv[2], sys.argv[3]
676
+ print(tpl.replace('{bug_id}', bug_id).replace('{summary}', summary))
677
+ PY
678
+ )
679
+ MR_BODY=$(python3 - "$BODY_TPL" "$BUG_ID_VAR" "$SUMMARY_VAR" "$DESCRIPTION_VAR" "$SUMMARY" "$DIFF_STAT" <<'PY'
680
+ import sys
681
+ tpl, bug_id, summary, description, claude, diff_stat = sys.argv[1:7]
682
+ out = (tpl.replace('{bug_id}', bug_id)
683
+ .replace('{summary}', summary)
684
+ .replace('{description}', description)
685
+ .replace('{claude_summary}', claude)
686
+ .replace('{diff_stat}', diff_stat))
687
+ print(out)
688
+ PY
689
+ )
690
+ # Bug assignee → MR reviewer. Mantis assignee comes as "Jane Doe (jdoe)";
691
+ # extract the (username) and pass to glab. If parens absent or empty,
692
+ # skip the flag — glab errors on --reviewer "" .
693
+ ASSIGNEE_RAW=$'{{input.assignee}}'
694
+ REVIEWER_USERNAME=$(echo "$ASSIGNEE_RAW" | grep -oE '\\(([a-zA-Z0-9._-]+)\\)' | tail -1 | tr -d '()')
695
+ REVIEWER_FLAGS=()
696
+ if [ -n "$REVIEWER_USERNAME" ]; then
697
+ REVIEWER_FLAGS+=(--reviewer "$REVIEWER_USERNAME")
698
+ echo "Setting MR reviewer: $REVIEWER_USERNAME (from assignee '$ASSIGNEE_RAW')"
699
+ else
700
+ echo "No (username) found in assignee '$ASSIGNEE_RAW' — skipping --reviewer"
701
+ fi
702
+ # Capture glab output AND exit code so we can debug failures. Old
703
+ # pipeline that just grep'd for the URL ate everything if the
704
+ # regex didn't match — leaving MR_URL empty with no clue why
705
+ # (auth refresh needed, reviewer username wrong, MR already exists
706
+ # at a non-canonical URL, etc).
707
+ set +e
708
+ GLAB_OUT=$(glab mr create -R "$PROJECT_PATH" --target-branch "$BASE" --source-branch "$BRANCH" \\
709
+ --title "$MR_TITLE" --description "$MR_BODY" \\
710
+ "\${REVIEWER_FLAGS[@]}" \\
711
+ --yes 2>&1)
712
+ GLAB_RC=$?
713
+ set -e
714
+ # Surface glab's output to the task log (truncated) so a failed
715
+ # retry doesn't disappear into the void.
716
+ echo "--- glab mr create (rc=$GLAB_RC) ---"
717
+ echo "$GLAB_OUT" | head -50
718
+ echo "--- end glab output ---"
719
+
720
+ # Common, very actionable failure: corp GitLab revoked the glab
721
+ # token. Detect + give the exact command to fix instead of making
722
+ # the user dig through stack-trace-like output.
723
+ if echo "$GLAB_OUT" | grep -qE '401|invalid_token|Token was revoked|unauthorized'; then
724
+ HOST=$(echo "$PROJECT_PATH" | sed -E 's#^.+@?##; s#:.*##' || true)
725
+ REMOTE_HOST=$(git config --get remote.origin.url 2>/dev/null | sed -E 's#^(https?://)?(git@)?([^:/]+).*#\\3#')
726
+ echo "ERROR: glab token revoked / expired for \${REMOTE_HOST:-this GitLab server}." >&2
727
+ echo "Fix: glab auth login --hostname \${REMOTE_HOST:-<gitlab-host>}" >&2
728
+ echo "Then retry this node from the pipeline UI." >&2
729
+ fi
730
+
731
+ MR_URL=$(echo "$GLAB_OUT" | grep -oE 'https://[^[:space:]]+/-/merge_requests/[0-9]+' | head -1)
732
+
733
+ # If glab succeeded but we didn't parse a URL (output format
734
+ # variant), OR if the MR already existed (glab errors on conflict),
735
+ # fall back to looking up the MR by source branch.
736
+ if [ -z "$MR_URL" ]; then
737
+ echo "Fallback: looking up existing MR for branch $BRANCH"
738
+ MR_URL=$(glab mr view "$BRANCH" -R "$PROJECT_PATH" --output json 2>/dev/null | jq -r '.web_url // empty')
739
+ if [ -z "$MR_URL" ]; then
740
+ # Last resort: glab mr list with our source branch.
741
+ MR_URL=$(glab mr list -R "$PROJECT_PATH" --source-branch "$BRANCH" --output json 2>/dev/null \\
742
+ | jq -r '.[0].web_url // empty')
743
+ fi
744
+ fi
745
+
746
+ if [ -z "$MR_URL" ]; then
747
+ echo "ERROR: could not create OR find MR for $BRANCH. See glab output above." >&2
748
+ # Don't fail the node hard — push succeeded, the user can create
749
+ # the MR manually via the GitLab "create MR" hint in the push
750
+ # output. notify-teams will see empty MR_URL and skip.
751
+ fi
752
+
753
+ echo "MR_URL=$MR_URL"
754
+ echo "REVIEWER=$REVIEWER_USERNAME"
755
+ outputs:
756
+ - name: mr
757
+ extract: stdout
758
+ notify-teams:
759
+ mode: shell
760
+ project: "{{input.project}}"
761
+ worktree: false
762
+ depends_on: [push-and-mr]
763
+ prompt: |
764
+ set -e
765
+ MR_OUT=$'{{nodes.push-and-mr.outputs.mr}}'
766
+ eval "$(echo "$MR_OUT" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
767
+ if [ -z "$MR_URL" ]; then
768
+ echo "SKIP — no MR URL (Claude likely made no changes)"
769
+ exit 0
770
+ fi
771
+ MSG_TPL="{{input.teams_message_template}}"
772
+ [ -z "$MSG_TPL" ] && MSG_TPL="🤖 Mantis #{bug_id} fixed — please review MR: {mr_url}"$'\\n'"Bug: {summary}"
773
+ # Forge exposes /api/connector-tool on loopback (no auth) — pipelines
774
+ # call connector tools through it. teams.send_message takes name (fuzzy
775
+ # match against any chat the user is in) + text.
776
+ send_to() {
777
+ local who="$1"
778
+ local role="$2"
779
+ if [ -z "$who" ]; then echo "SKIP — no $role name"; return 0; fi
780
+ # Render template + build JSON payload in one Python invocation so we
781
+ # never have to worry about shell quoting of summary / description /
782
+ # MR URL when they contain |, $, backticks, etc.
783
+ local payload
784
+ payload=$(python3 - "$MSG_TPL" "{{input.bug_id}}" "{{input.summary}}" "$role" "$MR_URL" "$who" <<'PY'
785
+ import json, sys
786
+ tpl, bug_id, summary, role, mr_url, who = sys.argv[1:7]
787
+ text = (tpl.replace('{bug_id}', bug_id)
788
+ .replace('{summary}', summary)
789
+ .replace('{role}', role)
790
+ .replace('{mr_url}', mr_url))
791
+ print(json.dumps({'plugin_id': 'teams', 'tool': 'send_message',
792
+ 'input': {'name': who, 'text': text}}))
793
+ PY
794
+ )
795
+ echo "Sending to Teams chat '$who' ($role)…"
796
+ curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
797
+ -H 'content-type: application/json' \\
798
+ --data-binary "$payload" \\
799
+ | jq .
800
+ }
801
+ send_to "{{input.assignee}}" "assignee"
802
+ send_to "{{input.reporter}}" "reporter"
803
+ echo "DONE — MR: $MR_URL"
804
+ outputs:
805
+ - name: result
806
+ extract: stdout
807
+ cleanup:
808
+ mode: shell
809
+ project: "{{input.project}}"
810
+ worktree: false
811
+ depends_on: [notify-teams]
812
+ prompt: |
813
+ # Best-effort cleanup so the project working tree doesn't pile up
814
+ # one full checkout per fixed bug. Runs only after notify-teams
815
+ # succeeded — if anything upstream failed, the worktree is kept
816
+ # for inspection. The remote branch + MR still live on GitLab.
817
+ set +e
818
+ WORKTREE_DIR=$'{{nodes.worktree-setup.outputs.wt}}'
819
+ WORKTREE_DIR=$(echo "$WORKTREE_DIR" | head -1 | tr -d '[:space:]')
820
+ # Derive BRANCH from inside the worktree before we delete it.
821
+ BRANCH=$(git -C "$WORKTREE_DIR" symbolic-ref --short HEAD 2>/dev/null)
822
+ cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
823
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
824
+ [ -z "$ROOT" ] && { echo "SKIP — not in a git repo"; exit 0; }
825
+ # Paranoia — only clean under our .forge/worktrees/.
826
+ case "$WORKTREE_DIR" in
827
+ "$ROOT/.forge/worktrees/"*) : ;;
828
+ *) echo "SKIP — WORKTREE_DIR=$WORKTREE_DIR not under .forge/worktrees/"; exit 0 ;;
829
+ esac
830
+ echo "removing worktree $WORKTREE_DIR …"
831
+ git worktree remove --force "$WORKTREE_DIR" 2>&1 || rm -rf "$WORKTREE_DIR"
832
+ git worktree prune
833
+ # Drop the local branch too — remote copy is on GitLab + the MR.
834
+ [ -n "$BRANCH" ] && git branch -D "$BRANCH" 2>/dev/null && echo "deleted local branch $BRANCH"
835
+ echo "cleanup done"
836
+ outputs:
837
+ - name: result
838
+ extract: stdout
245
839
  `,
246
840
  'multi-agent-collaboration': `
247
841
  name: multi-agent-collaboration
@@ -439,6 +1033,7 @@ function parseWorkflow(raw: string): Workflow {
439
1033
  agent: n.agent || undefined,
440
1034
  branch: n.branch || undefined,
441
1035
  worktree: n.worktree !== undefined ? n.worktree : undefined,
1036
+ workdir: n.workdir || undefined,
442
1037
  plugin: n.plugin || undefined,
443
1038
  pluginAction: n.plugin_action || n.pluginAction || undefined,
444
1039
  pluginParams: n.plugin_params || n.pluginParams || n.params || undefined,
@@ -511,24 +1106,102 @@ export function deletePipeline(id: string): boolean {
511
1106
  if (existsSync(filePath)) {
512
1107
  const { unlinkSync } = require('node:fs');
513
1108
  unlinkSync(filePath);
1109
+ __pipelineCache.delete(filePath);
514
1110
  return true;
515
1111
  }
516
1112
  } catch {}
517
1113
  return false;
518
1114
  }
519
1115
 
1116
+ // Parsed-pipeline cache keyed by absolute path → { mtime, pipeline }.
1117
+ // Pipeline files only change when a pipeline run advances (savePipeline) —
1118
+ // re-reading + JSON.parse on every list call was burning 1-3s with 200
1119
+ // runs accumulated (largest file 136KB, conversation history + node
1120
+ // outputs blow up). With this cache, subsequent listPipelines() calls
1121
+ // only re-parse files whose mtime changed since the last read.
1122
+ const __pipelineCache = new Map<string, { mtimeMs: number; pipeline: Pipeline }>();
1123
+
520
1124
  export function listPipelines(): Pipeline[] {
521
1125
  ensureDir();
522
- return readdirSync(PIPELINES_DIR)
523
- .filter(f => f.endsWith('.json'))
524
- .map(f => {
525
- try {
526
- return JSON.parse(readFileSync(join(PIPELINES_DIR, f), 'utf-8')) as Pipeline;
527
- } catch {
528
- return null;
1126
+ const files = readdirSync(PIPELINES_DIR).filter(f => f.endsWith('.json'));
1127
+ const out: Pipeline[] = [];
1128
+ for (const f of files) {
1129
+ const path = join(PIPELINES_DIR, f);
1130
+ try {
1131
+ const stat = statSync(path);
1132
+ const cached = __pipelineCache.get(path);
1133
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
1134
+ out.push(cached.pipeline);
1135
+ continue;
529
1136
  }
530
- })
531
- .filter(Boolean) as Pipeline[];
1137
+ const pipeline = JSON.parse(readFileSync(path, 'utf-8')) as Pipeline;
1138
+ __pipelineCache.set(path, { mtimeMs: stat.mtimeMs, pipeline });
1139
+ out.push(pipeline);
1140
+ } catch { /* skip unreadable */ }
1141
+ }
1142
+ return out;
1143
+ }
1144
+
1145
+ /**
1146
+ * Light summary for the pipeline LIST UI — strips the heavy fields
1147
+ * (node outputs, conversation message bodies, errors). The full pipeline
1148
+ * is fetched via GET /api/pipelines/:id when the user opens a specific
1149
+ * run. Cuts /api/pipelines payload from ~1.6MB to <50KB with 200 runs.
1150
+ */
1151
+ export interface PipelineSummary {
1152
+ id: string;
1153
+ workflowName: string;
1154
+ status: Pipeline['status'];
1155
+ type?: Pipeline['type'];
1156
+ createdAt: string;
1157
+ completedAt?: string;
1158
+ nodeOrder: string[];
1159
+ nodes: Record<string, { status: PipelineNodeState['status']; iterations?: number }>;
1160
+ conversation?: { currentRound?: number; config?: { maxRounds?: number } };
1161
+ input?: Record<string, string>;
1162
+ }
1163
+
1164
+ export interface ListPipelinesOptions {
1165
+ /** Newest-first limit. Defaults to 100. Hard ceiling 500 (the heavy
1166
+ * pipeline detail view fetches one run at a time anyway, so a
1167
+ * five-hundred-deep list covers every practical workflow grouping). */
1168
+ limit?: number;
1169
+ /** Filter to a single workflow. Combine with a higher limit when the
1170
+ * user expands one workflow and wants its full history. */
1171
+ workflow?: string;
1172
+ /** Cursor for "Load more" — return runs with createdAt < before. */
1173
+ before?: string;
1174
+ }
1175
+
1176
+ export function listPipelinesSummary(opts: ListPipelinesOptions = {}): PipelineSummary[] {
1177
+ const limit = Math.min(Math.max(1, opts.limit ?? 100), 500);
1178
+ // listPipelines already builds parsed objects (cached). Sort by
1179
+ // createdAt desc, optionally filter, then slice. Avoids serializing the
1180
+ // hundreds of older runs the UI never shows.
1181
+ let all = listPipelines();
1182
+ if (opts.workflow) {
1183
+ const target = opts.workflow;
1184
+ all = all.filter(p => p.workflowName === target);
1185
+ }
1186
+ all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
1187
+ if (opts.before) all = all.filter(p => p.createdAt < opts.before!);
1188
+ const slice = all.slice(0, limit);
1189
+ return slice.map(p => ({
1190
+ id: p.id,
1191
+ workflowName: p.workflowName,
1192
+ status: p.status,
1193
+ type: p.type,
1194
+ createdAt: p.createdAt,
1195
+ completedAt: p.completedAt,
1196
+ nodeOrder: p.nodeOrder,
1197
+ nodes: Object.fromEntries(
1198
+ Object.entries(p.nodes).map(([k, v]) => [k, { status: v.status, iterations: v.iterations }]),
1199
+ ),
1200
+ conversation: p.conversation
1201
+ ? { currentRound: p.conversation.currentRound, config: { maxRounds: p.conversation.config?.maxRounds } }
1202
+ : undefined,
1203
+ input: p.input,
1204
+ }));
532
1205
  }
533
1206
 
534
1207
  // ─── Template Resolution ──────────────────────────────────
@@ -1135,6 +1808,80 @@ setInterval(recoverStuckPipelines, 30_000);
1135
1808
  // Also run once on load
1136
1809
  setTimeout(recoverStuckPipelines, 5000);
1137
1810
 
1811
+ /**
1812
+ * Retry a single failed node. Cascades-reset to any downstream nodes that
1813
+ * got marked 'skipped' (because this one failed) so they re-run too. The
1814
+ * pipeline flips back to 'running' if it had completed as failed.
1815
+ *
1816
+ * Use case: pipeline failed at push-and-mr because of a shell quoting bug;
1817
+ * fix-code already produced a valid commit in the worktree. Retry push-and-mr
1818
+ * without re-running the 15-minute fix-code.
1819
+ *
1820
+ * Returns { ok, error? }. Validation:
1821
+ * - node must exist + be in failed status
1822
+ * - all of the node's *dependencies* must be 'done' (we don't retry into
1823
+ * a broken upstream — fix the upstream first)
1824
+ */
1825
+ export async function retryNode(pipelineId: string, nodeId: string): Promise<{ ok: boolean; error?: string }> {
1826
+ const pipeline = getPipeline(pipelineId);
1827
+ if (!pipeline) return { ok: false, error: 'pipeline not found' };
1828
+
1829
+ const nodeState = pipeline.nodes[nodeId];
1830
+ if (!nodeState) return { ok: false, error: `node '${nodeId}' is not in this pipeline` };
1831
+ if (nodeState.status !== 'failed') {
1832
+ return { ok: false, error: `node is in status '${nodeState.status}' — only failed nodes can be retried` };
1833
+ }
1834
+
1835
+ const workflow = getWorkflow(pipeline.workflowName);
1836
+ if (!workflow) return { ok: false, error: `workflow '${pipeline.workflowName}' not found` };
1837
+
1838
+ const nodeDef = workflow.nodes[nodeId];
1839
+ if (!nodeDef) return { ok: false, error: `node '${nodeId}' is not in workflow definition` };
1840
+
1841
+ for (const dep of nodeDef.dependsOn) {
1842
+ const depState = pipeline.nodes[dep];
1843
+ if (!depState || depState.status !== 'done') {
1844
+ return { ok: false, error: `dependency '${dep}' is in status '${depState?.status || 'missing'}' — retry upstream first` };
1845
+ }
1846
+ }
1847
+
1848
+ // BFS downstream — any node whose chain of dependencies reaches `nodeId`
1849
+ // needs to be reset too (it likely got marked 'skipped' when this failed).
1850
+ const toReset = new Set<string>([nodeId]);
1851
+ let added = true;
1852
+ while (added) {
1853
+ added = false;
1854
+ for (const [otherId, otherDef] of Object.entries(workflow.nodes)) {
1855
+ if (toReset.has(otherId)) continue;
1856
+ if (otherDef.dependsOn.some(d => toReset.has(d))) {
1857
+ toReset.add(otherId);
1858
+ added = true;
1859
+ }
1860
+ }
1861
+ }
1862
+
1863
+ for (const rid of toReset) {
1864
+ const s = pipeline.nodes[rid];
1865
+ if (!s) continue;
1866
+ s.status = 'pending';
1867
+ s.error = undefined;
1868
+ s.taskId = undefined;
1869
+ s.startedAt = undefined;
1870
+ s.completedAt = undefined;
1871
+ s.outputs = {};
1872
+ // Don't reset s.iterations — keep monotonic so retries show up in history.
1873
+ }
1874
+
1875
+ if (pipeline.status === 'failed' || pipeline.status === 'done') {
1876
+ pipeline.status = 'running';
1877
+ pipeline.completedAt = undefined;
1878
+ }
1879
+
1880
+ savePipeline(pipeline);
1881
+ await scheduleReadyNodes(pipeline, workflow);
1882
+ return { ok: true };
1883
+ }
1884
+
1138
1885
  export function cancelPipeline(id: string): boolean {
1139
1886
  const pipeline = getPipeline(id);
1140
1887
  if (!pipeline || pipeline.status !== 'running') return false;
@@ -1243,6 +1990,32 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
1243
1990
  console.warn(`[pipeline] Worktree creation failed, falling back to project dir: ${e.message}`);
1244
1991
  }
1245
1992
 
1993
+ // Explicit workdir override — wins over auto-worktree. Use case:
1994
+ // worktree-setup emits a path on stdout; downstream nodes (fix-code,
1995
+ // push-and-mr) declare workdir: "{{nodes.worktree-setup.outputs.wt}}"
1996
+ // to actually execute inside that worktree instead of the auto one.
1997
+ // Before this support existed, the YAML's workdir was silently ignored
1998
+ // and Claude wrote its changes into the wrong tree.
1999
+ if (nodeDef.workdir) {
2000
+ const resolved = resolveTemplate(nodeDef.workdir, ctx).trim();
2001
+ // Reject empties + multi-line strings — those are signs of malformed
2002
+ // upstream output (e.g. accidentally including KEY=value lines).
2003
+ if (resolved && !resolved.includes('\n')) {
2004
+ try {
2005
+ const { statSync } = require('node:fs');
2006
+ if (statSync(resolved).isDirectory()) {
2007
+ effectivePath = resolved;
2008
+ } else {
2009
+ console.warn(`[pipeline] workdir resolved to non-directory '${resolved}', keeping ${effectivePath}`);
2010
+ }
2011
+ } catch (e: any) {
2012
+ console.warn(`[pipeline] workdir '${resolved}' not accessible (${e.code || e.message}), keeping ${effectivePath}`);
2013
+ }
2014
+ } else if (resolved) {
2015
+ console.warn(`[pipeline] workdir resolved to a multi-line value — ignoring (upstream likely emitted KEY=value pairs instead of a clean path)`);
2016
+ }
2017
+ }
2018
+
1246
2019
  // ── Plugin mode: execute plugin action directly ──
1247
2020
  if (nodeDef.mode === 'plugin' && nodeDef.plugin) {
1248
2021
  nodeState.status = 'running';
@@ -1306,6 +2079,13 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
1306
2079
  prompt: effectivePrompt,
1307
2080
  mode: taskMode as any,
1308
2081
  agent: nodeDef.agent || undefined,
2082
+ // Pipeline nodes always start a fresh Claude session. Without this,
2083
+ // createTask falls back to getProjectConversationId() which returns
2084
+ // the project's last interactive session id — but pipeline nodes
2085
+ // often run in a per-node worktree where that session file doesn't
2086
+ // exist, producing 'No conversation found with session ID ...' on
2087
+ // first execution.
2088
+ conversationId: '',
1309
2089
  });
1310
2090
  pipelineTaskIds.add(task.id);
1311
2091
  // Pipeline tasks use the same model selection as normal tasks: