@aion0/forge 0.5.26 → 0.5.27
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/.forge/worktrees/pipeline-4dd8dc2d/CLAUDE.md +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/README.md +136 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/RELEASE_NOTES.md +36 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/agents/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/[...nextauth]/route.ts +3 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/verify/route.ts +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/route.ts +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/stream/route.ts +63 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/route.ts +28 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/entries/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/sync/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-templates/route.ts +145 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/code/route.ts +299 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/[id]/route.ts +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/route.ts +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/detect-cli/route.ts +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/route.ts +176 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/sessions/route.ts +54 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/favorites/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/route.ts +6 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/run/route.ts +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/git/route.ts +149 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/help/route.ts +84 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/issue-scanner/route.ts +116 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/logs/route.ts +100 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/mobile-chat/route.ts +115 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/monitor/route.ts +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notifications/route.ts +42 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notify/test/route.ts +33 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/online/route.ts +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/[id]/route.ts +41 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/route.ts +90 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/plugins/route.ts +75 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/[...path]/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/route.ts +156 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-pipelines/route.ts +91 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-sessions/route.ts +61 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/projects/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/chat/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/messages/route.ts +9 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/route.ts +20 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/settings/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/local/route.ts +228 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/route.ts +182 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/smith-templates/route.ts +81 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/status/route.ts +12 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tabs/route.ts +25 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/route.ts +51 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/stream/route.ts +77 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/link/route.ts +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/route.ts +44 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/session/route.ts +14 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/telegram/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/templates/route.ts +6 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-bell/route.ts +39 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-cwd/route.ts +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-state/route.ts +15 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tunnel/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/upgrade/route.ts +43 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/usage/route.ts +20 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/version/route.ts +78 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/watchers/route.ts +33 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/agents/route.ts +35 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/memory/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/smith/route.ts +22 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/stream/route.ts +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/route.ts +79 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/global-error.tsx +21 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/globals.css +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.ico +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.png +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.svg +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/layout.tsx +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/login/LoginForm.tsx +96 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/login/page.tsx +10 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/mobile/page.tsx +10 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/page.tsx +22 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/bin/forge-server.mjs +484 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/check-forge-status.sh +71 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/cli/mw.ts +579 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/BrowserPanel.tsx +175 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ChatPanel.tsx +191 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ClaudeTerminal.tsx +267 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/CodeViewer.tsx +787 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationEditor.tsx +411 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationGraphView.tsx +347 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationTerminalView.tsx +303 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/Dashboard.tsx +807 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DashboardWrapper.tsx +9 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryFlowEditor.tsx +491 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryList.tsx +230 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryWorkspace.tsx +589 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DocTerminal.tsx +187 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DocsViewer.tsx +574 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpDialog.tsx +169 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpTerminal.tsx +141 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/InlinePipelineView.tsx +111 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/LogViewer.tsx +194 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MarkdownContent.tsx +73 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MobileView.tsx +385 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MonitorPanel.tsx +122 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/NewSessionModal.tsx +93 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/NewTaskModal.tsx +492 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineEditor.tsx +570 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineView.tsx +1018 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PluginsPanel.tsx +472 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectDetail.tsx +1618 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectList.tsx +108 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectManager.tsx +401 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionList.tsx +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionView.tsx +726 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SettingsModal.tsx +1647 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SkillsPanel.tsx +969 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/StatusBar.tsx +99 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TabBar.tsx +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskBoard.tsx +113 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskDetail.tsx +372 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TerminalLauncher.tsx +398 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TunnelToggle.tsx +206 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/UsagePanel.tsx +207 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WebTerminal.tsx +1743 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceTree.tsx +221 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceView.tsx +4048 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/dev-test.sh +5 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Memory_Layer_Design.docx +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Strategy_Research_2026.docx +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/LOCAL-DEPLOY.md +144 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/roadmap-multi-agent-workflow.md +330 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.png +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.svg +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/hooks/useSidebarResize.ts +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/install.sh +29 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/instrumentation.ts +35 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/claude-adapter.ts +104 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/generic-adapter.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/index.ts +245 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/types.ts +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/artifacts.ts +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/auth.ts +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/docker.yaml +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/http.yaml +66 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/jenkins.yaml +92 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/llm-vision.yaml +85 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/playwright.yaml +111 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/shell-command.yaml +60 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/slack.yaml +48 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/webhook.yaml +56 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-process.ts +361 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-sessions.ts +266 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-templates.ts +227 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/cloudflared.ts +424 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/crypto.ts +67 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/delivery.ts +787 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/dirs.ts +99 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/flows.ts +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-mcp-server.ts +732 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-inbox.md +38 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-send.md +47 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-status.md +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/00-overview.md +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +194 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/02-telegram.md +41 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/03-tunnel.md +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/04-tasks.md +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/05-pipelines.md +460 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/06-skills.md +43 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +73 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/08-rules.md +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/09-issue-autofix.md +55 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/10-troubleshooting.md +89 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/11-workspace.md +810 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/CLAUDE.md +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/init.ts +266 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/issue-scanner.ts +298 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/logger.ts +79 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/notifications.ts +75 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/notify.ts +108 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/password.ts +97 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline-scheduler.ts +373 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline.ts +1565 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/executor.ts +347 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/registry.ts +228 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/types.ts +103 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/project-sessions.ts +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/projects.ts +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-manager.ts +156 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-utils.ts +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-watcher.ts +345 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/settings.ts +195 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/skills.ts +458 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/task-manager.ts +951 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-bot.ts +1477 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-standalone.ts +83 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-server.ts +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-standalone.ts +438 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/usage-scanner.ts +249 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-bus.ts +416 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-worker.ts +655 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/api-backend.ts +262 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/cli-backend.ts +491 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/index.ts +84 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/manager.ts +136 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/orchestrator.ts +3415 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/persistence.ts +309 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/presets.ts +649 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/requests.ts +287 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/session-monitor.ts +240 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/skill-installer.ts +275 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/smith-memory.ts +498 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/types.ts +241 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/watch-manager.ts +560 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace-standalone.ts +978 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/middleware.ts +51 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/next.config.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/package.json +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-lock.yaml +3719 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-workspace.yaml +1 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/postcss.config.mjs +7 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/publish.sh +133 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/README.md +66 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/results/.gitignore +2 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/run.ts +635 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/task.md +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/validator.sh +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/setup.sh +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/task.md +48 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/validator.sh +69 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/setup.sh +82 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/task.md +30 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/validator.sh +29 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/verify-usage.ts +178 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/config/index.ts +129 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/db/database.ts +259 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/memory/strategy.ts +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/chat.ts +65 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/registry.ts +60 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/session/manager.ts +190 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/types/index.ts +129 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/start.sh +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/templates/smith-lead.json +45 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/tsconfig.json +42 -0
- package/RELEASE_NOTES.md +11 -28
- package/app/api/terminal-bell/route.ts +6 -2
- package/components/WebTerminal.tsx +36 -2
- package/lib/terminal-standalone.ts +19 -2
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline Engine — DAG-based workflow orchestration on top of the Task system.
|
|
3
|
+
*
|
|
4
|
+
* Workflow YAML → Pipeline instance → Nodes executed as Tasks
|
|
5
|
+
* Supports: dependencies, output passing, conditional routing, parallel execution, notifications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import YAML from 'yaml';
|
|
12
|
+
import { createTask, getTask, onTaskEvent, taskModelOverrides } from './task-manager';
|
|
13
|
+
import { getProjectInfo } from './projects';
|
|
14
|
+
import { loadSettings } from './settings';
|
|
15
|
+
import { getAgent, listAgents } from './agents';
|
|
16
|
+
import type { Task } from '@/src/types';
|
|
17
|
+
import { getDataDir } from './dirs';
|
|
18
|
+
|
|
19
|
+
const PIPELINES_DIR = join(getDataDir(), 'pipelines');
|
|
20
|
+
const WORKFLOWS_DIR = join(getDataDir(), 'flows');
|
|
21
|
+
|
|
22
|
+
// Track pipeline task IDs so terminal notifications can skip them (persists across hot-reloads)
|
|
23
|
+
const pipelineTaskKey = Symbol.for('mw-pipeline-task-ids');
|
|
24
|
+
const gPipeline = globalThis as any;
|
|
25
|
+
if (!gPipeline[pipelineTaskKey]) gPipeline[pipelineTaskKey] = new Set<string>();
|
|
26
|
+
export const pipelineTaskIds: Set<string> = gPipeline[pipelineTaskKey];
|
|
27
|
+
|
|
28
|
+
// ─── Types ────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface WorkflowNode {
|
|
31
|
+
id: string;
|
|
32
|
+
project: string;
|
|
33
|
+
prompt: string;
|
|
34
|
+
mode?: 'claude' | 'shell' | 'plugin'; // default: 'claude', 'shell' runs command, 'plugin' runs plugin action
|
|
35
|
+
agent?: string; // agent ID (default: from settings)
|
|
36
|
+
branch?: string; // auto checkout this branch before running (supports templates)
|
|
37
|
+
worktree?: boolean; // default: true. Set false to skip worktree isolation (run in project dir directly)
|
|
38
|
+
// Plugin mode fields
|
|
39
|
+
plugin?: string; // plugin ID (e.g., 'jenkins', 'docker')
|
|
40
|
+
pluginAction?: string; // action name (e.g., 'trigger', 'build'), defaults to plugin's defaultAction
|
|
41
|
+
pluginParams?: Record<string, any>; // per-use parameters
|
|
42
|
+
pluginWait?: boolean; // auto-run 'wait' action after main action
|
|
43
|
+
dependsOn: string[];
|
|
44
|
+
outputs: { name: string; extract: 'result' | 'git_diff' | 'stdout' | 'plugin' }[];
|
|
45
|
+
routes: { condition: string; next: string }[];
|
|
46
|
+
maxIterations: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Conversation Mode Types ──────────────────────────────
|
|
50
|
+
|
|
51
|
+
export interface ConversationAgent {
|
|
52
|
+
id: string; // logical ID within this conversation (e.g., 'architect', 'implementer')
|
|
53
|
+
agent: string; // agent registry ID (e.g., 'claude', 'codex', 'aider')
|
|
54
|
+
role: string; // system prompt / role description
|
|
55
|
+
project?: string; // project context (optional, defaults to workflow input.project)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ConversationMessage {
|
|
59
|
+
round: number;
|
|
60
|
+
agentId: string; // logical ID from ConversationAgent
|
|
61
|
+
agentName: string; // display name (resolved from registry)
|
|
62
|
+
content: string;
|
|
63
|
+
timestamp: string;
|
|
64
|
+
taskId?: string; // backing task ID
|
|
65
|
+
status: 'pending' | 'running' | 'done' | 'failed';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ConversationConfig {
|
|
69
|
+
agents: ConversationAgent[];
|
|
70
|
+
maxRounds: number; // max back-and-forth rounds
|
|
71
|
+
stopCondition?: string; // e.g., "all agents say DONE", "any agent says DONE"
|
|
72
|
+
initialPrompt: string; // the seed prompt to kick off the conversation
|
|
73
|
+
contextStrategy?: 'full' | 'window' | 'summary'; // how to pass history, default: 'summary'
|
|
74
|
+
contextWindow?: number; // for 'window'/'summary': how many recent messages to include in full (default: 4)
|
|
75
|
+
maxContentLength?: number; // truncate each message to this length (default: 3000)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Workflow ─────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export interface Workflow {
|
|
81
|
+
name: string;
|
|
82
|
+
type?: 'dag' | 'conversation'; // default: 'dag'
|
|
83
|
+
description?: string;
|
|
84
|
+
vars: Record<string, string>;
|
|
85
|
+
input: Record<string, string>; // required input fields
|
|
86
|
+
nodes: Record<string, WorkflowNode>;
|
|
87
|
+
// Conversation mode fields (only when type === 'conversation')
|
|
88
|
+
conversation?: ConversationConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type PipelineNodeStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped';
|
|
92
|
+
|
|
93
|
+
export interface PipelineNodeState {
|
|
94
|
+
status: PipelineNodeStatus;
|
|
95
|
+
taskId?: string;
|
|
96
|
+
outputs: Record<string, string>;
|
|
97
|
+
iterations: number;
|
|
98
|
+
startedAt?: string;
|
|
99
|
+
completedAt?: string;
|
|
100
|
+
error?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Pipeline {
|
|
104
|
+
id: string;
|
|
105
|
+
workflowName: string;
|
|
106
|
+
type?: 'dag' | 'conversation'; // default: 'dag'
|
|
107
|
+
status: 'running' | 'done' | 'failed' | 'cancelled';
|
|
108
|
+
input: Record<string, string>;
|
|
109
|
+
vars: Record<string, string>;
|
|
110
|
+
nodes: Record<string, PipelineNodeState>;
|
|
111
|
+
nodeOrder: string[]; // for UI display
|
|
112
|
+
createdAt: string;
|
|
113
|
+
completedAt?: string;
|
|
114
|
+
// Conversation mode state
|
|
115
|
+
conversation?: {
|
|
116
|
+
config: ConversationConfig;
|
|
117
|
+
messages: ConversationMessage[];
|
|
118
|
+
currentRound: number;
|
|
119
|
+
currentAgentIndex: number; // index into config.agents
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Workflow Loading ─────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
// ─── Built-in workflows ──────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export const BUILTIN_WORKFLOWS: Record<string, string> = {
|
|
128
|
+
'issue-fix-and-review': `
|
|
129
|
+
name: issue-fix-and-review
|
|
130
|
+
description: "Fetch GitHub issue → fix code → create PR → review PR → notify"
|
|
131
|
+
input:
|
|
132
|
+
issue_id: "GitHub issue number"
|
|
133
|
+
project: "Project name"
|
|
134
|
+
base_branch: "Base branch (default: auto-detect)"
|
|
135
|
+
extra_context: "Additional instructions for the fix (optional)"
|
|
136
|
+
nodes:
|
|
137
|
+
setup:
|
|
138
|
+
mode: shell
|
|
139
|
+
project: "{{input.project}}"
|
|
140
|
+
prompt: |
|
|
141
|
+
cd "$(git rev-parse --show-toplevel)" && \
|
|
142
|
+
if [ -n "$(git status --porcelain)" ]; then echo "ERROR: Working directory has uncommitted changes. Please commit or stash first." && exit 1; fi && \
|
|
143
|
+
ORIG_BRANCH=$(git branch --show-current || git rev-parse --short HEAD) && \
|
|
144
|
+
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git remote get-url origin | sed 's/.*github.com[:/]//;s/.git$//') && \
|
|
145
|
+
BASE="{{input.base_branch}}" && \
|
|
146
|
+
if [ -z "$BASE" ] || [ "$BASE" = "auto-detect" ]; then BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo main); fi && \
|
|
147
|
+
git checkout "$BASE" 2>/dev/null || true && \
|
|
148
|
+
git pull origin "$BASE" 2>/dev/null || true && \
|
|
149
|
+
OLD_BRANCH=$(git branch --list "fix/{{input.issue_id}}-*" | head -1 | tr -d ' *') && \
|
|
150
|
+
if [ -n "$OLD_BRANCH" ]; then git branch -D "$OLD_BRANCH" 2>/dev/null || true; fi && \
|
|
151
|
+
echo "REPO=$REPO" && echo "BASE=$BASE" && echo "ORIG_BRANCH=$ORIG_BRANCH"
|
|
152
|
+
outputs:
|
|
153
|
+
- name: info
|
|
154
|
+
extract: stdout
|
|
155
|
+
fetch-issue:
|
|
156
|
+
mode: shell
|
|
157
|
+
project: "{{input.project}}"
|
|
158
|
+
depends_on: [setup]
|
|
159
|
+
prompt: |
|
|
160
|
+
ISSUE_ID="{{input.issue_id}}" && \
|
|
161
|
+
if [ -z "$ISSUE_ID" ]; then echo "__SKIP__ No issue_id provided" && exit 0; fi && \
|
|
162
|
+
SETUP_INFO=$'{{nodes.setup.outputs.info}}' && \
|
|
163
|
+
REPO=$(echo "$SETUP_INFO" | grep REPO= | cut -d= -f2) && \
|
|
164
|
+
gh issue view "$ISSUE_ID" --json title,body,labels,number -R "$REPO"
|
|
165
|
+
outputs:
|
|
166
|
+
- name: issue_json
|
|
167
|
+
extract: stdout
|
|
168
|
+
fix-code:
|
|
169
|
+
project: "{{input.project}}"
|
|
170
|
+
depends_on: [fetch-issue]
|
|
171
|
+
prompt: |
|
|
172
|
+
A GitHub issue needs to be fixed. Here is the issue data:
|
|
173
|
+
|
|
174
|
+
{{nodes.fetch-issue.outputs.issue_json}}
|
|
175
|
+
|
|
176
|
+
Steps:
|
|
177
|
+
1. Create a new branch from the current branch (which is already on the base). Name format: fix/{{input.issue_id}}-<short-description> (e.g. fix/3-add-validation, fix/15-null-pointer). Any old branch for this issue has been cleaned up.
|
|
178
|
+
2. Analyze the issue and fix the code.
|
|
179
|
+
3. Stage and commit with a message referencing #{{input.issue_id}}.
|
|
180
|
+
|
|
181
|
+
Base branch info: {{nodes.setup.outputs.info}}
|
|
182
|
+
|
|
183
|
+
Additional context from user: {{input.extra_context}}
|
|
184
|
+
outputs:
|
|
185
|
+
- name: summary
|
|
186
|
+
extract: result
|
|
187
|
+
- name: diff
|
|
188
|
+
extract: git_diff
|
|
189
|
+
push-and-pr:
|
|
190
|
+
mode: shell
|
|
191
|
+
project: "{{input.project}}"
|
|
192
|
+
depends_on: [fix-code]
|
|
193
|
+
prompt: |
|
|
194
|
+
SETUP_INFO=$'{{nodes.setup.outputs.info}}' && \
|
|
195
|
+
REPO=$(echo "$SETUP_INFO" | grep REPO= | cut -d= -f2) && \
|
|
196
|
+
BRANCH=$(git branch --show-current) && \
|
|
197
|
+
git push -u origin "$BRANCH" --force-with-lease 2>&1 && \
|
|
198
|
+
PR_URL=$(gh pr create --title "Fix #{{input.issue_id}}" \
|
|
199
|
+
--body "Auto-fix by Forge Pipeline for issue #{{input.issue_id}}." -R "$REPO" 2>/dev/null || \
|
|
200
|
+
gh pr view "$BRANCH" --json url -q .url -R "$REPO" 2>/dev/null) && \
|
|
201
|
+
echo "$PR_URL"
|
|
202
|
+
outputs:
|
|
203
|
+
- name: pr_url
|
|
204
|
+
extract: stdout
|
|
205
|
+
review:
|
|
206
|
+
project: "{{input.project}}"
|
|
207
|
+
depends_on: [push-and-pr]
|
|
208
|
+
prompt: |
|
|
209
|
+
Review the code changes for issue #{{input.issue_id}}.
|
|
210
|
+
|
|
211
|
+
Fix summary: {{nodes.fix-code.outputs.summary}}
|
|
212
|
+
|
|
213
|
+
Git diff:
|
|
214
|
+
{{nodes.fix-code.outputs.diff}}
|
|
215
|
+
|
|
216
|
+
Check for:
|
|
217
|
+
- Bugs and logic errors
|
|
218
|
+
- Security vulnerabilities
|
|
219
|
+
- Performance issues
|
|
220
|
+
- Whether the fix actually addresses the issue
|
|
221
|
+
|
|
222
|
+
Respond with:
|
|
223
|
+
1. APPROVED or CHANGES_REQUESTED
|
|
224
|
+
2. Specific issues found with file paths and line numbers
|
|
225
|
+
outputs:
|
|
226
|
+
- name: review_result
|
|
227
|
+
extract: result
|
|
228
|
+
cleanup:
|
|
229
|
+
mode: shell
|
|
230
|
+
project: "{{input.project}}"
|
|
231
|
+
depends_on: [review]
|
|
232
|
+
prompt: |
|
|
233
|
+
SETUP_INFO=$'{{nodes.setup.outputs.info}}' && \
|
|
234
|
+
ORIG=$(echo "$SETUP_INFO" | grep ORIG_BRANCH= | cut -d= -f2) && \
|
|
235
|
+
PR_URL=$'{{nodes.push-and-pr.outputs.pr_url}}' && \
|
|
236
|
+
if [ -n "$(git status --porcelain)" ]; then
|
|
237
|
+
echo "Issue #{{input.issue_id}} — PR: $PR_URL (staying on $(git branch --show-current))"
|
|
238
|
+
else
|
|
239
|
+
git checkout "$ORIG" 2>/dev/null || true
|
|
240
|
+
echo "Issue #{{input.issue_id}} — PR: $PR_URL (switched back to $ORIG)"
|
|
241
|
+
fi
|
|
242
|
+
outputs:
|
|
243
|
+
- name: result
|
|
244
|
+
extract: stdout
|
|
245
|
+
`,
|
|
246
|
+
'multi-agent-collaboration': `
|
|
247
|
+
name: multi-agent-collaboration
|
|
248
|
+
type: conversation
|
|
249
|
+
description: "Two agents collaborate: one designs, one implements"
|
|
250
|
+
input:
|
|
251
|
+
project: "Project name"
|
|
252
|
+
task: "What to build or fix"
|
|
253
|
+
agents:
|
|
254
|
+
- id: architect
|
|
255
|
+
agent: claude
|
|
256
|
+
role: "You are a software architect. Round 1: design the solution with clear steps. Later rounds: review the implementation and say DONE if satisfied."
|
|
257
|
+
- id: implementer
|
|
258
|
+
agent: claude
|
|
259
|
+
role: "You are a developer. Implement what the architect designs. After implementing, say DONE."
|
|
260
|
+
max_rounds: 3
|
|
261
|
+
stop_condition: "both agents say DONE"
|
|
262
|
+
initial_prompt: "Task: {{input.task}}"
|
|
263
|
+
`,
|
|
264
|
+
'review-mr': `
|
|
265
|
+
name: review-mr
|
|
266
|
+
description: "Review PR — AI code review with GitHub comment"
|
|
267
|
+
input:
|
|
268
|
+
project: "Project name"
|
|
269
|
+
branch: "Branch name or PR number (empty = auto-detect latest open PR)"
|
|
270
|
+
base_branch: "Target branch (default: main)"
|
|
271
|
+
vars:
|
|
272
|
+
default_base: main
|
|
273
|
+
nodes:
|
|
274
|
+
resolve-pr:
|
|
275
|
+
mode: shell
|
|
276
|
+
project: "{{input.project}}"
|
|
277
|
+
worktree: false
|
|
278
|
+
prompt: |
|
|
279
|
+
INPUT_BRANCH="{{input.branch}}" && \\
|
|
280
|
+
BASE="{{input.base_branch}}" && \\
|
|
281
|
+
if [ -z "$BASE" ] || echo "$BASE" | grep -q '{{'; then BASE="main"; fi && \\
|
|
282
|
+
if [ -z "$INPUT_BRANCH" ] || echo "$INPUT_BRANCH" | grep -q '{{'; then \\
|
|
283
|
+
INPUT_BRANCH=$(gh pr list --state open --base "$BASE" --json number -q '.[0].number' 2>/dev/null); \\
|
|
284
|
+
if [ -z "$INPUT_BRANCH" ]; then echo "ERROR: No open PR found targeting $BASE" && exit 1; fi; \\
|
|
285
|
+
fi && \\
|
|
286
|
+
if echo "$INPUT_BRANCH" | grep -qE '^[0-9]+$'; then \\
|
|
287
|
+
PR_NUM="$INPUT_BRANCH"; \\
|
|
288
|
+
else \\
|
|
289
|
+
PR_NUM=$(gh pr list --state open --head "$INPUT_BRANCH" --json number -q '.[0].number' 2>/dev/null); \\
|
|
290
|
+
if [ -z "$PR_NUM" ]; then echo "ERROR: No open PR for branch $INPUT_BRANCH" && exit 1; fi; \\
|
|
291
|
+
fi && \\
|
|
292
|
+
echo "$PR_NUM"
|
|
293
|
+
outputs:
|
|
294
|
+
- name: pr_number
|
|
295
|
+
extract: stdout
|
|
296
|
+
fetch-diff:
|
|
297
|
+
mode: shell
|
|
298
|
+
project: "{{input.project}}"
|
|
299
|
+
worktree: false
|
|
300
|
+
depends_on: [resolve-pr]
|
|
301
|
+
prompt: "gh pr diff {{nodes.resolve-pr.outputs.pr_number}}"
|
|
302
|
+
outputs:
|
|
303
|
+
- name: diff
|
|
304
|
+
extract: stdout
|
|
305
|
+
fetch-files:
|
|
306
|
+
mode: shell
|
|
307
|
+
project: "{{input.project}}"
|
|
308
|
+
worktree: false
|
|
309
|
+
depends_on: [resolve-pr]
|
|
310
|
+
prompt: |
|
|
311
|
+
PR_NUM="{{nodes.resolve-pr.outputs.pr_number}}" && \\
|
|
312
|
+
echo "=== PR #$PR_NUM ===" && \\
|
|
313
|
+
gh pr view "$PR_NUM" --json title,author,additions,deletions,changedFiles,commits,body --jq '"Title: " + .title + "\\nAuthor: " + .author.login + "\\nFiles: " + (.changedFiles|tostring) + " changed, +" + (.additions|tostring) + "/-" + (.deletions|tostring) + "\\nCommits: " + (.commits|length|tostring) + "\\n\\n=== PR Description ===\\n" + (.body // "(no description)")' && \\
|
|
314
|
+
echo "" && \\
|
|
315
|
+
echo "=== Changed Files ===" && \\
|
|
316
|
+
gh pr diff "$PR_NUM" --name-only
|
|
317
|
+
outputs:
|
|
318
|
+
- name: stats
|
|
319
|
+
extract: stdout
|
|
320
|
+
review:
|
|
321
|
+
project: "{{input.project}}"
|
|
322
|
+
worktree: false
|
|
323
|
+
depends_on: [fetch-diff, fetch-files, resolve-pr]
|
|
324
|
+
prompt: |
|
|
325
|
+
You are a senior code reviewer. Perform a thorough code review of this PR.
|
|
326
|
+
|
|
327
|
+
## PR Info & Description
|
|
328
|
+
{{nodes.fetch-files.outputs.stats}}
|
|
329
|
+
|
|
330
|
+
## Diff
|
|
331
|
+
{{nodes.fetch-diff.outputs.diff}}
|
|
332
|
+
|
|
333
|
+
## Review Requirements
|
|
334
|
+
|
|
335
|
+
**First**: Verify the PR description against actual changes:
|
|
336
|
+
- Is every claimed change actually implemented?
|
|
337
|
+
- Any claimed changes that are NOT in the diff?
|
|
338
|
+
- Any changes in the diff NOT mentioned in the description?
|
|
339
|
+
|
|
340
|
+
**Then**: Review code quality:
|
|
341
|
+
1. Bug risk — logic errors, edge cases, null references
|
|
342
|
+
2. Security — injection, hardcoded secrets, sensitive data exposure
|
|
343
|
+
3. Performance — obvious bottlenecks
|
|
344
|
+
4. Code quality — readability, naming, DRY
|
|
345
|
+
|
|
346
|
+
## Output
|
|
347
|
+
|
|
348
|
+
Write the full review report to /tmp/forge-review-pr{{nodes.resolve-pr.outputs.pr_number}}.md in this format:
|
|
349
|
+
|
|
350
|
+
## 🤖 Forge AI Code Review — PR #{{nodes.resolve-pr.outputs.pr_number}}
|
|
351
|
+
|
|
352
|
+
### 📋 Summary
|
|
353
|
+
- Verdict: ✅ Approve / ⚠️ Request Changes / ❌ Reject
|
|
354
|
+
- One-line summary
|
|
355
|
+
|
|
356
|
+
### ✅ PR Description Verification
|
|
357
|
+
List each change claimed in the PR description, mark ✓ implemented / ✗ not implemented / ⚠️ partial
|
|
358
|
+
|
|
359
|
+
### 🔴 Blockers (must fix)
|
|
360
|
+
(write "None" if none)
|
|
361
|
+
|
|
362
|
+
### 🟡 Suggestions
|
|
363
|
+
|
|
364
|
+
### 🟢 Nice-to-have
|
|
365
|
+
|
|
366
|
+
### 💡 Highlights
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
_Generated by [Forge](https://github.com/aiwatching/forge) Pipeline_
|
|
370
|
+
|
|
371
|
+
**You MUST write the complete report to /tmp/forge-review-pr{{nodes.resolve-pr.outputs.pr_number}}.md. This is the most important step.**
|
|
372
|
+
outputs:
|
|
373
|
+
- name: report
|
|
374
|
+
extract: result
|
|
375
|
+
post-comment:
|
|
376
|
+
mode: shell
|
|
377
|
+
project: "{{input.project}}"
|
|
378
|
+
worktree: false
|
|
379
|
+
depends_on: [review, resolve-pr]
|
|
380
|
+
prompt: |
|
|
381
|
+
PR_NUM="{{nodes.resolve-pr.outputs.pr_number}}" && \\
|
|
382
|
+
REPORT="/tmp/forge-review-pr\${PR_NUM}.md" && \\
|
|
383
|
+
if [ ! -f "$REPORT" ]; then echo "ERROR: Review report not found at $REPORT" && exit 1; fi && \\
|
|
384
|
+
gh pr comment "$PR_NUM" --body-file "$REPORT" && \\
|
|
385
|
+
rm -f "$REPORT" && \\
|
|
386
|
+
echo "Comment posted to PR #$PR_NUM"
|
|
387
|
+
outputs:
|
|
388
|
+
- name: result
|
|
389
|
+
extract: stdout
|
|
390
|
+
`,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export interface WorkflowWithMeta extends Workflow {
|
|
394
|
+
builtin?: boolean;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function listWorkflows(): WorkflowWithMeta[] {
|
|
398
|
+
// User workflows
|
|
399
|
+
const userWorkflows: WorkflowWithMeta[] = [];
|
|
400
|
+
if (existsSync(WORKFLOWS_DIR)) {
|
|
401
|
+
for (const f of readdirSync(WORKFLOWS_DIR).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
402
|
+
try {
|
|
403
|
+
userWorkflows.push({ ...parseWorkflow(readFileSync(join(WORKFLOWS_DIR, f), 'utf-8')), builtin: false });
|
|
404
|
+
} catch {}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Built-in workflows (don't override user ones with same name)
|
|
409
|
+
const userNames = new Set(userWorkflows.map(w => w.name));
|
|
410
|
+
const builtins: WorkflowWithMeta[] = [];
|
|
411
|
+
for (const [, yaml] of Object.entries(BUILTIN_WORKFLOWS)) {
|
|
412
|
+
try {
|
|
413
|
+
const w = parseWorkflow(yaml);
|
|
414
|
+
if (!userNames.has(w.name)) {
|
|
415
|
+
builtins.push({ ...w, builtin: true });
|
|
416
|
+
}
|
|
417
|
+
} catch {}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return [...builtins, ...userWorkflows];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function getWorkflow(name: string): WorkflowWithMeta | null {
|
|
424
|
+
return listWorkflows().find(w => w.name === name) || null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function parseWorkflow(raw: string): Workflow {
|
|
428
|
+
const parsed = YAML.parse(raw);
|
|
429
|
+
const workflowType = parsed.type || 'dag';
|
|
430
|
+
const nodes: Record<string, WorkflowNode> = {};
|
|
431
|
+
|
|
432
|
+
for (const [id, def] of Object.entries(parsed.nodes || {})) {
|
|
433
|
+
const n = def as any;
|
|
434
|
+
nodes[id] = {
|
|
435
|
+
id,
|
|
436
|
+
project: n.project || '',
|
|
437
|
+
prompt: n.prompt || '',
|
|
438
|
+
mode: n.mode || (n.plugin ? 'plugin' : 'claude'),
|
|
439
|
+
agent: n.agent || undefined,
|
|
440
|
+
branch: n.branch || undefined,
|
|
441
|
+
worktree: n.worktree !== undefined ? n.worktree : undefined,
|
|
442
|
+
plugin: n.plugin || undefined,
|
|
443
|
+
pluginAction: n.plugin_action || n.pluginAction || undefined,
|
|
444
|
+
pluginParams: n.plugin_params || n.pluginParams || n.params || undefined,
|
|
445
|
+
pluginWait: n.plugin_wait || n.pluginWait || n.wait || false,
|
|
446
|
+
dependsOn: n.depends_on || n.dependsOn || [],
|
|
447
|
+
outputs: (n.outputs || []).map((o: any) => ({
|
|
448
|
+
name: o.name,
|
|
449
|
+
extract: o.extract || 'result',
|
|
450
|
+
})),
|
|
451
|
+
routes: (n.routes || []).map((r: any) => ({
|
|
452
|
+
condition: r.condition || 'default',
|
|
453
|
+
next: r.next,
|
|
454
|
+
})),
|
|
455
|
+
maxIterations: n.max_iterations || n.maxIterations || 3,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Parse conversation config
|
|
460
|
+
let conversation: ConversationConfig | undefined;
|
|
461
|
+
if (workflowType === 'conversation' && parsed.agents) {
|
|
462
|
+
conversation = {
|
|
463
|
+
agents: (parsed.agents as any[]).map((a: any) => ({
|
|
464
|
+
id: a.id,
|
|
465
|
+
agent: a.agent || 'claude',
|
|
466
|
+
role: a.role || '',
|
|
467
|
+
project: a.project || undefined,
|
|
468
|
+
})),
|
|
469
|
+
maxRounds: parsed.max_rounds || parsed.maxRounds || 10,
|
|
470
|
+
stopCondition: parsed.stop_condition || parsed.stopCondition || undefined,
|
|
471
|
+
initialPrompt: parsed.initial_prompt || parsed.initialPrompt || '',
|
|
472
|
+
contextStrategy: parsed.context_strategy || parsed.contextStrategy || 'summary',
|
|
473
|
+
contextWindow: parsed.context_window || parsed.contextWindow || 4,
|
|
474
|
+
maxContentLength: parsed.max_content_length || parsed.maxContentLength || 3000,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
name: parsed.name || 'unnamed',
|
|
480
|
+
type: workflowType,
|
|
481
|
+
description: parsed.description,
|
|
482
|
+
vars: parsed.vars || {},
|
|
483
|
+
input: parsed.input || {},
|
|
484
|
+
nodes,
|
|
485
|
+
conversation,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ─── Pipeline Persistence ─────────────────────────────────
|
|
490
|
+
|
|
491
|
+
function ensureDir() {
|
|
492
|
+
if (!existsSync(PIPELINES_DIR)) mkdirSync(PIPELINES_DIR, { recursive: true });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function savePipeline(pipeline: Pipeline) {
|
|
496
|
+
ensureDir();
|
|
497
|
+
writeFileSync(join(PIPELINES_DIR, `${pipeline.id}.json`), JSON.stringify(pipeline, null, 2));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function getPipeline(id: string): Pipeline | null {
|
|
501
|
+
try {
|
|
502
|
+
return JSON.parse(readFileSync(join(PIPELINES_DIR, `${id}.json`), 'utf-8'));
|
|
503
|
+
} catch {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function deletePipeline(id: string): boolean {
|
|
509
|
+
const filePath = join(PIPELINES_DIR, `${id}.json`);
|
|
510
|
+
try {
|
|
511
|
+
if (existsSync(filePath)) {
|
|
512
|
+
const { unlinkSync } = require('node:fs');
|
|
513
|
+
unlinkSync(filePath);
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
} catch {}
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function listPipelines(): Pipeline[] {
|
|
521
|
+
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;
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
.filter(Boolean) as Pipeline[];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ─── Template Resolution ──────────────────────────────────
|
|
535
|
+
|
|
536
|
+
/** Escape a string for safe embedding in single-quoted shell strings */
|
|
537
|
+
function shellEscape(s: string): string {
|
|
538
|
+
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
|
|
539
|
+
return s.replace(/'/g, "'\\''");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Escape a string for safe embedding in $'...' shell strings (ANSI-C quoting) */
|
|
543
|
+
function shellEscapeAnsiC(s: string): string {
|
|
544
|
+
return s
|
|
545
|
+
.replace(/\\/g, '\\\\')
|
|
546
|
+
.replace(/'/g, "\\'")
|
|
547
|
+
.replace(/\n/g, '\\n')
|
|
548
|
+
.replace(/\r/g, '\\r')
|
|
549
|
+
.replace(/\t/g, '\\t');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function resolveTemplate(template: string, ctx: {
|
|
553
|
+
input: Record<string, string>;
|
|
554
|
+
vars: Record<string, string>;
|
|
555
|
+
nodes: Record<string, PipelineNodeState>;
|
|
556
|
+
}, shellMode?: boolean): string {
|
|
557
|
+
return template.replace(/\{\{(.*?)\}\}/g, (_, expr) => {
|
|
558
|
+
const path = expr.trim();
|
|
559
|
+
let value = '';
|
|
560
|
+
|
|
561
|
+
// {{input.xxx}}
|
|
562
|
+
if (path.startsWith('input.')) value = ctx.input[path.slice(6)] || '';
|
|
563
|
+
// {{vars.xxx}}
|
|
564
|
+
else if (path.startsWith('vars.')) value = ctx.vars[path.slice(5)] || '';
|
|
565
|
+
// {{nodes.xxx.outputs.yyy}}
|
|
566
|
+
else {
|
|
567
|
+
const nodeMatch = path.match(/^nodes\.([\w-]+)\.outputs\.([\w-]+)$/);
|
|
568
|
+
if (nodeMatch) {
|
|
569
|
+
const [, nodeId, outputName] = nodeMatch;
|
|
570
|
+
value = ctx.nodes[nodeId]?.outputs[outputName] || '';
|
|
571
|
+
} else {
|
|
572
|
+
return `{{${path}}}`;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return shellMode ? shellEscapeAnsiC(value) : value;
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ─── Project-level pipeline lock ─────────────────────────
|
|
581
|
+
const projectPipelineLocks = new Map<string, string>(); // projectPath → pipelineId
|
|
582
|
+
|
|
583
|
+
function acquireProjectLock(projectPath: string, pipelineId: string): boolean {
|
|
584
|
+
const existing = projectPipelineLocks.get(projectPath);
|
|
585
|
+
if (existing && existing !== pipelineId) {
|
|
586
|
+
// Check if the existing pipeline is still running
|
|
587
|
+
const p = getPipeline(existing);
|
|
588
|
+
if (p && p.status === 'running') return false;
|
|
589
|
+
// Stale lock, clear it
|
|
590
|
+
}
|
|
591
|
+
projectPipelineLocks.set(projectPath, pipelineId);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function releaseProjectLock(projectPath: string, pipelineId: string) {
|
|
596
|
+
if (projectPipelineLocks.get(projectPath) === pipelineId) {
|
|
597
|
+
projectPipelineLocks.delete(projectPath);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ─── Pipeline Execution ───────────────────────────────────
|
|
602
|
+
|
|
603
|
+
export function startPipeline(workflowName: string, input: Record<string, string>): Pipeline {
|
|
604
|
+
const workflow = getWorkflow(workflowName);
|
|
605
|
+
if (!workflow) throw new Error(`Workflow not found: ${workflowName}`);
|
|
606
|
+
|
|
607
|
+
// Conversation mode — separate execution path
|
|
608
|
+
if (workflow.type === 'conversation' && workflow.conversation) {
|
|
609
|
+
return startConversationPipeline(workflow, input);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const id = randomUUID().slice(0, 8);
|
|
613
|
+
const nodes: Record<string, PipelineNodeState> = {};
|
|
614
|
+
const nodeOrder = topologicalSort(workflow.nodes);
|
|
615
|
+
|
|
616
|
+
for (const nodeId of nodeOrder) {
|
|
617
|
+
nodes[nodeId] = {
|
|
618
|
+
status: 'pending',
|
|
619
|
+
outputs: {},
|
|
620
|
+
iterations: 0,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const pipeline: Pipeline = {
|
|
625
|
+
id,
|
|
626
|
+
workflowName,
|
|
627
|
+
status: 'running',
|
|
628
|
+
input,
|
|
629
|
+
vars: { ...workflow.vars },
|
|
630
|
+
nodes,
|
|
631
|
+
nodeOrder,
|
|
632
|
+
createdAt: new Date().toISOString(),
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
savePipeline(pipeline);
|
|
636
|
+
|
|
637
|
+
// Start nodes that have no dependencies
|
|
638
|
+
scheduleReadyNodes(pipeline, workflow);
|
|
639
|
+
|
|
640
|
+
// Listen for task completions
|
|
641
|
+
setupTaskListener(pipeline.id);
|
|
642
|
+
|
|
643
|
+
return pipeline;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ─── Conversation State Type (extracted to avoid Turbopack parse issues) ──
|
|
647
|
+
type ConversationState = {
|
|
648
|
+
config: ConversationConfig;
|
|
649
|
+
messages: ConversationMessage[];
|
|
650
|
+
currentRound: number;
|
|
651
|
+
currentAgentIndex: number;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// ─── Conversation Mode Execution ──────────────────────────
|
|
655
|
+
|
|
656
|
+
function startConversationPipeline(workflow: Workflow, input: Record<string, string>): Pipeline {
|
|
657
|
+
const conv = workflow.conversation!;
|
|
658
|
+
const id = randomUUID().slice(0, 8);
|
|
659
|
+
|
|
660
|
+
// Resolve agent display names
|
|
661
|
+
const agentNames: Record<string, string> = {};
|
|
662
|
+
const allAgents = listAgents();
|
|
663
|
+
for (const ca of conv.agents) {
|
|
664
|
+
const found = allAgents.find(a => a.id === ca.agent);
|
|
665
|
+
agentNames[ca.id] = found?.name || ca.agent;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const pipeline: Pipeline = {
|
|
669
|
+
id,
|
|
670
|
+
workflowName: workflow.name,
|
|
671
|
+
type: 'conversation',
|
|
672
|
+
status: 'running',
|
|
673
|
+
input,
|
|
674
|
+
vars: { ...workflow.vars },
|
|
675
|
+
nodes: {},
|
|
676
|
+
nodeOrder: [],
|
|
677
|
+
createdAt: new Date().toISOString(),
|
|
678
|
+
conversation: {
|
|
679
|
+
config: {
|
|
680
|
+
...conv,
|
|
681
|
+
// Store resolved initial prompt so buildConversationContext uses it
|
|
682
|
+
initialPrompt: conv.initialPrompt.replace(/\{\{input\.(\w+)\}\}/g, (_, key) => input[key] || ''),
|
|
683
|
+
},
|
|
684
|
+
messages: [],
|
|
685
|
+
currentRound: 1,
|
|
686
|
+
currentAgentIndex: 0,
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
savePipeline(pipeline);
|
|
691
|
+
|
|
692
|
+
const resolvedPrompt = pipeline.conversation!.config.initialPrompt;
|
|
693
|
+
|
|
694
|
+
// Start the first round
|
|
695
|
+
scheduleNextConversationTurn(pipeline, resolvedPrompt, agentNames);
|
|
696
|
+
|
|
697
|
+
return pipeline;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: string, agentNames?: Record<string, string>) {
|
|
701
|
+
const conv = pipeline.conversation!;
|
|
702
|
+
const config = conv.config;
|
|
703
|
+
const agentDef = config.agents[conv.currentAgentIndex];
|
|
704
|
+
|
|
705
|
+
if (!agentDef) {
|
|
706
|
+
pipeline.status = 'failed';
|
|
707
|
+
pipeline.completedAt = new Date().toISOString();
|
|
708
|
+
savePipeline(pipeline);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Resolve project
|
|
713
|
+
const projectName = agentDef.project || pipeline.input.project || '';
|
|
714
|
+
const projectInfo = getProjectInfo(projectName);
|
|
715
|
+
if (!projectInfo) {
|
|
716
|
+
pipeline.status = 'failed';
|
|
717
|
+
pipeline.completedAt = new Date().toISOString();
|
|
718
|
+
savePipeline(pipeline);
|
|
719
|
+
notifyPipelineComplete(pipeline);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Build the prompt: role context + conversation history + new message
|
|
724
|
+
const rolePrefix = agentDef.role ? `[Your role: ${agentDef.role}]\n\n` : '';
|
|
725
|
+
const fullPrompt = `${rolePrefix}${contextForAgent}`;
|
|
726
|
+
|
|
727
|
+
// Create a task for this agent's turn
|
|
728
|
+
const task = createTask({
|
|
729
|
+
projectName: projectInfo.name,
|
|
730
|
+
projectPath: projectInfo.path,
|
|
731
|
+
prompt: fullPrompt,
|
|
732
|
+
mode: 'prompt',
|
|
733
|
+
agent: agentDef.agent,
|
|
734
|
+
conversationId: '', // fresh session — no resume for conversation mode
|
|
735
|
+
});
|
|
736
|
+
pipelineTaskIds.add(task.id);
|
|
737
|
+
|
|
738
|
+
// Add pending message
|
|
739
|
+
const names = agentNames || resolveAgentNames(config.agents);
|
|
740
|
+
conv.messages.push({
|
|
741
|
+
round: conv.currentRound,
|
|
742
|
+
agentId: agentDef.id,
|
|
743
|
+
agentName: names[agentDef.id] || agentDef.agent,
|
|
744
|
+
content: '',
|
|
745
|
+
timestamp: new Date().toISOString(),
|
|
746
|
+
taskId: task.id,
|
|
747
|
+
status: 'running',
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
savePipeline(pipeline);
|
|
751
|
+
|
|
752
|
+
// Listen for this task to complete
|
|
753
|
+
setupConversationTaskListener(pipeline.id, task.id);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function resolveAgentNames(agents: ConversationAgent[]): Record<string, string> {
|
|
757
|
+
const allAgents = listAgents();
|
|
758
|
+
const names: Record<string, string> = {};
|
|
759
|
+
for (const ca of agents) {
|
|
760
|
+
const found = allAgents.find(a => a.id === ca.agent);
|
|
761
|
+
names[ca.id] = found?.name || ca.agent;
|
|
762
|
+
}
|
|
763
|
+
return names;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function setupConversationTaskListener(pipelineId: string, taskId: string) {
|
|
767
|
+
const cleanup = onTaskEvent((evtTaskId, event, data) => {
|
|
768
|
+
if (evtTaskId !== taskId) return;
|
|
769
|
+
if (event !== 'status') return;
|
|
770
|
+
if (data !== 'done' && data !== 'failed') return;
|
|
771
|
+
|
|
772
|
+
cleanup(); // one-shot listener
|
|
773
|
+
|
|
774
|
+
const pipeline = getPipeline(pipelineId);
|
|
775
|
+
if (!pipeline || pipeline.status !== 'running' || !pipeline.conversation) return;
|
|
776
|
+
|
|
777
|
+
const conv = pipeline.conversation;
|
|
778
|
+
const config = conv.config;
|
|
779
|
+
const msgIndex = conv.messages.findIndex(m => m.taskId === taskId);
|
|
780
|
+
if (msgIndex < 0) return;
|
|
781
|
+
|
|
782
|
+
const task = getTask(taskId);
|
|
783
|
+
|
|
784
|
+
if (data === 'failed' || !task) {
|
|
785
|
+
conv.messages[msgIndex].status = 'failed';
|
|
786
|
+
conv.messages[msgIndex].content = task?.error || 'Task failed';
|
|
787
|
+
pipeline.status = 'failed';
|
|
788
|
+
pipeline.completedAt = new Date().toISOString();
|
|
789
|
+
savePipeline(pipeline);
|
|
790
|
+
notifyPipelineComplete(pipeline);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Task completed — extract response
|
|
795
|
+
const response = task.resultSummary || '';
|
|
796
|
+
conv.messages[msgIndex].status = 'done';
|
|
797
|
+
conv.messages[msgIndex].content = response;
|
|
798
|
+
|
|
799
|
+
// Check stop condition
|
|
800
|
+
if (checkConversationStopCondition(conv, response)) {
|
|
801
|
+
finishConversation(pipeline, 'done');
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Move to next agent in round, or next round
|
|
806
|
+
conv.currentAgentIndex++;
|
|
807
|
+
if (conv.currentAgentIndex >= config.agents.length) {
|
|
808
|
+
// Completed a full round
|
|
809
|
+
conv.currentAgentIndex = 0;
|
|
810
|
+
conv.currentRound++;
|
|
811
|
+
|
|
812
|
+
if (conv.currentRound > config.maxRounds) {
|
|
813
|
+
finishConversation(pipeline, 'done');
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
savePipeline(pipeline);
|
|
819
|
+
|
|
820
|
+
// Build context for next agent: accumulate conversation history
|
|
821
|
+
const contextForNext = buildConversationContext(conv);
|
|
822
|
+
scheduleNextConversationTurn(pipeline, contextForNext);
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Build context string for the next agent in conversation.
|
|
828
|
+
*
|
|
829
|
+
* Three strategies:
|
|
830
|
+
* - 'full' : pass ALL history as-is (token-heavy, good for short convos)
|
|
831
|
+
* - 'window' : pass only the last N messages in full, drop older ones
|
|
832
|
+
* - 'summary' : pass older messages as one-line summaries + last N in full (default)
|
|
833
|
+
*/
|
|
834
|
+
function buildConversationContext(conv: ConversationState): string {
|
|
835
|
+
const config = conv.config;
|
|
836
|
+
const strategy = config.contextStrategy || 'summary';
|
|
837
|
+
const windowSize = config.contextWindow || 4;
|
|
838
|
+
const maxLen = config.maxContentLength || 3000;
|
|
839
|
+
|
|
840
|
+
const doneMessages = conv.messages.filter(m => m.status === 'done' && m.content);
|
|
841
|
+
|
|
842
|
+
let context = `[Conversation — Round ${conv.currentRound}]\n\n`;
|
|
843
|
+
context += `Task: ${config.initialPrompt}\n\n`;
|
|
844
|
+
|
|
845
|
+
if (doneMessages.length === 0) {
|
|
846
|
+
context += `--- Your Turn ---\nYou are the first to respond. Please address the task above. If you believe the task is complete, include "DONE" in your response.`;
|
|
847
|
+
return context;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
context += `--- Conversation History ---\n\n`;
|
|
851
|
+
|
|
852
|
+
if (strategy === 'full') {
|
|
853
|
+
// Full: all messages, truncated per maxLen
|
|
854
|
+
for (const msg of doneMessages) {
|
|
855
|
+
context += formatMessage(msg, config, maxLen);
|
|
856
|
+
}
|
|
857
|
+
} else if (strategy === 'window') {
|
|
858
|
+
// Window: only last N messages
|
|
859
|
+
const recent = doneMessages.slice(-windowSize);
|
|
860
|
+
if (doneMessages.length > windowSize) {
|
|
861
|
+
context += `[... ${doneMessages.length - windowSize} earlier messages omitted ...]\n\n`;
|
|
862
|
+
}
|
|
863
|
+
for (const msg of recent) {
|
|
864
|
+
context += formatMessage(msg, config, maxLen);
|
|
865
|
+
}
|
|
866
|
+
} else {
|
|
867
|
+
// Summary (default): older messages as one-line summaries, recent in full
|
|
868
|
+
const cutoff = doneMessages.length - windowSize;
|
|
869
|
+
if (cutoff > 0) {
|
|
870
|
+
context += `[Previous rounds summary]\n`;
|
|
871
|
+
for (let i = 0; i < cutoff; i++) {
|
|
872
|
+
const msg = doneMessages[i];
|
|
873
|
+
const summary = extractSummaryLine(msg.content);
|
|
874
|
+
context += ` R${msg.round} ${msg.agentName}: ${summary}\n`;
|
|
875
|
+
}
|
|
876
|
+
context += `\n`;
|
|
877
|
+
}
|
|
878
|
+
// Recent messages in full
|
|
879
|
+
const recent = doneMessages.slice(Math.max(0, cutoff));
|
|
880
|
+
for (const msg of recent) {
|
|
881
|
+
context += formatMessage(msg, config, maxLen);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
context += `--- Your Turn ---\nRespond based on the conversation above. If you believe the task is complete, include "DONE" in your response.`;
|
|
886
|
+
return context;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function formatMessage(msg: ConversationMessage, config: ConversationConfig, maxLen: number): string {
|
|
890
|
+
const agentDef = config.agents.find(a => a.id === msg.agentId);
|
|
891
|
+
const label = `${msg.agentName} (${agentDef?.id || '?'})`;
|
|
892
|
+
const content = msg.content.length > maxLen
|
|
893
|
+
? msg.content.slice(0, maxLen) + '\n[... truncated]'
|
|
894
|
+
: msg.content;
|
|
895
|
+
return `[${label} — Round ${msg.round}]:\n${content}\n\n`;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Extract a one-line summary from agent output (first meaningful line or first 120 chars) */
|
|
899
|
+
function extractSummaryLine(content: string): string {
|
|
900
|
+
const lines = content.split('\n').map(l => l.trim()).filter(l => l.length > 10);
|
|
901
|
+
const first = lines[0] || content.slice(0, 120);
|
|
902
|
+
return first.length > 120 ? first.slice(0, 117) + '...' : first;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function checkConversationStopCondition(conv: ConversationState, latestResponse: string): boolean {
|
|
906
|
+
const condition = conv.config.stopCondition;
|
|
907
|
+
if (!condition) return false;
|
|
908
|
+
|
|
909
|
+
const lower = condition.toLowerCase();
|
|
910
|
+
|
|
911
|
+
// "any agent says DONE"
|
|
912
|
+
if (lower.includes('any') && lower.includes('done')) {
|
|
913
|
+
return latestResponse.toUpperCase().includes('DONE');
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// "all agents say DONE" / "both agents say DONE"
|
|
917
|
+
if ((lower.includes('all') || lower.includes('both')) && lower.includes('done')) {
|
|
918
|
+
// Only check messages from the CURRENT round — don't mix rounds
|
|
919
|
+
const currentRound = conv.currentRound;
|
|
920
|
+
const agentIds = conv.config.agents.map(a => a.id);
|
|
921
|
+
const roundMessages = new Map<string, string>();
|
|
922
|
+
for (const msg of conv.messages) {
|
|
923
|
+
if (msg.status === 'done' && msg.round === currentRound && msg.agentId !== 'user') {
|
|
924
|
+
roundMessages.set(msg.agentId, msg.content);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// All agents in this round must have responded AND said DONE
|
|
928
|
+
return agentIds.every(id => {
|
|
929
|
+
const content = roundMessages.get(id);
|
|
930
|
+
return content && content.toUpperCase().includes('DONE');
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Default: check if latest response contains DONE
|
|
935
|
+
return latestResponse.toUpperCase().includes('DONE');
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Cleanly finish a conversation — cancel any still-running tasks, mark messages */
|
|
939
|
+
function finishConversation(pipeline: Pipeline, status: 'done' | 'failed') {
|
|
940
|
+
const conv = pipeline.conversation!;
|
|
941
|
+
for (const msg of conv.messages) {
|
|
942
|
+
if (msg.status === 'running' && msg.taskId) {
|
|
943
|
+
// Cancel the running task
|
|
944
|
+
try { const { cancelTask } = require('./task-manager'); cancelTask(msg.taskId); } catch {}
|
|
945
|
+
msg.status = status === 'done' ? 'done' : 'failed';
|
|
946
|
+
if (!msg.content) msg.content = status === 'done' ? '(conversation ended)' : '(conversation failed)';
|
|
947
|
+
}
|
|
948
|
+
if (msg.status === 'pending') {
|
|
949
|
+
msg.status = 'failed';
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
pipeline.status = status;
|
|
953
|
+
pipeline.completedAt = new Date().toISOString();
|
|
954
|
+
savePipeline(pipeline);
|
|
955
|
+
notifyPipelineComplete(pipeline);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Cancel a conversation pipeline */
|
|
959
|
+
export function cancelConversation(pipelineId: string): boolean {
|
|
960
|
+
const pipeline = getPipeline(pipelineId);
|
|
961
|
+
if (!pipeline || pipeline.status !== 'running' || !pipeline.conversation) return false;
|
|
962
|
+
|
|
963
|
+
// Cancel any running task
|
|
964
|
+
for (const msg of pipeline.conversation.messages) {
|
|
965
|
+
if (msg.status === 'running' && msg.taskId) {
|
|
966
|
+
const { cancelTask } = require('./task-manager');
|
|
967
|
+
cancelTask(msg.taskId);
|
|
968
|
+
}
|
|
969
|
+
if (msg.status === 'pending') msg.status = 'failed';
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
pipeline.status = 'cancelled';
|
|
973
|
+
pipeline.completedAt = new Date().toISOString();
|
|
974
|
+
savePipeline(pipeline);
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Inject a user message into a running conversation.
|
|
980
|
+
* Waits for current agent to finish, then sends the injected message
|
|
981
|
+
* as additional context to the specified agent on the next turn.
|
|
982
|
+
*/
|
|
983
|
+
export function injectConversationMessage(pipelineId: string, targetAgentId: string, message: string): boolean {
|
|
984
|
+
const pipeline = getPipeline(pipelineId);
|
|
985
|
+
if (!pipeline || pipeline.status !== 'running' || !pipeline.conversation) {
|
|
986
|
+
throw new Error('Pipeline not running or not a conversation');
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const conv = pipeline.conversation;
|
|
990
|
+
const agentDef = conv.config.agents.find(a => a.id === targetAgentId);
|
|
991
|
+
if (!agentDef) throw new Error(`Agent not found: ${targetAgentId}`);
|
|
992
|
+
|
|
993
|
+
// Add a "user" message to the conversation
|
|
994
|
+
conv.messages.push({
|
|
995
|
+
round: conv.currentRound,
|
|
996
|
+
agentId: 'user',
|
|
997
|
+
agentName: 'Operator',
|
|
998
|
+
content: `[@${targetAgentId}] ${message}`,
|
|
999
|
+
timestamp: new Date().toISOString(),
|
|
1000
|
+
status: 'done',
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
savePipeline(pipeline);
|
|
1004
|
+
|
|
1005
|
+
// If no agent is currently running, immediately schedule the target agent
|
|
1006
|
+
const hasRunning = conv.messages.some(m => m.status === 'running');
|
|
1007
|
+
if (!hasRunning) {
|
|
1008
|
+
// Point to the target agent for next turn
|
|
1009
|
+
const targetIdx = conv.config.agents.findIndex(a => a.id === targetAgentId);
|
|
1010
|
+
if (targetIdx >= 0) {
|
|
1011
|
+
conv.currentAgentIndex = targetIdx;
|
|
1012
|
+
savePipeline(pipeline);
|
|
1013
|
+
const context = buildConversationContext(conv);
|
|
1014
|
+
scheduleNextConversationTurn(pipeline, context);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
// If an agent IS running, the injected message will be included in the next context build
|
|
1018
|
+
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ─── Conversation Recovery ────────────────────────────────
|
|
1023
|
+
|
|
1024
|
+
function recoverConversationPipeline(pipeline: Pipeline) {
|
|
1025
|
+
const conv = pipeline.conversation!;
|
|
1026
|
+
const runningMsg = conv.messages.find(m => m.status === 'running');
|
|
1027
|
+
if (!runningMsg || !runningMsg.taskId) return;
|
|
1028
|
+
|
|
1029
|
+
const task = getTask(runningMsg.taskId);
|
|
1030
|
+
if (!task) {
|
|
1031
|
+
// Task gone — mark as done with empty content, try next turn
|
|
1032
|
+
runningMsg.status = 'done';
|
|
1033
|
+
runningMsg.content = '(no response — task was cleaned up)';
|
|
1034
|
+
savePipeline(pipeline);
|
|
1035
|
+
advanceConversation(pipeline);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (task.status === 'done') {
|
|
1039
|
+
runningMsg.status = 'done';
|
|
1040
|
+
runningMsg.content = task.resultSummary || '';
|
|
1041
|
+
savePipeline(pipeline);
|
|
1042
|
+
advanceConversation(pipeline);
|
|
1043
|
+
} else if (task.status === 'failed' || task.status === 'cancelled') {
|
|
1044
|
+
runningMsg.status = 'failed';
|
|
1045
|
+
runningMsg.content = task.error || 'Task failed';
|
|
1046
|
+
pipeline.status = 'failed';
|
|
1047
|
+
pipeline.completedAt = new Date().toISOString();
|
|
1048
|
+
savePipeline(pipeline);
|
|
1049
|
+
} else {
|
|
1050
|
+
// Still running — re-attach listener
|
|
1051
|
+
setupConversationTaskListener(pipeline.id, runningMsg.taskId);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function advanceConversation(pipeline: Pipeline) {
|
|
1056
|
+
const conv = pipeline.conversation!;
|
|
1057
|
+
const config = conv.config;
|
|
1058
|
+
const lastDoneMsg = [...conv.messages].reverse().find(m => m.status === 'done');
|
|
1059
|
+
|
|
1060
|
+
if (lastDoneMsg && checkConversationStopCondition(conv, lastDoneMsg.content)) {
|
|
1061
|
+
finishConversation(pipeline, 'done');
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
conv.currentAgentIndex++;
|
|
1066
|
+
if (conv.currentAgentIndex >= config.agents.length) {
|
|
1067
|
+
conv.currentAgentIndex = 0;
|
|
1068
|
+
conv.currentRound++;
|
|
1069
|
+
if (conv.currentRound > config.maxRounds) {
|
|
1070
|
+
finishConversation(pipeline, 'done');
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
savePipeline(pipeline);
|
|
1076
|
+
const contextForNext = buildConversationContext(conv);
|
|
1077
|
+
scheduleNextConversationTurn(pipeline, contextForNext);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// ─── Recovery: check for stuck pipelines ──────────────────
|
|
1081
|
+
|
|
1082
|
+
function recoverStuckPipelines() {
|
|
1083
|
+
const pipelines = listPipelines().filter(p => p.status === 'running');
|
|
1084
|
+
for (const pipeline of pipelines) {
|
|
1085
|
+
// Conversation mode recovery
|
|
1086
|
+
if (pipeline.type === 'conversation' && pipeline.conversation) {
|
|
1087
|
+
recoverConversationPipeline(pipeline);
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const workflow = getWorkflow(pipeline.workflowName);
|
|
1092
|
+
if (!workflow) continue;
|
|
1093
|
+
|
|
1094
|
+
let changed = false;
|
|
1095
|
+
for (const [nodeId, node] of Object.entries(pipeline.nodes)) {
|
|
1096
|
+
if (node.status === 'running' && node.taskId) {
|
|
1097
|
+
const task = getTask(node.taskId);
|
|
1098
|
+
if (!task) {
|
|
1099
|
+
// Task gone — mark node as done (task completed and was cleaned up)
|
|
1100
|
+
node.status = 'done';
|
|
1101
|
+
node.completedAt = new Date().toISOString();
|
|
1102
|
+
changed = true;
|
|
1103
|
+
} else if (task.status === 'done') {
|
|
1104
|
+
// Extract outputs
|
|
1105
|
+
const nodeDef = workflow.nodes[nodeId];
|
|
1106
|
+
if (nodeDef) {
|
|
1107
|
+
for (const outputDef of nodeDef.outputs) {
|
|
1108
|
+
if (outputDef.extract === 'result' || outputDef.extract === 'stdout') node.outputs[outputDef.name] = task.resultSummary || '';
|
|
1109
|
+
else if (outputDef.extract === 'git_diff') node.outputs[outputDef.name] = task.gitDiff || '';
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
node.status = 'done';
|
|
1113
|
+
node.completedAt = new Date().toISOString();
|
|
1114
|
+
changed = true;
|
|
1115
|
+
} else if (task.status === 'failed' || task.status === 'cancelled') {
|
|
1116
|
+
node.status = 'failed';
|
|
1117
|
+
node.error = task.error || 'Task failed';
|
|
1118
|
+
node.completedAt = new Date().toISOString();
|
|
1119
|
+
changed = true;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (changed) {
|
|
1125
|
+
savePipeline(pipeline);
|
|
1126
|
+
// Re-setup listener and schedule next nodes
|
|
1127
|
+
setupTaskListener(pipeline.id);
|
|
1128
|
+
scheduleReadyNodes(pipeline, workflow);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Run recovery every 30 seconds
|
|
1134
|
+
setInterval(recoverStuckPipelines, 30_000);
|
|
1135
|
+
// Also run once on load
|
|
1136
|
+
setTimeout(recoverStuckPipelines, 5000);
|
|
1137
|
+
|
|
1138
|
+
export function cancelPipeline(id: string): boolean {
|
|
1139
|
+
const pipeline = getPipeline(id);
|
|
1140
|
+
if (!pipeline || pipeline.status !== 'running') return false;
|
|
1141
|
+
|
|
1142
|
+
// Conversation mode
|
|
1143
|
+
if (pipeline.type === 'conversation') {
|
|
1144
|
+
return cancelConversation(id);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
pipeline.status = 'cancelled';
|
|
1148
|
+
pipeline.completedAt = new Date().toISOString();
|
|
1149
|
+
|
|
1150
|
+
// Cancel all running tasks
|
|
1151
|
+
for (const [, node] of Object.entries(pipeline.nodes)) {
|
|
1152
|
+
if (node.status === 'running' && node.taskId) {
|
|
1153
|
+
const { cancelTask } = require('./task-manager');
|
|
1154
|
+
cancelTask(node.taskId);
|
|
1155
|
+
}
|
|
1156
|
+
if (node.status === 'pending') node.status = 'skipped';
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
savePipeline(pipeline);
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ─── Node Scheduling ──────────────────────────────────────
|
|
1164
|
+
|
|
1165
|
+
async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
1166
|
+
const ctx = { input: pipeline.input, vars: pipeline.vars, nodes: pipeline.nodes };
|
|
1167
|
+
|
|
1168
|
+
for (const nodeId of pipeline.nodeOrder) {
|
|
1169
|
+
const nodeState = pipeline.nodes[nodeId];
|
|
1170
|
+
if (nodeState.status !== 'pending') continue;
|
|
1171
|
+
|
|
1172
|
+
const nodeDef = workflow.nodes[nodeId];
|
|
1173
|
+
if (!nodeDef) continue;
|
|
1174
|
+
|
|
1175
|
+
// Check all dependencies are done
|
|
1176
|
+
const depsReady = nodeDef.dependsOn.every(dep => {
|
|
1177
|
+
const depState = pipeline.nodes[dep];
|
|
1178
|
+
return depState && depState.status === 'done';
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// Check if any dependency failed (skip this node)
|
|
1182
|
+
const depsFailed = nodeDef.dependsOn.some(dep => {
|
|
1183
|
+
const depState = pipeline.nodes[dep];
|
|
1184
|
+
return depState && (depState.status === 'failed' || depState.status === 'skipped');
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
if (depsFailed) {
|
|
1188
|
+
nodeState.status = 'skipped';
|
|
1189
|
+
savePipeline(pipeline);
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (!depsReady) continue;
|
|
1194
|
+
|
|
1195
|
+
// Resolve templates
|
|
1196
|
+
const isShell = nodeDef.mode === 'shell';
|
|
1197
|
+
const project = resolveTemplate(nodeDef.project, ctx);
|
|
1198
|
+
const prompt = resolveTemplate(nodeDef.prompt, ctx, isShell);
|
|
1199
|
+
|
|
1200
|
+
const projectInfo = getProjectInfo(project);
|
|
1201
|
+
if (!projectInfo) {
|
|
1202
|
+
nodeState.status = 'failed';
|
|
1203
|
+
nodeState.error = `Project not found: ${project}`;
|
|
1204
|
+
savePipeline(pipeline);
|
|
1205
|
+
notifyStep(pipeline, nodeId, 'failed', nodeState.error);
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// All pipeline steps use worktree for isolated execution.
|
|
1210
|
+
// Shell steps receive env vars: FORGE_WORKTREE, FORGE_WORKTREE_BRANCH, FORGE_PROJECT_ROOT.
|
|
1211
|
+
// Set worktree: false on a node to skip (e.g. read-only gh commands that don't need isolation).
|
|
1212
|
+
let effectivePath = projectInfo.path;
|
|
1213
|
+
const useWorktree = nodeDef.worktree !== false;
|
|
1214
|
+
const branchName = nodeDef.branch ? resolveTemplate(nodeDef.branch, ctx) : `pipeline/${pipeline.id.slice(0, 8)}`;
|
|
1215
|
+
if (useWorktree) try {
|
|
1216
|
+
const { execSync } = require('node:child_process');
|
|
1217
|
+
const worktreePath = `${projectInfo.path}/.forge/worktrees/${branchName.replace(/\//g, '-')}`;
|
|
1218
|
+
const { mkdirSync } = require('node:fs');
|
|
1219
|
+
mkdirSync(`${projectInfo.path}/.forge/worktrees`, { recursive: true });
|
|
1220
|
+
|
|
1221
|
+
// Create branch if needed
|
|
1222
|
+
try { execSync(`git branch ${branchName}`, { cwd: projectInfo.path, stdio: 'pipe' }); } catch {}
|
|
1223
|
+
|
|
1224
|
+
// Create or reuse worktree
|
|
1225
|
+
try {
|
|
1226
|
+
execSync(`git worktree add "${worktreePath}" ${branchName}`, { cwd: projectInfo.path, stdio: 'pipe' });
|
|
1227
|
+
console.log(`[pipeline] Created worktree: ${worktreePath} (branch: ${branchName})`);
|
|
1228
|
+
} catch {
|
|
1229
|
+
const { existsSync } = require('node:fs');
|
|
1230
|
+
if (existsSync(worktreePath)) {
|
|
1231
|
+
console.log(`[pipeline] Reusing worktree: ${worktreePath}`);
|
|
1232
|
+
} else {
|
|
1233
|
+
try { execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectInfo.path, stdio: 'pipe' }); } catch {}
|
|
1234
|
+
execSync(`git worktree add "${worktreePath}" ${branchName}`, { cwd: projectInfo.path, stdio: 'pipe' });
|
|
1235
|
+
console.log(`[pipeline] Recreated worktree: ${worktreePath}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
effectivePath = worktreePath;
|
|
1240
|
+
(nodeState as any).worktreePath = worktreePath;
|
|
1241
|
+
(nodeState as any).worktreeBranch = branchName;
|
|
1242
|
+
} catch (e: any) {
|
|
1243
|
+
console.warn(`[pipeline] Worktree creation failed, falling back to project dir: ${e.message}`);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// ── Plugin mode: execute plugin action directly ──
|
|
1247
|
+
if (nodeDef.mode === 'plugin' && nodeDef.plugin) {
|
|
1248
|
+
nodeState.status = 'running';
|
|
1249
|
+
nodeState.startedAt = new Date().toISOString();
|
|
1250
|
+
savePipeline(pipeline);
|
|
1251
|
+
notifyStep(pipeline, nodeId, 'running');
|
|
1252
|
+
|
|
1253
|
+
try {
|
|
1254
|
+
const { getInstalledPlugin } = await import('./plugins/registry');
|
|
1255
|
+
const { executePluginWithWait } = await import('./plugins/executor');
|
|
1256
|
+
|
|
1257
|
+
const inst = getInstalledPlugin(nodeDef.plugin);
|
|
1258
|
+
if (!inst) throw new Error(`Plugin "${nodeDef.plugin}" not installed`);
|
|
1259
|
+
if (!inst.enabled) throw new Error(`Plugin "${nodeDef.plugin}" is disabled`);
|
|
1260
|
+
|
|
1261
|
+
// Resolve template params
|
|
1262
|
+
const resolvedParams: Record<string, any> = {};
|
|
1263
|
+
for (const [k, v] of Object.entries(nodeDef.pluginParams || {})) {
|
|
1264
|
+
resolvedParams[k] = typeof v === 'string' ? resolveTemplate(v, ctx) : v;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const actionName = nodeDef.pluginAction || inst.definition.defaultAction || Object.keys(inst.definition.actions)[0];
|
|
1268
|
+
const result = await executePluginWithWait(inst, actionName, resolvedParams, nodeDef.pluginWait);
|
|
1269
|
+
|
|
1270
|
+
if (result.ok) {
|
|
1271
|
+
nodeState.status = 'done';
|
|
1272
|
+
nodeState.completedAt = new Date().toISOString();
|
|
1273
|
+
// Store plugin outputs
|
|
1274
|
+
for (const [name, value] of Object.entries(result.output)) {
|
|
1275
|
+
nodeState.outputs[name] = typeof value === 'string' ? value : JSON.stringify(value);
|
|
1276
|
+
}
|
|
1277
|
+
savePipeline(pipeline);
|
|
1278
|
+
notifyStep(pipeline, nodeId, 'done');
|
|
1279
|
+
console.log(`[pipeline] Plugin ${nodeDef.plugin}.${actionName}: done (${result.duration}ms)`);
|
|
1280
|
+
} else {
|
|
1281
|
+
throw new Error(result.error || 'Plugin action failed');
|
|
1282
|
+
}
|
|
1283
|
+
} catch (err: any) {
|
|
1284
|
+
nodeState.status = 'failed';
|
|
1285
|
+
nodeState.error = err.message;
|
|
1286
|
+
nodeState.completedAt = new Date().toISOString();
|
|
1287
|
+
savePipeline(pipeline);
|
|
1288
|
+
notifyStep(pipeline, nodeId, 'failed', err.message);
|
|
1289
|
+
console.error(`[pipeline] Plugin ${nodeDef.plugin}: failed — ${err.message}`);
|
|
1290
|
+
}
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// ── Shell/Agent mode: create task ──
|
|
1295
|
+
// Inject worktree info into shell prompt so git commands work correctly
|
|
1296
|
+
const taskMode = nodeDef.mode === 'shell' ? 'shell' : 'prompt';
|
|
1297
|
+
let effectivePrompt = prompt;
|
|
1298
|
+
if (taskMode === 'shell' && effectivePath !== projectInfo.path) {
|
|
1299
|
+
// Prepend env vars so shell scripts know they're in a worktree
|
|
1300
|
+
const envPrefix = `export FORGE_WORKTREE="${effectivePath}" FORGE_WORKTREE_BRANCH="${(nodeState as any).worktreeBranch || branchName}" FORGE_PROJECT_ROOT="${projectInfo.path}" && `;
|
|
1301
|
+
effectivePrompt = envPrefix + prompt;
|
|
1302
|
+
}
|
|
1303
|
+
const task = createTask({
|
|
1304
|
+
projectName: projectInfo.name,
|
|
1305
|
+
projectPath: effectivePath,
|
|
1306
|
+
prompt: effectivePrompt,
|
|
1307
|
+
mode: taskMode as any,
|
|
1308
|
+
agent: nodeDef.agent || undefined,
|
|
1309
|
+
});
|
|
1310
|
+
pipelineTaskIds.add(task.id);
|
|
1311
|
+
// Pipeline tasks use the same model selection as normal tasks:
|
|
1312
|
+
// agent model > global taskModel. No separate pipelineModel override.
|
|
1313
|
+
|
|
1314
|
+
nodeState.status = 'running';
|
|
1315
|
+
nodeState.taskId = task.id;
|
|
1316
|
+
nodeState.iterations++;
|
|
1317
|
+
nodeState.startedAt = new Date().toISOString();
|
|
1318
|
+
savePipeline(pipeline);
|
|
1319
|
+
|
|
1320
|
+
notifyStep(pipeline, nodeId, 'running');
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Check if pipeline is complete
|
|
1324
|
+
checkPipelineCompletion(pipeline);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function checkPipelineCompletion(pipeline: Pipeline) {
|
|
1328
|
+
const states = Object.values(pipeline.nodes);
|
|
1329
|
+
const allDone = states.every(s => s.status === 'done' || s.status === 'skipped' || s.status === 'failed');
|
|
1330
|
+
|
|
1331
|
+
if (allDone && pipeline.status === 'running') {
|
|
1332
|
+
const anyFailed = states.some(s => s.status === 'failed');
|
|
1333
|
+
pipeline.status = anyFailed ? 'failed' : 'done';
|
|
1334
|
+
pipeline.completedAt = new Date().toISOString();
|
|
1335
|
+
savePipeline(pipeline);
|
|
1336
|
+
notifyPipelineComplete(pipeline);
|
|
1337
|
+
|
|
1338
|
+
// Sync run status to project pipeline runs
|
|
1339
|
+
try {
|
|
1340
|
+
const { syncRunStatus } = require('./pipeline-scheduler');
|
|
1341
|
+
syncRunStatus(pipeline.id);
|
|
1342
|
+
} catch {}
|
|
1343
|
+
|
|
1344
|
+
// Log worktree info for user review
|
|
1345
|
+
for (const [nodeId, state] of Object.entries(pipeline.nodes)) {
|
|
1346
|
+
const wt = (state as any).worktreePath;
|
|
1347
|
+
const branch = (state as any).worktreeBranch;
|
|
1348
|
+
if (wt && branch) {
|
|
1349
|
+
console.log(`[pipeline] Worktree preserved: ${wt} (branch: ${branch}) — review changes, then: git worktree remove "${wt}"`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Release project lock
|
|
1354
|
+
const workflow = getWorkflow(pipeline.workflowName);
|
|
1355
|
+
if (workflow) {
|
|
1356
|
+
const projectNames = new Set(Object.values(workflow.nodes).map(n => n.project));
|
|
1357
|
+
for (const pName of projectNames) {
|
|
1358
|
+
const pInfo = getProjectInfo(resolveTemplate(pName, { input: pipeline.input, vars: pipeline.vars, nodes: pipeline.nodes }));
|
|
1359
|
+
if (pInfo) releaseProjectLock(pInfo.path, pipeline.id);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// ─── Task Event Listener ──────────────────────────────────
|
|
1366
|
+
|
|
1367
|
+
const activeListeners = new Set<string>();
|
|
1368
|
+
|
|
1369
|
+
function setupTaskListener(pipelineId: string) {
|
|
1370
|
+
if (activeListeners.has(pipelineId)) return;
|
|
1371
|
+
activeListeners.add(pipelineId);
|
|
1372
|
+
|
|
1373
|
+
const cleanup = onTaskEvent((taskId, event, data) => {
|
|
1374
|
+
if (event !== 'status') return;
|
|
1375
|
+
if (data !== 'done' && data !== 'failed') return;
|
|
1376
|
+
|
|
1377
|
+
const pipeline = getPipeline(pipelineId);
|
|
1378
|
+
if (!pipeline || pipeline.status !== 'running') {
|
|
1379
|
+
cleanup();
|
|
1380
|
+
activeListeners.delete(pipelineId);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Find the node for this task
|
|
1385
|
+
const nodeEntry = Object.entries(pipeline.nodes).find(([, n]) => n.taskId === taskId);
|
|
1386
|
+
if (!nodeEntry) return;
|
|
1387
|
+
|
|
1388
|
+
const [nodeId, nodeState] = nodeEntry;
|
|
1389
|
+
const workflow = getWorkflow(pipeline.workflowName);
|
|
1390
|
+
if (!workflow) return;
|
|
1391
|
+
|
|
1392
|
+
const nodeDef = workflow.nodes[nodeId];
|
|
1393
|
+
const task = getTask(taskId);
|
|
1394
|
+
|
|
1395
|
+
if (data === 'done' && task) {
|
|
1396
|
+
// Extract outputs
|
|
1397
|
+
for (const outputDef of nodeDef.outputs) {
|
|
1398
|
+
if (outputDef.extract === 'result') {
|
|
1399
|
+
nodeState.outputs[outputDef.name] = task.resultSummary || '';
|
|
1400
|
+
} else if (outputDef.extract === 'stdout') {
|
|
1401
|
+
nodeState.outputs[outputDef.name] = task.resultSummary || '';
|
|
1402
|
+
} else if (outputDef.extract === 'git_diff') {
|
|
1403
|
+
nodeState.outputs[outputDef.name] = task.gitDiff || '';
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Convention: if stdout contains __SKIP__, mark node as skipped (downstream nodes will also skip)
|
|
1408
|
+
const outputStr = task.resultSummary || '';
|
|
1409
|
+
if (outputStr.includes('__SKIP__')) {
|
|
1410
|
+
nodeState.status = 'skipped';
|
|
1411
|
+
nodeState.completedAt = new Date().toISOString();
|
|
1412
|
+
savePipeline(pipeline);
|
|
1413
|
+
scheduleReadyNodes(pipeline, workflow);
|
|
1414
|
+
checkPipelineCompletion(pipeline);
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Check routes for conditional next step
|
|
1419
|
+
if (nodeDef.routes.length > 0) {
|
|
1420
|
+
const nextNode = evaluateRoutes(nodeDef.routes, nodeState.outputs, pipeline);
|
|
1421
|
+
if (nextNode && nextNode !== nodeId) {
|
|
1422
|
+
// Route to next node — mark this as done
|
|
1423
|
+
nodeState.status = 'done';
|
|
1424
|
+
nodeState.completedAt = new Date().toISOString();
|
|
1425
|
+
// Reset next node to pending so it gets scheduled
|
|
1426
|
+
if (pipeline.nodes[nextNode] && pipeline.nodes[nextNode].status !== 'done') {
|
|
1427
|
+
pipeline.nodes[nextNode].status = 'pending';
|
|
1428
|
+
}
|
|
1429
|
+
} else if (nextNode === nodeId) {
|
|
1430
|
+
// Loop back — check iteration limit
|
|
1431
|
+
if (nodeState.iterations < nodeDef.maxIterations) {
|
|
1432
|
+
nodeState.status = 'pending';
|
|
1433
|
+
nodeState.taskId = undefined;
|
|
1434
|
+
} else {
|
|
1435
|
+
nodeState.status = 'done';
|
|
1436
|
+
nodeState.completedAt = new Date().toISOString();
|
|
1437
|
+
}
|
|
1438
|
+
} else {
|
|
1439
|
+
nodeState.status = 'done';
|
|
1440
|
+
nodeState.completedAt = new Date().toISOString();
|
|
1441
|
+
}
|
|
1442
|
+
} else {
|
|
1443
|
+
nodeState.status = 'done';
|
|
1444
|
+
nodeState.completedAt = new Date().toISOString();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
savePipeline(pipeline);
|
|
1448
|
+
// No per-step done notification — only notify on start and failure
|
|
1449
|
+
} else if (data === 'failed') {
|
|
1450
|
+
nodeState.status = 'failed';
|
|
1451
|
+
nodeState.error = task?.error || 'Task failed';
|
|
1452
|
+
nodeState.completedAt = new Date().toISOString();
|
|
1453
|
+
savePipeline(pipeline);
|
|
1454
|
+
notifyStep(pipeline, nodeId, 'failed', nodeState.error);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Schedule next ready nodes
|
|
1458
|
+
scheduleReadyNodes(pipeline, workflow);
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function evaluateRoutes(
|
|
1463
|
+
routes: { condition: string; next: string }[],
|
|
1464
|
+
outputs: Record<string, string>,
|
|
1465
|
+
pipeline: Pipeline
|
|
1466
|
+
): string | null {
|
|
1467
|
+
for (const route of routes) {
|
|
1468
|
+
if (route.condition === 'default') return route.next;
|
|
1469
|
+
|
|
1470
|
+
// Simple "contains" check: {{outputs.xxx contains 'YYY'}}
|
|
1471
|
+
const containsMatch = route.condition.match(/\{\{outputs\.(\w+)\s+contains\s+'([^']+)'\}\}/);
|
|
1472
|
+
if (containsMatch) {
|
|
1473
|
+
const [, outputName, keyword] = containsMatch;
|
|
1474
|
+
if (outputs[outputName]?.includes(keyword)) return route.next;
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// Default: treat as truthy check
|
|
1479
|
+
return route.next;
|
|
1480
|
+
}
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// ─── Topological Sort ─────────────────────────────────────
|
|
1485
|
+
|
|
1486
|
+
function topologicalSort(nodes: Record<string, WorkflowNode>): string[] {
|
|
1487
|
+
const sorted: string[] = [];
|
|
1488
|
+
const visited = new Set<string>();
|
|
1489
|
+
const visiting = new Set<string>();
|
|
1490
|
+
|
|
1491
|
+
function visit(id: string) {
|
|
1492
|
+
if (visited.has(id)) return;
|
|
1493
|
+
if (visiting.has(id)) return; // cycle — skip
|
|
1494
|
+
visiting.add(id);
|
|
1495
|
+
|
|
1496
|
+
const node = nodes[id];
|
|
1497
|
+
if (node) {
|
|
1498
|
+
for (const dep of node.dependsOn) {
|
|
1499
|
+
visit(dep);
|
|
1500
|
+
}
|
|
1501
|
+
// Also add route targets
|
|
1502
|
+
for (const route of node.routes) {
|
|
1503
|
+
if (nodes[route.next] && !node.dependsOn.includes(route.next)) {
|
|
1504
|
+
// Don't visit route targets in topo sort to avoid cycles
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
visiting.delete(id);
|
|
1510
|
+
visited.add(id);
|
|
1511
|
+
sorted.push(id);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
for (const id of Object.keys(nodes)) {
|
|
1515
|
+
visit(id);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
return sorted;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// ─── Notifications ────────────────────────────────────────
|
|
1522
|
+
|
|
1523
|
+
async function notifyStep(pipeline: Pipeline, nodeId: string, status: string, error?: string) {
|
|
1524
|
+
const settings = loadSettings();
|
|
1525
|
+
if (!settings.telegramBotToken || !settings.telegramChatId) return;
|
|
1526
|
+
|
|
1527
|
+
const icon = status === 'done' ? '✅' : status === 'failed' ? '❌' : status === 'running' ? '🔄' : '⏳';
|
|
1528
|
+
const msg = `${icon} Pipeline ${pipeline.id}/${nodeId}: ${status}${error ? `\n${error}` : ''}`;
|
|
1529
|
+
|
|
1530
|
+
try {
|
|
1531
|
+
await fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`, {
|
|
1532
|
+
method: 'POST',
|
|
1533
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1534
|
+
body: JSON.stringify({
|
|
1535
|
+
chat_id: settings.telegramChatId.split(',')[0].trim(),
|
|
1536
|
+
text: msg,
|
|
1537
|
+
disable_web_page_preview: true,
|
|
1538
|
+
}),
|
|
1539
|
+
});
|
|
1540
|
+
} catch {}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
async function notifyPipelineComplete(pipeline: Pipeline) {
|
|
1544
|
+
const settings = loadSettings();
|
|
1545
|
+
if (!settings.telegramBotToken || !settings.telegramChatId) return;
|
|
1546
|
+
|
|
1547
|
+
const icon = pipeline.status === 'done' ? '🎉' : '💥';
|
|
1548
|
+
const nodes = Object.entries(pipeline.nodes)
|
|
1549
|
+
.map(([id, n]) => ` ${n.status === 'done' ? '✅' : n.status === 'failed' ? '❌' : '⏭'} ${id}`)
|
|
1550
|
+
.join('\n');
|
|
1551
|
+
|
|
1552
|
+
const msg = `${icon} Pipeline ${pipeline.id} (${pipeline.workflowName}) ${pipeline.status}\n\n${nodes}`;
|
|
1553
|
+
|
|
1554
|
+
try {
|
|
1555
|
+
await fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`, {
|
|
1556
|
+
method: 'POST',
|
|
1557
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1558
|
+
body: JSON.stringify({
|
|
1559
|
+
chat_id: settings.telegramChatId.split(',')[0].trim(),
|
|
1560
|
+
text: msg,
|
|
1561
|
+
disable_web_page_preview: true,
|
|
1562
|
+
}),
|
|
1563
|
+
});
|
|
1564
|
+
} catch {}
|
|
1565
|
+
}
|