Compare commits
11 Commits
v2.8.5-aud
...
v2.8.16-om
| Author | SHA1 | Date | |
|---|---|---|---|
| e0feb53437 | |||
| 3c5b2c2bcf | |||
| 524a0deaa1 | |||
| a7a40c5b46 | |||
| e5183cc71b | |||
| 9abc14ef82 | |||
| 7ef479639a | |||
| 89a6ffe8a0 | |||
| a8e475fdf4 | |||
| 02063072ab | |||
| ec48066a80 |
12
.ascli.json
Normal file
12
.ascli.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"binding": {
|
||||
"apiBaseUrl": "https://agentspace.so",
|
||||
"claimToken": "5Jr5_HEFEH_4Mc-7_dzUTEhYUWKFC-uOi58RrqMQ7RTGTA01",
|
||||
"claimUrl": "https://agentspace.so/claim?workspaceId=ws_iTSoXqyy7Mcf&token=5Jr5_HEFEH_4Mc-7_dzUTEhYUWKFC-uOi58RrqMQ7RTGTA01",
|
||||
"clientId": "ascli",
|
||||
"createdAt": "2026-06-07T17:39:16.001Z",
|
||||
"workspaceId": "ws_iTSoXqyy7Mcf",
|
||||
"workspaceName": "fork-lifts-phases-3-11"
|
||||
}
|
||||
}
|
||||
1439
.codesight/CODESIGHT.md
Normal file
1439
.codesight/CODESIGHT.md
Normal file
File diff suppressed because it is too large
Load Diff
71
.codesight/components.md
Normal file
71
.codesight/components.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Components
|
||||
|
||||
- **App** — `apps/web/src/App.tsx`
|
||||
- **AddProjectModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/AddProjectModal.tsx`
|
||||
- **AgentComposerBar** — props: projectPath, value, onChange, onProviderCommandsChange, connected, agentStatus — `apps/web/src/components/AgentComposerBar.tsx`
|
||||
- **AgentPicker** — props: projectId, value, onChange — `apps/web/src/components/AgentPicker.tsx`
|
||||
- **ArenaLauncherDialog** — `apps/web/src/components/ArenaLauncherDialog.tsx`
|
||||
- **ArtifactPaneHeader** — props: title, defaultTitle, onDownload, downloadDisabled, onClose, onCopy, justCopied, copyDisabled — `apps/web/src/components/ArtifactPaneHeader.tsx`
|
||||
- **AskUserInputCard** — props: toolCall, toolResult, chatId, apiPrefix — `apps/web/src/components/AskUserInputCard.tsx`
|
||||
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
|
||||
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
|
||||
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.tsx`
|
||||
- **CapHitSentinel** — props: message, capHitPosition, isLatest — `apps/web/src/components/CapHitSentinel.tsx`
|
||||
- **ChatInput** — props: disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop — `apps/web/src/components/ChatInput.tsx`
|
||||
- **ChatTabBar** — props: pane, tabs, tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, onCloseToRight, onCloseAll, onNewTab, onSplitPane — `apps/web/src/components/ChatTabBar.tsx`
|
||||
- **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
|
||||
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.tsx`
|
||||
- **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
|
||||
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.tsx`
|
||||
- **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
|
||||
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.tsx`
|
||||
- **FileMentionPopover** — props: query, files, anchorRect, onSelect, onClose — `apps/web/src/components/FileMentionPopover.tsx`
|
||||
- **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
|
||||
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.tsx`
|
||||
- **GitDiffView** — props: result, loading, error, mode, onSelectMode, onRefresh, mutating, mutateError, onStage, onUnstage — `apps/web/src/components/GitDiffView.tsx`
|
||||
- **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
|
||||
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.tsx`
|
||||
- **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
|
||||
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.tsx`
|
||||
- **MessageBubble** — props: message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled — `apps/web/src/components/MessageBubble.tsx`
|
||||
- **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
|
||||
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.tsx`
|
||||
- **ModelPicker** — props: value, onChange — `apps/web/src/components/ModelPicker.tsx`
|
||||
- **NewPaneMenu** — props: onAddPane, disabled, projectId — `apps/web/src/components/NewPaneMenu.tsx`
|
||||
- **PaneHeaderActions** — props: onNewTab, onSplitPane, onNewOrchestrator, onNewArena, onReopenPane, onShowHistory, onRemovePane, historyActive, className — `apps/web/src/components/PaneHeaderActions.tsx`
|
||||
- **PermissionCard** — props: prompt, onRespond, busy — `apps/web/src/components/PermissionCard.tsx`
|
||||
- **ProjectSidebar** — `apps/web/src/components/ProjectSidebar.tsx`
|
||||
- **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
|
||||
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.tsx`
|
||||
- **SessionLandingPage** — props: projectId, sessionId, agentId, onAgentChange, onSend, onSkillInvoke, createChat, chats, onOpenChat, onUnarchiveChat — `apps/web/src/components/SessionLandingPage.tsx`
|
||||
- **SlashCommandPicker** — props: query, items, groups, inputRef, onSelect, onClose, emptyLabel — `apps/web/src/components/SlashCommandPicker.tsx`
|
||||
- **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
|
||||
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
|
||||
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
|
||||
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.tsx`
|
||||
- **ToolCallLine** — props: run, insideGroup — `apps/web/src/components/ToolCallLine.tsx`
|
||||
- **Workspace** — props: sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, onAddPane — `apps/web/src/components/Workspace.tsx`
|
||||
- **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
|
||||
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
|
||||
- **MatrixRain** — props: enabled, density, speed, opacity — `apps/web/src/components/fx/MatrixRain.tsx`
|
||||
- **NeonField** — props: enabled, opacity, speed — `apps/web/src/components/fx/NeonField.tsx`
|
||||
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
|
||||
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
||||
- **OpenCodeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
||||
- **ArenaPane** — props: state, onClose — `apps/web/src/components/panes/ArenaPane.tsx`
|
||||
- **ChatPane** — props: sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled — `apps/web/src/components/panes/ChatPane.tsx`
|
||||
- **CoderMessageList** — props: messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled — `apps/web/src/components/panes/CoderMessageList.tsx`
|
||||
- **CoderPane** — props: sessionId, paneId, chatId, chatPending, projectPath, onConnectedChange, onAgentLabelChange — `apps/web/src/components/panes/CoderPane.tsx`
|
||||
- **OrchestratorPane** — props: state, onClose — `apps/web/src/components/panes/OrchestratorPane.tsx`
|
||||
- **SettingsPane** — props: session, project, maximized, onToggleMaximize, onClose, isMobile — `apps/web/src/components/panes/SettingsPane.tsx`
|
||||
- **TerminalPane** — props: sessionId, paneId, label, active — `apps/web/src/components/panes/TerminalPane.tsx`
|
||||
- **FloatingMenu** — props: x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss — `apps/web/src/components/panes/terminal/FloatingMenu.tsx`
|
||||
- **SearchBar** — props: searchRef, theme, onClose — `apps/web/src/components/panes/terminal/SearchBar.tsx`
|
||||
- **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
|
||||
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
|
||||
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
|
||||
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
|
||||
- **Home** — `apps/web/src/pages/Home.tsx`
|
||||
- **Project** — `apps/web/src/pages/Project.tsx`
|
||||
- **Session** — `apps/web/src/pages/Session.tsx`
|
||||
- **Settings** — `apps/web/src/pages/Settings.tsx`
|
||||
50
.codesight/config.md
Normal file
50
.codesight/config.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Config
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `AUDIT_DOT_DIR` **required** — apps/server/src/services/audit/runs-dir.ts
|
||||
- `BOOCODE_DATA_DIR` **required** — apps/server/src/routes/inference-settings.ts
|
||||
- `BOOCODE_TOOLS` **required** — apps/server/src/services/agents.ts
|
||||
- `BOOCODE_TRUNCATION_DIR` **required** — apps/server/src/services/__tests__/truncate.test.ts
|
||||
- `BOOCODER_DEV_URL` **required** — apps/web/vite.config.ts
|
||||
- `BOOCODER_URL` **required** — apps/coder/src/cli.ts
|
||||
- `BOOTERM_DEV_URL` **required** — apps/web/vite.config.ts
|
||||
- `BOOTERM_SSH_HOST` **required** — apps/booterm/src/pty/manager.ts
|
||||
- `BOOTERM_SSH_USER` **required** — apps/booterm/src/pty/manager.ts
|
||||
- `BOOTSTRAP_ROOT` (has default) — .env.example
|
||||
- `BRAINSTORM_DIR` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||
- `BRAINSTORM_HOST` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||
- `BRAINSTORM_OWNER_PID` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||
- `BRAINSTORM_PORT` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||
- `BRAINSTORM_URL_HOST` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||
- `CODECONTEXT_CHILD` **required** — codecontext/shim.go
|
||||
- `CODECONTEXT_URL` **required** — apps/server/src/services/codecontext_client.ts
|
||||
- `CONDUCTOR_MODEL` **required** — conductor/src/dispatch.ts
|
||||
- `CONDUCTOR_OPENCODE_BIN` **required** — conductor/src/dispatch.ts
|
||||
- `CONDUCTOR_TIMEOUT_MS` **required** — conductor/src/dispatch.ts
|
||||
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
|
||||
- `CONTEXT7_API_KEY` (has default) — .env
|
||||
- `DATABASE_URL` (has default) — .env.example
|
||||
- `DEFAULT_MODEL` (has default) — .env.example
|
||||
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts
|
||||
- `GITEA_BASE_URL` (has default) — .env
|
||||
- `GITEA_SSH_HOST` (has default) — .env
|
||||
- `GITEA_TOKEN` (has default) — .env
|
||||
- `GITEA_USER` (has default) — .env
|
||||
- `LLAMA_SWAP_URL` (has default) — .env.example
|
||||
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
||||
- `MCP_TEST_SECRET` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
||||
- `NODE_ENV` (has default) — .env.example
|
||||
- `PORT` (has default) — .env.example
|
||||
- `POSTGRES_PASSWORD` (has default) — .env.example
|
||||
- `PROJECT_ROOT_WHITELIST` (has default) — .env.example
|
||||
- `SEARXNG_URL` (has default) — .env.example
|
||||
- `SKILLS_ROOT` **required** — apps/server/src/services/skills.ts
|
||||
- `WEB_DIST_PATH` **required** — apps/server/src/index.ts
|
||||
|
||||
## Config Files
|
||||
|
||||
- `.env.example`
|
||||
- `Dockerfile`
|
||||
- `apps/web/vite.config.ts`
|
||||
- `docker-compose.yml`
|
||||
37
.codesight/graph.md
Normal file
37
.codesight/graph.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Dependency Graph
|
||||
|
||||
## Most Imported Files (change these carefully)
|
||||
|
||||
- `apps/coder/src/db.ts` — imported by **40** files
|
||||
- `apps/server/src/types/api.ts` — imported by **28** files
|
||||
- `apps/server/src/db.ts` — imported by **25** files
|
||||
- `packages/ion/src/cli/utils.ts` — imported by **24** files
|
||||
- `apps/coder/src/services/tools/types.ts` — imported by **18** files
|
||||
- `apps/coder/src/conductor/types.ts` — imported by **14** files
|
||||
- `apps/coder/src/services/agent-backend.ts` — imported by **14** files
|
||||
- `apps/coder/src/services/acp-tool-snapshot.ts` — imported by **14** files
|
||||
- `apps/server/src/services/tools/codecontext/factory.ts` — imported by **14** files
|
||||
- `apps/server/src/services/tools.ts` — imported by **13** files
|
||||
- `conductor/src/types.ts` — imported by **13** files
|
||||
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** files
|
||||
- `apps/server/src/config.ts` — imported by **12** files
|
||||
- `apps/coder/src/config.ts` — imported by **11** files
|
||||
- `apps/coder/src/services/provider-types.ts` — imported by **11** files
|
||||
- `apps/server/src/services/agents.ts` — imported by **10** files
|
||||
- `apps/coder/src/services/pending_changes.ts` — imported by **9** files
|
||||
- `apps/server/src/services/broker.ts` — imported by **9** files
|
||||
- `apps/server/src/services/path_guard.ts` — imported by **9** files
|
||||
- `apps/server/src/services/inference/payload.ts` — imported by **9** files
|
||||
|
||||
## Import Map (who imports what)
|
||||
|
||||
- `apps/coder/src/db.ts` ← `apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +35 more
|
||||
- `apps/server/src/types/api.ts` ← `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +23 more
|
||||
- `apps/server/src/db.ts` ← `apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts` +20 more
|
||||
- `packages/ion/src/cli/utils.ts` ← `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/cleanup.ts` +19 more
|
||||
- `apps/coder/src/services/tools/types.ts` ← `apps/coder/src/routes/messages.ts`, `apps/coder/src/services/dispatcher.ts`, `apps/coder/src/services/tools/adapter.ts`, `apps/coder/src/services/tools/apply_pending.ts`, `apps/coder/src/services/tools/check_task_status.ts` +13 more
|
||||
- `apps/coder/src/conductor/types.ts` ← `apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +9 more
|
||||
- `apps/coder/src/services/agent-backend.ts` ← `apps/coder/src/routes/lifecycle.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-event-map.ts`, `apps/coder/src/services/agent-pool.ts`, `apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts` +9 more
|
||||
- `apps/coder/src/services/acp-tool-snapshot.ts` ← `apps/coder/src/services/__tests__/acp-event-map.test.ts`, `apps/coder/src/services/__tests__/frame-emitter.test.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-dispatch.ts`, `apps/coder/src/services/acp-event-map.ts` +9 more
|
||||
- `apps/server/src/services/tools/codecontext/factory.ts` ← `apps/server/src/services/tools/codecontext/get_blast_radius.ts`, `apps/server/src/services/tools/codecontext/get_call_graph.ts`, `apps/server/src/services/tools/codecontext/get_codebase_overview.ts`, `apps/server/src/services/tools/codecontext/get_dependencies.ts`, `apps/server/src/services/tools/codecontext/get_file_analysis.ts` +9 more
|
||||
- `apps/server/src/services/tools.ts` ← `apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +8 more
|
||||
927
.codesight/libs.md
Normal file
927
.codesight/libs.md
Normal file
@@ -0,0 +1,927 @@
|
||||
# Libraries
|
||||
|
||||
- `apps/booterm/src/auth.ts` — function getUser: (req) => string
|
||||
- `apps/booterm/src/config.ts` — function loadConfig: () => Config
|
||||
- `apps/booterm/src/db.ts`
|
||||
- function getPool: (databaseUrl) => pg.Pool
|
||||
- function getSessionInfo: (sessionId) => Promise<SessionInfo | null>
|
||||
- function pingDb: () => Promise<boolean>
|
||||
- function closeDb: () => Promise<void>
|
||||
- `apps/booterm/src/pty/manager.ts`
|
||||
- function sanitizeId: (raw) => string | null
|
||||
- function tmuxSessionName: (paneId) => string
|
||||
- function hasSession: (tmuxConfPath, sessionName) => Promise<boolean>
|
||||
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
|
||||
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
|
||||
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
|
||||
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty
|
||||
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath) => void
|
||||
- `apps/coder/src/conductor/contracts.ts`
|
||||
- function produceContract: (contracts) => string
|
||||
- function reviewContract: (contracts) => string
|
||||
- type Contract
|
||||
- const EVIDENCE_PRODUCE
|
||||
- const EVIDENCE_REVIEW
|
||||
- const YAGNI_PRODUCE
|
||||
- _...1 more_
|
||||
- `apps/coder/src/conductor/flows/_util.ts` — function q, function repoLine
|
||||
- `apps/coder/src/conductor/flows/index.ts`
|
||||
- function describeFlows: () => string
|
||||
- function getFlow: (name) => Flow | undefined
|
||||
- const FLOWS: Record<string, Flow>
|
||||
- const FLOW_NAMES: string[]
|
||||
- `apps/coder/src/conductor/persona-loader.ts` — function loadPersona: (agent) => Promise<string>, const AGENTS_DIR
|
||||
- `apps/coder/src/conductor/render.ts` — function slugify: (s) => string
|
||||
- `apps/coder/src/conductor/spine.ts`
|
||||
- function readBand: (input) => Band
|
||||
- function fastNote: (ctx) => string
|
||||
- function buildSpineFlow: (spine) => Flow
|
||||
- `apps/coder/src/config.ts` — function loadConfig: () => Config, type Config
|
||||
- `apps/coder/src/db.ts`
|
||||
- function getSql: (config) => Sql
|
||||
- function applySchema: (sql) => Promise<void>
|
||||
- function pingDb: (sql) => Promise<boolean>
|
||||
- function closeDb: () => Promise<void>
|
||||
- type Sql
|
||||
- `apps/coder/src/plugins/host.ts`
|
||||
- function registerHook: (name, fn) => void
|
||||
- function emitHook: (name, ctx) => Promise<any>
|
||||
- function clearHooks: () => void
|
||||
- interface ToolHookContext
|
||||
- interface ToolResultContext
|
||||
- type HookName
|
||||
- _...1 more_
|
||||
- `apps/coder/src/services/acp-client-fs.ts` — function readWorktreeTextFile: (worktreePath, filePath, line?, limit?) => Promise<string>, function writeWorktreeTextFile: (worktreePath, filePath, content) => Promise<void>
|
||||
- `apps/coder/src/services/acp-client.ts` — function buildAcpClient: (worktreePath, resolveTurn) => void, interface AcpTurnContext
|
||||
- `apps/coder/src/services/acp-derive.ts`
|
||||
- function deriveModesFromACP: (fallbackModes, modeState?, configOptions?) => void
|
||||
- function deriveModelDefinitionsFromACP: (models, configOptions?) => ProviderModel[]
|
||||
- function findThoughtLevelConfigId: (configOptions) => string | null
|
||||
- `apps/coder/src/services/acp-dispatch.ts`
|
||||
- function dispatchViaAcp: (opts) => Promise<AcpDispatchResult>
|
||||
- interface AcpDispatchResult
|
||||
- interface AcpDispatchOpts
|
||||
- `apps/coder/src/services/acp-event-map.ts` — function mapSessionUpdate: (params, priorSnapshots, AcpToolSnapshot>) => void
|
||||
- `apps/coder/src/services/acp-probe.ts` — function probeAcpProvider: (agent, installPath, cwd) => Promise<AcpProbeResult>, interface AcpProbeResult
|
||||
- `apps/coder/src/services/acp-spawn.ts`
|
||||
- function resolveAcpSpawnArgs: (agent) => string[] | null
|
||||
- function resolveLaunchSpec: (resolved, installPath) => void
|
||||
- function resolveAcpProbeBinaries: (agent) => string[]
|
||||
- `apps/coder/src/services/acp-stream.ts` — function createAcpNdJsonStream: (child) => void
|
||||
- `apps/coder/src/services/acp-tool-snapshot.ts`
|
||||
- function mergeToolSnapshot: (toolCallId, update, previous?) => AcpToolSnapshot
|
||||
- function mapToolLifecycleStatus: (status, rawOutput?) => AcpToolLifecycleStatus
|
||||
- function snapshotToWireToolCall: (snapshot) => void
|
||||
- function snapshotToPartPayload: (snapshot) => void
|
||||
- function synthesizeCanceledSnapshots: (snapshots) => AcpToolSnapshot[]
|
||||
- interface AcpToolSnapshot
|
||||
- _...2 more_
|
||||
- `apps/coder/src/services/agent-commands-cache.ts`
|
||||
- function setTaskCommands: (taskId, commands) => void
|
||||
- function mergeTaskCommands: (taskId, commands) => void
|
||||
- function getTaskCommands: (taskId) => AgentCommand[] | null
|
||||
- function clearTaskCommands: (taskId) => void
|
||||
- `apps/coder/src/services/agent-pool.ts`
|
||||
- class AgentPool
|
||||
- interface AgentPoolOpts
|
||||
- const OPENCODE_POOL_KEY
|
||||
- const agentPool
|
||||
- `apps/coder/src/services/agent-probe.ts` — function probeAgents: (sql, log) => Promise<void>
|
||||
- `apps/coder/src/services/agent-status-publish.ts` — function publishAgentStatus: (publishFrame, sessionId, chatId, agent, status, reason?, at) => void
|
||||
- `apps/coder/src/services/agent-turn-persist.ts` — function persistExternalAgentTurn: (sql, assistantMessageId, snapshots, reasoningText) => Promise<void>
|
||||
- `apps/coder/src/services/arena-analyzer-helpers.ts`
|
||||
- function buildDigestPrompt: (input) => void
|
||||
- function buildJudgePrompt: (originalPrompt, digests) => void
|
||||
- function shouldNameWinner: (succeededCount) => boolean
|
||||
- function extractWinner: (judgeOutput) => void
|
||||
- function buildCrossExamPrompt: (opts) => void
|
||||
- interface ContestantDigestInput
|
||||
- _...1 more_
|
||||
- `apps/coder/src/services/arena-analyzer.ts` — function createAnalyzer: (deps) => Analyzer, interface Analyzer
|
||||
- `apps/coder/src/services/arena-decisions.ts`
|
||||
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
|
||||
- function nextLocalContestant: (contestants) => string | null
|
||||
- function isBattleComplete: (contestants) => boolean
|
||||
- function computeBenchmark: (startedAt, endedAt, costTokens, lane) => Benchmark
|
||||
- function sanitizeSlug: (s) => string
|
||||
- function buildBattleSlug: (battleId, battleType, createdAt) => string
|
||||
- _...7 more_
|
||||
- `apps/coder/src/services/arena-model-call.ts` — function arenaModelCall: (opts, 'LLAMA_SWAP_URL'>;
|
||||
model) => Promise<string>
|
||||
- `apps/coder/src/services/arena-runner.ts`
|
||||
- function createBattleRunner: (deps) => BattleRunner
|
||||
- interface ContestantSpec
|
||||
- interface BattleStartOpts
|
||||
- interface BattleRunner
|
||||
- type DispatchContestantFn
|
||||
- type OnBattleComplete
|
||||
- _...1 more_
|
||||
- `apps/coder/src/services/audit-session.ts`
|
||||
- function generateSessionId: () => string
|
||||
- function getCurrentSession: (basePath?) => Promise<string | null>
|
||||
- function getSessionJson: (sessionId, basePath?) => Promise<SessionJson | null>
|
||||
- function getIndex: (basePath?) => Promise<IndexJson | null>
|
||||
- function startSession: (task, basePath?) => Promise<StartSessionResult>
|
||||
- function endSession: (basePath?) => Promise<EndSessionResult | null>
|
||||
- _...18 more_
|
||||
- `apps/coder/src/services/backends/claude-sdk-map.ts`
|
||||
- function createClaudeSdkMapState: () => ClaudeSdkMapState
|
||||
- function mapSdkMessage: (msg, state) => AgentEvent[]
|
||||
- interface ClaudeSdkMapState
|
||||
- `apps/coder/src/services/backends/claude-sdk-routing.ts` — function claudeSdkBackendEnabled: (env) => boolean, function shouldUseClaudeSdk: (task, env) => boolean
|
||||
- `apps/coder/src/services/backends/claude-sdk.ts` — class ClaudeSdkBackend, interface ClaudeSdkBackendDeps
|
||||
- `apps/coder/src/services/backends/claude-session-store.ts` — class PostgresSessionStore
|
||||
- `apps/coder/src/services/backends/lifecycle-decisions.ts`
|
||||
- function selectIdleEvictionTargets: (entries, now, ttlMs) => string[]
|
||||
- function selectLruEvictionTargets: (entries, cap) => string[]
|
||||
- function decideRestart: (input) => RestartDecision
|
||||
- function selectOrphanWorktreeTargets: (onDisk, liveWorktreePaths, now, graceMs) => string[]
|
||||
- interface PoolEntrySnapshot
|
||||
- interface RestartDecisionInput
|
||||
- _...7 more_
|
||||
- `apps/coder/src/services/backends/opencode-event-map.ts`
|
||||
- function stripDcpTags: (s) => string
|
||||
- function eventSessionId: (ev) => string | null
|
||||
- function resolvePartDedupeKey: (part, type) => string | null
|
||||
- function mapToolStatus: (s) => ToolCallStatus | null
|
||||
- function toolPartToSnapshot: (part) => AcpToolSnapshot
|
||||
- function toolCalledSnapshot: (p) => AcpToolSnapshot
|
||||
- _...7 more_
|
||||
- `apps/coder/src/services/backends/opencode-server-process.ts`
|
||||
- function shouldStartServer: (s) => boolean
|
||||
- class OpenCodeServerSupervisor
|
||||
- interface ServerDownInfo
|
||||
- interface SupervisorHooks
|
||||
- interface OpenCodeServerSupervisorDeps
|
||||
- `apps/coder/src/services/backends/opencode-server.ts` — class OpenCodeServerBackend, interface OpenCodeServerBackendDeps
|
||||
- `apps/coder/src/services/backends/opencode-sse.ts`
|
||||
- function reconnectDecision: (failures, policy) => ReconnectDecision
|
||||
- function startSessionEventLoop: (state, deps) => void
|
||||
- function runSessionEventLoop: (state, abort, deps) => Promise<void>
|
||||
- interface TurnState
|
||||
- interface SessionState
|
||||
- interface ReconnectPolicy
|
||||
- _...4 more_
|
||||
- `apps/coder/src/services/backends/opencode-usage.ts`
|
||||
- function stepEndedToUsage: (props) => StepUsage
|
||||
- interface StepEndedProps
|
||||
- interface StepUsage
|
||||
- `apps/coder/src/services/backends/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
|
||||
- `apps/coder/src/services/backends/turn-guard.ts`
|
||||
- function armAbortGuard: (g) => void
|
||||
- function noteTurnActivity: (g) => void
|
||||
- function consumeTerminal: (g) => 'swallow' | 'settle'
|
||||
- interface AbortTerminalGuard
|
||||
- `apps/coder/src/services/backends/warm-acp-routing.ts` — function shouldUseWarmBackend: (task) => boolean, function isTurnOkForStopReason: (stopReason) => boolean
|
||||
- `apps/coder/src/services/backends/warm-acp.ts` — class WarmAcpBackend, interface WarmAcpBackendDeps
|
||||
- `apps/coder/src/services/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
|
||||
- `apps/coder/src/services/checkpoints.ts`
|
||||
- function buildShadowCommitCommand: (worktreePath, id) => string
|
||||
- function createCheckpoint: (sql, args, opts?) => Promise<
|
||||
- function restoreCheckpoint: (sql, checkpointId, opts?) => Promise<RestoreCheckpointResult>
|
||||
- class CheckpointNotFoundError
|
||||
- interface CreateCheckpointArgs
|
||||
- interface RestoreCheckpointResult
|
||||
- _...1 more_
|
||||
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[]
|
||||
- `apps/coder/src/services/command-availability.ts` — function isCommandAvailable: (binary) => Promise<boolean>
|
||||
- `apps/coder/src/services/correction-service.ts`
|
||||
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
|
||||
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
|
||||
- function checkContradiction: (action, corrections) => void
|
||||
- function markPersisted: (correctionId, filePath, basePath?) => Promise<UserCorrectionRecord | null>
|
||||
- function listCorrections: (basePath?) => Promise<UserCorrectionRecord[]>
|
||||
- function appendCorrectionToTrail: (trailPath, correction) => Promise<void>
|
||||
- _...2 more_
|
||||
- `apps/coder/src/services/dcp-strip.ts`
|
||||
- function stripDcpTags: (s) => string
|
||||
- function makeDcpStreamStripper: () => DcpStreamStripper
|
||||
- interface DcpStreamStripper
|
||||
- `apps/coder/src/services/dispatcher.ts` — function createDispatcher: (deps) => void
|
||||
- `apps/coder/src/services/edit-guards-imports.ts` — function checkDroppedImports: (original, updated, filePath) => ImportCheckResult, interface ImportCheckResult
|
||||
- `apps/coder/src/services/edit-guards.ts`
|
||||
- function validateEditResult: (original, updated, filePath) => GuardResult
|
||||
- function formatGuardError: (guard, filePath) => string
|
||||
- interface GuardResult
|
||||
- `apps/coder/src/services/finalize-message.ts`
|
||||
- function classifyTerminalStatus: (opts) => TerminalMessageStatus
|
||||
- function finalizeStreamingMessage: (sql, publishFrame, frame) => void
|
||||
- type TerminalMessageStatus
|
||||
- `apps/coder/src/services/flow-artifacts.ts` — function getArtifactPath: (flowRunId, stepId) => string, function writeFlowArtifact: (flowRunId, stepId, content) => Promise<string>
|
||||
- `apps/coder/src/services/flow-runner-decisions.ts`
|
||||
- function manifestSteps: (flow, launchCtx) => Step[]
|
||||
- function readySteps: (flow, state) => Step[]
|
||||
- function partitionReady: (ready, ctx) => void
|
||||
- function isRunComplete: (flow, state) => boolean
|
||||
- function isStuck: (flow, state) => boolean
|
||||
- function reconcileResumeStep: (status, taskId, taskState) => ResumeAction
|
||||
- _...5 more_
|
||||
- `apps/coder/src/services/flow-runner.ts`
|
||||
- function createFlowRunner: (deps) => FlowRunner
|
||||
- interface LaunchOpts
|
||||
- interface FlowRunner
|
||||
- `apps/coder/src/services/frame-emitter.ts`
|
||||
- function makeFrameEmitter: (opts) => FrameEmitter
|
||||
- interface FrameEmitterOpts
|
||||
- interface FrameEmitter
|
||||
- `apps/coder/src/services/fuzzy-match.ts`
|
||||
- function locateMatch: (content, needle) => MatchResult
|
||||
- type MatchResult
|
||||
- const SIMILARITY_THRESHOLD
|
||||
- const AMBIGUITY_EPSILON
|
||||
- `apps/coder/src/services/guideline-service.ts`
|
||||
- function createGuideline: (params, basePath?) => Promise<Guideline>
|
||||
- function listGuidelines: (filter?, basePath?) => Promise<Guideline[]>
|
||||
- function readGuideline: (id, basePath?) => Promise<Guideline | null>
|
||||
- function updateGuideline: (id, params, basePath?) => Promise<Guideline | null>
|
||||
- function deleteGuideline: (id, basePath?) => Promise<boolean>
|
||||
- function findGuideline: (content, basePath?) => Promise<Guideline | null>
|
||||
- _...14 more_
|
||||
- `apps/coder/src/services/host-exec.ts` — function hostExec: (command, opts?) => Promise<HostExecResult>, interface HostExecResult
|
||||
- `apps/coder/src/services/lsp/client.ts` — class LspClient
|
||||
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig
|
||||
- `apps/coder/src/services/lsp/operations.ts`
|
||||
- function openDocument: (client, filePath, content, version) => Promise<void>
|
||||
- function closeDocument: (client, filePath) => Promise<void>
|
||||
- function getDiagnostics: (client, filePath, content) => Promise<Diagnostic[]>
|
||||
- function gotoDefinition: (client, filePath, content, line, character) => Promise<Location | null>
|
||||
- function findReferences: (client, filePath, content, line, character) => Promise<Location[]>
|
||||
- `apps/coder/src/services/lsp/server-manager.ts` — class LspServerManager, const lspManager
|
||||
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void>
|
||||
- `apps/coder/src/services/net/port-utils.ts`
|
||||
- function reclaimPort: (port) => void
|
||||
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
|
||||
- function freePort: () => Promise<number>
|
||||
- `apps/coder/src/services/orphan-worktree-reaper.ts`
|
||||
- function reapOrphanWorktrees: (sql, log, graceMs, now) => void
|
||||
- function createOrphanWorktreeReaper: (deps) => void
|
||||
- interface OrphanWorktreeReaperDeps
|
||||
- interface OrphanReaperResult
|
||||
- `apps/coder/src/services/pending_changes.ts`
|
||||
- function planEdit: (content, oldStr, newStr) => EditPlan
|
||||
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void
|
||||
- function queueCreate: (sql, sessionId, taskId, filePath, content, projectRoot, // See queueEdit) => Promise<PendingChange>
|
||||
- function queueDelete: (sql, sessionId, taskId, filePath, projectRoot, // See queueEdit) => Promise<PendingChange>
|
||||
- function applyOne: (sql, changeId, projectRoot) => Promise<ApplyResult>
|
||||
- function applyAll: (sql, sessionId, projectRoot) => Promise<ApplyResult[]>
|
||||
- _...6 more_
|
||||
- `apps/coder/src/services/permission-waiter.ts`
|
||||
- function setPermissionHooks: (next) => void
|
||||
- function waitForPermissionResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<RequestPermissionResponse>
|
||||
- function respondToPermission: (taskId, optionId, updatedInput?, unknown>) => boolean
|
||||
- function getPendingPermission: (taskId) => PermissionPrompt | null
|
||||
- function waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
|
||||
- function cancelPendingPermission: (taskId) => void
|
||||
- _...3 more_
|
||||
- `apps/coder/src/services/provider-commands.ts`
|
||||
- function getManifestCommands: (provider) => AgentCommand[]
|
||||
- function mergeCommands: (...lists) => AgentCommand[]
|
||||
- const PROVIDER_COMMANDS: Record<string, AgentCommand[]>
|
||||
- `apps/coder/src/services/provider-config-registry.ts`
|
||||
- function buildResolvedRegistry: (builtins, config) => Map<string, ResolvedProviderDef>
|
||||
- function loadProviderConfig: (path) => Map<string, ResolvedProviderDef>
|
||||
- function reloadProviderConfig: () => Map<string, ResolvedProviderDef>
|
||||
- function getResolvedRegistry: () => Map<string, ResolvedProviderDef>
|
||||
- interface ResolvedProviderDef
|
||||
- `apps/coder/src/services/provider-config.ts`
|
||||
- function mergeProviderConfigPatch: (current, patch) => CoderProvidersFile
|
||||
- function load: (path) => CoderProvidersFile
|
||||
- function save: (path, config) => void
|
||||
- `apps/coder/src/services/provider-diagnostic.ts` — function getProviderDiagnostic: (resolved, agentRow, opts) => Promise<string>, interface DiagnosticAgentRow
|
||||
- `apps/coder/src/services/provider-manifest.ts`
|
||||
- function getManifestModes: (provider) => ProviderMode[]
|
||||
- function getManifestDefaultModeId: (provider) => string | null
|
||||
- function isUnattendedMode: (provider, modeId) => boolean
|
||||
- interface ProviderManifestEntry
|
||||
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
|
||||
- `apps/coder/src/services/provider-snapshot.ts`
|
||||
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
|
||||
- function prefixLlamaSwapModels: (models) => ProviderModel[]
|
||||
- function mergeModels: (...lists) => ProviderModel[]
|
||||
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
|
||||
- function clearProviderSnapshotCache: () => void
|
||||
- function peekSnapshotEntry: (name, cwd?) => ProviderSnapshotEntry | undefined
|
||||
- _...1 more_
|
||||
- `apps/coder/src/services/pty-dispatch.ts`
|
||||
- function dispatchViaPty: (opts) => Promise<DispatchResult>
|
||||
- interface DispatchResult
|
||||
- interface PtyDispatchOpts
|
||||
- `apps/coder/src/services/qwen-settings.ts` — function readQwenSettingsModels: () => Promise<ProviderModel[]>
|
||||
- `apps/coder/src/services/stream-json-parser.ts`
|
||||
- function makeStreamJsonState: () => StreamJsonState
|
||||
- function parseStreamJsonLine: (line, state) => AgentEvent[]
|
||||
- function makeStreamJsonParser: () => StreamJsonParser
|
||||
- interface StreamJsonUsage
|
||||
- interface StreamJsonState
|
||||
- interface StreamJsonParser
|
||||
- _...1 more_
|
||||
- `apps/coder/src/services/token-analysis/analyzer.ts` — function analyzeMessages: (parts) => TokenBreakdown, interface TokenBreakdown
|
||||
- `apps/coder/src/services/token-analysis/persist.ts`
|
||||
- function persistTaskBreakdown: (sql, taskId, breakdown) => Promise<void>
|
||||
- function getTaskBreakdown: (sql, taskId) => Promise<TokenBreakdown | null>
|
||||
- function analyzeAndPersistTaskBreakdown: (sql, taskId, parts) => Promise<TokenBreakdown>
|
||||
- `apps/coder/src/services/tools/adapter.ts` — function adaptWriteTool: (tool) => ServerToolDef<any>
|
||||
- `apps/coder/src/services/tools/inference_context.ts`
|
||||
- function runWithInferenceContext: (ctx, fn) => void
|
||||
- function getInferenceContext: () => InferenceContext
|
||||
- interface InferenceContext
|
||||
- `apps/coder/src/services/tools/types.ts`
|
||||
- function asPermissionMode: (id) => PermissionMode | undefined
|
||||
- interface ToolJsonSchema
|
||||
- interface ToolContext
|
||||
- interface ToolDef
|
||||
- type PermissionMode
|
||||
- `apps/coder/src/services/tools/write-gate.ts` — function denyReadOnly: (operation) => unknown, function finalizeWrite: (context, projectRoot, change, queuedHint) => Promise<unknown>
|
||||
- `apps/coder/src/services/worktree-risk.ts` — function checkWorktreeWorkAtRisk: (worktreePath, opts?) => Promise<WorktreeRiskReport>, function stashWorktree: (worktreePath, opts?) => Promise<
|
||||
- `apps/coder/src/services/worktrees.ts`
|
||||
- function createWorktree: (projectPath, taskId, opts?) => Promise<string>
|
||||
- function diffWorktree: (worktreePath, projectPath, opts?) => Promise<string>
|
||||
- function cleanupWorktree: (projectPath, taskId) => Promise<void>
|
||||
- function ensureSessionWorktree: (sql, projectPath, sessionId, opts?) => Promise<SessionWorktree>
|
||||
- function removeSessionWorktree: (sql, projectPath, worktree, opts?) => Promise<void>
|
||||
- function closeChatBackendState: (sql, chatId, opts?) => Promise<ChatCloseResult>
|
||||
- _...4 more_
|
||||
- `apps/coder/src/services/write_guard.ts`
|
||||
- function isSecretPath: (filePath) => boolean
|
||||
- function resolveWritePath: (projectRoot, filePath) => string
|
||||
- class WriteGuardError
|
||||
- `apps/server/src/config.ts` — function loadConfig: () => Config, type Config
|
||||
- `apps/server/src/db.ts`
|
||||
- function getSql: (config) => Sql
|
||||
- function applySchema: (sql) => Promise<void>
|
||||
- function pingDb: (sql) => Promise<boolean>
|
||||
- function closeDb: () => Promise<void>
|
||||
- type Sql
|
||||
- `apps/server/src/services/agents.ts`
|
||||
- function refreshToolNames: () => void
|
||||
- function matchToolGlob: (toolName, patterns) => boolean
|
||||
- function slugify: (name) => string
|
||||
- function parseAgentsMd: (content) => ParseResult
|
||||
- function isAgentRegistryMarkdown: (content) => boolean
|
||||
- function getAgentsMtimes: (projectPath) => void
|
||||
- _...2 more_
|
||||
- `apps/server/src/services/artifacts.ts`
|
||||
- function deriveMarkdownSlug: (messageContent) => string
|
||||
- function deriveHtmlSlug: (payload) => string
|
||||
- function deriveHtmlTitle: (html) => string | null
|
||||
- function detectHtmlArtifact: (text) => string | null
|
||||
- function decideHtmlArtifactWrite: (htmlContent) => HtmlArtifactDecision
|
||||
- function writeMarkdownArtifact: (message, 'content'>, ctx) => Promise<ArtifactWriteResult>
|
||||
- _...6 more_
|
||||
- `apps/server/src/services/audit/corrections.ts`
|
||||
- function createCorrection: (params) => UserCorrectionRecord
|
||||
- function findCorrections: (records, unknown>[]) => UserCorrectionRecord[]
|
||||
- function checkCorrectionConflict: (proposedAction, corrections) => UserCorrectionRecord | null
|
||||
- interface UserCorrectionRecord
|
||||
- `apps/server/src/services/audit/guideline-store.ts`
|
||||
- class GuidelineDocumentStore
|
||||
- interface GuidelineContent
|
||||
- interface Guideline
|
||||
- interface GuidelineDocument
|
||||
- interface GuidelineUpdateParams
|
||||
- type GuidelineId
|
||||
- _...3 more_
|
||||
- `apps/server/src/services/audit/journey-projection.ts`
|
||||
- function projectJourneyToGuidelines: (journey, nodes, edges) => ProjectedGuideline[]
|
||||
- function detectJourneyBacktrack: (journey, nodes, edges, currentNodeId, previousNodeId) => BacktrackCheck
|
||||
- interface ProjectedGuideline
|
||||
- interface BacktrackCheck
|
||||
- `apps/server/src/services/audit/journey-store.ts`
|
||||
- class JourneyStore
|
||||
- interface JourneyNode
|
||||
- interface JourneyEdge
|
||||
- interface Journey
|
||||
- type JourneyId
|
||||
- type JourneyNodeId
|
||||
- _...1 more_
|
||||
- `apps/server/src/services/audit/runs-dir.ts`
|
||||
- function findRunsDir: (projectRoot?) => string
|
||||
- function ensureRunsDir: (projectRoot?) => string
|
||||
- function readCurrentSession: (projectRoot?) => string | null
|
||||
- function writeCurrentSession: (sessionId, projectRoot?) => void
|
||||
- function clearCurrentSession: (projectRoot?) => void
|
||||
- function readIndex: (projectRoot?) => IndexFile
|
||||
- _...7 more_
|
||||
- `apps/server/src/services/audit/session-manager.ts`
|
||||
- function generateSessionId: () => string
|
||||
- function isoNow: () => string
|
||||
- function createSession: (task, sessionId?, projectRoot?) => string
|
||||
- function getSessionDir: (sessionId, projectRoot?) => string
|
||||
- function getActiveSession: (projectRoot?) => SessionJson | null
|
||||
- function readSession: (sessionId, projectRoot?) => SessionJson | null
|
||||
- _...9 more_
|
||||
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void>
|
||||
- `apps/server/src/services/broker.ts`
|
||||
- function createBroker: (log?) => Broker
|
||||
- interface Broker
|
||||
- type Frame
|
||||
- type Listener
|
||||
- `apps/server/src/services/codecontext_client.ts`
|
||||
- function callCodecontext: (req, fetcher) => Promise<CodecontextResponse>
|
||||
- interface CodecontextRequest
|
||||
- interface CodecontextResponse
|
||||
- `apps/server/src/services/coder-notify.ts` — function notifyCoderClose: (kind, id, log?, 'debug'>, fetcher) => Promise<boolean>, type CoderCloseKind
|
||||
- `apps/server/src/services/compaction.ts`
|
||||
- function usable: (contextLimit) => number
|
||||
- function isOverflow: (usage, contextLimit) => boolean
|
||||
- function estimate: (messages) => number
|
||||
- function turns: (messages) => Turn[]
|
||||
- function select: (messages, contextLimit, tailTurns) => SelectResult
|
||||
- function deriveFilesRead: (head) => string[]
|
||||
- _...8 more_
|
||||
- `apps/server/src/services/file_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
|
||||
- `apps/server/src/services/file_ops.ts`
|
||||
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
|
||||
- function viewFile: (projectRoot, relPath, opts?) => Promise<ViewFileResult>
|
||||
- function grep: (projectRoot, pattern, opts?) => Promise<GrepResult>
|
||||
- function findFiles: (projectRoot, pattern?, opts?) => Promise<FindFilesResult>
|
||||
- interface FileEntry
|
||||
- interface ListDirResult
|
||||
- _...4 more_
|
||||
- `apps/server/src/services/git_diff.ts`
|
||||
- function parseNameStatus: (output) => void
|
||||
- function parseNumStatLine: (line) => void
|
||||
- function splitDiffByFile: (diffText) => Map<string, string>
|
||||
- function classifyDiffBody: (body, cap) => 'diff' | 'binary' | 'too_large'
|
||||
- function autoSelectMode: (isDirty) => GitDiffMode
|
||||
- function canCommit: (files) => boolean
|
||||
- _...17 more_
|
||||
- `apps/server/src/services/git_meta.ts` — function getGitMeta: (rootPath) => Promise<GitMeta | null>, interface GitMeta
|
||||
- `apps/server/src/services/gitea.ts`
|
||||
- function createGiteaRepo: (cfg, name, options) => Promise<GiteaRepo>
|
||||
- class GiteaRepoExistsError
|
||||
- interface GiteaConfig
|
||||
- interface GiteaRepo
|
||||
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution
|
||||
- `apps/server/src/services/inference/budget.ts` — function resolveToolBudget: (agent) => number
|
||||
- `apps/server/src/services/inference/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
|
||||
- `apps/server/src/services/inference/dcp/messages.ts`
|
||||
- function toDcpMessages: (parts) => DcpMessage[]
|
||||
- function fromDcpMessages: (msgs) => any[]
|
||||
- interface DcpMessage
|
||||
- `apps/server/src/services/inference/dcp/state.ts`
|
||||
- function getDcpState: (chatId) => ChatDcpState | undefined
|
||||
- function setDcpState: (chatId, messageCount) => void
|
||||
- function clearDcpState: (chatId) => void
|
||||
- function shouldTransform: (chatId, messageCount) => boolean
|
||||
- `apps/server/src/services/inference/dcp/strategies/deduplication.ts` — function deduplicate: (messages) => void
|
||||
- `apps/server/src/services/inference/dcp/strategies/purge-errors.ts` — function purgeErrors: (messages, windowSize) => void
|
||||
- `apps/server/src/services/inference/dcp/transform.ts`
|
||||
- function transformMessages: (chatId, messages) => TransformResult
|
||||
- interface TransformStats
|
||||
- interface TransformResult
|
||||
- `apps/server/src/services/inference/error-handler.ts`
|
||||
- function handleAbortOrError: (ctx, args, accumulated, err) => Promise<void>
|
||||
- function finalizeStreamedRow: (ctx, opts) => void
|
||||
- function finalizeEmpty: (ctx, args) => Promise<void>
|
||||
- function finalizeCompletion: (ctx, args, result, startedAt, session) => Promise<void>
|
||||
- `apps/server/src/services/inference/llama-args-validator.ts`
|
||||
- function validateExtraArgs: (args?) => string[]
|
||||
- function isManagedFlag: (flag) => boolean
|
||||
- function stripShadowingFlags: (args, opts?) => string[]
|
||||
- interface StripOptions
|
||||
- `apps/server/src/services/inference/loop-detectors.ts`
|
||||
- function detectContentRepeat: (messages) => LoopDetectionResult
|
||||
- function detectToolLoop: (toolNames) => LoopDetectionResult
|
||||
- function detectDoomLoop: (messages, toolNames) => LoopDetectionResult
|
||||
- interface LoopDetectionResult
|
||||
- `apps/server/src/services/inference/mistake-tracker.ts`
|
||||
- function freshMistakeState: () => MistakeState
|
||||
- function recordStep: (state, outcome) => void
|
||||
- function detectMistakePattern: (state) => 'nudge' | 'escalate' | null
|
||||
- interface MistakeState
|
||||
- type FailureKind
|
||||
- const MISTAKE_THRESHOLD
|
||||
- _...1 more_
|
||||
- `apps/server/src/services/inference/parts.ts`
|
||||
- function insertParts: (sql, parts) => Promise<void>
|
||||
- function partsFromAssistantMessage: (args) => void
|
||||
- function partsFromToolMessage: (args) => Omit<PartInsert, 'message_id'>[]
|
||||
- interface PartInsert
|
||||
- type PartKind
|
||||
- `apps/server/src/services/inference/payload.ts`
|
||||
- function buildMessagesPayload: (session, project, history, agent, log?) => Promise<OpenAiMessage[]>
|
||||
- function loadContext: (sql, sessionId, chatId) => Promise<
|
||||
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
|
||||
- interface OpenAiMessage
|
||||
- `apps/server/src/services/inference/provider.ts`
|
||||
- function resolveRoute: (agent, config?) => RoutingInfo
|
||||
- function upstreamModel: (config, modelId, agent?) => LanguageModel
|
||||
- interface RoutingInfo
|
||||
- type InferenceRoute
|
||||
- `apps/server/src/services/inference/prune.ts`
|
||||
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
|
||||
- function prune: (args) => Promise<PruneResult>
|
||||
- interface PruneResult
|
||||
- interface PartForPrune
|
||||
- const PROTECTED_TOKENS
|
||||
- const PRUNE_TRIGGER_TOKENS
|
||||
- `apps/server/src/services/inference/sentinel-summaries.ts`
|
||||
- function runCapHitSummary: (ctx, args, session, project, history, agent, budget) => Promise<void>
|
||||
- function runDoomLoopSummary: (ctx, args, session, project, history, agent, loop, unknown> }) => Promise<void>
|
||||
- function runStepCapSummary: (ctx, args, session, project, history, agent, steps, cap) => Promise<void>
|
||||
- function insertMistakeRecoverySentinel: (ctx, sessionId, chatId, opts) => Promise<void>
|
||||
- `apps/server/src/services/inference/sentinels.ts`
|
||||
- function detectDoomLoop: (recentToolCalls) => void
|
||||
- function isCapHitSentinel: (m) => boolean
|
||||
- function isDoomLoopSentinel: (m) => boolean
|
||||
- function isMistakeRecoverySentinel: (m) => boolean
|
||||
- function isAnySentinel: (m) => boolean
|
||||
- const DOOM_LOOP_THRESHOLD
|
||||
- _...1 more_
|
||||
- `apps/server/src/services/inference/step-decision.ts`
|
||||
- function decideStep: (input) => PreStepDecision
|
||||
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision
|
||||
- type PreStepDecision
|
||||
- type PostToolDecision
|
||||
- `apps/server/src/services/inference/stream-error-classifier.ts` — function classifyStreamError: (err) => StreamErrorKind, type StreamErrorKind
|
||||
- `apps/server/src/services/inference/stream-phase-adapter.ts`
|
||||
- function samplerOptsFromAgent: (agent) => SamplerOpts
|
||||
- function streamCompletion: (ctx, model, messages, opts, onDelta) => void
|
||||
- interface StreamAdapterContext
|
||||
- interface StreamOptions
|
||||
- type SamplerOpts
|
||||
- const STALL_TIMEOUT_MS
|
||||
- `apps/server/src/services/inference/stream-phase.ts` — function executeStreamPhase: (ctx, args, session, messages, state, agent, // v1.11.8, web_search and web_fetch are stripped from the
|
||||
// tool list sent to the LLM, so the model can't even attempt them.
|
||||
webToolsEnabled) => Promise<StreamResult>
|
||||
- `apps/server/src/services/inference/tool-call-parser.ts`
|
||||
- function stripToolMarkup: (text, opts?) => string
|
||||
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
|
||||
- interface ParsedCall
|
||||
- interface ToolCallExtraction
|
||||
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
|
||||
- `apps/server/src/services/inference/tool-shim.ts`
|
||||
- function extractToolCalls: (text) => ParsedToolCall[]
|
||||
- function hasToolCallMarkup: (text) => boolean
|
||||
- interface ParsedToolCall
|
||||
- `apps/server/src/services/inference/tool-suggestions.ts`
|
||||
- function levenshtein: (a, b) => number
|
||||
- function suggestToolName: (name, available) => string | null
|
||||
- function formatUnknownToolError: (name, available) => string
|
||||
- `apps/server/src/services/inference/turn-config.ts`
|
||||
- function resolveTurnConfig: (agent) => TurnConfig
|
||||
- interface TurnConfig
|
||||
- const MAX_STEPS
|
||||
- `apps/server/src/services/inference/turn.ts`
|
||||
- function runAssistantTurn: (ctx, args) => Promise<void>
|
||||
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => Promise<void>
|
||||
- function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
|
||||
- `apps/server/src/services/mcp-client.ts`
|
||||
- function initialize: (entries, logger) => Promise<void>
|
||||
- function callTool: (prefixedName, args, unknown>) => Promise<unknown>
|
||||
- function getTools: () => ToolDef<Record<string, unknown>>[]
|
||||
- function getMcpServers: () => Array<
|
||||
- function shutdown: () => Promise<void>
|
||||
- function wrapMcpTool: (serverName, mcpTool) => ToolDef<Record<string, unknown>>
|
||||
- _...2 more_
|
||||
- `apps/server/src/services/mcp-config.ts`
|
||||
- function substituteEnvVars: (value, log, unsetVars?) => unknown
|
||||
- function loadMcpConfig: (configPath, log) => McpServerEntry[]
|
||||
- interface McpServerEntry
|
||||
- type McpServerConfig
|
||||
- `apps/server/src/services/memory/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
|
||||
- `apps/server/src/services/memory/paths.ts`
|
||||
- function getMemoryRoot: (projectRoot) => string
|
||||
- function getTopicDir: (root, topic) => string
|
||||
- function ensureMemoryScaffold: (root) => Promise<void>
|
||||
- type MemoryTopic
|
||||
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string
|
||||
- `apps/server/src/services/memory/recall.ts` — function rankByRelevance: (query, entries) => MemoryEntry[], function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
|
||||
- `apps/server/src/services/memory/scan.ts`
|
||||
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
|
||||
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
|
||||
- interface MemoryScope
|
||||
- `apps/server/src/services/memory/store.ts` — function readTopicFiles: (root, topic) => Promise<Map<string, string>>, function writeEntry: (root, topic, title, content, tags) => Promise<void>
|
||||
- `apps/server/src/services/model-context.ts`
|
||||
- function configureModelContext: (opts) => void
|
||||
- function getModelContext: (model) => Promise<ModelContext | null>
|
||||
- function invalidateModelContext: (model?) => void
|
||||
- interface ModelContext
|
||||
- `apps/server/src/services/path_guard.ts`
|
||||
- function resolveProjectRoot: (projectPath) => Promise<string>
|
||||
- function pathGuard: (projectRoot, requested, extraRoots) => Promise<string>
|
||||
- class PathScopeError
|
||||
- `apps/server/src/services/project_bootstrap.ts`
|
||||
- function sanitizeFolderName: (raw) => string
|
||||
- function bootstrapProject: (config, log, options) => Promise<BootstrapResult>
|
||||
- class BootstrapNameError
|
||||
- class BootstrapCollisionError
|
||||
- class BootstrapPathError
|
||||
- interface BootstrapResult
|
||||
- `apps/server/src/services/read_tab_by_number.ts`
|
||||
- function executeReadTabByNumber: (input, sql, sessionId) => Promise<string>
|
||||
- type ReadTabByNumberInputT
|
||||
- const readTabByNumber: ToolDef<ReadTabByNumberInputT>
|
||||
- `apps/server/src/services/secret_guard.ts`
|
||||
- function isSecretPath: (relPath) => boolean
|
||||
- function filterSecretEntries: (entries, pathOf) => void
|
||||
- class SecretBlockedError
|
||||
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string>
|
||||
- `apps/server/src/services/skill-invoke.ts`
|
||||
- function runSkillInvokeTransaction: (sql, args) => Promise<
|
||||
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
|
||||
- function buildSkillInvokeUserFrames: (chatId, userMessageId, userText) => SkillInvokeSessionFrame[]
|
||||
- interface SkillInvokeTransactionResult
|
||||
- interface SkillInvokeToolCall
|
||||
- type SkillInvokeSessionFrame
|
||||
- _...1 more_
|
||||
- `apps/server/src/services/skills.ts`
|
||||
- function listSkills: () => Promise<Skill[]>
|
||||
- function findSkills: (query) => Promise<SkillSummary[]>
|
||||
- function getSkillBody: (name) => Promise<string | null>
|
||||
- function getSkillResource: (name, relativePath) => Promise<SkillResourceResult>
|
||||
- interface Skill
|
||||
- interface SkillSummary
|
||||
- _...2 more_
|
||||
- `apps/server/src/services/synthesisPipeline.ts`
|
||||
- function runSynthesisPass: (p) => Promise<boolean>
|
||||
- interface SynthesisParams
|
||||
- const SYNTHESIS_TOOLS: ReadonlySet<string>
|
||||
- `apps/server/src/services/system-prompt.ts`
|
||||
- function loadContainerGuidance: () => Promise<string | null>
|
||||
- function getContainerGuidance: () => Promise<string | null>
|
||||
- function _resetContainerGuidanceCacheForTests: () => void
|
||||
- function _resetPrefixObserverForTests: () => void
|
||||
- function buildSystemPromptWithFingerprint: (project, session, agent) => Promise<
|
||||
- function buildSystemPrompt: (project, session, agent) => Promise<string>
|
||||
- _...2 more_
|
||||
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string>
|
||||
- `apps/server/src/services/task-search-rewrite.ts` — function rewriteSearchQuery: (userMessage) => Promise<string>
|
||||
- `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
|
||||
mapArgs) => void
|
||||
- `apps/server/src/services/tools/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
|
||||
- `apps/server/src/services/tools/tiers.ts`
|
||||
- function resolveToolTier: (tier) => readonly string[]
|
||||
- const CORE_TOOL_NAMES
|
||||
- const STANDARD_TOOL_NAMES
|
||||
- `apps/server/src/services/truncate.ts`
|
||||
- function storeTruncation: (fullContent) => Promise<string>
|
||||
- function readTruncation: (id) => Promise<string | null>
|
||||
- function truncateIfNeeded: (args) => Promise<
|
||||
- function cleanupTruncations: (args, msg) => void
|
||||
- const TRUNCATION_DIR
|
||||
- const TRUNCATION_TTL_MS
|
||||
- _...1 more_
|
||||
- `apps/server/src/services/url_guard.ts` — function isPublicUrl: (input) => UrlGuardResult, interface UrlGuardResult
|
||||
- `apps/server/src/services/web/html-to-md.ts` — function htmlToMarkdown: (sourceHtml) => string
|
||||
- `apps/server/src/services/web_fetch.ts`
|
||||
- function executeWebFetch: (input, fetcher) => Promise<WebFetchOutput>
|
||||
- type WebFetchInputT
|
||||
- type WebFetchOutput
|
||||
- const webFetch: ToolDef<WebFetchInputT>
|
||||
- `apps/server/src/services/web_search.ts`
|
||||
- function executeWebSearch: (input, searxngUrl, fetcher) => Promise<WebSearchOutput>
|
||||
- interface WebSearchOutput
|
||||
- type WebSearchInputT
|
||||
- const webSearch: ToolDef<WebSearchInputT>
|
||||
- `apps/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
|
||||
- `apps/web/src/api/client.ts`
|
||||
- class ApiError
|
||||
- interface AgentSessionInfo
|
||||
- interface CoderCheckpoint
|
||||
- interface CoderRestoreResult
|
||||
- const api
|
||||
- `apps/web/src/data/acp-provider-catalog.ts`
|
||||
- function buildAcpProviderConfigPatch: (entry) => ProviderConfigPatch
|
||||
- interface AcpCatalogEntry
|
||||
- const ACP_PROVIDER_CATALOG: AcpCatalogEntry[]
|
||||
- `apps/web/src/hooks/terminal/useTerminalFit.ts`
|
||||
- function cellSize: (term, container) => void
|
||||
- function useTerminalFit: ({...}, containerRef, sessionId, paneId }) => TerminalFit
|
||||
- interface TerminalFit
|
||||
- `apps/web/src/hooks/terminal/useTerminalSelection.ts`
|
||||
- function useTerminalSelection: ({...}, containerRef, sessionId, paneId, label, send, }) => TerminalSelection
|
||||
- interface TerminalSelectionActions
|
||||
- interface TerminalSelection
|
||||
- `apps/web/src/hooks/terminal/useTerminalSocket.ts`
|
||||
- function useTerminalSocket: ({...}, sessionId, paneId, fit, getSize, setSize, }) => TerminalSocket
|
||||
- interface TerminalSocket
|
||||
- type ConnState
|
||||
- `apps/web/src/hooks/useActivePane.ts`
|
||||
- function setActivePaneInfo: (next) => void
|
||||
- function clearActivePane: () => void
|
||||
- function useActivePane: () => ActivePaneSnapshot
|
||||
- interface ActivePaneSnapshot
|
||||
- `apps/web/src/hooks/useAgentSessions.ts` — function refreshAgentSessions: (sessionId) => Promise<AgentSessionInfo[]>, function useAgentSessions: (sessionId) => void
|
||||
- `apps/web/src/hooks/useAgentStatus.ts`
|
||||
- function useAgentStatus: () => void
|
||||
- interface AgentStatusEntry
|
||||
- type AgentStatus
|
||||
- `apps/web/src/hooks/useArtifactDownload.ts` — function useArtifactDownload: (chatId, messageId, format) => void
|
||||
- `apps/web/src/hooks/useChatStatus.ts`
|
||||
- function useChatStatus: (chatId) => DerivedStatus
|
||||
- type RawStatus
|
||||
- type DerivedStatus
|
||||
- `apps/web/src/hooks/useChatThroughput.ts`
|
||||
- function recordUsage: (chatId, data) => void
|
||||
- function useChatThroughput: (chatId) => ThroughputSample | null
|
||||
- interface ThroughputSample
|
||||
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
|
||||
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences
|
||||
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId) => void
|
||||
- `apps/web/src/hooks/useLongPress.ts` — function useLongPress: (callback) => void
|
||||
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null
|
||||
- `apps/web/src/hooks/useProviderSnapshot.ts` — function refreshProviderSnapshot: (cwd?) => Promise<ProviderSnapshotEntry[]>, function useProviderSnapshot: (cwd?) => ProviderSnapshotEntry[] | null
|
||||
- `apps/web/src/hooks/usePullToRefresh.ts` — function usePullToRefresh: (onRefresh) => void
|
||||
- `apps/web/src/hooks/useSessionChats.ts`
|
||||
- function useSessionChats: (sessionId, opts) => UseSessionChatsResult
|
||||
- interface UseSessionChatsOpts
|
||||
- interface UseSessionChatsResult
|
||||
- `apps/web/src/hooks/useSessionStream.ts` — function useSessionStream: (sessionId) => void
|
||||
- `apps/web/src/hooks/useSessions.ts` — function useSessions: (projectId) => void
|
||||
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
|
||||
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => void
|
||||
- `apps/web/src/hooks/useUserEvents.ts` — function useUserEvents: () => void
|
||||
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
|
||||
- `apps/web/src/hooks/useWorkspacePanes.ts`
|
||||
- function activePaneChatId: (pane) => string | undefined
|
||||
- function useWorkspacePanes: (sessionId) => UseWorkspacePanesResult
|
||||
- interface UseWorkspacePanesResult
|
||||
- const MAX_PANES
|
||||
- `apps/web/src/hooks/wsReconnectToast.ts` — function createWsReconnectToast: (opts) => WsReconnectToast, interface WsReconnectToast
|
||||
- `apps/web/src/lib/anim.ts`
|
||||
- function getAnimBg: () => boolean
|
||||
- function setAnimBg: (on) => void
|
||||
- function setAnimDensity: (v) => void
|
||||
- function setAnimSpeed: (v) => void
|
||||
- function setAnimOpacity: (v) => void
|
||||
- function useAnimBg: () => boolean
|
||||
- _...3 more_
|
||||
- `apps/web/src/lib/attachments.ts`
|
||||
- function looksBinary: (content) => boolean
|
||||
- function inferLanguage: (filename) => string | null
|
||||
- function flattenToMessage: (attachments, text) => string
|
||||
- type Attachment
|
||||
- const MAX_FILE_SIZE_BYTES
|
||||
- const PASTE_INLINE_MAX_LINES
|
||||
- _...1 more_
|
||||
- `apps/web/src/lib/coder-session.ts` — function isCoderSessionName: (name) => boolean
|
||||
- `apps/web/src/lib/coder-tools.ts`
|
||||
- function wireToolCallToRun: (wire) => ToolRun
|
||||
- function mergeWireToolCall: (existing, incoming, unknown> }) => CoderToolCallWire[]
|
||||
- interface AcpWireMeta
|
||||
- interface CoderToolCallWire
|
||||
- `apps/web/src/lib/format.ts`
|
||||
- function relTime: (iso) => string
|
||||
- function formatRelative: (iso) => string
|
||||
- function formatAgo: (iso) => string
|
||||
- `apps/web/src/lib/model-label.ts` — function formatModelLabel: (raw) => string
|
||||
- `apps/web/src/lib/modelName.ts` — function shortenModelName: (model) => string | null
|
||||
- `apps/web/src/lib/permission-mode.ts`
|
||||
- function nativeModeForPermission: (mode, modes, defaultModeId) => string | null
|
||||
- function permissionForModeId: (modeId, modes) => PermissionMode
|
||||
- function availablePermissionModes: (modes) => Array<
|
||||
- type PermissionMode
|
||||
- const PERMISSION_LABELS: Record<PermissionMode, string>
|
||||
- `apps/web/src/lib/projectUrls.ts` — function giteaUrlFor: (project) => string
|
||||
- `apps/web/src/lib/slash-command.ts`
|
||||
- function isSlashCommandToken: (value) => boolean
|
||||
- function slashQuery: (value) => string
|
||||
- function parseSlashInput: (text) => void
|
||||
- function mergeCommandsByName: (...lists) => T[]
|
||||
- interface SlashCommandItem
|
||||
- `apps/web/src/lib/terminal-protocol.ts`
|
||||
- function encodeInput: (text) => Uint8Array
|
||||
- function encodeResize: (cols, rows) => string
|
||||
- function parseServerFrame: (data) => ServerControlFrame | null
|
||||
- type ServerControlFrame
|
||||
- `apps/web/src/lib/theme.ts`
|
||||
- function isThemeId: (s) => s is ThemeId
|
||||
- function applyTheme: (id, mode) => void
|
||||
- function setTheme: (id, mode) => Promise<void>
|
||||
- function useTheme: () => ThemeState
|
||||
- interface ThemeMeta
|
||||
- type ThemeId
|
||||
- _...5 more_
|
||||
- `apps/web/src/lib/utils.ts` — function cn: (...inputs) => void
|
||||
- `apps/web/src/utils/diff-layout.ts`
|
||||
- function parseDiff: (diffBody) => ParsedDiffFile[]
|
||||
- function buildSplitRows: (file) => SplitRow[]
|
||||
- function reconstructNewContent: (hunks) => string
|
||||
- interface DiffLine
|
||||
- interface DiffHunk
|
||||
- interface ParsedDiffFile
|
||||
- _...3 more_
|
||||
- `conductor/src/contracts.ts`
|
||||
- function produceContract: (contracts) => string
|
||||
- function reviewContract: (contracts) => string
|
||||
- type Contract
|
||||
- const EVIDENCE_PRODUCE
|
||||
- const EVIDENCE_REVIEW
|
||||
- const YAGNI_PRODUCE
|
||||
- _...1 more_
|
||||
- `conductor/src/dispatch.ts`
|
||||
- function loadPersona: (agent) => Promise<string>
|
||||
- function dispatchAgent: (agent, task, opts) => Promise<string>
|
||||
- function cleanOutput: (raw) => string
|
||||
- `conductor/src/flow.ts` — function runFlow: (flow, input, opts) => Promise<RunResult>, interface RunOptions
|
||||
- `conductor/src/flows/_util.ts` — function q, function repoLine
|
||||
- `conductor/src/flows/index.ts`
|
||||
- function describeFlows: () => string
|
||||
- function getFlow: (name) => Flow | undefined
|
||||
- const FLOWS: Record<string, Flow>
|
||||
- const FLOW_NAMES: string[]
|
||||
- `conductor/src/render.ts` — function slugify: (s) => string
|
||||
- `conductor/src/spine.ts`
|
||||
- function readBand: (input) => Band
|
||||
- function fastNote: (ctx) => string
|
||||
- function buildSpineFlow: (spine) => Flow
|
||||
- `data/skills/superpowers/systematic-debugging/condition-based-waiting-example.ts`
|
||||
- function waitForEvent: (threadManager, threadId, eventType, timeoutMs) => Promise<LaceEvent>
|
||||
- function waitForEventCount: (threadManager, threadId, eventType, count, timeoutMs) => Promise<LaceEvent[]>
|
||||
- function waitForEventMatch: (threadManager, threadId, predicate) => void
|
||||
- `packages/ion/src/cli/commands/abandon.ts` — function abandonCommand: (args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/approve.ts` — function approveCommand: (args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/cleanup.ts` — function cleanupCommand: (args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/convert.ts` — function convertCommand: (args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/list.ts` — function listCommand: (_args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/reject.ts` — function rejectCommand: (args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/resume.ts` — function resumeCommand: (args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/run.ts` — function runCommand: (args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/runs.ts` — function runsCommand: (args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/status.ts` — function statusCommand: (_args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/commands/validate.ts` — function validateCommand: (args, options) => Promise<void>
|
||||
- `packages/ion/src/cli/index.ts` — function main: (argv) => void
|
||||
- `packages/ion/src/cli/utils.ts`
|
||||
- function formatDuration: (ms) => string
|
||||
- function formatTimestamp: (date) => string
|
||||
- function truncate: (str, max) => string
|
||||
- function printTable: (rows, unknown>[], columns) => void
|
||||
- function printJson: (data) => void
|
||||
- function parseArgs: (argv) => void
|
||||
- _...3 more_
|
||||
- `packages/ion/src/engine/command-validation.ts` — function isValidCommandName: (name) => boolean
|
||||
- `packages/ion/src/engine/condition-evaluator.ts` — function evaluateCondition: (expression, nodeOutputs, Record<string, unknown>>) => boolean, class ConditionError
|
||||
- `packages/ion/src/engine/dag-executor.ts`
|
||||
- function buildTopologicalLayers: (nodes) => DagNode[][]
|
||||
- function checkTriggerRule: (node, nodeOutputs, NodeOutput>) => 'run' | 'skip'
|
||||
- function executeNodeInternal: (node, deps, platform, conversationId, cwd, config, nodeOutputs, NodeOutput>, workflowVariables, unknown>) => Promise<NodeExecutionResult>
|
||||
- function executeScriptNode: (node, cwd, envVars, string>, artifactsDir) => Promise<NodeExecutionResult>
|
||||
- function handleApprovalNode: (node, deps, platform, conversationId, workflowRunId, nodeOutputs, NodeOutput>, workflowVariables, unknown>) => Promise<NodeExecutionResult>
|
||||
- function handleLoopNode: (node, deps, platform, conversationId, cwd, config, nodeOutputs, NodeOutput>, workflowVariables, unknown>) => Promise<NodeExecutionResult>
|
||||
- _...2 more_
|
||||
- `packages/ion/src/engine/event-emitter.ts`
|
||||
- function getWorkflowEventEmitter: () => WorkflowEventEmitter
|
||||
- class WorkflowEventEmitter
|
||||
- interface WorkflowEventBase
|
||||
- interface WorkflowStartedEvent
|
||||
- interface WorkflowCompletedEvent
|
||||
- interface WorkflowFailedEvent
|
||||
- _...11 more_
|
||||
- `packages/ion/src/engine/executor-shared.ts`
|
||||
- function substituteWorkflowVariables: (template, context) => string
|
||||
- function buildPromptWithContext: (template, context, issueContext?) => string
|
||||
- function classifyError: (error) => ErrorClassification
|
||||
- function safeSendMessage: (platform, conversationId, message, metadata?, unknown>) => Promise<boolean>
|
||||
- function detectCompletionSignal: (output, until) => boolean
|
||||
- function stripCompletionTags: (output, until) => string
|
||||
- _...5 more_
|
||||
- `packages/ion/src/engine/executor.ts`
|
||||
- function executeWorkflow: (deps, platform, conversationId, cwd, workflow, userMessage, opts) => Promise<WorkflowExecutionResult>
|
||||
- function hydrateResumableRun: (deps, candidate) => Promise<HydratedResumableRun>
|
||||
- function resolveProjectPaths: (_deps, cwd, workflowRunId, codebaseId?) => ProjectPaths
|
||||
- interface WorkflowExecutionOptions
|
||||
- interface WorkflowExecutionResult
|
||||
- interface HydratedResumableRun
|
||||
- _...1 more_
|
||||
- `packages/ion/src/engine/model-validation.ts`
|
||||
- function isLiteralSpec: (spec) => spec is LiteralModelSpec
|
||||
- function buildAiProfile: (opts) => AiProfile
|
||||
- function resolveModelSpec: (profile, modelRef) => LiteralModelSpec
|
||||
- interface LiteralModelSpec
|
||||
- interface ModelAliasPreset
|
||||
- interface AiProfileTiers
|
||||
- _...2 more_
|
||||
- `packages/ion/src/engine/output-ref.ts`
|
||||
- function declaredFieldsFromSchema: (outputFormat, unknown> | string | undefined) => Set<string>
|
||||
- function resolveNodeOutputField: (nodeOutput, unknown>, nodeId, field, declaredFields?) => OutputRefResult
|
||||
- class OutputRefError
|
||||
- interface OutputRefResult
|
||||
- type OutputRefKind
|
||||
- `packages/ion/src/engine/utils.ts`
|
||||
- function substituteWorkflowVariables: (template, variables, unknown>) => string
|
||||
- function substituteNodeOutputRefs: (prompt, nodeOutputs, NodeOutput>, escapedForBash) => string
|
||||
- function resolveNodeOutputField: (output, field) => string
|
||||
- function buildPromptWithContext: (prompt, variables, unknown>, nodeOutputs, NodeOutput>, escapedForBash) => string
|
||||
- function evaluateCondition: (condition, variables, unknown>) => boolean
|
||||
- function classifyError: (error) => ErrorCategory
|
||||
- _...10 more_
|
||||
- `packages/ion/src/format/sop-discovery.ts` — function discoverSopFiles: (cwd, globFn) => Promise<string[]>, type GlobFn
|
||||
- `packages/ion/src/format/sop-parser.ts`
|
||||
- function parseSopContent: (markdown) => SopDocument
|
||||
- interface SopParameter
|
||||
- interface SopStep
|
||||
- interface SopDocument
|
||||
- `packages/ion/src/format/sop-to-yaml.ts` — function convertSopToWorkflowYaml: (sop) => string
|
||||
- `packages/ion/src/schema/dag-node.ts`
|
||||
- function isBashNode: (node) => node is BashNode
|
||||
- function isScriptNode: (node) => node is ScriptNode
|
||||
- function isLoopNode: (node) => node is LoopNode
|
||||
- function isApprovalNode: (node) => node is ApprovalNode
|
||||
- function isCancelNode: (node) => node is CancelNode
|
||||
- function isPromptNode: (node) => node is PromptNode
|
||||
- _...27 more_
|
||||
- `packages/ion/src/store/fs-store.ts` — function createFsStore: (basePath) => IWorkflowStore
|
||||
- `packages/ion/src/store/pg-store.ts` — function createPostgresStore: (connectionString) => Promise<IWorkflowStore>
|
||||
- `packages/ion/src/store/sqlite-store.ts` — function createSqliteStore: (dbPath) => Promise<IWorkflowStore>
|
||||
23
.codesight/middleware.md
Normal file
23
.codesight/middleware.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Middleware
|
||||
|
||||
## auth
|
||||
- auth — `apps/booterm/src/auth.ts`
|
||||
- authoring — `apps/coder/src/conductor/flows/authoring.ts`
|
||||
- turn-guard.test — `apps/coder/src/services/backends/__tests__/turn-guard.test.ts`
|
||||
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
|
||||
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
|
||||
- authoring — `conductor/src/flows/authoring.ts`
|
||||
|
||||
## custom
|
||||
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`
|
||||
- write_guard_fuzz.test — `apps/coder/src/services/__tests__/write_guard_fuzz.test.ts`
|
||||
- edit-guards-imports — `apps/coder/src/services/edit-guards-imports.ts`
|
||||
- write_guard — `apps/coder/src/services/write_guard.ts`
|
||||
- secret_guard.test — `apps/server/src/services/__tests__/secret_guard.test.ts`
|
||||
- path_guard — `apps/server/src/services/path_guard.ts`
|
||||
- secret_guard — `apps/server/src/services/secret_guard.ts`
|
||||
- url_guard — `apps/server/src/services/url_guard.ts`
|
||||
|
||||
## validation
|
||||
- edit-guards — `apps/coder/src/services/edit-guards.ts`
|
||||
- path_guard.test — `apps/server/src/services/__tests__/path_guard.test.ts`
|
||||
141
.codesight/routes.md
Normal file
141
.codesight/routes.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Routes
|
||||
|
||||
## CRUD Resources
|
||||
|
||||
- **`/api/battles`** GET | POST | GET/:id → Battle
|
||||
- **`/api/runs`** GET | POST | GET/:id → Run
|
||||
- **`/api/tasks`** GET | POST | GET/:id → Task
|
||||
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
|
||||
- **`/api/projects`** GET | POST | GET/:id | PATCH/:id | DELETE/:id → Project
|
||||
- **`/api/sessions`** GET/:id | PATCH/:id | DELETE/:id → Session
|
||||
|
||||
## Other Routes
|
||||
|
||||
### fastify
|
||||
|
||||
- `GET` `/api/term/health` params()
|
||||
- `POST` `/api/term/sessions/:sid/panes/:pid/start` params(sid, pid) [auth]
|
||||
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` params(sid, pid) [auth]
|
||||
- `GET` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
|
||||
- `GET` `/api/health` params() [auth, db, queue, ai]
|
||||
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db]
|
||||
- `POST` `/api/battles/generate-prompt` params() [auth, db]
|
||||
- `POST` `/api/battles/:id/stop` params(id) [auth, db]
|
||||
- `GET` `/api/battles/:id/analysis` params(id) [auth, db]
|
||||
- `POST` `/api/battles/:id/analyze` params(id) [auth, db]
|
||||
- `PATCH` `/api/battles/:id/winner` params(id) [auth, db]
|
||||
- `GET` `/api/battles/:id/contestants/:cid/diff` params(id, cid) [auth, db]
|
||||
- `POST` `/api/battles/:id/cross-examine` params(id) [auth, db]
|
||||
- `GET` `/api/sessions/:sessionId/checkpoints` params(sessionId) [auth, db]
|
||||
- `POST` `/api/sessions/:sessionId/checkpoints/:checkpointId/restore` params(sessionId, checkpointId) [auth, db]
|
||||
- `GET` `/api/inbox` params() [auth, db]
|
||||
- `POST` `/api/inbox/:id/retry` params(id) [auth, db]
|
||||
- `POST` `/api/chats/:chatId/close` params(chatId) [auth, db]
|
||||
- `POST` `/api/sessions/:sessionId/close` params(sessionId) [auth, db]
|
||||
- `GET` `/api/sessions/:sessionId/messages` params(sessionId) [auth, db, queue]
|
||||
- `POST` `/api/sessions/:sessionId/messages` params(sessionId) [auth, db, queue]
|
||||
- `POST` `/api/chats/:id/answer_user_input` params(id) [auth, db, queue]
|
||||
- `POST` `/api/sessions/:sessionId/stop` params(sessionId) [auth, db, queue]
|
||||
- `GET` `/api/sessions/:sessionId/pending` params(sessionId) [auth, db, queue]
|
||||
- `POST` `/api/sessions/:sessionId/pending/create` params(sessionId) [auth, db, queue]
|
||||
- `POST` `/api/sessions/:sessionId/pending/apply` params(sessionId) [auth, db, queue]
|
||||
- `POST` `/api/pending/:id/apply` params(id) [auth, db, queue]
|
||||
- `POST` `/api/pending/:id/reject` params(id) [auth, db, queue]
|
||||
- `POST` `/api/pending/:id/rewind` params(id) [auth, db, queue]
|
||||
- `GET` `/api/providers/snapshot` params() [db, cache]
|
||||
- `GET` `/api/providers/config` params() [db, cache]
|
||||
- `PATCH` `/api/providers/config` params() [db, cache]
|
||||
- `POST` `/api/providers/refresh` params() [db, cache]
|
||||
- `GET` `/api/providers/:id/diagnostic` params(id) [db, cache]
|
||||
- `POST` `/api/runs/:id/cancel` params(id) [auth, db]
|
||||
- `POST` `/api/sessions/:sessionId/skill_invoke` params(sessionId) [auth, db, queue]
|
||||
- `GET` `/api/stats/costs` params() [auth, db]
|
||||
- `POST` `/api/tasks/:id/cancel` params(id) [auth, db, cache, ai]
|
||||
- `GET` `/api/tasks/:id/permission` params(id) [auth, db, cache, ai]
|
||||
- `POST` `/api/tasks/:id/permission` params(id) [auth, db, cache, ai]
|
||||
- `GET` `/api/tasks/:id/commands` params(id) [auth, db, cache, ai]
|
||||
- `GET` `/api/sessions/:sessionId/worktree-risk` params(sessionId) [auth, db]
|
||||
- `POST` `/api/sessions/:sessionId/worktree-stash` params(sessionId) [auth, db]
|
||||
- `GET` `/api/ws/sessions/:sessionId` params(sessionId) [auth, db]
|
||||
- `GET` `/api/ws/user` params() [auth, db]
|
||||
- `GET` `/api/projects/:id/agents` params(id) [db, cache]
|
||||
- `POST` `/api/chats/:id/messages/:msg_id/artifacts/download` params(id, msg_id) [auth, db]
|
||||
- `GET` `/api/chats/:id/messages/:msg_id/html_artifact` params(id, msg_id) [auth, db]
|
||||
- `GET` `/api/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
|
||||
- `GET` `/api/sessions/:id/chats` params(id) [auth, db]
|
||||
- `POST` `/api/sessions/:id/chats` params(id) [auth, db]
|
||||
- `PATCH` `/api/chats/:id` params(id) [auth, db]
|
||||
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db]
|
||||
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db]
|
||||
- `POST` `/api/chats/:id/archive` params(id) [auth, db]
|
||||
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db]
|
||||
- `DELETE` `/api/chats/:id` params(id) [auth, db]
|
||||
- `POST` `/api/chats/:id/fork` params(id) [auth, db]
|
||||
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db]
|
||||
- `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
|
||||
- `ALL` `/api/coder/*` params() [auth]
|
||||
- `GET` `/api/settings/inference` params() [cache]
|
||||
- `PATCH` `/api/settings/inference` params() [cache]
|
||||
- `GET` `/api/sessions/:id/messages` params(id) [auth, db, queue]
|
||||
- `POST` `/api/chats/:id/messages/:message_id/regenerate` params(id, message_id) [auth, db, queue]
|
||||
- `POST` `/api/chats/:id/compact` params(id) [auth, db, queue]
|
||||
- `POST` `/api/chats/:id/stop` params(id) [auth, db, queue]
|
||||
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue]
|
||||
- `POST` `/api/chats/:id/force_send` params(id) [auth, db, queue]
|
||||
- `POST` `/api/chats/:id/grant_read_access` params(id) [auth, db, queue]
|
||||
- `GET` `/api/models` params()
|
||||
- `POST` `/api/projects/create` params() [auth, db]
|
||||
- `POST` `/api/projects/:id/archive` params(id) [auth, db]
|
||||
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
|
||||
- `GET` `/api/projects/available` params() [auth, db]
|
||||
- `GET` `/api/projects/:id/list_dir` params(id) [auth, db]
|
||||
- `GET` `/api/projects/:id/view_file` params(id) [auth, db]
|
||||
- `GET` `/api/projects/:id/git` params(id) [auth, db]
|
||||
- `GET` `/api/projects/:id/git/diff` params(id) [auth, db]
|
||||
- `POST` `/api/projects/:id/git/stage` params(id) [auth, db]
|
||||
- `POST` `/api/projects/:id/git/unstage` params(id) [auth, db]
|
||||
- `POST` `/api/projects/:id/git/commit` params(id) [auth, db]
|
||||
- `POST` `/api/projects/:id/git/discard` params(id) [auth, db]
|
||||
- `POST` `/api/projects/:id/write_file` params(id) [auth, db]
|
||||
- `GET` `/api/projects/:id/files` params(id) [auth, db]
|
||||
- `GET` `/api/projects/:id/sessions` params(id) [auth, db]
|
||||
- `POST` `/api/projects/:id/sessions` params(id) [auth, db]
|
||||
- `PATCH` `/api/sessions/:id/workspace` params(id) [auth, db]
|
||||
- `POST` `/api/projects/:id/sessions/archive-all` params(id) [auth, db]
|
||||
- `GET` `/api/projects/:id/sessions/open-count` params(id) [auth, db]
|
||||
- `POST` `/api/sessions/:id/archive` params(id) [auth, db]
|
||||
- `POST` `/api/sessions/:id/unarchive` params(id) [auth, db]
|
||||
- `GET` `/api/settings` params() [db]
|
||||
- `PATCH` `/api/settings` params() [db]
|
||||
- `GET` `/api/sidebar` params() [auth, db]
|
||||
- `GET` `/api/skills` params() [auth, db, queue]
|
||||
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
|
||||
- `GET` `/api/tools/cost_stats` params() [auth, db]
|
||||
- `GET` `/api/ws/sessions/:id` params(id) [auth, db]
|
||||
|
||||
### go-net-http
|
||||
|
||||
- `GET` `/health` params() [queue]
|
||||
- `POST` `/v1/get_codebase_overview` params() [queue]
|
||||
- `POST` `/v1/get_file_analysis` params() [queue]
|
||||
- `POST` `/v1/get_symbol_info` params() [queue]
|
||||
- `POST` `/v1/search_symbols` params() [queue]
|
||||
- `POST` `/v1/get_dependencies` params() [queue]
|
||||
- `POST` `/v1/watch_changes` params() [queue]
|
||||
- `POST` `/v1/get_semantic_neighborhoods` params() [queue]
|
||||
- `POST` `/v1/get_framework_analysis` params() [queue]
|
||||
- `POST` `/v1/get_symbol_details` params() [queue]
|
||||
- `POST` `/v1/get_call_graph` params() [queue]
|
||||
- `POST` `/v1/get_blast_radius` params() [queue]
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
- `WS` `message` — `apps/booterm/src/ws/attach.ts`
|
||||
- `WS` `close` — `apps/booterm/src/ws/attach.ts`
|
||||
- `WS` `message` — `apps/coder/src/cli.ts`
|
||||
- `WS` `error` — `apps/coder/src/cli.ts`
|
||||
- `WS` `close` — `apps/coder/src/cli.ts`
|
||||
- `WS` `close` — `apps/coder/src/routes/ws.ts`
|
||||
- `WS` `error` — `apps/coder/src/routes/ws.ts`
|
||||
- `WS` `close` — `apps/server/src/routes/ws.ts`
|
||||
- `WS` `error` — `apps/server/src/routes/ws.ts`
|
||||
157
.codesight/schema.md
Normal file
157
.codesight/schema.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Schema
|
||||
|
||||
### pending_changes
|
||||
- id: uuid (pk)
|
||||
- session_id: uuid (required, fk)
|
||||
- task_id: uuid (fk)
|
||||
- file_path: text (required)
|
||||
- operation: text (required)
|
||||
- diff: text (required)
|
||||
- status: text (required)
|
||||
|
||||
### tasks
|
||||
- id: uuid (pk)
|
||||
- project_id: uuid (required, fk)
|
||||
- parent_task_id: uuid (fk)
|
||||
- state: text (required)
|
||||
- input: text (required)
|
||||
- output_summary: text
|
||||
- agent: text
|
||||
- model: text
|
||||
- execution_path: text
|
||||
- cost_tokens: integer
|
||||
- started_at: timestamp(tz)
|
||||
- ended_at: timestamp(tz)
|
||||
|
||||
### available_agents
|
||||
- name: text (pk)
|
||||
- install_path: text
|
||||
- version: text
|
||||
- supports_acp: boolean (required)
|
||||
- last_probed_at: timestamp(tz)
|
||||
|
||||
### agent_sessions
|
||||
- session_id: uuid (required, fk)
|
||||
- agent: text (required)
|
||||
- backend: text (required)
|
||||
- agent_session_id: text (fk)
|
||||
- server_port: integer
|
||||
- status: text (required)
|
||||
- last_active_at: timestamp(tz)
|
||||
|
||||
### worktrees
|
||||
- id: uuid (pk)
|
||||
- session_id: uuid (fk)
|
||||
- project_id: uuid (fk)
|
||||
- path: text (required)
|
||||
- branch: text
|
||||
- base_commit: text
|
||||
- slug: text
|
||||
- status: text (required)
|
||||
|
||||
### checkpoints
|
||||
- id: uuid (pk)
|
||||
- chat_id: uuid (required, fk)
|
||||
- session_id: uuid (fk)
|
||||
- worktree_id: uuid (fk)
|
||||
- message_id: uuid (fk)
|
||||
|
||||
### claude_session_entries
|
||||
- id: bigint(auto) (pk)
|
||||
- project_key: text (required)
|
||||
- session_id: text (required, fk)
|
||||
- subpath: text (required)
|
||||
|
||||
### flow_runs
|
||||
- id: uuid (pk)
|
||||
- project_id: uuid (required, fk)
|
||||
- flow_name: text (required)
|
||||
- band: text (required)
|
||||
- model: text (required)
|
||||
- status: text (required)
|
||||
- input: jsonb (required)
|
||||
- report: text
|
||||
- error: text
|
||||
|
||||
### flow_steps
|
||||
- id: uuid (pk)
|
||||
- run_id: uuid (required, fk)
|
||||
- step_id: text (required, fk)
|
||||
- kind: text (required)
|
||||
- agent: text
|
||||
- status: text (required)
|
||||
- task_id: uuid (fk)
|
||||
- chat_id: uuid (fk)
|
||||
- input: text
|
||||
- output: text
|
||||
- error: text
|
||||
|
||||
### battles
|
||||
- id: uuid (pk)
|
||||
- project_id: uuid (required, fk)
|
||||
- battle_type: text (required)
|
||||
- prompt: text (required)
|
||||
- status: text (required)
|
||||
- winner_contestant_id: uuid (fk)
|
||||
- results_path: text
|
||||
- error: text
|
||||
|
||||
### contestants
|
||||
- id: uuid (pk)
|
||||
- battle_id: uuid (required, fk)
|
||||
- identity: text (required)
|
||||
- model: text (required)
|
||||
- lane: text (required)
|
||||
- task_id: uuid (fk)
|
||||
- worktree_id: uuid (fk)
|
||||
- status: text (required)
|
||||
- duration_ms: integer
|
||||
- tokens_per_sec: float8
|
||||
- cost_tokens: integer
|
||||
- result_path: text
|
||||
- error: text
|
||||
|
||||
### cross_examinations
|
||||
- id: uuid (pk)
|
||||
- battle_id: uuid (required, fk)
|
||||
- identity: text (required)
|
||||
- model: text (required)
|
||||
- verdict: text
|
||||
|
||||
### projects
|
||||
- id: uuid (pk)
|
||||
- name: text (required)
|
||||
- path: text (required)
|
||||
- added_at: timestamp(tz) (required)
|
||||
- last_session_id: uuid (fk)
|
||||
|
||||
### sessions
|
||||
- id: uuid (pk)
|
||||
- project_id: uuid (required, fk)
|
||||
- name: text (required)
|
||||
- model: text (required)
|
||||
- system_prompt: text (required)
|
||||
|
||||
### messages
|
||||
- id: uuid (pk)
|
||||
- session_id: uuid (required, fk)
|
||||
- role: text (required)
|
||||
- content: text (required)
|
||||
- status: text (required)
|
||||
- last_seq: integer (required)
|
||||
|
||||
### message_parts
|
||||
- id: uuid (pk)
|
||||
- message_id: uuid (required, fk)
|
||||
- sequence: integer (required)
|
||||
- kind: text (required)
|
||||
- payload: jsonb (required)
|
||||
|
||||
### settings
|
||||
- value: jsonb (required)
|
||||
|
||||
### chats
|
||||
- id: uuid (pk)
|
||||
- session_id: uuid (required, fk)
|
||||
- name: text
|
||||
- status: text (required)
|
||||
37
.learnings/HEALS.md
Normal file
37
.learnings/HEALS.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Self-healing log
|
||||
|
||||
Verified fixes for runtime failures. Each entry documents a failure, its root cause, the applied fix, and the verification proof.
|
||||
|
||||
**Pattern-Key discipline:** before filing a new HEAL, search this file for an existing Pattern-Key. If found, increment `Recurrence-Count` and update `Last-Seen` — do not duplicate.
|
||||
|
||||
**Lifecycle:** verified heals at Recurrence-Count ≥ 3 across distinct tasks get a `Handoff` block for promotion to project memory (`CLAUDE.md`, `AGENTS.md`, or a skill).
|
||||
|
||||
---
|
||||
|
||||
## [HEAL-YYYYMMDD-XXX] short_kebab_name
|
||||
|
||||
**Logged**: ISO-8601 timestamp
|
||||
**Status**: pending-verify
|
||||
**Trigger**: tool-failure | missing-capability | env-issue | external-change | <free-form>
|
||||
**Area**: free-form tag (e.g. `build`, `tests`, `ci`, `auth`, `data-pipeline`)
|
||||
**Priority**: low | medium | high | critical
|
||||
|
||||
### Failure
|
||||
Concrete error: command, error message, exit code, blocked action.
|
||||
|
||||
### Diagnosis
|
||||
Root cause as understood after investigation. What was verified during diagnosis.
|
||||
|
||||
### Fix
|
||||
Patch applied. Verbatim commands, code snippets, or pointers to `.learnings/heals/<HEAL-ID>/`.
|
||||
|
||||
### Verification
|
||||
What was run after the fix and what it returned. Exit code, output snippet, test pass count. **Proof.**
|
||||
|
||||
### Metadata
|
||||
- Related Files: path/to/file.ext
|
||||
- See Also: HEAL-... | LRN-... | ERR-...
|
||||
- Pattern-Key: lower.snake.case (e.g. `env.lockfile_mismatch`)
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: YYYY-MM-DD
|
||||
- Last-Seen: YYYY-MM-DD
|
||||
89
.omo/drafts/openspec-cleanup.md
Normal file
89
.omo/drafts/openspec-cleanup.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Draft: openspec-cleanup
|
||||
|
||||
## Cross-Reference: Git Tags vs openspec Batches
|
||||
|
||||
### Archived Stub Files — Tag Verification
|
||||
|
||||
| Stub File | Claims Version | Actual Tag | Verdict |
|
||||
|---|---|---|---|
|
||||
| `v1.13.12-skills-audit.md` (57B) | v1.13.12 | `v1.13.14-skills-audit` | **WRONG** — off by 2 versions |
|
||||
| `v1.13.15-codecontext-synth.md` (62B) | v1.13.15 | `v1.13.15-codecontext-synth` | ✅ correct |
|
||||
| `v1.13.17-cross-repo-reads.md` (61B) | v1.13.17 | `v1.13.17-cross-repo-reads` | ✅ correct |
|
||||
| `v1.13.18-codecontext-file-path.md` (66B) | v1.13.18 | `v1.13.18-codecontext-file-path` | ✅ correct |
|
||||
| `v1.13.20-drop-legacy-cols.md` (61B) | v1.13.20 | `v1.13.20-drop-legacy-cols` | ✅ correct |
|
||||
| `v1.14-outer-loop.md` (52B) | v1.14 | `v1.14.0-outer-loop` | ⚠️ close (1.14 → 1.14.0) |
|
||||
| `v1.14.1-mcp-poc.md` (51B) | v1.14.1 | `v1.14.1-mcp-poc` | ✅ correct |
|
||||
| `v1.14.x-html-artifact-panes.md` (63B) | v1.14.x | `v1.13.19-html-artifact-panes` | **WRONG** — shipped as 1.13.19 |
|
||||
| `v1.15-mcp-multi.md` (51B) | v1.15 | `v1.15.0-mcp-multi` | ⚠️ close (1.15 → 1.15.0) |
|
||||
| `v2.0-boocoder.md` (49B) | v2.0 | `v2.0.0` | ⚠️ close (2.0 → 2.0.0) |
|
||||
| `v2.2-paseo-providers.md` (222B) | v2.2 | `v2.2-paseo-providers` | ✅ correct |
|
||||
|
||||
### Archived Folder Entries — Tag Verification
|
||||
|
||||
| Archived Folder | Git Tag(s) | Status |
|
||||
|---|---|---|
|
||||
| `agent-status-normalize/` | `v2.7.6-agent-status-normalize` | ✅ shipped |
|
||||
| `claude-sdk-sessionstore/` | `v2.7.5-claude-sdk-sessionstore` | ✅ shipped |
|
||||
| `contracts-ssot/` | `v2.7.13-contracts-ssot` | ✅ shipped |
|
||||
| `license-debt-mit/` | `v2.7.0-mit` | ✅ shipped |
|
||||
| `mistake-tracker-file-ledger/` | `v2.7.4-mistake-tracker-ledger` | ✅ shipped (slug differs slightly) |
|
||||
| `orchestrator/` | `v2.7.17-orchestrator` | ✅ shipped |
|
||||
| `sampling-streamjson-tokens/` | `v2.7.3-sampling-streamjson-tokens` | ✅ shipped |
|
||||
| `v2-3-provider-lifecycle/` | `v2.5.4-*` through `v2.5.13-*` | ✅ shipped (diff version numbering) |
|
||||
| `v2-6-persistent-agent-sessions/` | `v2.6.4-*`, `v2.6.8-*` | ✅ shipped |
|
||||
| `write-edit-robustness/` | `v2.7.1-write-edit-robustness` | ✅ shipped |
|
||||
|
||||
### Misplaced Proposals in Archived/
|
||||
|
||||
| 2026-06-07 Folder | Git Tag? | Actually Shipped? | Should Be |
|
||||
|---|---|---|---|
|
||||
| `2026-06-07-boocontext/` | **None** | No | `changes/boocontext/` (partly shipped in v2.8.0) |
|
||||
| `2026-06-07-eval-sandbox-agent-runtime/` | **None** | No | Merge into `changes/import-*` |
|
||||
| `2026-06-07-hybrid-workflow-engine/` | **None** | No | Merge into `changes/orchestrator-flow-advanced/` |
|
||||
| `2026-06-07-memory-context-engineering/` | **None** | No | Merge into `changes/memory-context/` |
|
||||
| `2026-06-07-port-audit-parlant-patterns/` | **None** | No | Merge into `changes/add-behavioral-engine/` |
|
||||
|
||||
## Active Batches — All Uncommitted, All Unshipped
|
||||
|
||||
All 22 active batches (changes/*/) have **zero** git tags or commits referencing them. Every batch was created locally on 2026-06-07 and exists only on the filesystem.
|
||||
|
||||
## High-Value Prioritization (for Implementation Plan)
|
||||
|
||||
### Tier 1: Ship in Current Batch (small scope, high value)
|
||||
1. **openspec-cleanup** — Fix folder structure: delete stubs, move misplaced proposals, add .openspec.yaml, populate config.yaml
|
||||
2. **llama-cache-and-spec** — KV cache quantization + ngram speculative decoding (llama-server arg changes only)
|
||||
3. **results-page** — New `/results` route, uses existing API endpoints
|
||||
4. **token-analyzer-ui** — New `/analytics` route, uses existing DB data
|
||||
|
||||
### Tier 2: Current+ Batch (moderate scope)
|
||||
5. **enhanced-file-panel** — Side-by-side diff, inline comments, in-browser editing
|
||||
6. **pty-enhancements** — Exit notifications, session metadata, X-Agent-Flags
|
||||
|
||||
### Tier 3: Next Batch (larger scope, foundation work)
|
||||
7. **memory-v2-hybrid-search** — BM25 + local embedding hybrid search
|
||||
8. **orchestrator-flow-advanced** — Trigger rules, conditional branching, HITL
|
||||
9. **omo-paseo-bridge** — OMO subagent visibility in Paseo
|
||||
|
||||
### Tier 4: Future Batches (speculative / big effort)
|
||||
10. **add-behavioral-engine** / **audit-harness-integration** / **import-llm-evaluator** / **import-pregel-engine** — Big integration efforts
|
||||
11. **code-intelligence-upgrade** / **dev-workflow** / **conductor-evolution** — Platform work
|
||||
12. **plugin-platform** / **ui-overhaul** / **add-3tier-memory** / **add-type-inject-mcp** — Future
|
||||
|
||||
## Scope Boundaries for This Plan
|
||||
|
||||
**IN SCOPE:**
|
||||
- Delete 11 stub files from archived/
|
||||
- Move 5 misplaced 2026-06-07 proposals from archived/ to changes/ (with dedup)
|
||||
- Add missing .openspec.yaml to 6 active batches
|
||||
- Populate openspec/config.yaml with project context
|
||||
- Implement Tier 1-2 high-value batches:
|
||||
- llama-cache-and-spec (llama-server args)
|
||||
- results-page (new route, frontend)
|
||||
- token-analyzer-ui (new route, frontend + backend)
|
||||
- enhanced-file-panel (frontend changes)
|
||||
- pty-enhancements (backend changes)
|
||||
|
||||
**OUT OF SCOPE:**
|
||||
- Tier 3-4 batches (future planning)
|
||||
- Full behavioral engine or Pregel state machine integration
|
||||
- Plugin platform architecture
|
||||
485
.omo/plans/enhanced-file-panel.md
Normal file
485
.omo/plans/enhanced-file-panel.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# Enhanced File Panel — Implementation Plan
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: Add side-by-side diff, hide whitespace, wrap lines, expand all files, inline diff comments, and in-browser file editing to BooCode's right-rail file panel.
|
||||
>
|
||||
> **Deliverables**:
|
||||
> - Enhanced `GitDiffView.tsx` with toolbar (layout/whitespace/wrap/expand-all toggles)
|
||||
> - Split-layout diff renderer (side-by-side)
|
||||
> - `useDiffPreferences` hook (localStorage persistence)
|
||||
> - Inline diff comment components + Zustand store
|
||||
> - File editing mode in file tree + server write endpoint
|
||||
> - Server `git diff -w` support
|
||||
>
|
||||
> **Estimated Effort**: Medium-Large
|
||||
> **Parallel Execution**: YES — 4 waves
|
||||
> **Critical Path**: Wave 1 (server) → Wave 2 (diff preferences + toolbar) → Wave 3 (split layout) → Wave 4 (comments + editing)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
User wants to implement these features from Paseo into BooCode's file manager:
|
||||
1. Unified diff ✅ (exists) / Side by side diff ❌
|
||||
2. Hide whitespace ❌
|
||||
3. Wrap long lines ❌
|
||||
4. Expand all files ❌ (only per-file)
|
||||
5. Refresh ✅ (exists)
|
||||
6. Comments on specific diffs ❌
|
||||
7. File edits (editing in the file browser) ❌
|
||||
|
||||
### Research Findings
|
||||
- **Paseo** (`/opt/forks/paseo`): Best reference for all features. Key files: `diff-pane.tsx`, `diff-layout.ts`, `diff-rendering.ts`, `review/surface.tsx`, `review/store.ts`, `use-changes-preferences/`
|
||||
- **Existing BooCode files**: `GitDiffView.tsx`, `RightRail.tsx`, `useGitDiff.ts`, `git_diff.ts`, `FileViewerOverlay.tsx`
|
||||
- Key insight: None of the web references have true inline file editing in the browser — this is new ground
|
||||
|
||||
---
|
||||
|
||||
## Work Objectives
|
||||
|
||||
### Core Objective
|
||||
Augment the existing file panel with side-by-side diff, whitespace/wrap/expand toggles, inline comments, and inline file editing.
|
||||
|
||||
### Definition of Done
|
||||
- [x] `pnpm -C apps/web build` succeeds with no errors
|
||||
- [x] `pnpm -C apps/server build` succeeds with no errors
|
||||
- [ ] Side-by-side diff renders correctly (two aligned columns)
|
||||
- [ ] Hide whitespace toggles and re-fetches diff
|
||||
- [ ] Wrap lines toggles between pre / pre-wrap
|
||||
- [ ] Expand/Collapse all toggles all file diffs
|
||||
- [ ] Inline comments: click gutter → type → save → display thread
|
||||
- [ ] File edit: double-click tree → edit → save → file changes on disk
|
||||
- [ ] All preferences persist across page refresh
|
||||
|
||||
### Must Have
|
||||
- Side-by-side diff view
|
||||
- Hide whitespace toggle (server param)
|
||||
- Wrap long lines toggle (CSS)
|
||||
- Expand/Collapse all file diffs
|
||||
- Inline diff comments with thread UI
|
||||
- In-browser file editing with save
|
||||
- Preference persistence
|
||||
|
||||
### Must NOT Have (Guardrails)
|
||||
- No DB migration (comments are client-side)
|
||||
- No new WS frames (reuse git_diff_refresh)
|
||||
- No new `@boocode/contracts` types
|
||||
- No multi-user comment sharing
|
||||
- No git push/pull/PR operations
|
||||
- No inline hunk staging
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Test Decision
|
||||
- **Infrastructure exists**: YES (vitest for server)
|
||||
- **Automated tests**: Tests-after for new server route + `git_diff.ts` changes
|
||||
- **Agent-Executed QA**: Playwright for diff interactions, curl for API endpoints
|
||||
|
||||
### QA Policy
|
||||
Every task includes agent-executed scenarios. Evidence saved to `.omo/evidence/`.
|
||||
|
||||
---
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
### Waves
|
||||
|
||||
```
|
||||
Wave 1 (Server — foundation):
|
||||
├── Task 1: Server: whitespace param in git_diff.ts
|
||||
├── Task 2: Server: POST /api/projects/:id/write_file endpoint
|
||||
├── Task 3: Server tests for whitespace + write
|
||||
└── [tests + typecheck]
|
||||
|
||||
Wave 2 (Frontend — preferences + toolbar):
|
||||
├── Task 4: useDiffPreferences hook (localStorage)
|
||||
├── Task 5: GitDiffView toolbar (layout/whitespace/wrap/expand-all toggles)
|
||||
├── Task 6: Wrap lines CSS + hide whitespace re-fetch
|
||||
└── [pnpm build]
|
||||
|
||||
Wave 3 (Frontend — split layout):
|
||||
├── Task 7: Diff layout utilities (buildSplitDiffRows etc.)
|
||||
├── Task 8: Side-by-side renderer in GitDiffView
|
||||
├── Task 9: Line number gutter + alignment
|
||||
└── [pnpm build]
|
||||
|
||||
Wave 4 (Frontend — comments + file editing):
|
||||
├── Task 10: InlineComment store (Zustand + localStorage)
|
||||
├── Task 11: InlineReviewGutterCell + InlineReviewEditor
|
||||
├── Task 12: InlineReviewThread (comment display)
|
||||
├── Task 13: File editing mode in RightRail file tree
|
||||
└── [pnpm build + full smoke test]
|
||||
```
|
||||
|
||||
Critical Path: T1 → T2 → T4 → T5 → T7 → T8 → T10 → T11 → T12 → T13
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
- [x] 1. **Server: Add `ignoreWhitespace` param to git diff**
|
||||
|
||||
**What to do**:
|
||||
- In `apps/server/src/services/git_diff.ts`, add `ignoreWhitespace?: boolean` to the `getGitDiff` function signature
|
||||
- When `ignoreWhitespace` is true, append `'-w'` to the git diff argv call in `getGitDiff` (the main diff command, not name-status)
|
||||
- Update `GET /api/projects/:id/git/diff` route in `routes/projects.ts` to accept optional query param `whitespace=1`
|
||||
- The param should be optional (backward compatible) — default false
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/server/src/services/git_diff.ts` — update `getGitDiff()` to accept and use `ignoreWhitespace`
|
||||
- `apps/server/src/routes/projects.ts` — add `whitespace` query param
|
||||
|
||||
**References**:
|
||||
- Paseo: `useCheckoutDiffQuery({ ignoreWhitespace })` passes to server → `git diff -w`
|
||||
- Existing `git_diff.ts:36-48` `runGit` function — argv pattern to follow
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Diff with whitespace changes respects ignoreWhitespace param
|
||||
Tool: Bash (curl)
|
||||
Preconditions: A file exists with whitespace-only changes (extra spaces)
|
||||
Steps:
|
||||
1. GET /api/projects/:id/git/diff ⇒ verify diff_body includes whitespace changes
|
||||
2. GET /api/projects/:id/git/diff?whitespace=1 ⇒ verify diff_body excludes whitespace-only changes
|
||||
Expected: With whitespace=1, files that only had whitespace changes show as unchanged
|
||||
Evidence: .omo/evidence/task-1-whitespace.txt
|
||||
```
|
||||
|
||||
- [x] 2. **Server: Add POST /api/projects/:id/write_file endpoint**
|
||||
|
||||
**What to do**:
|
||||
- Add `POST /api/projects/:id/write_file` route in `routes/projects.ts`
|
||||
- Accept `{ path: string, content: string }` body
|
||||
- Validate path via existing `pathGuard` helper (same as git discard)
|
||||
- Write file content atomically: write to `.tmp` then `rename` the file
|
||||
- Return `{ ok: boolean }` on success
|
||||
- Reuse the safe file-write pattern from `services/file_ops.ts`
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/server/src/routes/projects.ts` — add POST route
|
||||
- `apps/web/src/api/client.ts` — add `writeFile` method
|
||||
- `apps/web/src/api/types.ts` — add write types if needed
|
||||
|
||||
**References**:
|
||||
- `apps/server/src/services/file_ops.ts` — existing file operations pattern
|
||||
- `apps/server/src/routes/projects.ts:544-592` — git write routes (same security pattern)
|
||||
- `apps/server/src/services/path_guard.ts` — path validation
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Write file content and verify on disk
|
||||
Tool: Bash (curl)
|
||||
Preconditions: A project exists with a writable path
|
||||
Steps:
|
||||
1. POST /api/projects/:id/write_file { path: "test.txt", content: "hello" }
|
||||
2. GET /api/projects/:id/view_file?path=test.txt
|
||||
Expected: Status 200, view_file returns "hello"
|
||||
Evidence: .omo/evidence/task-2-write.txt
|
||||
```
|
||||
|
||||
- [x] 3. **Frontend: useDiffPreferences hook**
|
||||
|
||||
**What to do**:
|
||||
- Create `apps/web/src/hooks/useDiffPreferences.ts`
|
||||
- Define `DiffPreferences` interface: `{ layout: 'unified'|'split', wrapLines: boolean, hideWhitespace: boolean }`
|
||||
- Default: `{ layout: 'unified', wrapLines: false, hideWhitespace: false }`
|
||||
- Read/write to localStorage key `boocode.diff.preferences`
|
||||
- Return `{ preferences, updatePreferences, resetPreferences }`
|
||||
- Zod-validate on read for forward compatibility
|
||||
|
||||
**Files to create/modify**:
|
||||
- Create `apps/web/src/hooks/useDiffPreferences.ts`
|
||||
|
||||
**References**:
|
||||
- `/opt/forks/paseo/packages/app/src/hooks/use-changes-preferences/storage.ts` — exact pattern
|
||||
- `apps/web/src/hooks/useProjectGit.ts` — hooks pattern in BooCode
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Preferences persist across page refresh
|
||||
Tool: Playwright
|
||||
Preconditions: Page loaded
|
||||
Steps:
|
||||
1. Call updatePreferences({ layout: 'split' })
|
||||
2. Read localStorage.getItem('boocode.diff.preferences')
|
||||
3. Reload page, read preferences again
|
||||
Expected: layout is 'split' after reload
|
||||
Evidence: .omo/evidence/task-3-prefs.txt
|
||||
```
|
||||
|
||||
- [x] 4. **Frontend: GitDiffView toolbar with all toggles**
|
||||
|
||||
**What to do**:
|
||||
- Add a toolbar row inside `GitDiffView.tsx` between the mode selector and file list
|
||||
- Controls (left to right):
|
||||
- **Layout toggle**: two-segment button (Unified | Split) — uses `AlignJustify` / `Columns2` icons
|
||||
- **Hide whitespace**: toggle button — `Pilcrow` icon, active state highlights
|
||||
- **Wrap lines**: toggle button — `WrapText` icon
|
||||
- **Expand/Collapse all**: toggle button — `ListChevronsUpDown` / `ListChevronsDownUp` icons
|
||||
- **Refresh**: existing button (already present)
|
||||
- Wire each toggle to the `useDiffPreferences` hook
|
||||
- Expand all state: compute `allExpanded = files.every(f => expandedPaths.has(f.path))`
|
||||
- Pass expand state as a new prop or local state
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/web/src/components/GitDiffView.tsx` — add toolbar section, expand-all logic
|
||||
|
||||
**References**:
|
||||
- Paseo `diff-pane.tsx:1114-1273` — `DiffLayoutToggleGroup`, `DiffWhitespaceToggle`, `DiffFilesToolbar`
|
||||
- openchamber `DiffViewToggle.tsx` — simple toggle pattern
|
||||
- happy `InlineFileDiff.tsx:196-219` — `DiffStyleToggle` segment control
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: All toolbar controls render and toggle
|
||||
Tool: Playwright
|
||||
Preconditions: Git tab active with changed files
|
||||
Steps:
|
||||
1. Verify layout toggle shows "Unified" / "Split" buttons
|
||||
2. Click "Split" — verify visual change
|
||||
3. Click "Wrap" — verify wrap toggle
|
||||
4. Click "Expand all" — verify all files expand
|
||||
5. Click "Collapse all" — verify all files collapse
|
||||
Expected: Each toggle works and updates state
|
||||
Evidence: .omo/evidence/task-4-toolbar.png
|
||||
```
|
||||
|
||||
- [x] 5. **Frontend: Diff layout utilities + side-by-side renderer**
|
||||
|
||||
**What to do**:
|
||||
- Create `apps/web/src/utils/diff-layout.ts` with pure functions:
|
||||
- `buildNumberedDiffHunks(diffBody: string): NumberedDiffHunk[]` — parse diff text into hunks with old/new line numbers
|
||||
- `buildUnifiedDiffLines(file): UnifiedDiffDisplayLine[]` — existing behavior
|
||||
- `buildSplitDiffRows(file): SplitDiffRow[]` — pair removals/additions into left/right rows
|
||||
- Create `apps/web/src/components/DiffSplitView.tsx` — the side-by-side renderer:
|
||||
- Two columns (left = deletions, right = additions) with a thin divider
|
||||
- Each column has its own gutter (line numbers) + code content
|
||||
- Use Shiki `codeToHtml(language)` for syntax highlighting per side
|
||||
- Handle empty cells (unpaired lines render as blank)
|
||||
- In `GitDiffView.tsx`, when `layout === 'split'`, render `DiffSplitView` instead of the unified diff body
|
||||
|
||||
**Files to create/modify**:
|
||||
- Create `apps/web/src/utils/diff-layout.ts`
|
||||
- Create `apps/web/src/components/DiffSplitView.tsx`
|
||||
- Modify `apps/web/src/components/GitDiffView.tsx` — add layout branching
|
||||
|
||||
**References**:
|
||||
- `/opt/forks/paseo/packages/app/src/utils/diff-layout.ts` — full algorithm
|
||||
- `/opt/forks/paseo/packages/app/src/git/diff-pane.tsx:968-989` — split layout rendering
|
||||
- existing `git_diff.ts` `splitDiffByFile` — already splits unified diff per file
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Side-by-side diff renders correctly
|
||||
Tool: Playwright
|
||||
Preconditions: Git tab active, files with changes
|
||||
Steps:
|
||||
1. Click "Split" layout toggle
|
||||
2. Verify two columns appear with a divider
|
||||
3. Verify deleted lines are on left side (red background)
|
||||
4. Verify added lines are on right side (green background)
|
||||
5. Verify context lines appear on both sides, aligned
|
||||
Expected: Layout matches Paseo's split diff
|
||||
Evidence: .omo/evidence/task-5-splitdiff.png
|
||||
```
|
||||
|
||||
- [x] 6. **Frontend: Inline comment store + Zustand**
|
||||
|
||||
**What to do**:
|
||||
- Create `apps/web/src/stores/useDiffCommentStore.ts`
|
||||
- Define `DiffComment` interface: `{ id, filePath, side, lineNumber, body, createdAt, updatedAt }`
|
||||
- Create Zustand store with:
|
||||
- `commentsByKey: Map<string, DiffComment[]>` keyed by `${sessionId}:${mode}:${filePath}`
|
||||
- `addComment(key, comment)` / `updateComment(key, id, body)` / `deleteComment(key, id)`
|
||||
- `loadComments(key)` — load from localStorage
|
||||
- `persist()` — subscribe to store changes, write to localStorage key `boocode.diff.comments.[key]`
|
||||
- Export `useDiffCommentStore`
|
||||
|
||||
**Files to create**:
|
||||
- Create `apps/web/src/stores/useDiffCommentStore.ts`
|
||||
|
||||
**References**:
|
||||
- `/opt/forks/paseo/packages/app/src/review/store.ts` — zustand store for comments
|
||||
- `/opt/forks/paseo/packages/app/src/review/state.ts` — CRUD operations
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Comments persist across page refresh
|
||||
Tool: Playwright
|
||||
Preconditions: Diff panel open with changes
|
||||
Steps:
|
||||
1. Add comment on a diff line
|
||||
2. Verify comment thread appears
|
||||
3. Reload page
|
||||
4. Navigate to same diff
|
||||
Expected: Comment thread still visible after reload
|
||||
Evidence: .omo/evidence/task-6-comment-store.txt
|
||||
```
|
||||
|
||||
- [x] 7. **Frontend: InlineReviewGutterCell + InlineReviewEditor**
|
||||
|
||||
**What to do**:
|
||||
- Create `apps/web/src/components/InlineReviewGutterCell.tsx`:
|
||||
- Replaces the plain line-number display in diff rows
|
||||
- Shows line number + "+" icon on hover (to start a comment)
|
||||
- Uses `ReviewableDiffTarget { filePath, side, lineNumber }` for tracking
|
||||
- Create `apps/web/src/components/InlineReviewEditor.tsx`:
|
||||
- Textarea with placeholder "Add comment..."
|
||||
- Save (Ctrl+Enter) / Cancel (Escape) buttons
|
||||
- Animates in below the target line
|
||||
- Integrate into `GitDiffView.tsx` — gutter cells render in the diff line view
|
||||
- Wire to `useDiffCommentStore`
|
||||
|
||||
**Files to create/modify**:
|
||||
- Create `apps/web/src/components/InlineReviewGutterCell.tsx`
|
||||
- Create `apps/web/src/components/InlineReviewEditor.tsx`
|
||||
- Modify `apps/web/src/components/GitDiffView.tsx` — integrate gutter cells
|
||||
|
||||
**References**:
|
||||
- Paseo `review/surface.tsx:245-309` — `DiffGutterCell` + `InlineReviewGutterCell`
|
||||
- Paseo `InlineReviewEditor` pattern
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Create inline comment on diff line
|
||||
Tool: Playwright
|
||||
Preconditions: Git tab, file expanded
|
||||
Steps:
|
||||
1. Hover over a gutter cell
|
||||
2. Click "+" button
|
||||
3. Type comment text
|
||||
4. Click Save (or Ctrl+Enter)
|
||||
Expected: Comment thread appears below the line
|
||||
Evidence: .omo/evidence/task-7-comment-create.png
|
||||
```
|
||||
|
||||
- [x] 8. **Frontend: InlineReviewThread component**
|
||||
|
||||
**What to do**:
|
||||
- Create `apps/web/src/components/InlineReviewThread.tsx`:
|
||||
- Renders below a diff line when comments exist for that target
|
||||
- Each comment shown as a card: avatar placeholder, body, timestamp, edit/delete actions
|
||||
- Collapsed state shows comment count badge
|
||||
- Expanded state shows full thread
|
||||
- Integrate into `GitDiffView.tsx` below diff line rows
|
||||
|
||||
**Files to create/modify**:
|
||||
- Create `apps/web/src/components/InlineReviewThread.tsx`
|
||||
- Modify `apps/web/src/components/GitDiffView.tsx` — render thread below lines
|
||||
|
||||
**Reference**:
|
||||
- Paseo `review/surface.tsx:537-573` — `InlineReviewThreadContent`
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Comment thread displays and supports edit/delete
|
||||
Tool: Playwright
|
||||
Preconditions: Comments exist on a diff line
|
||||
Steps:
|
||||
1. Expand comment thread
|
||||
2. Verify comment body is visible with timestamp
|
||||
3. Click edit → modify text → save
|
||||
4. Click delete → verify comment removed
|
||||
Expected: Full CRUD works on comments
|
||||
Evidence: .omo/evidence/task-8-thread.png
|
||||
```
|
||||
|
||||
- [x] 9. **Frontend: File editing in the file tree**
|
||||
|
||||
**What to do**:
|
||||
- In `RightRail.tsx`, add a file edit mode:
|
||||
- Double-click a file in the tree (or context menu "Edit") enters edit mode
|
||||
- The file row transforms: file name becomes a monospace textarea pre-filled with file content (fetched via existing `api.projects.viewFile`)
|
||||
- The row shows Save / Cancel buttons
|
||||
- Save: calls `api.projects.writeFile(projectId, path, content)` — the new endpoint from Task 2
|
||||
- Cancel: reverts to the original content and exits edit mode
|
||||
- After save: re-fetch the file tree + emit `git_diff_refresh`
|
||||
- Only one file editable at a time (close any existing editor before opening new)
|
||||
- Visual indicator (highlighted row) when in edit mode
|
||||
|
||||
**Files to modify**:
|
||||
- `apps/web/src/components/RightRail.tsx` — add edit mode state, edit UI
|
||||
- `apps/web/src/api/client.ts` — add `writeFile` method (from Task 2)
|
||||
- `apps/web/src/components/TreeLevel.tsx` (inline in RightRail) — accept edit mode props
|
||||
|
||||
**References**:
|
||||
- Existing `RightRail.tsx:170-175` `openFile` function — pattern for file interaction
|
||||
- Existing `FileViewerOverlay.tsx` — Shiki highlighting reference
|
||||
- Paseo `file-explorer-pane.tsx` — context menu actions pattern
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Edit file in file tree and save
|
||||
Tool: Playwright
|
||||
Preconditions: Project with a text file
|
||||
Steps:
|
||||
1. Double-click a file in the file tree
|
||||
2. Verify file enters edit mode (textarea replaces filename)
|
||||
3. Modify content
|
||||
4. Ctrl+Enter to save
|
||||
5. Verify success indicator
|
||||
Expected: File content updated on disk, tree refreshes
|
||||
Evidence: .omo/evidence/task-9-edit-save.png
|
||||
|
||||
Scenario: Cancel file edit reverts changes
|
||||
Tool: Playwright
|
||||
Preconditions: File in edit mode
|
||||
Steps:
|
||||
1. Modify content in textarea
|
||||
2. Click Cancel / press Escape
|
||||
3. Re-open file
|
||||
Expected: Original content preserved, edit mode exited
|
||||
Evidence: .omo/evidence/task-9-edit-cancel.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] F1. **Plan Compliance Audit** — `oracle`
|
||||
Verify all Must Have features are implemented, Must NOT Have are absent.
|
||||
Output: VERDICT
|
||||
|
||||
- [ ] F2. **Code Quality** — `unspecified-high`
|
||||
Run `pnpm -C apps/web build`, `pnpm -C apps/server build`, check for `as any`/`@ts-ignore`/console.log.
|
||||
Output: VERDICT
|
||||
|
||||
- [ ] F3. **Real Manual QA** — `unspecified-high` + `playwright`
|
||||
Execute all QA scenarios from every task, capture evidence.
|
||||
Output: Scenarios [N/N pass]
|
||||
|
||||
- [ ] F4. **Scope Fidelity** — `deep`
|
||||
Verify spec matches implementation, no scope creep.
|
||||
Output: Tasks [N/N compliant]
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
- **1**: `feat(server): add whitespace param to git diff + write_file endpoint`
|
||||
- **2**: `feat(web): diff preferences hook, toolbar toggles, split layout`
|
||||
- **3**: `feat(web): inline diff comments with zustand store`
|
||||
- **4**: `feat(web): in-browser file editing in file tree`
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Verification Commands
|
||||
```bash
|
||||
pnpm -C apps/web build # Must pass
|
||||
pnpm -C apps/server build # Must pass
|
||||
```
|
||||
|
||||
### Final Checklist
|
||||
- [ ] Side-by-side diff renders correctly
|
||||
- [ ] Hide whitespace re-fetches with `-w`
|
||||
- [ ] Wrap lines toggles CSS
|
||||
- [ ] Expand/Collapse all toggles
|
||||
- [ ] Inline comments: create, read, update, delete
|
||||
- [ ] File editing: read, modify, save, cancel
|
||||
- [ ] All preferences survive page reload
|
||||
1015
.omo/plans/openspec-cleanup.md
Normal file
1015
.omo/plans/openspec-cleanup.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { loadConfig } from './config.js';
|
||||
import { getPool, closeDb } from './db.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import { registerTerminalRoutes } from './routes/terminals.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerWsAttachRoute } from './ws/attach.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -33,6 +34,7 @@ async function main(): Promise<void> {
|
||||
|
||||
registerHealthRoutes(app);
|
||||
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
|
||||
registerSessionRoutes(app);
|
||||
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
|
||||
44
apps/booterm/src/pty/registry.ts
Normal file
44
apps/booterm/src/pty/registry.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface SessionMeta {
|
||||
paneId: string;
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
title?: string;
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, SessionMeta>();
|
||||
|
||||
export function register(
|
||||
sessionId: string,
|
||||
paneId: string,
|
||||
projectPath: string,
|
||||
title?: string,
|
||||
): void {
|
||||
const now = new Date();
|
||||
const existing = sessions.get(paneId);
|
||||
if (existing) {
|
||||
existing.lastActivityAt = now;
|
||||
return;
|
||||
}
|
||||
sessions.set(paneId, {
|
||||
paneId,
|
||||
sessionId,
|
||||
projectPath,
|
||||
title,
|
||||
createdAt: now,
|
||||
lastActivityAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister(paneId: string): void {
|
||||
sessions.delete(paneId);
|
||||
}
|
||||
|
||||
export function list(): SessionMeta[] {
|
||||
return Array.from(sessions.values());
|
||||
}
|
||||
|
||||
export function get(paneId: string): SessionMeta | undefined {
|
||||
return sessions.get(paneId);
|
||||
}
|
||||
18
apps/booterm/src/routes/sessions.ts
Normal file
18
apps/booterm/src/routes/sessions.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { list } from '../pty/registry.js';
|
||||
|
||||
export function registerSessionRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/term/sessions', async (_req, reply) => {
|
||||
const active = list();
|
||||
return reply.code(200).send({
|
||||
sessions: active.map((s) => ({
|
||||
paneId: s.paneId,
|
||||
sessionId: s.sessionId,
|
||||
projectPath: s.projectPath,
|
||||
title: s.title ?? null,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
lastActivityAt: s.lastActivityAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../pty/manager.js';
|
||||
import { attachPty } from '../pty/pty.js';
|
||||
import { getUser } from '../auth.js';
|
||||
import { register, unregister } from '../pty/registry.js';
|
||||
|
||||
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
||||
app.get<{
|
||||
@@ -57,6 +58,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
||||
return;
|
||||
}
|
||||
|
||||
register(sid, pid, session.project_path);
|
||||
|
||||
let handle: IPty;
|
||||
try {
|
||||
handle = attachPty({
|
||||
@@ -157,6 +160,7 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
||||
// teardown happens via the /kill route called from the frontend when the
|
||||
// user closes the pane.
|
||||
socket.on('close', () => {
|
||||
unregister(pid);
|
||||
try {
|
||||
handle.kill();
|
||||
} catch {
|
||||
|
||||
204
apps/coder/src/services/behavioral/generation.ts
Normal file
204
apps/coder/src/services/behavioral/generation.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Schematic generator for behavioral guideline batches.
|
||||
*
|
||||
* Port of boocontext-audit/src/generation.ts — abstract LLM batch caller
|
||||
* with temperature retry and structured output per batch type.
|
||||
*/
|
||||
|
||||
import { type GenerationInfo } from './matching.js';
|
||||
|
||||
// ─── Output types per batch ───
|
||||
|
||||
export interface ObservationalOutput {
|
||||
checks: {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
rationale: string;
|
||||
applies: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ActionableOutput {
|
||||
checks: {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
action: string;
|
||||
rationale: string;
|
||||
applies: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface PreviouslyAppliedOutput {
|
||||
checks: {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
action_segment: string;
|
||||
rationale: string;
|
||||
is_still_applicable: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface DisambiguationOutput {
|
||||
source_guideline_id: string;
|
||||
rationale: string;
|
||||
enriched_action: string;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface ResponseAnalysisOutput {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
was_followed: boolean;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
// ─── Batch output map ───
|
||||
|
||||
export interface BatchOutputMap {
|
||||
observational: ObservationalOutput;
|
||||
actionable: ActionableOutput;
|
||||
previously_applied: PreviouslyAppliedOutput;
|
||||
disambiguation: DisambiguationOutput;
|
||||
response_analysis: ResponseAnalysisOutput;
|
||||
}
|
||||
|
||||
export type BatchTypeKey = keyof BatchOutputMap;
|
||||
|
||||
export type OutputForBatch<T extends BatchTypeKey> = BatchOutputMap[T];
|
||||
|
||||
// ─── SchematicGenerator ───
|
||||
|
||||
export abstract class SchematicGenerator<TSchema> {
|
||||
constructor(public modelName: string) {}
|
||||
|
||||
abstract generate(
|
||||
prompt: string,
|
||||
hints?: Record<string, unknown>,
|
||||
): Promise<{
|
||||
content: TSchema;
|
||||
info: GenerationInfo;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default stub implementation that returns empty results.
|
||||
* Replace with a real LLM caller in production.
|
||||
*/
|
||||
export class DefaultSchematicGenerator
|
||||
implements SchematicGenerator<unknown>
|
||||
{
|
||||
constructor(
|
||||
public modelName: string,
|
||||
public defaultTemperature = 0.7,
|
||||
) {}
|
||||
|
||||
async generate(
|
||||
_prompt: string,
|
||||
hints?: Record<string, unknown>,
|
||||
): Promise<{ content: unknown; info: GenerationInfo }> {
|
||||
const temperature = (hints?.temperature as number) ?? this.defaultTemperature;
|
||||
return {
|
||||
content: {},
|
||||
info: {
|
||||
model: this.modelName,
|
||||
duration: 0,
|
||||
tokens: 0,
|
||||
temperature,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Execution plans ───
|
||||
|
||||
export interface BatchExecutionPlan {
|
||||
batchType: BatchTypeKey;
|
||||
guidelines: { id: string; condition: string; action?: string | null }[];
|
||||
priority: number;
|
||||
independent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ordered execution plan from categorized guideline collections.
|
||||
* Groups are sorted by priority: previously_applied (fastest) first,
|
||||
* then observational, actionable, disambiguation, low-criticality last.
|
||||
*/
|
||||
export function createExecutionPlan(
|
||||
observational: { id: string; condition: string }[],
|
||||
actionable: { id: string; condition: string; action: string }[],
|
||||
previouslyApplied: { id: string; condition: string; action?: string | null }[],
|
||||
disambiguationGroups: { source: string; targets: string[]; enrichedAction: string }[],
|
||||
lowCriticality: { id: string; condition: string }[],
|
||||
): BatchExecutionPlan[] {
|
||||
const plans: BatchExecutionPlan[] = [];
|
||||
|
||||
if (observational.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'observational',
|
||||
guidelines: observational.map((g) => ({ id: g.id, condition: g.condition })),
|
||||
priority: 1,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (actionable.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'actionable',
|
||||
guidelines: actionable.map((g) => ({
|
||||
id: g.id,
|
||||
condition: g.condition,
|
||||
action: g.action,
|
||||
})),
|
||||
priority: 2,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (previouslyApplied.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'previously_applied',
|
||||
guidelines: previouslyApplied.map((g) => ({
|
||||
id: g.id,
|
||||
condition: g.condition,
|
||||
action: g.action,
|
||||
})),
|
||||
priority: 0,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (disambiguationGroups.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'disambiguation',
|
||||
guidelines: disambiguationGroups.map((g) => ({
|
||||
id: g.source,
|
||||
condition: g.enrichedAction,
|
||||
})),
|
||||
priority: 3,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (lowCriticality.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'observational',
|
||||
guidelines: lowCriticality.map((g) => ({ id: g.id, condition: g.condition })),
|
||||
priority: 10,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
return plans.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute retry temperatures: base + 0.2 * attempt.
|
||||
* Provides progressive temperature increases for failed calls.
|
||||
*/
|
||||
export function getRetryTemperatures(baseTemp: number, maxAttempts = 3): number[] {
|
||||
const temps: number[] = [];
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
temps.push(baseTemp + i * 0.2);
|
||||
}
|
||||
return temps;
|
||||
}
|
||||
77
apps/coder/src/services/behavioral/index.ts
Normal file
77
apps/coder/src/services/behavioral/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Behavioral engine — multi-batch matcher and relational resolver.
|
||||
*
|
||||
* Import from the existing guideline-service.ts:
|
||||
* import { MultiBatchMatcher } from './behavioral/matching.js';
|
||||
* import { RelationalResolver } from './behavioral/resolver.js';
|
||||
*/
|
||||
|
||||
// matching.ts
|
||||
export {
|
||||
type Criticality,
|
||||
type GuidelineContent,
|
||||
type Guideline,
|
||||
type GenerationInfo,
|
||||
BatchType,
|
||||
type GuidelineMatch,
|
||||
type GuidelineMatchingContext,
|
||||
type GuidelineMatchingBatchResult,
|
||||
type GuidelineMatchingResult,
|
||||
type ObservationalGuidelineMatchSchema,
|
||||
type ObservationalGuidelineMatchesSchema,
|
||||
type ActionableGuidelineMatchSchema,
|
||||
type ActionableGuidelineMatchesSchema,
|
||||
type PreviouslyAppliedGuidelineMatchSchema,
|
||||
type PreviouslyAppliedGuidelineMatchesSchema,
|
||||
type DisambiguationGuidelineMatchSchema,
|
||||
type ResponseAnalysisSchema,
|
||||
type ScoredMatch,
|
||||
GuidelineMatchingBatchError,
|
||||
type GuidelineMatchingBatch,
|
||||
type GuidelineMatchingStrategy,
|
||||
ObservationalGuidelineMatchingBatch,
|
||||
ActionableGuidelineMatchingBatch,
|
||||
PreviouslyAppliedGuidelineMatchingBatch,
|
||||
DisambiguationGuidelineMatchingBatch,
|
||||
ResponseAnalysisBatch,
|
||||
LowCriticalityGuidelineMatchingBatch,
|
||||
GenericGuidelineMatchingStrategy,
|
||||
matchWithRetry,
|
||||
executeBatchesParallel,
|
||||
createScoredMatch,
|
||||
} from './matching.js';
|
||||
|
||||
// resolver.ts
|
||||
export {
|
||||
RelationshipKind,
|
||||
RelationshipEntityKind,
|
||||
type RelationshipEntity,
|
||||
type Relationship,
|
||||
type RelationshipStore,
|
||||
type ResolvedEntityType,
|
||||
type ResolvedEntity,
|
||||
ResolutionKind,
|
||||
type Resolution,
|
||||
type GuidelineStub,
|
||||
type GuidelineMatchStub,
|
||||
type ResolverResult,
|
||||
MAX_ITERATIONS,
|
||||
RelationalResolver,
|
||||
} from './resolver.js';
|
||||
|
||||
// generation.ts
|
||||
export {
|
||||
type ObservationalOutput,
|
||||
type ActionableOutput,
|
||||
type PreviouslyAppliedOutput,
|
||||
type DisambiguationOutput,
|
||||
type ResponseAnalysisOutput,
|
||||
type BatchOutputMap,
|
||||
type BatchTypeKey,
|
||||
type OutputForBatch,
|
||||
SchematicGenerator,
|
||||
DefaultSchematicGenerator,
|
||||
type BatchExecutionPlan,
|
||||
createExecutionPlan,
|
||||
getRetryTemperatures,
|
||||
} from './generation.js';
|
||||
435
apps/coder/src/services/behavioral/matching.ts
Normal file
435
apps/coder/src/services/behavioral/matching.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Multi-batch matcher for behavioral guidelines.
|
||||
*
|
||||
* Port of boocontext-audit/src/matching.ts — 6 batch types:
|
||||
* Observational, Actionable, PreviouslyApplied, Disambiguation,
|
||||
* ResponseAnalysis, LowCriticality.
|
||||
*/
|
||||
|
||||
// ─── Guideline types (compatible with guideline-service.ts) ───
|
||||
|
||||
export type Criticality = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface GuidelineContent {
|
||||
condition: string;
|
||||
action: string | null;
|
||||
}
|
||||
|
||||
export interface Guideline {
|
||||
id: string;
|
||||
content: GuidelineContent;
|
||||
enabled: boolean;
|
||||
criticality: Criticality;
|
||||
priority: number;
|
||||
labels: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
tags: string[];
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
// ─── Generation info (self-contained to avoid circular dep) ───
|
||||
|
||||
export interface GenerationInfo {
|
||||
model: string;
|
||||
duration: number;
|
||||
tokens: number;
|
||||
temperature: number;
|
||||
attempt?: number;
|
||||
}
|
||||
|
||||
// ─── Batch type enum ───
|
||||
|
||||
export enum BatchType {
|
||||
Observational = 'observational',
|
||||
Actionable = 'actionable',
|
||||
PreviouslyApplied = 'previously_applied',
|
||||
Disambiguation = 'disambiguation',
|
||||
ResponseAnalysis = 'response_analysis',
|
||||
LowCriticality = 'low_criticality',
|
||||
}
|
||||
|
||||
// ─── Match result types ───
|
||||
|
||||
export interface GuidelineMatch {
|
||||
guideline: Guideline;
|
||||
score: number;
|
||||
rationale: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingContext {
|
||||
agent: string;
|
||||
session: string;
|
||||
customer: string;
|
||||
contextVariables: Record<string, string>[];
|
||||
interactionHistory: unknown[];
|
||||
terms: string[];
|
||||
capabilities?: string[];
|
||||
stagedEvents?: unknown[];
|
||||
activeJourneys?: unknown[];
|
||||
journeyPaths?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingBatchResult {
|
||||
matches: GuidelineMatch[];
|
||||
generationInfo: GenerationInfo;
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingResult {
|
||||
totalDuration: number;
|
||||
batchCount: number;
|
||||
batchGenerations: GenerationInfo[];
|
||||
batches: GuidelineMatch[][];
|
||||
matches: GuidelineMatch[];
|
||||
}
|
||||
|
||||
// ─── Schema types for structured LLM output ───
|
||||
|
||||
export interface ObservationalGuidelineMatchSchema {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
rationale: string;
|
||||
applies: boolean;
|
||||
}
|
||||
|
||||
export interface ObservationalGuidelineMatchesSchema {
|
||||
checks: ObservationalGuidelineMatchSchema[];
|
||||
}
|
||||
|
||||
export interface ActionableGuidelineMatchSchema {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
action: string;
|
||||
rationale: string;
|
||||
applies: boolean;
|
||||
}
|
||||
|
||||
export interface ActionableGuidelineMatchesSchema {
|
||||
checks: ActionableGuidelineMatchSchema[];
|
||||
}
|
||||
|
||||
export interface PreviouslyAppliedGuidelineMatchSchema {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
action_segment: string;
|
||||
rationale: string;
|
||||
is_still_applicable: boolean;
|
||||
}
|
||||
|
||||
export interface PreviouslyAppliedGuidelineMatchesSchema {
|
||||
checks: PreviouslyAppliedGuidelineMatchSchema[];
|
||||
}
|
||||
|
||||
export interface DisambiguationGuidelineMatchSchema {
|
||||
source_guideline_id: string;
|
||||
rationale: string;
|
||||
enriched_action: string;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface ResponseAnalysisSchema {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
was_followed: boolean;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
export interface ScoredMatch {
|
||||
guideline_id: string;
|
||||
score: number;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
// ─── Matching batch contract ───
|
||||
|
||||
export class GuidelineMatchingBatchError extends Error {
|
||||
constructor(message = 'Guideline Matching Batch failed') {
|
||||
super(message);
|
||||
this.name = 'GuidelineMatchingBatchError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingBatch {
|
||||
readonly size: number;
|
||||
process(): Promise<GuidelineMatchingBatchResult>;
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingStrategy {
|
||||
createMatchingBatches(
|
||||
guidelines: Guideline[],
|
||||
context: GuidelineMatchingContext,
|
||||
): GuidelineMatchingBatch[];
|
||||
|
||||
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[];
|
||||
}
|
||||
|
||||
// ─── Batch implementations ───
|
||||
|
||||
function scoreFromApplies(applies: boolean): number {
|
||||
return applies ? 10 : 1;
|
||||
}
|
||||
|
||||
export class ObservationalGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public guidelines: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelines.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const matches: GuidelineMatch[] = [];
|
||||
for (const g of this.guidelines) {
|
||||
if (g.content.action !== null && g.content.action !== undefined) continue;
|
||||
matches.push({
|
||||
guideline: g,
|
||||
score: 10,
|
||||
rationale: `Observational batch evaluated: "${g.content.condition}"`,
|
||||
metadata: { batch_type: BatchType.Observational },
|
||||
});
|
||||
}
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionableGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public guidelines: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelines.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const matches: GuidelineMatch[] = [];
|
||||
for (const g of this.guidelines) {
|
||||
if (g.content.action === null || g.content.action === undefined) continue;
|
||||
if (g.content.action === '') continue;
|
||||
matches.push({
|
||||
guideline: g,
|
||||
score: 10,
|
||||
rationale: `Actionable batch evaluated: when "${g.content.condition}", then "${g.content.action}"`,
|
||||
metadata: { batch_type: BatchType.Actionable },
|
||||
});
|
||||
}
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class PreviouslyAppliedGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public guidelines: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public priorMatches: GuidelineMatch[],
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelines.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const alreadyApplied = new Set(
|
||||
this.priorMatches.filter((m) => m.score >= 10).map((m) => m.guideline.id),
|
||||
);
|
||||
const matches: GuidelineMatch[] = [];
|
||||
for (const g of this.guidelines) {
|
||||
if (alreadyApplied.has(g.id)) {
|
||||
matches.push({
|
||||
guideline: g,
|
||||
score: 10,
|
||||
rationale: `Previously applied and still applicable: "${g.content.condition}"`,
|
||||
metadata: { batch_type: BatchType.PreviouslyApplied },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class DisambiguationGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public disambiguationGuideline: Guideline,
|
||||
public targets: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return 1 + this.targets.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const matches: GuidelineMatch[] = [];
|
||||
matches.push({
|
||||
guideline: this.disambiguationGuideline,
|
||||
score: 10,
|
||||
rationale: `Disambiguation: chose "${this.disambiguationGuideline.content.condition}" over targets`,
|
||||
metadata: {
|
||||
batch_type: BatchType.Disambiguation,
|
||||
disambiguation: {
|
||||
targets: this.targets.map((t) => t.id),
|
||||
enriched_action: this.disambiguationGuideline.content.action ?? '',
|
||||
},
|
||||
},
|
||||
});
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseAnalysisBatch {
|
||||
constructor(
|
||||
public guidelineMatches: GuidelineMatch[],
|
||||
public context: Record<string, unknown>,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelineMatches.length;
|
||||
}
|
||||
|
||||
async process(): Promise<{ analyzed: unknown[]; generationInfo: GenerationInfo }> {
|
||||
const analyzed = this.guidelineMatches.map((m) => ({
|
||||
guideline: m.guideline,
|
||||
is_previously_applied: m.score >= 10,
|
||||
}));
|
||||
return { analyzed, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class LowCriticalityGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public guidelines: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelines.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const matches: GuidelineMatch[] = [];
|
||||
for (const g of this.guidelines) {
|
||||
if (g.criticality !== 'low') continue;
|
||||
matches.push({
|
||||
guideline: g,
|
||||
score: g.content.action ? 10 : 1,
|
||||
rationale: `Low-criticality batch: "${g.content.condition}"`,
|
||||
metadata: { batch_type: BatchType.LowCriticality },
|
||||
});
|
||||
}
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Strategy ───
|
||||
|
||||
export class GenericGuidelineMatchingStrategy implements GuidelineMatchingStrategy {
|
||||
constructor(public generationInfo: GenerationInfo) {}
|
||||
|
||||
createMatchingBatches(
|
||||
guidelines: Guideline[],
|
||||
context: GuidelineMatchingContext,
|
||||
): GuidelineMatchingBatch[] {
|
||||
const observational: Guideline[] = [];
|
||||
const actionable: Guideline[] = [];
|
||||
const lowCriticality: Guideline[] = [];
|
||||
const disambiguationCandidates: Guideline[] = [];
|
||||
|
||||
for (const g of guidelines) {
|
||||
if (g.criticality === 'low') {
|
||||
lowCriticality.push(g);
|
||||
} else if (!g.content.action) {
|
||||
disambiguationCandidates.push(g);
|
||||
} else if (g.content.action) {
|
||||
actionable.push(g);
|
||||
} else {
|
||||
observational.push(g);
|
||||
}
|
||||
}
|
||||
|
||||
const batches: GuidelineMatchingBatch[] = [];
|
||||
|
||||
if (observational.length > 0) {
|
||||
batches.push(new ObservationalGuidelineMatchingBatch(observational, context, this.generationInfo));
|
||||
}
|
||||
|
||||
if (actionable.length > 0) {
|
||||
batches.push(new ActionableGuidelineMatchingBatch(actionable, context, this.generationInfo));
|
||||
}
|
||||
|
||||
if (lowCriticality.length > 0) {
|
||||
batches.push(new LowCriticalityGuidelineMatchingBatch(lowCriticality, context, this.generationInfo));
|
||||
}
|
||||
|
||||
return batches;
|
||||
}
|
||||
|
||||
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[] {
|
||||
const seen = new Set<string>();
|
||||
return matches.filter((m) => {
|
||||
const key = m.guideline.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Utilities ───
|
||||
|
||||
export async function matchWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxAttempts = 3,
|
||||
_baseTemperature = 0.7,
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < maxAttempts - 1) {
|
||||
// will retry
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function executeBatchesParallel(
|
||||
batches: GuidelineMatchingBatch[],
|
||||
_generationInfo: GenerationInfo,
|
||||
): Promise<GuidelineMatchingResult> {
|
||||
const start = Date.now();
|
||||
const results = await Promise.all(
|
||||
batches.map((batch) => matchWithRetry(() => batch.process())),
|
||||
);
|
||||
|
||||
const allBatches = results.map((r) => r.matches);
|
||||
const allMatches = allBatches.flat();
|
||||
const allGenInfos = results.map((r) => r.generationInfo);
|
||||
|
||||
return {
|
||||
totalDuration: Date.now() - start,
|
||||
batchCount: batches.length,
|
||||
batchGenerations: allGenInfos,
|
||||
batches: allBatches,
|
||||
matches: allMatches,
|
||||
};
|
||||
}
|
||||
|
||||
export function createScoredMatch(
|
||||
guidelineId: string,
|
||||
score: number,
|
||||
rationale: string,
|
||||
): ScoredMatch {
|
||||
return { guideline_id: guidelineId, score, rationale };
|
||||
}
|
||||
355
apps/coder/src/services/behavioral/resolver.ts
Normal file
355
apps/coder/src/services/behavioral/resolver.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Relational resolver for behavioral guidelines.
|
||||
*
|
||||
* Port of boocontext-audit/src/resolver.ts — resolves DEPENDS_ON,
|
||||
* PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES relationships
|
||||
* with an iterative convergence loop.
|
||||
*/
|
||||
|
||||
// ─── Relationship types (self-contained) ───
|
||||
|
||||
export enum RelationshipKind {
|
||||
DEPENDS_ON = 'depends_on',
|
||||
PRIORITIZES = 'prioritizes',
|
||||
ENTAILS = 'entails',
|
||||
TAG_ALL = 'tag_all',
|
||||
TAG_PRIORITIZES = 'tag_prioritizes',
|
||||
}
|
||||
|
||||
export enum RelationshipEntityKind {
|
||||
GUIDELINE = 'guideline',
|
||||
TAG = 'tag',
|
||||
}
|
||||
|
||||
export interface RelationshipEntity {
|
||||
id: string;
|
||||
kind: RelationshipEntityKind;
|
||||
}
|
||||
|
||||
export interface Relationship {
|
||||
id: string;
|
||||
creation_utc: string;
|
||||
source: RelationshipEntity;
|
||||
target: RelationshipEntity;
|
||||
kind: RelationshipKind;
|
||||
group_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal relationship store interface.
|
||||
* The resolver only needs listRelationships. Implementations
|
||||
* can back against files, postgres, or in-memory maps.
|
||||
*/
|
||||
export interface RelationshipStore {
|
||||
listRelationships(
|
||||
kind?: RelationshipKind,
|
||||
sourceId?: string,
|
||||
targetId?: string,
|
||||
): Promise<Relationship[]>;
|
||||
}
|
||||
|
||||
// ─── Resolution types ───
|
||||
|
||||
export type ResolvedEntityType = 'guideline' | 'journey' | 'tag';
|
||||
|
||||
export interface ResolvedEntity {
|
||||
entityType: ResolvedEntityType;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
export enum ResolutionKind {
|
||||
NONE = 'none',
|
||||
UNMET_DEPENDENCY = 'unmet_dependency',
|
||||
DEPRIORITIZED = 'deprioritized',
|
||||
ENTAILED = 'entailed',
|
||||
}
|
||||
|
||||
export interface Resolution {
|
||||
kind: ResolutionKind;
|
||||
description: string;
|
||||
relationshipId?: string;
|
||||
counterparts?: ResolvedEntity[];
|
||||
}
|
||||
|
||||
export interface GuidelineStub {
|
||||
id: string;
|
||||
priority: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface GuidelineMatchStub {
|
||||
guideline: GuidelineStub;
|
||||
}
|
||||
|
||||
export interface ResolverResult {
|
||||
matchedIds: Set<string>;
|
||||
resolutions: Map<string, Resolution[]>;
|
||||
converged: boolean;
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
// ─── Constants ───
|
||||
|
||||
export const MAX_ITERATIONS = 100;
|
||||
|
||||
// ─── RelationalResolver ───
|
||||
|
||||
export class RelationalResolver {
|
||||
private store: RelationshipStore;
|
||||
|
||||
constructor(store: RelationshipStore) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
async resolve(
|
||||
matchedIds: Set<string>,
|
||||
allGuidelines: GuidelineStub[],
|
||||
): Promise<ResolverResult> {
|
||||
const resolutions = new Map<string, Resolution[]>();
|
||||
const guidelinesById = new Map(allGuidelines.map((g) => [g.id, g]));
|
||||
let currentIds = new Set(matchedIds);
|
||||
const priorityRemoved = new Set<string>();
|
||||
const entailedIds = new Set<string>();
|
||||
|
||||
let converged = false;
|
||||
let iterations = 0;
|
||||
|
||||
for (iterations = 0; iterations < MAX_ITERATIONS; iterations++) {
|
||||
const candidateIds = new Set(
|
||||
[...currentIds].filter((id) => !priorityRemoved.has(id)),
|
||||
);
|
||||
|
||||
const step1Ids = await this.applyDependencies(candidateIds, guidelinesById, resolutions);
|
||||
|
||||
const step2Ids = await this.applyPrioritization(
|
||||
step1Ids,
|
||||
guidelinesById,
|
||||
resolutions,
|
||||
priorityRemoved,
|
||||
);
|
||||
|
||||
const step3Ids = this.applyNumericalPriority(
|
||||
step2Ids,
|
||||
guidelinesById,
|
||||
resolutions,
|
||||
priorityRemoved,
|
||||
entailedIds,
|
||||
);
|
||||
|
||||
const step4Ids = await this.applyEntailment(
|
||||
step3Ids,
|
||||
guidelinesById,
|
||||
resolutions,
|
||||
priorityRemoved,
|
||||
entailedIds,
|
||||
);
|
||||
|
||||
if (this.setsEqual(step4Ids, currentIds)) {
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
|
||||
currentIds = step4Ids;
|
||||
}
|
||||
|
||||
for (const id of allGuidelines.map((g) => g.id)) {
|
||||
if (!resolutions.has(id)) {
|
||||
resolutions.set(id, [
|
||||
{ kind: ResolutionKind.NONE, description: 'No relational changes' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matchedIds: currentIds,
|
||||
resolutions,
|
||||
converged,
|
||||
iterations: iterations + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private steps ──
|
||||
|
||||
private async applyDependencies(
|
||||
candidateIds: Set<string>,
|
||||
_guidelinesById: Map<string, GuidelineStub>,
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
): Promise<Set<string>> {
|
||||
const surviving = new Set(candidateIds);
|
||||
const cache = new Map<string, Relationship[]>();
|
||||
|
||||
for (const gid of candidateIds) {
|
||||
const rels = await this.getRelationshipsFromCache(cache, gid, RelationshipKind.DEPENDS_ON);
|
||||
|
||||
for (const rel of rels) {
|
||||
const targetId = rel.target.id;
|
||||
if (!candidateIds.has(targetId)) {
|
||||
surviving.delete(gid);
|
||||
this.addResolution(resolutions, gid, {
|
||||
kind: ResolutionKind.UNMET_DEPENDENCY,
|
||||
description: `Depends on ${targetId} which is not matched`,
|
||||
relationshipId: rel.id,
|
||||
counterparts: [{ entityType: 'guideline' as const, entityId: targetId }],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return surviving;
|
||||
}
|
||||
|
||||
private async applyPrioritization(
|
||||
candidateIds: Set<string>,
|
||||
guidelinesById: Map<string, GuidelineStub>,
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
priorityRemoved: Set<string>,
|
||||
): Promise<Set<string>> {
|
||||
const surviving = new Set(candidateIds);
|
||||
const cache = new Map<string, Relationship[]>();
|
||||
|
||||
for (const gid of candidateIds) {
|
||||
if (priorityRemoved.has(gid)) continue;
|
||||
|
||||
const allRels = await this.getAllRelationships(cache, gid);
|
||||
const priorityRels = allRels.filter((r) => r.kind === RelationshipKind.PRIORITIZES);
|
||||
|
||||
for (const rel of priorityRels) {
|
||||
const sourceId = rel.source.id;
|
||||
if (sourceId !== gid) continue;
|
||||
const targetId = rel.target.id;
|
||||
|
||||
if (candidateIds.has(targetId)) {
|
||||
surviving.delete(targetId);
|
||||
priorityRemoved.add(targetId);
|
||||
this.addResolution(resolutions, targetId, {
|
||||
kind: ResolutionKind.DEPRIORITIZED,
|
||||
description: `Deprioritized by ${gid}`,
|
||||
relationshipId: rel.id,
|
||||
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return surviving;
|
||||
}
|
||||
|
||||
private applyNumericalPriority(
|
||||
candidateIds: Set<string>,
|
||||
guidelinesById: Map<string, GuidelineStub>,
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
priorityRemoved: Set<string>,
|
||||
entailedIds: Set<string>,
|
||||
): Set<string> {
|
||||
if (candidateIds.size === 0) return candidateIds;
|
||||
|
||||
const nonEntailed = [...candidateIds].filter((id) => !entailedIds.has(id));
|
||||
const entailed = [...candidateIds].filter((id) => entailedIds.has(id));
|
||||
|
||||
if (nonEntailed.length === 0) return new Set(entailed);
|
||||
|
||||
const priorities = nonEntailed.map((id) => guidelinesById.get(id)?.priority ?? 0);
|
||||
const maxPriority = Math.max(...priorities);
|
||||
|
||||
const surviving = new Set<string>();
|
||||
|
||||
for (const id of nonEntailed) {
|
||||
const priority = guidelinesById.get(id)?.priority ?? 0;
|
||||
if (priority >= maxPriority) {
|
||||
surviving.add(id);
|
||||
} else {
|
||||
priorityRemoved.add(id);
|
||||
this.addResolution(resolutions, id, {
|
||||
kind: ResolutionKind.DEPRIORITIZED,
|
||||
description: `Lower priority (${priority} < ${maxPriority})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of entailed) {
|
||||
surviving.add(id);
|
||||
}
|
||||
|
||||
return surviving;
|
||||
}
|
||||
|
||||
private async applyEntailment(
|
||||
candidateIds: Set<string>,
|
||||
guidelinesById: Map<string, GuidelineStub>,
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
priorityRemoved: Set<string>,
|
||||
entailedIds: Set<string>,
|
||||
): Promise<Set<string>> {
|
||||
const result = new Set(candidateIds);
|
||||
const cache = new Map<string, Relationship[]>();
|
||||
|
||||
for (const gid of candidateIds) {
|
||||
if (priorityRemoved.has(gid)) continue;
|
||||
|
||||
const allRels = await this.getAllRelationships(cache, gid);
|
||||
const entailRels = allRels.filter((r) => r.kind === RelationshipKind.ENTAILS);
|
||||
|
||||
for (const rel of entailRels) {
|
||||
const targetId = rel.target.id;
|
||||
if (!guidelinesById.has(targetId)) continue;
|
||||
if (priorityRemoved.has(targetId)) continue;
|
||||
if (entailedIds.has(targetId)) continue;
|
||||
|
||||
result.add(targetId);
|
||||
entailedIds.add(targetId);
|
||||
this.addResolution(resolutions, targetId, {
|
||||
kind: ResolutionKind.ENTAILED,
|
||||
description: `Entailed by ${gid}`,
|
||||
relationshipId: rel.id,
|
||||
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Cache helpers ──
|
||||
|
||||
private async getRelationshipsFromCache(
|
||||
cache: Map<string, Relationship[]>,
|
||||
gid: string,
|
||||
kind: RelationshipKind,
|
||||
): Promise<Relationship[]> {
|
||||
const key = `${kind}:${gid}`;
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, await this.store.listRelationships(kind, gid));
|
||||
}
|
||||
return cache.get(key)!;
|
||||
}
|
||||
|
||||
private async getAllRelationships(
|
||||
cache: Map<string, Relationship[]>,
|
||||
gid: string,
|
||||
): Promise<Relationship[]> {
|
||||
const result: Relationship[] = [];
|
||||
const kinds = Object.values(RelationshipKind) as RelationshipKind[];
|
||||
for (const kind of kinds) {
|
||||
const rels = await this.getRelationshipsFromCache(cache, gid, kind);
|
||||
const targetRels = await this.getRelationshipsFromCache(cache, `target:${gid}`, kind);
|
||||
result.push(...rels, ...targetRels);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private addResolution(
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
id: string,
|
||||
resolution: Resolution,
|
||||
): void {
|
||||
if (!resolutions.has(id)) resolutions.set(id, []);
|
||||
resolutions.get(id)!.push(resolution);
|
||||
}
|
||||
|
||||
private setsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const item of a) if (!b.has(item)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type TerminalMessageStatus,
|
||||
} from './finalize-message.js';
|
||||
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
||||
import { emitHook } from '../plugins/host.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (
|
||||
@@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): {
|
||||
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
|
||||
}
|
||||
|
||||
// EmitHook: fire-and-forget turn.end notification. Best-effort — a hook throwing
|
||||
// is silently swallowed so it never blocks the dispatch flow.
|
||||
function emitTurnEnd(
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
state: string,
|
||||
agent?: string | null,
|
||||
model?: string | null,
|
||||
outputSummary?: string,
|
||||
): void {
|
||||
void emitHook('turn.end', {
|
||||
sessionId,
|
||||
turnSummary: { taskId, state, agent, model: model ?? undefined, outputSummary },
|
||||
});
|
||||
}
|
||||
|
||||
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
|
||||
// state and publish the matching message_complete frame. Best-effort + idempotent
|
||||
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
|
||||
@@ -318,6 +335,7 @@ export function createDispatcher(deps: Deps): {
|
||||
|
||||
// Declared before try so the catch block can write it back on the task row.
|
||||
let chatId: string | null = null;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
try {
|
||||
// Mark running
|
||||
@@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): {
|
||||
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants
|
||||
// whose persona is stamped on the session via agent_id) or create a fresh one.
|
||||
const model = task.model ?? config.DEFAULT_MODEL;
|
||||
let sessionId: string;
|
||||
if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
} else {
|
||||
@@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): {
|
||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||
emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary);
|
||||
} else {
|
||||
const [msg] = await sql<{ content: string | null }[]>`
|
||||
SELECT content FROM messages WHERE id = ${assistantId}
|
||||
@@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
||||
emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -419,6 +439,7 @@ export function createDispatcher(deps: Deps): {
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, 'failed', null, task.model, errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +705,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
clearTaskCommands(taskId);
|
||||
return;
|
||||
@@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): {
|
||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||
// #10: external-agent turn completed cleanly.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
||||
emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
|
||||
} catch (err) {
|
||||
@@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): {
|
||||
// preceded its assignment — guard so the status publish never masks the real
|
||||
// error.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
|
||||
// Best-effort cleanup
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
@@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1104,6 +1130,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -1308,6 +1335,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1381,6 +1410,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -1576,6 +1606,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1652,6 +1684,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { AgentEvent } from './agent-backend.js';
|
||||
import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js';
|
||||
import { type AcpToolSnapshot, snapshotToWireToolCall, mapToolLifecycleStatus } from './acp-tool-snapshot.js';
|
||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||
import type { DcpStreamStripper } from './dcp-strip.js';
|
||||
import { emitHook } from '../plugins/host.js';
|
||||
|
||||
export interface FrameEmitterOpts {
|
||||
broker?: Broker;
|
||||
@@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter {
|
||||
}
|
||||
break;
|
||||
case 'tool_call':
|
||||
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
||||
if (canStream()) {
|
||||
broker!.publishFrame(sessionId!, {
|
||||
type: 'tool_call',
|
||||
message_id: assistantId!,
|
||||
chat_id: chatId!,
|
||||
tool_call: snapshotToWireToolCall(e.toolCall),
|
||||
} as WsFrame);
|
||||
}
|
||||
break;
|
||||
case 'tool_update':
|
||||
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
||||
{
|
||||
const lifecycle = mapToolLifecycleStatus(e.toolCall.status, e.toolCall.rawOutput);
|
||||
if (lifecycle === 'completed' || lifecycle === 'failed') {
|
||||
void emitHook('tool.execute.after', {
|
||||
toolName: e.toolCall.title,
|
||||
args: e.toolCall.rawInput,
|
||||
result: e.toolCall.rawOutput,
|
||||
duration: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (canStream()) {
|
||||
broker!.publishFrame(sessionId!, {
|
||||
type: 'tool_call',
|
||||
|
||||
10
apps/coder/src/services/hashline/constants.ts
Normal file
10
apps/coder/src/services/hashline/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"
|
||||
|
||||
export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
|
||||
const high = i >>> 4
|
||||
const low = i & 0x0f
|
||||
return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}`
|
||||
})
|
||||
|
||||
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
|
||||
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/
|
||||
31
apps/coder/src/services/hashline/hash-computation.ts
Normal file
31
apps/coder/src/services/hashline/hash-computation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { HASHLINE_DICT } from "./constants.js"
|
||||
import { hashXxh32 } from "./xxhash32.js"
|
||||
|
||||
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
|
||||
|
||||
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
|
||||
const stripped = normalizedContent
|
||||
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
|
||||
const hash = hashXxh32(stripped, seed)
|
||||
const index = hash % 256
|
||||
return HASHLINE_DICT[index]!
|
||||
}
|
||||
|
||||
export function computeLineHash(lineNumber: number, content: string): string {
|
||||
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd())
|
||||
}
|
||||
|
||||
export function computeLegacyLineHash(lineNumber: number, content: string): string {
|
||||
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, ""))
|
||||
}
|
||||
|
||||
export function formatHashLine(lineNumber: number, content: string): string {
|
||||
const hash = computeLineHash(lineNumber, content)
|
||||
return `${lineNumber}#${hash}|${content}`
|
||||
}
|
||||
|
||||
export function formatHashLines(content: string): string {
|
||||
if (!content) return ""
|
||||
const lines = content.split("\n")
|
||||
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
|
||||
}
|
||||
11
apps/coder/src/services/hashline/index.ts
Normal file
11
apps/coder/src/services/hashline/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Hashline editing core — content-hash anchors for edit_file stale-patch detection.
|
||||
*
|
||||
* Ported from oh-my-openagent/packages/hashline-core/.
|
||||
* Bundles a runtime-aware xxHash32 (Bun fast-path, pure-JS fallback).
|
||||
*/
|
||||
export { computeLineHash, formatHashLines, formatHashLine, computeLegacyLineHash } from "./hash-computation.js"
|
||||
export { parseLineRef, validateLineRef, validateLineRefs, HashlineMismatchError, normalizeLineRef } from "./validation.js"
|
||||
export type { LineRef } from "./validation.js"
|
||||
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants.js"
|
||||
export type { ReplaceEdit, AppendEdit, PrependEdit, HashlineEdit } from "./types.js"
|
||||
20
apps/coder/src/services/hashline/types.ts
Normal file
20
apps/coder/src/services/hashline/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface ReplaceEdit {
|
||||
op: "replace"
|
||||
pos: string
|
||||
end?: string
|
||||
lines: string | string[]
|
||||
}
|
||||
|
||||
export interface AppendEdit {
|
||||
op: "append"
|
||||
pos?: string
|
||||
lines: string | string[]
|
||||
}
|
||||
|
||||
export interface PrependEdit {
|
||||
op: "prepend"
|
||||
pos?: string
|
||||
lines: string | string[]
|
||||
}
|
||||
|
||||
export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit
|
||||
192
apps/coder/src/services/hashline/validation.ts
Normal file
192
apps/coder/src/services/hashline/validation.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { computeLegacyLineHash, computeLineHash } from "./hash-computation.js"
|
||||
import { HASHLINE_REF_PATTERN } from "./constants.js"
|
||||
|
||||
export interface LineRef {
|
||||
line: number
|
||||
hash: string
|
||||
}
|
||||
|
||||
interface HashMismatch {
|
||||
line: number
|
||||
expected: string
|
||||
}
|
||||
|
||||
const MISMATCH_CONTEXT = 2
|
||||
|
||||
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
|
||||
|
||||
function isCompatibleLineHash(line: number, content: string, hash: string): boolean {
|
||||
return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash
|
||||
}
|
||||
|
||||
export function normalizeLineRef(ref: string): string {
|
||||
const originalTrimmed = ref.trim()
|
||||
let trimmed = originalTrimmed
|
||||
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
|
||||
trimmed = trimmed.replace(/\s*#\s*/, "#")
|
||||
trimmed = trimmed.replace(/\|.*$/, "")
|
||||
trimmed = trimmed.trim()
|
||||
|
||||
if (HASHLINE_REF_PATTERN.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
|
||||
if (extracted) {
|
||||
return extracted[1]!
|
||||
}
|
||||
|
||||
return originalTrimmed
|
||||
}
|
||||
|
||||
export function parseLineRef(ref: string): LineRef {
|
||||
const normalized = normalizeLineRef(ref)
|
||||
const match = normalized.match(HASHLINE_REF_PATTERN)
|
||||
if (match) {
|
||||
return {
|
||||
line: Number.parseInt(match[1]!, 10),
|
||||
hash: match[2]!,
|
||||
}
|
||||
}
|
||||
const hashIdx = normalized.indexOf('#')
|
||||
if (hashIdx > 0) {
|
||||
const prefix = normalized.slice(0, hashIdx)
|
||||
const suffix = normalized.slice(hashIdx + 1)
|
||||
if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) {
|
||||
throw new Error(
|
||||
`Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
|
||||
`Use the actual line number from the read output.`
|
||||
)
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
|
||||
)
|
||||
}
|
||||
|
||||
export function validateLineRef(lines: string[], ref: string): void {
|
||||
const { line, hash } = parseLineRefWithHint(ref, lines)
|
||||
|
||||
if (line < 1 || line > lines.length) {
|
||||
throw new Error(
|
||||
`Line number ${line} out of bounds. File has ${lines.length} lines.`
|
||||
)
|
||||
}
|
||||
|
||||
const content = lines[line - 1]
|
||||
if (content === undefined) {
|
||||
throw new Error(
|
||||
`Line number ${line} out of bounds. File has ${lines.length} lines.`
|
||||
)
|
||||
}
|
||||
if (!isCompatibleLineHash(line, content, hash)) {
|
||||
throw new HashlineMismatchError([{ line, expected: hash }], lines)
|
||||
}
|
||||
}
|
||||
|
||||
export class HashlineMismatchError extends Error {
|
||||
readonly remaps: ReadonlyMap<string, string>
|
||||
|
||||
constructor(
|
||||
private readonly mismatches: HashMismatch[],
|
||||
private readonly fileLines: string[]
|
||||
) {
|
||||
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
|
||||
this.name = "HashlineMismatchError"
|
||||
const remaps = new Map<string, string>()
|
||||
for (const mismatch of mismatches) {
|
||||
const content = fileLines[mismatch.line - 1]
|
||||
const actualLine = content ?? ""
|
||||
const actual = computeLineHash(mismatch.line, actualLine)
|
||||
remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`)
|
||||
}
|
||||
this.remaps = remaps
|
||||
}
|
||||
|
||||
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
||||
const mismatchByLine = new Map<number, HashMismatch>()
|
||||
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
|
||||
|
||||
const displayLines = new Set<number>()
|
||||
for (const mismatch of mismatches) {
|
||||
const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT)
|
||||
const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT)
|
||||
for (let line = low; line <= high; line++) displayLines.add(line)
|
||||
}
|
||||
|
||||
const sortedLines = [...displayLines].sort((a, b) => a - b)
|
||||
const output: string[] = []
|
||||
output.push(
|
||||
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
|
||||
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
|
||||
)
|
||||
output.push("")
|
||||
|
||||
let previousLine = -1
|
||||
for (const line of sortedLines) {
|
||||
if (previousLine !== -1 && line > previousLine + 1) {
|
||||
output.push(" ...")
|
||||
}
|
||||
previousLine = line
|
||||
|
||||
const content = fileLines[line - 1] ?? ""
|
||||
const hash = computeLineHash(line, content)
|
||||
const prefix = `${line}#${hash}|${content}`
|
||||
if (mismatchByLine.has(line)) {
|
||||
output.push(`>>> ${prefix}`)
|
||||
} else {
|
||||
output.push(` ${prefix}`)
|
||||
}
|
||||
}
|
||||
|
||||
return output.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
function suggestLineForHash(ref: string, lines: string[]): string | null {
|
||||
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
|
||||
if (!hashMatch) return null
|
||||
const hash = hashMatch[1]!
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (isCompatibleLineHash(i + 1, lines[i] ?? "", hash)) {
|
||||
return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i] ?? "")}"?`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
|
||||
try {
|
||||
return parseLineRef(ref)
|
||||
} catch (parseError) {
|
||||
const hint = suggestLineForHash(ref, lines)
|
||||
if (hint && parseError instanceof Error) {
|
||||
throw new Error(`${parseError.message} ${hint}`)
|
||||
}
|
||||
throw parseError
|
||||
}
|
||||
}
|
||||
|
||||
export function validateLineRefs(lines: string[], refs: string[]): void {
|
||||
const mismatches: HashMismatch[] = []
|
||||
|
||||
for (const ref of refs) {
|
||||
const { line, hash } = parseLineRefWithHint(ref, lines)
|
||||
|
||||
if (line < 1 || line > lines.length) {
|
||||
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
|
||||
}
|
||||
|
||||
const content = lines[line - 1]
|
||||
if (content === undefined) {
|
||||
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
|
||||
}
|
||||
if (!isCompatibleLineHash(line, content, hash)) {
|
||||
mismatches.push({ line, expected: hash })
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
throw new HashlineMismatchError(mismatches, lines)
|
||||
}
|
||||
}
|
||||
90
apps/coder/src/services/hashline/xxhash32.ts
Normal file
90
apps/coder/src/services/hashline/xxhash32.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } }
|
||||
|
||||
const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime }
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const PRIME32_1 = 0x9e3779b1
|
||||
const PRIME32_2 = 0x85ebca77
|
||||
const PRIME32_3 = 0xc2b2ae3d
|
||||
const PRIME32_4 = 0x27d4eb2f
|
||||
const PRIME32_5 = 0x165667b1
|
||||
|
||||
function rotateLeft32(value: number, bits: number): number {
|
||||
return ((value << bits) | (value >>> (32 - bits))) >>> 0
|
||||
}
|
||||
|
||||
function readUint32LittleEndian(input: Uint8Array, offset: number): number {
|
||||
return (
|
||||
((input[offset] ?? 0) |
|
||||
((input[offset + 1] ?? 0) << 8) |
|
||||
((input[offset + 2] ?? 0) << 16) |
|
||||
((input[offset + 3] ?? 0) << 24)) >>>
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
function round32(accumulator: number, value: number): number {
|
||||
const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0
|
||||
return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0
|
||||
}
|
||||
|
||||
function xxHash32Js(input: Uint8Array, seed: number): number {
|
||||
let offset = 0
|
||||
const length = input.length
|
||||
let hash: number
|
||||
|
||||
if (length >= 16) {
|
||||
const limit = length - 16
|
||||
let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0
|
||||
let value2 = (seed + PRIME32_2) >>> 0
|
||||
let value3 = seed >>> 0
|
||||
let value4 = (seed - PRIME32_1) >>> 0
|
||||
|
||||
while (offset <= limit) {
|
||||
value1 = round32(value1, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
value2 = round32(value2, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
value3 = round32(value3, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
value4 = round32(value4, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
}
|
||||
|
||||
hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0
|
||||
hash = (hash + rotateLeft32(value3, 12)) >>> 0
|
||||
hash = (hash + rotateLeft32(value4, 18)) >>> 0
|
||||
} else {
|
||||
hash = (seed + PRIME32_5) >>> 0
|
||||
}
|
||||
|
||||
hash = (hash + length) >>> 0
|
||||
|
||||
while (offset + 4 <= length) {
|
||||
hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0
|
||||
hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0
|
||||
offset += 4
|
||||
}
|
||||
|
||||
while (offset < length) {
|
||||
hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0
|
||||
hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0
|
||||
offset += 1
|
||||
}
|
||||
|
||||
hash = (hash ^ (hash >>> 15)) >>> 0
|
||||
hash = Math.imul(hash, PRIME32_2) >>> 0
|
||||
hash = (hash ^ (hash >>> 13)) >>> 0
|
||||
hash = Math.imul(hash, PRIME32_3) >>> 0
|
||||
|
||||
return (hash ^ (hash >>> 16)) >>> 0
|
||||
}
|
||||
|
||||
export function hashXxh32(input: string, seed: number): number {
|
||||
const bun = runtime.Bun
|
||||
if (bun !== undefined) {
|
||||
return bun.hash.xxHash32(input, seed)
|
||||
}
|
||||
|
||||
return xxHash32Js(encoder.encode(input), seed >>> 0)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ModelMetadata } from "./provider-cache.js"
|
||||
|
||||
export interface ProviderModelsCache {
|
||||
readonly models: Record<string, readonly string[] | readonly ModelMetadata[]>
|
||||
readonly connected: readonly string[]
|
||||
readonly updatedAt: string
|
||||
}
|
||||
|
||||
export interface ConnectedProvidersAdapter {
|
||||
readConnectedProvidersCache(): string[] | null
|
||||
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
|
||||
readProviderModelsCache(): ProviderModelsCache | null
|
||||
}
|
||||
|
||||
export function readConnectedProvidersCache(): string[] | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function findProviderModelMetadata(
|
||||
_providerID: string,
|
||||
_modelID: string,
|
||||
): ModelMetadata | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function readProviderModelsCache(): ProviderModelsCache | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export const connectedProvidersAdapter: ConnectedProvidersAdapter = {
|
||||
readConnectedProvidersCache,
|
||||
findProviderModelMetadata,
|
||||
readProviderModelsCache,
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||
import type { FallbackModelObject } from "./fallback-model-object.js"
|
||||
import { normalizeFallbackModels } from "./model-resolver.js"
|
||||
import { KNOWN_VARIANTS } from "./known-variants.js"
|
||||
|
||||
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
|
||||
if (typeof rawModel !== "string") {
|
||||
return { modelID: "" }
|
||||
}
|
||||
const trimmedModel = rawModel.trim()
|
||||
if (!trimmedModel) {
|
||||
return { modelID: "" }
|
||||
}
|
||||
|
||||
const parenthesizedVariant = trimmedModel.match(/^(.*)\(([^()]+)\)\s*$/)
|
||||
if (parenthesizedVariant) {
|
||||
const modelID = parenthesizedVariant[1]?.trim() ?? ""
|
||||
const variant = parenthesizedVariant[2]?.trim()
|
||||
return variant ? { modelID, variant } : { modelID }
|
||||
}
|
||||
|
||||
const spaceVariant = trimmedModel.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
|
||||
if (spaceVariant) {
|
||||
const modelID = spaceVariant[1]?.trim() ?? ""
|
||||
const variant = spaceVariant[2]?.trim().toLowerCase()
|
||||
if (variant && KNOWN_VARIANTS.has(variant)) {
|
||||
return { modelID, variant }
|
||||
}
|
||||
}
|
||||
|
||||
return { modelID: trimmedModel }
|
||||
}
|
||||
|
||||
export function parseFallbackModelEntry(
|
||||
model: string,
|
||||
contextProviderID: string | undefined,
|
||||
defaultProviderID = "opencode",
|
||||
): FallbackEntry | undefined {
|
||||
if (typeof model !== "string") return undefined
|
||||
const trimmed = model.trim()
|
||||
if (!trimmed) return undefined
|
||||
|
||||
const parts = trimmed.split("/")
|
||||
const providerID =
|
||||
parts.length >= 2 ? (parts[0]?.trim() ?? "") : (contextProviderID?.trim() || defaultProviderID)
|
||||
const rawModelID = parts.length >= 2 ? parts.slice(1).join("/").trim() : trimmed
|
||||
if (!providerID || !rawModelID) return undefined
|
||||
|
||||
const parsed = parseVariantFromModel(rawModelID)
|
||||
if (!parsed.modelID) return undefined
|
||||
|
||||
return {
|
||||
providers: [providerID],
|
||||
model: parsed.modelID,
|
||||
variant: parsed.variant,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFallbackModelObjectEntry(
|
||||
obj: FallbackModelObject,
|
||||
contextProviderID: string | undefined,
|
||||
defaultProviderID = "opencode",
|
||||
): FallbackEntry | undefined {
|
||||
const base = parseFallbackModelEntry(obj.model, contextProviderID, defaultProviderID)
|
||||
if (!base) return undefined
|
||||
|
||||
return {
|
||||
...base,
|
||||
variant: obj.variant ?? base.variant,
|
||||
reasoningEffort: obj.reasoningEffort,
|
||||
temperature: obj.temperature,
|
||||
top_p: obj.top_p,
|
||||
maxTokens: obj.maxTokens,
|
||||
thinking: obj.thinking,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most specific FallbackEntry whose `provider/model` is a prefix of
|
||||
* the resolved `provider/modelID`. Longest match wins so that e.g.
|
||||
* `openai/gpt-5.4-preview` picks the entry for `openai/gpt-5.4-preview` over
|
||||
* the shorter `openai/gpt-5.4`.
|
||||
*/
|
||||
export function findMostSpecificFallbackEntry(
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
chain: FallbackEntry[],
|
||||
): FallbackEntry | undefined {
|
||||
const resolved = `${providerID}/${modelID}`.toLowerCase()
|
||||
|
||||
// Collect entries whose provider/model is a prefix of the resolved model,
|
||||
// together with the length of the matching prefix (longest match wins).
|
||||
const matches: { entry: FallbackEntry; matchLen: number }[] = []
|
||||
for (const entry of chain) {
|
||||
for (const p of entry.providers) {
|
||||
const candidate = `${p}/${entry.model}`.toLowerCase()
|
||||
if (resolved.startsWith(candidate)) {
|
||||
matches.push({ entry, matchLen: candidate.length })
|
||||
break // one match per entry is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return undefined
|
||||
matches.sort((a, b) => b.matchLen - a.matchLen)
|
||||
return matches[0]!.entry
|
||||
}
|
||||
|
||||
export function buildFallbackChainFromModels(
|
||||
fallbackModels: string | (string | FallbackModelObject)[] | undefined,
|
||||
contextProviderID: string | undefined,
|
||||
defaultProviderID = "opencode",
|
||||
): FallbackEntry[] | undefined {
|
||||
const normalized = normalizeFallbackModels(fallbackModels)
|
||||
if (!normalized || normalized.length === 0) return undefined
|
||||
|
||||
const parsed = normalized
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
return parseFallbackModelEntry(entry, contextProviderID, defaultProviderID)
|
||||
}
|
||||
return parseFallbackModelObjectEntry(entry, contextProviderID, defaultProviderID)
|
||||
})
|
||||
.filter((entry): entry is FallbackEntry => entry !== undefined)
|
||||
|
||||
if (parsed.length === 0) return undefined
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type FallbackModelObject = {
|
||||
readonly model: string
|
||||
readonly variant?: string
|
||||
readonly reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
|
||||
readonly temperature?: number
|
||||
readonly top_p?: number
|
||||
readonly maxTokens?: number
|
||||
readonly thinking?: { readonly type: "enabled" | "disabled"; readonly budgetTokens?: number }
|
||||
}
|
||||
80
apps/coder/src/services/model-resolution/index.ts
Normal file
80
apps/coder/src/services/model-resolution/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export type {
|
||||
FallbackEntry,
|
||||
ModelRequirement,
|
||||
} from "./model-requirement-types.js"
|
||||
export type {
|
||||
FallbackModelObject,
|
||||
} from "./fallback-model-object.js"
|
||||
export type {
|
||||
DelegatedModelConfig,
|
||||
ModelResolutionRequest,
|
||||
ModelResolutionProvenance,
|
||||
ModelResolutionResult,
|
||||
} from "./model-resolution-types.js"
|
||||
export type {
|
||||
ModelResolutionInput,
|
||||
ModelSource,
|
||||
ExtendedModelResolutionInput,
|
||||
} from "./model-resolver.js"
|
||||
export {
|
||||
resolveModel,
|
||||
resolveModelWithFallback,
|
||||
normalizeFallbackModels,
|
||||
flattenToFallbackModelStrings,
|
||||
} from "./model-resolver.js"
|
||||
export {
|
||||
normalizeModel,
|
||||
normalizeModelID,
|
||||
} from "./model-normalization.js"
|
||||
export {
|
||||
fuzzyMatchModel,
|
||||
isModelAvailable,
|
||||
} from "./model-availability.js"
|
||||
export {
|
||||
transformModelForProvider,
|
||||
transformModelForProviderDisplay,
|
||||
} from "./provider-model-id-transform.js"
|
||||
export {
|
||||
buildFallbackChainFromModels,
|
||||
parseFallbackModelEntry,
|
||||
parseFallbackModelObjectEntry,
|
||||
findMostSpecificFallbackEntry,
|
||||
} from "./fallback-chain-from-models.js"
|
||||
export {
|
||||
KNOWN_VARIANTS,
|
||||
} from "./known-variants.js"
|
||||
export {
|
||||
_setModelResolutionLogImplementationForTesting,
|
||||
resolveModelPipeline,
|
||||
} from "./model-resolution-pipeline.js"
|
||||
export type {
|
||||
ModelResolutionRequest as PipelineModelResolutionRequest,
|
||||
ModelResolutionProvenance as PipelineModelResolutionProvenance,
|
||||
ModelResolutionResult as PipelineModelResolutionResult,
|
||||
ModelResolutionDeps,
|
||||
} from "./model-resolution-pipeline.js"
|
||||
export {
|
||||
isRetryableModelError,
|
||||
shouldRetryError,
|
||||
getNextFallback,
|
||||
hasMoreFallbacks,
|
||||
selectFallbackProvider,
|
||||
selectFallbackProviderWithCache,
|
||||
} from "./model-error-classifier.js"
|
||||
export type {
|
||||
ErrorInfo,
|
||||
} from "./model-error-classifier.js"
|
||||
export type {
|
||||
ProviderCache,
|
||||
ModelMetadata,
|
||||
} from "./provider-cache.js"
|
||||
export type {
|
||||
ProviderModelsCache,
|
||||
ConnectedProvidersAdapter,
|
||||
} from "./connected-providers-cache.js"
|
||||
export {
|
||||
readConnectedProvidersCache,
|
||||
findProviderModelMetadata,
|
||||
readProviderModelsCache,
|
||||
connectedProvidersAdapter,
|
||||
} from "./connected-providers-cache.js"
|
||||
16
apps/coder/src/services/model-resolution/known-variants.ts
Normal file
16
apps/coder/src/services/model-resolution/known-variants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Canonical set of recognised variant / effort tokens.
|
||||
* Used by parseFallbackModelEntry (space-suffix detection) and
|
||||
* flattenToFallbackModelStrings (inline-variant stripping).
|
||||
*/
|
||||
export const KNOWN_VARIANTS = new Set([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"minimal",
|
||||
"none",
|
||||
"auto",
|
||||
"thinking",
|
||||
])
|
||||
@@ -0,0 +1,64 @@
|
||||
function normalizeModelName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/claude-(opus|sonnet|haiku)-(\d+)[.-](\d+)/g, "claude-$1-$2.$3")
|
||||
}
|
||||
|
||||
export function fuzzyMatchModel(
|
||||
target: string,
|
||||
available: Set<string>,
|
||||
providers?: string[],
|
||||
): string | null {
|
||||
if (available.size === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetNormalized = normalizeModelName(target)
|
||||
|
||||
let candidates = Array.from(available)
|
||||
if (providers && providers.length > 0) {
|
||||
const providerSet = new Set(providers)
|
||||
candidates = candidates.filter((model) => {
|
||||
const [provider] = model.split("/")
|
||||
return providerSet.has(provider!)
|
||||
})
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matches = candidates.filter((model) =>
|
||||
normalizeModelName(model).includes(targetNormalized),
|
||||
)
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized)
|
||||
if (exactMatch) {
|
||||
return exactMatch
|
||||
}
|
||||
|
||||
const exactModelIdMatches = matches.filter((model) => {
|
||||
const modelId = model.split("/").slice(1).join("/")
|
||||
return normalizeModelName(modelId) === targetNormalized
|
||||
})
|
||||
if (exactModelIdMatches.length > 0) {
|
||||
return exactModelIdMatches.reduce((shortest, current) =>
|
||||
current.length < shortest.length ? current : shortest,
|
||||
)
|
||||
}
|
||||
|
||||
return matches.reduce((shortest, current) =>
|
||||
current.length < shortest.length ? current : shortest,
|
||||
)
|
||||
}
|
||||
|
||||
export function isModelAvailable(
|
||||
targetModel: string,
|
||||
availableModels: Set<string>,
|
||||
): boolean {
|
||||
return fuzzyMatchModel(targetModel, availableModels) !== null
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||
import type { ProviderCache } from "./provider-cache.js"
|
||||
import * as connectedProvidersCache from "./connected-providers-cache.js"
|
||||
|
||||
/**
|
||||
* Error names that indicate a retryable model error.
|
||||
* These errors halt execution and should trigger fallback retry.
|
||||
*/
|
||||
const RETRYABLE_ERROR_NAMES = new Set([
|
||||
"providermodelnotfounderror",
|
||||
"ratelimiterror",
|
||||
"modelunavailableerror",
|
||||
"providerconnectionerror",
|
||||
"authenticationerror",
|
||||
])
|
||||
|
||||
const STOP_ERROR_NAMES = new Set([
|
||||
"quotaexceedederror",
|
||||
"insufficientcreditserror",
|
||||
"freeusagelimiterror",
|
||||
])
|
||||
|
||||
/**
|
||||
* Error names that should NOT trigger retry.
|
||||
* These errors are typically user-induced or fixable without switching models.
|
||||
*/
|
||||
const NON_RETRYABLE_ERROR_NAMES = new Set([
|
||||
"messageabortederror",
|
||||
"permissiondeniederror",
|
||||
"contextlengtherror",
|
||||
"timeouterror",
|
||||
"validationerror",
|
||||
"syntaxerror",
|
||||
"usererror",
|
||||
])
|
||||
|
||||
/**
|
||||
* Message patterns that indicate a retryable error even without a known error name.
|
||||
*/
|
||||
const RETRYABLE_MESSAGE_PATTERNS = [
|
||||
"rate_limit",
|
||||
"rate limit",
|
||||
"usage_limit_reached",
|
||||
"usage limit has been reached",
|
||||
"quota",
|
||||
"all credentials for model",
|
||||
"cooling down",
|
||||
"exhausted your capacity",
|
||||
"not found",
|
||||
"unavailable",
|
||||
"insufficient",
|
||||
"too many requests",
|
||||
"over limit",
|
||||
"overloaded",
|
||||
"bad gateway",
|
||||
"bad request",
|
||||
"unknown provider",
|
||||
"provider not found",
|
||||
"model_not_supported",
|
||||
"model not supported",
|
||||
"model is not supported",
|
||||
"connection error",
|
||||
"network error",
|
||||
"timeout",
|
||||
"service unavailable",
|
||||
"internal_server_error",
|
||||
"free usage",
|
||||
"usage exceeded",
|
||||
"credit",
|
||||
"balance",
|
||||
"temporarily unavailable",
|
||||
"try again",
|
||||
"请稍后重试",
|
||||
"503",
|
||||
"502",
|
||||
"504",
|
||||
"429",
|
||||
"529",
|
||||
"selected provider is forbidden",
|
||||
"provider is forbidden",
|
||||
// Chinese retryable patterns (Zhipu, etc.)
|
||||
"频率限制", // "rate limit"
|
||||
"请求过于频繁", // "too many requests"
|
||||
"暂时不可用", // "temporarily unavailable"
|
||||
"服务不可用", // "service unavailable"
|
||||
"server_error",
|
||||
"an error occurred while processing",
|
||||
]
|
||||
|
||||
/**
|
||||
* Message patterns that indicate a non-retryable STOP error (quota/billing exhaustion).
|
||||
* These take precedence over RETRYABLE_MESSAGE_PATTERNS.
|
||||
*/
|
||||
const STOP_MESSAGE_PATTERNS = [
|
||||
"quota will reset after",
|
||||
"quota exceeded",
|
||||
"free usage limit",
|
||||
"billing limit",
|
||||
"billing hard limit",
|
||||
"monthly limit",
|
||||
"plan limit",
|
||||
"subscription quota",
|
||||
"subscription limit",
|
||||
"payment required",
|
||||
"out of credits",
|
||||
"credits exhausted",
|
||||
"insufficient credits",
|
||||
"insufficient balance",
|
||||
"credit balance",
|
||||
"usage limit for this month",
|
||||
"exhausted your capacity",
|
||||
// GLM/Z.ai business error codes that indicate permanent quota/billing exhaustion
|
||||
"daily call limit",
|
||||
"daily limit",
|
||||
"usage limit reached for",
|
||||
"in arrears",
|
||||
"fair use policy",
|
||||
"recharge and try",
|
||||
"使用上限",
|
||||
"额度不足",
|
||||
"余额不足",
|
||||
"已耗尽",
|
||||
]
|
||||
|
||||
const AUTO_RETRY_GATE_PATTERNS = [
|
||||
"rate limit",
|
||||
"cooling down",
|
||||
"credentials for model",
|
||||
]
|
||||
|
||||
function hasProviderAutoRetrySignal(message: string): boolean {
|
||||
if (!message.includes("retrying in")) {
|
||||
return false
|
||||
}
|
||||
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
|
||||
}
|
||||
|
||||
export interface ErrorInfo {
|
||||
name?: string
|
||||
message?: string
|
||||
/** HTTP status code from the provider response (e.g., 429 for rate limit) */
|
||||
statusCode?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error is a retryable model error.
|
||||
* Returns true if it's a known retryable type OR matches retryable message patterns.
|
||||
*/
|
||||
export function isRetryableModelError(error: ErrorInfo): boolean {
|
||||
// If we have an error name, check against known lists
|
||||
if (error.name) {
|
||||
const errorNameLower = error.name.toLowerCase()
|
||||
// Explicit non-retryable takes precedence
|
||||
if (NON_RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
|
||||
return false
|
||||
}
|
||||
if (STOP_ERROR_NAMES.has(errorNameLower)) {
|
||||
return false
|
||||
}
|
||||
// Check if it's a known retryable error
|
||||
if (RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check message patterns for unknown errors
|
||||
const msg = error.message?.toLowerCase() ?? ""
|
||||
|
||||
// STOP patterns take precedence over retryable patterns
|
||||
if (STOP_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (hasProviderAutoRetrySignal(msg)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// HTTP status code check: catches rate-limit errors regardless of message format/language.
|
||||
// Uses the same codes as runtime-fallback config (400 excluded as it is a permanent client error).
|
||||
if (
|
||||
error.statusCode != null &&
|
||||
(error.statusCode === 429 || error.statusCode === 503 || error.statusCode === 529)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error should trigger a fallback retry.
|
||||
* Returns true for errors that halt execution.
|
||||
*/
|
||||
export function shouldRetryError(error: ErrorInfo): boolean {
|
||||
return isRetryableModelError(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next fallback model from the chain based on attempt count.
|
||||
* Returns undefined if all fallbacks have been exhausted.
|
||||
*/
|
||||
export function getNextFallback(
|
||||
fallbackChain: FallbackEntry[],
|
||||
attemptCount: number,
|
||||
): FallbackEntry | undefined {
|
||||
return fallbackChain[attemptCount]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are more fallbacks available after the current attempt.
|
||||
*/
|
||||
export function hasMoreFallbacks(
|
||||
fallbackChain: FallbackEntry[],
|
||||
attemptCount: number,
|
||||
): boolean {
|
||||
return attemptCount < fallbackChain.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the best provider for a fallback entry.
|
||||
* Priority:
|
||||
* 1) First connected provider in the entry's provider preference order
|
||||
* 2) Preferred provider when connected (and entry providers are unavailable)
|
||||
* 3) First provider listed in the fallback entry
|
||||
*/
|
||||
export function selectFallbackProvider(
|
||||
providers: string[],
|
||||
preferredProviderID?: string,
|
||||
): string {
|
||||
return selectFallbackProviderWithCache(
|
||||
providers,
|
||||
connectedProvidersCache,
|
||||
preferredProviderID,
|
||||
)
|
||||
}
|
||||
|
||||
export function selectFallbackProviderWithCache(
|
||||
providers: string[],
|
||||
providerCache: ProviderCache,
|
||||
preferredProviderID?: string,
|
||||
): string {
|
||||
const connectedProviders = providerCache.readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase()))
|
||||
|
||||
for (const provider of providers) {
|
||||
if (connectedSet.has(provider.toLowerCase())) {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
preferredProviderID &&
|
||||
connectedSet.has(preferredProviderID.toLowerCase())
|
||||
) {
|
||||
return preferredProviderID
|
||||
}
|
||||
}
|
||||
|
||||
return providers[0] ?? preferredProviderID ?? "opencode"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function normalizeModel(model?: string): string | undefined {
|
||||
const trimmed = model?.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
export function normalizeModelID(modelID: string): string {
|
||||
return modelID.replace(/\.(\d+)/g, "-$1")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type FallbackEntry = {
|
||||
providers: string[];
|
||||
model: string;
|
||||
variant?: string; // Entry-specific variant (e.g., GPT->high, Opus->max)
|
||||
reasoningEffort?: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
maxTokens?: number;
|
||||
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number };
|
||||
};
|
||||
|
||||
export type ModelRequirement = {
|
||||
fallbackChain: FallbackEntry[];
|
||||
variant?: string; // Default variant (used when entry doesn't specify one)
|
||||
requiresModel?: string; // If set, only activates when this model is available (fuzzy match)
|
||||
requiresAnyModel?: boolean; // If true, requires at least ONE model in fallbackChain to be available (or empty availability treated as unavailable)
|
||||
requiresProvider?: string[]; // If set, only activates when any of these providers is connected
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
import { fuzzyMatchModel } from "./model-availability.js"
|
||||
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||
import { transformModelForProvider } from "./provider-model-id-transform.js"
|
||||
import { normalizeModel } from "./model-normalization.js"
|
||||
import type { ProviderCache } from "./provider-cache.js"
|
||||
|
||||
type LogImplementation = (message: string, data?: unknown) => void
|
||||
|
||||
let logImplementationForTesting: LogImplementation | undefined
|
||||
|
||||
function log(message: string, data?: unknown): void {
|
||||
const logImpl = logImplementationForTesting
|
||||
if (!logImpl) {
|
||||
return
|
||||
}
|
||||
if (arguments.length === 1) {
|
||||
logImpl(message)
|
||||
return
|
||||
}
|
||||
logImpl(message, data)
|
||||
}
|
||||
|
||||
export function _setModelResolutionLogImplementationForTesting(
|
||||
logImplementation: LogImplementation | undefined,
|
||||
): void {
|
||||
logImplementationForTesting = logImplementation
|
||||
}
|
||||
|
||||
export type ModelResolutionRequest = {
|
||||
intent?: {
|
||||
uiSelectedModel?: string
|
||||
userModel?: string
|
||||
userFallbackModels?: string[]
|
||||
categoryDefaultModel?: string
|
||||
}
|
||||
constraints: {
|
||||
availableModels: Set<string>
|
||||
connectedProviders?: string[] | null
|
||||
}
|
||||
policy?: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelResolutionProvenance =
|
||||
| "override"
|
||||
| "category-default"
|
||||
| "provider-fallback"
|
||||
| "system-default"
|
||||
|
||||
export type ModelResolutionResult = {
|
||||
model: string
|
||||
provenance: ModelResolutionProvenance
|
||||
variant?: string
|
||||
attempted?: string[]
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type ModelResolutionDeps = {
|
||||
fuzzyMatchModel: (
|
||||
target: string,
|
||||
available: Set<string>,
|
||||
providers?: string[],
|
||||
) => string | null
|
||||
transformModelForProvider: (provider: string, model: string) => string
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL_RESOLUTION_DEPS: ModelResolutionDeps = {
|
||||
fuzzyMatchModel,
|
||||
transformModelForProvider,
|
||||
}
|
||||
|
||||
|
||||
export function resolveModelPipeline(
|
||||
request: ModelResolutionRequest,
|
||||
providerCache: ProviderCache = {
|
||||
readConnectedProvidersCache: () => null,
|
||||
findProviderModelMetadata: () => undefined,
|
||||
},
|
||||
deps: ModelResolutionDeps = DEFAULT_MODEL_RESOLUTION_DEPS,
|
||||
): ModelResolutionResult | undefined {
|
||||
const attempted: string[] = []
|
||||
const { intent, constraints, policy } = request
|
||||
const availableModels = constraints.availableModels
|
||||
const fallbackChain = policy?.fallbackChain
|
||||
const systemDefaultModel = policy?.systemDefaultModel
|
||||
|
||||
const normalizedUiModel = normalizeModel(intent?.uiSelectedModel)
|
||||
if (normalizedUiModel) {
|
||||
log("Model resolved via UI selection", { model: normalizedUiModel })
|
||||
return { model: normalizedUiModel, provenance: "override" }
|
||||
}
|
||||
|
||||
const normalizedUserModel = normalizeModel(intent?.userModel)
|
||||
if (normalizedUserModel) {
|
||||
log("Model resolved via config override", { model: normalizedUserModel })
|
||||
return { model: normalizedUserModel, provenance: "override" }
|
||||
}
|
||||
|
||||
const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel)
|
||||
if (normalizedCategoryDefault) {
|
||||
attempted.push(normalizedCategoryDefault)
|
||||
if (availableModels.size > 0) {
|
||||
const parts = normalizedCategoryDefault.split("/")
|
||||
const providerHint = parts.length >= 2 ? [parts[0]!] : undefined
|
||||
const match = deps.fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
|
||||
if (match) {
|
||||
log("Model resolved via category default (fuzzy matched)", {
|
||||
original: normalizedCategoryDefault,
|
||||
matched: match,
|
||||
})
|
||||
return { model: match, provenance: "category-default", attempted }
|
||||
}
|
||||
} else {
|
||||
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
|
||||
if (connectedProviders === null) {
|
||||
log("Model resolved via category default (no cache, first run)", {
|
||||
model: normalizedCategoryDefault,
|
||||
})
|
||||
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
|
||||
}
|
||||
const parts = normalizedCategoryDefault.split("/")
|
||||
if (parts.length >= 2) {
|
||||
const provider = parts[0]!
|
||||
if (connectedProviders.includes(provider)) {
|
||||
const modelName = parts.slice(1).join("/")
|
||||
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
|
||||
log("Model resolved via category default (connected provider)", {
|
||||
model: transformedModel,
|
||||
original: normalizedCategoryDefault,
|
||||
})
|
||||
return { model: transformedModel, provenance: "category-default", attempted }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("Category default model not available, falling through to fallback chain", {
|
||||
model: normalizedCategoryDefault,
|
||||
})
|
||||
}
|
||||
|
||||
//#when - user configured fallback_models, try them before hardcoded fallback chain
|
||||
const userFallbackModels = intent?.userFallbackModels
|
||||
if (userFallbackModels && userFallbackModels.length > 0) {
|
||||
if (availableModels.size === 0) {
|
||||
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
|
||||
if (connectedSet !== null) {
|
||||
for (const model of userFallbackModels) {
|
||||
attempted.push(model)
|
||||
const parts = model.split("/")
|
||||
if (parts.length >= 2) {
|
||||
const provider = parts[0]!
|
||||
if (connectedSet.has(provider)) {
|
||||
const modelName = parts.slice(1).join("/")
|
||||
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
|
||||
log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model })
|
||||
return { model: transformedModel, provenance: "provider-fallback", attempted }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No connected provider found in user fallback_models, falling through to hardcoded chain")
|
||||
}
|
||||
} else {
|
||||
for (const model of userFallbackModels) {
|
||||
attempted.push(model)
|
||||
const parts = model.split("/")
|
||||
const providerHint = parts.length >= 2 ? [parts[0]!] : undefined
|
||||
const match = deps.fuzzyMatchModel(model, availableModels, providerHint)
|
||||
if (match) {
|
||||
log("Model resolved via user fallback_models (availability confirmed)", { model, match })
|
||||
return { model: match, provenance: "provider-fallback", attempted }
|
||||
}
|
||||
}
|
||||
log("No available model found in user fallback_models, falling through to hardcoded chain")
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
if (availableModels.size === 0) {
|
||||
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
|
||||
if (connectedSet === null) {
|
||||
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
const transformedModelId = deps.transformModelForProvider(provider, entry.model)
|
||||
const model = `${provider}/${transformedModelId}`
|
||||
log("Model resolved via fallback chain (connected provider)", {
|
||||
provider,
|
||||
model: transformedModelId,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return {
|
||||
model,
|
||||
provenance: "provider-fallback",
|
||||
variant: entry.variant,
|
||||
attempted,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No connected provider found in fallback chain, falling through to system default")
|
||||
}
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
const fullModel = `${provider}/${entry.model}`
|
||||
const match = deps.fuzzyMatchModel(fullModel, availableModels, [provider])
|
||||
if (match) {
|
||||
log("Model resolved via fallback chain (availability confirmed)", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
match,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return {
|
||||
model: match,
|
||||
provenance: "provider-fallback",
|
||||
variant: entry.variant,
|
||||
attempted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const crossProviderMatch = deps.fuzzyMatchModel(entry.model, availableModels)
|
||||
if (crossProviderMatch) {
|
||||
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
|
||||
model: entry.model,
|
||||
match: crossProviderMatch,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return {
|
||||
model: crossProviderMatch,
|
||||
provenance: "provider-fallback",
|
||||
variant: entry.variant,
|
||||
attempted,
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No available model found in fallback chain, falling through to system default")
|
||||
}
|
||||
}
|
||||
|
||||
if (systemDefaultModel === undefined) {
|
||||
log("No model resolved - systemDefaultModel not configured")
|
||||
return undefined
|
||||
}
|
||||
|
||||
log("Model resolved via system default", { model: systemDefaultModel })
|
||||
return { model: systemDefaultModel, provenance: "system-default", attempted }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||
|
||||
export interface DelegatedModelConfig {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
maxTokens?: number
|
||||
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
|
||||
}
|
||||
|
||||
export type ModelResolutionRequest = {
|
||||
intent?: {
|
||||
uiSelectedModel?: string
|
||||
userModel?: string
|
||||
categoryDefaultModel?: string
|
||||
}
|
||||
constraints: {
|
||||
availableModels: Set<string>
|
||||
}
|
||||
policy?: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelResolutionProvenance =
|
||||
| "override"
|
||||
| "category-default"
|
||||
| "provider-fallback"
|
||||
| "system-default"
|
||||
|
||||
export type ModelResolutionResult = {
|
||||
model: string
|
||||
provenance: ModelResolutionProvenance
|
||||
variant?: string
|
||||
attempted?: string[]
|
||||
reason?: string
|
||||
}
|
||||
109
apps/coder/src/services/model-resolution/model-resolver.ts
Normal file
109
apps/coder/src/services/model-resolution/model-resolver.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||
import type { FallbackModelObject } from "./fallback-model-object.js"
|
||||
import { normalizeModel } from "./model-normalization.js"
|
||||
import { resolveModelPipeline } from "./model-resolution-pipeline.js"
|
||||
import { KNOWN_VARIANTS } from "./known-variants.js"
|
||||
import type { ConnectedProvidersAdapter } from "./connected-providers-cache.js"
|
||||
import * as connectedProvidersCache from "./connected-providers-cache.js"
|
||||
|
||||
export type ModelResolutionInput = {
|
||||
userModel?: string
|
||||
inheritedModel?: string
|
||||
systemDefault?: string
|
||||
}
|
||||
|
||||
export type ModelSource =
|
||||
| "override"
|
||||
| "category-default"
|
||||
| "provider-fallback"
|
||||
| "system-default"
|
||||
|
||||
export type ModelResolutionResult = {
|
||||
model: string
|
||||
source: ModelSource
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export type ExtendedModelResolutionInput = {
|
||||
uiSelectedModel?: string
|
||||
userModel?: string
|
||||
userFallbackModels?: string[]
|
||||
categoryDefaultModel?: string
|
||||
fallbackChain?: FallbackEntry[]
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
|
||||
|
||||
export function resolveModel(input: ModelResolutionInput): string | undefined {
|
||||
return (
|
||||
normalizeModel(input.userModel) ??
|
||||
normalizeModel(input.inheritedModel) ??
|
||||
input.systemDefault
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveModelWithFallback(
|
||||
input: ExtendedModelResolutionInput,
|
||||
connectedProvidersAdapter: ConnectedProvidersAdapter = connectedProvidersCache,
|
||||
): ModelResolutionResult | undefined {
|
||||
const { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
|
||||
const resolved = resolveModelPipeline({
|
||||
intent: { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel },
|
||||
constraints: { availableModels },
|
||||
policy: { fallbackChain, systemDefaultModel },
|
||||
}, connectedProvidersAdapter)
|
||||
|
||||
if (!resolved) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
model: resolved.model,
|
||||
source: resolved.provenance,
|
||||
variant: resolved.variant,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes fallback_models config to a mixed array.
|
||||
* Accepts string, string[], or mixed arrays of strings and FallbackModelObject entries.
|
||||
*/
|
||||
export function normalizeFallbackModels(
|
||||
models: string | (string | FallbackModelObject)[] | undefined,
|
||||
): (string | FallbackModelObject)[] | undefined {
|
||||
if (!models) return undefined
|
||||
if (typeof models === "string") return [models]
|
||||
return models
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts plain model strings from a mixed fallback models array.
|
||||
* Object entries are flattened to "model" or "model(variant)" strings.
|
||||
* Use this when consumers need string[] (e.g., resolveModelForDelegateTask).
|
||||
*/
|
||||
export function flattenToFallbackModelStrings(
|
||||
models: (string | FallbackModelObject)[] | undefined,
|
||||
): string[] | undefined {
|
||||
if (!models) return undefined
|
||||
return models.map((entry) => {
|
||||
if (typeof entry === "string") return entry
|
||||
const variant = entry.variant
|
||||
if (variant) {
|
||||
// Strip any supported inline variant syntax before appending explicit override.
|
||||
// Supports both parenthesized and space-suffix forms so we don't emit
|
||||
// invalid strings like "provider/model high(low)".
|
||||
const model = entry.model
|
||||
.replace(/\([^()]+\)\s*$/, "")
|
||||
.replace(/\s+([a-z][a-z0-9_-]*)\s*$/i, (_match: string, suffix: string) => {
|
||||
const normalized = String(suffix).toLowerCase()
|
||||
return KNOWN_VARIANTS.has(normalized)
|
||||
? ""
|
||||
: _match
|
||||
})
|
||||
.trim()
|
||||
return `${model}(${variant})`
|
||||
}
|
||||
return entry.model
|
||||
})
|
||||
}
|
||||
27
apps/coder/src/services/model-resolution/provider-cache.ts
Normal file
27
apps/coder/src/services/model-resolution/provider-cache.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface ModelMetadata {
|
||||
readonly id: string
|
||||
readonly provider?: string
|
||||
readonly context?: number
|
||||
readonly output?: number
|
||||
readonly name?: string
|
||||
readonly variants?: Record<string, unknown>
|
||||
readonly limit?: {
|
||||
readonly context?: number
|
||||
readonly input?: number
|
||||
readonly output?: number
|
||||
}
|
||||
readonly modalities?: {
|
||||
readonly input?: string[]
|
||||
readonly output?: string[]
|
||||
}
|
||||
readonly capabilities?: Record<string, unknown>
|
||||
readonly reasoning?: boolean
|
||||
readonly temperature?: boolean
|
||||
readonly tool_call?: boolean
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ProviderCache {
|
||||
readConnectedProvidersCache(): string[] | null
|
||||
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
function inferSubProvider(model: string): string | undefined {
|
||||
if (model.startsWith("claude-")) return "anthropic"
|
||||
if (model.startsWith("gpt-")) return "openai"
|
||||
if (model.startsWith("gemini-")) return "google"
|
||||
if (model.startsWith("grok-")) return "xai"
|
||||
if (model.startsWith("minimax-")) return "minimax"
|
||||
if (model.startsWith("kimi-")) return "moonshotai"
|
||||
if (model.startsWith("glm-")) return "zai"
|
||||
return undefined
|
||||
}
|
||||
|
||||
const CLAUDE_VERSION_DOT = /claude-(\w+)-(\d+)-(\d+)/g
|
||||
const GEMINI_31_PRO_PREVIEW = /gemini-3\.1-pro(?!-)/g
|
||||
const GEMINI_3_FLASH_PREVIEW = /gemini-3-flash(?!-)/g
|
||||
|
||||
function claudeVersionDot(model: string): string {
|
||||
return model.replace(CLAUDE_VERSION_DOT, "claude-$1-$2.$3")
|
||||
}
|
||||
|
||||
function applyGatewayTransforms(model: string): string {
|
||||
return claudeVersionDot(model).replace(
|
||||
GEMINI_31_PRO_PREVIEW,
|
||||
"gemini-3.1-pro-preview",
|
||||
)
|
||||
}
|
||||
|
||||
function transformModelForProviderUsingAnthropicBehavior(
|
||||
provider: string,
|
||||
model: string,
|
||||
): string {
|
||||
if (provider === "vercel") {
|
||||
const slashIndex = model.indexOf("/")
|
||||
if (slashIndex !== -1) {
|
||||
const subProvider = model.substring(0, slashIndex)
|
||||
const subModel = model.substring(slashIndex + 1)
|
||||
return `${subProvider}/${applyGatewayTransforms(subModel)}`
|
||||
}
|
||||
const subProvider = inferSubProvider(model)
|
||||
if (subProvider) {
|
||||
return `${subProvider}/${applyGatewayTransforms(model)}`
|
||||
}
|
||||
return model
|
||||
}
|
||||
if (provider === "github-copilot") {
|
||||
return claudeVersionDot(model)
|
||||
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
|
||||
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
|
||||
}
|
||||
if (provider === "google") {
|
||||
return model
|
||||
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
|
||||
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
|
||||
}
|
||||
if (provider === "anthropic") {
|
||||
return model
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
export function transformModelForProvider(provider: string, model: string): string {
|
||||
return transformModelForProviderUsingAnthropicBehavior(provider, model)
|
||||
}
|
||||
|
||||
export function transformModelForProviderDisplay(
|
||||
provider: string,
|
||||
model: string,
|
||||
): string {
|
||||
return transformModelForProviderUsingAnthropicBehavior(provider, model)
|
||||
}
|
||||
@@ -112,14 +112,14 @@ describe('stripShadowingFlags', () => {
|
||||
expect(result).toEqual(['-c', '4096']);
|
||||
});
|
||||
|
||||
it('strips cache flags by default', () => {
|
||||
it('passes through cache flags (no longer shadowed)', () => {
|
||||
const result = stripShadowingFlags(['--cache-type-k', 'q8_0']);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toEqual(['--cache-type-k', 'q8_0']);
|
||||
});
|
||||
|
||||
it('strips spec flags by default', () => {
|
||||
it('passes through spec flags (no longer shadowed)', () => {
|
||||
const result = stripShadowingFlags(['--spec-draft-n-max', '16']);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toEqual(['--spec-draft-n-max', '16']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
110
apps/server/src/services/boocontext_client.ts
Normal file
110
apps/server/src/services/boocontext_client.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* v2.7.18: shared MCP client wrapper for the boocontext sidecar.
|
||||
*
|
||||
* Calls into the existing multi-server MCP client infrastructure
|
||||
* (services/mcp-client.ts) which connects to boocontext as a stdio
|
||||
* MCP process defined in data/mcp.json (server name "boocontext",
|
||||
* command: `node /opt/forks/boocontext/dist/standalone.js`).
|
||||
*
|
||||
* The boocontext MCP server is initialized once at app boot in
|
||||
* index.ts via initMcp() and the actual MCP tool call routing is
|
||||
* handled by mcp-client.ts:callTool() — this module is a thin
|
||||
* convenience wrapper that prepends the "boocontext_" server prefix,
|
||||
* normalises the response, and applies inline truncation matching
|
||||
* the same pattern as codecontext_client.ts.
|
||||
*
|
||||
* Usage:
|
||||
* import { callBoocontext } from './services/boocontext_client.js';
|
||||
* const resp = await callBoocontext({
|
||||
* toolName: 'codesight_get_summary',
|
||||
* args: { directory: '/opt/boocode' },
|
||||
* });
|
||||
*/
|
||||
|
||||
import { callTool } from './mcp-client.js';
|
||||
import { truncateIfNeeded } from './truncate.js';
|
||||
|
||||
// ---- Exported types ----
|
||||
|
||||
export interface BoocontextRequest {
|
||||
/** Unprefixed tool name as defined on the boocontext MCP server
|
||||
* (e.g. "codesight_scan", "boocontext_overview", "codesight_get_summary"). */
|
||||
toolName: string;
|
||||
/** Arguments to pass to the tool. */
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BoocontextResponse {
|
||||
/** The tool output text. */
|
||||
result: string;
|
||||
/** Whether the result was truncated to fit the inline limit. */
|
||||
truncated: boolean;
|
||||
/** Opaque id pointing at the full pre-slice content on tmpfs, set when
|
||||
* truncated=true and storage succeeded. */
|
||||
outputPath?: string;
|
||||
}
|
||||
|
||||
// ---- Constants ----
|
||||
|
||||
/** Must match the server name in data/mcp.json. */
|
||||
const BOOCONTEXT_SERVER_NAME = 'boocontext';
|
||||
|
||||
/** Inline truncation limit, matching codecontext_client.ts. */
|
||||
const TRUNCATION_LIMIT = 32_000;
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
/**
|
||||
* Call a boocontext MCP tool by its unprefixed name.
|
||||
*
|
||||
* Prepends the "boocontext_" server prefix, delegates to the
|
||||
* multi-server MCP client's callTool(), and normalises the response
|
||||
* into a BoocontextResponse with inline truncation.
|
||||
*
|
||||
* @param req The tool name and arguments.
|
||||
* @param log Optional Fastify-compatible logger (for debug traces).
|
||||
* @returns The tool result, possibly truncated.
|
||||
* @throws If the boocontext server is not connected or the tool
|
||||
* returns an MCP-level error.
|
||||
*/
|
||||
export async function callBoocontext(
|
||||
req: BoocontextRequest,
|
||||
log?: { debug?: (obj: object, msg: string) => void; warn?: (obj: object, msg: string) => void },
|
||||
): Promise<BoocontextResponse> {
|
||||
const prefixedName = `${BOOCONTEXT_SERVER_NAME}_${req.toolName}`;
|
||||
|
||||
log?.debug?.({ tool: prefixedName }, 'boocontext: calling tool');
|
||||
|
||||
const raw = await callTool(prefixedName, req.args);
|
||||
|
||||
// callTool returns { error: true, output: string } on failure (both
|
||||
// for MCP-level isError and for network/protocol exceptions).
|
||||
if (typeof raw === 'object' && raw !== null && (raw as Record<string, unknown>).error === true) {
|
||||
const errOutput = (raw as Record<string, unknown>).output ?? 'Unknown MCP error';
|
||||
throw new Error(`boocontext error: ${String(errOutput)}`);
|
||||
}
|
||||
|
||||
const result = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||
|
||||
// Inline truncation at 32 kB, matching codecontext_client.ts.
|
||||
// The model gets a clear hint about how to narrow the next call
|
||||
// rather than a silent cut.
|
||||
if (result.length > TRUNCATION_LIMIT) {
|
||||
const truncated = result.slice(0, TRUNCATION_LIMIT);
|
||||
const omitted = result.length - TRUNCATION_LIMIT;
|
||||
const slicedWithMarker =
|
||||
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with additional filters]`;
|
||||
const wrapped = await truncateIfNeeded({
|
||||
fullContent: result,
|
||||
slicedContent: slicedWithMarker,
|
||||
wasTruncated: true,
|
||||
});
|
||||
return {
|
||||
result: wrapped.content,
|
||||
truncated: wrapped.truncated,
|
||||
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return { result, truncated: false };
|
||||
}
|
||||
@@ -131,23 +131,13 @@ export function isManagedFlag(flag: string): boolean {
|
||||
|
||||
const SHADOW_CONTEXT = ['-c', '--ctx-size'];
|
||||
|
||||
const SHADOW_CACHE = ['-ctk', '--cache-type-k', '-ctv', '--cache-type-v'];
|
||||
// Empty: agents should be able to opt into cache-type flags (lift analysis
|
||||
// found these are high-value features, not safety concerns).
|
||||
const SHADOW_CACHE: string[] = [];
|
||||
|
||||
const SHADOW_SPEC = [
|
||||
'--spec-default',
|
||||
'--spec-type',
|
||||
'--spec-ngram-size-n',
|
||||
'--spec-ngram-size',
|
||||
'--draft-min',
|
||||
'--draft-max',
|
||||
'--spec-draft-n-max',
|
||||
'--spec-draft-n-min',
|
||||
'--spec-draft-p-min',
|
||||
'--spec-draft-p-split',
|
||||
'--spec-ngram-mod-n-match',
|
||||
'--spec-ngram-mod-n-min',
|
||||
'--spec-ngram-mod-n-max',
|
||||
];
|
||||
// Empty: ngram speculative decoding is a performance feature agents should
|
||||
// be able to enable.
|
||||
const SHADOW_SPEC: string[] = [];
|
||||
|
||||
const SHADOW_TEMPLATE = [
|
||||
'--chat-template',
|
||||
@@ -160,7 +150,6 @@ const SHADOW_TEMPLATE = [
|
||||
// Shadowing flags that take no value — a boolean switch — so the stripper must
|
||||
// not also drop the following token.
|
||||
const VALUELESS_SHADOW_FLAGS: ReadonlySet<string> = new Set([
|
||||
'--spec-default',
|
||||
'--jinja',
|
||||
'--no-jinja',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../types.js';
|
||||
import { callBoocontext } from '../../boocontext_client.js';
|
||||
|
||||
export const GetCodeHealthInput = z.object({
|
||||
directory: z.string().optional().describe('Directory to analyze (defaults to project root)'),
|
||||
file: z.string().optional().describe('Optional: specific file to analyze'),
|
||||
});
|
||||
export type GetCodeHealthInputT = z.infer<typeof GetCodeHealthInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Code health analysis. Returns A–F grades per file across 7 dimensions ' +
|
||||
'(cohesion, coupling, complexity, documentation, duplication, unit size, test coverage). ' +
|
||||
'Includes project health summary and refactoring candidates.';
|
||||
|
||||
/**
|
||||
* Standalone execute function — calls the boocontext MCP server's
|
||||
* boocontext_health tool and returns the raw report text.
|
||||
*
|
||||
* Structured for direct test access: accepts input + projectPath,
|
||||
* no side effects beyond the MCP call.
|
||||
*/
|
||||
export async function executeGetCodeHealth(
|
||||
input: GetCodeHealthInputT,
|
||||
projectPath: string,
|
||||
): Promise<string> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (input.directory) args['directory'] = input.directory;
|
||||
if (input.file) args['file'] = input.file;
|
||||
const resp = await callBoocontext({ toolName: 'boocontext_health', args });
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
export const getCodeHealth: ToolDef<GetCodeHealthInputT> = {
|
||||
name: 'get_code_health',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetCodeHealthInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_code_health',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
directory: {
|
||||
type: 'string',
|
||||
description: 'Directory to analyze (defaults to project root)',
|
||||
},
|
||||
file: {
|
||||
type: 'string',
|
||||
description: 'Optional: specific file to analyze',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return executeGetCodeHealth(input, projectRoot);
|
||||
},
|
||||
};
|
||||
228
apps/server/src/services/tools/codecontext/get_code_impact.ts
Normal file
228
apps/server/src/services/tools/codecontext/get_code_impact.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../types.js';
|
||||
import type { CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
// ======================= MCP Client =======================
|
||||
|
||||
const BOOCONTEXT_PATH = resolve('/opt/forks/boocontext/dist/standalone.js');
|
||||
const TOOL_CALL_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface JsonRpcMessage {
|
||||
jsonrpc: '2.0';
|
||||
id?: number | string;
|
||||
result?: {
|
||||
content?: Array<{ type: string; text: string }>;
|
||||
};
|
||||
error?: { code?: number; message: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-shot MCP JSON-RPC client for boocontext.
|
||||
* Spawns the process, sends initialize + tools/call over NDJSON, returns the
|
||||
* text result from the content array. The boocontext MCP server auto-detects
|
||||
* newline-delimited JSON transport when the first input lacks Content-Length
|
||||
* headers, which is exactly what we send.
|
||||
*/
|
||||
async function callBoocontext(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: TOOL_CALL_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let resolved = false;
|
||||
|
||||
function finalize(err?: Error, result?: string): void {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (err) reject(err);
|
||||
else resolvePromise(result!);
|
||||
child.kill();
|
||||
}
|
||||
|
||||
child.stdout!.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr!.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
finalize(new Error(`boocontext spawn error: ${err.message}`));
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
if (resolved) return;
|
||||
|
||||
// Parse newline-delimited JSON responses from stdout
|
||||
const lines = stdout.split('\n').filter((l) => l.trim().length > 0);
|
||||
let toolText: string | undefined;
|
||||
let toolError: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg = JSON.parse(line) as JsonRpcMessage;
|
||||
if (msg.id === 2) {
|
||||
if (msg.error) {
|
||||
toolError = msg.error.message ?? 'boocontext tool call failed';
|
||||
} else if (msg.result?.content?.[0]?.text !== undefined) {
|
||||
toolText = msg.result.content[0].text;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
if (toolError) {
|
||||
finalize(new Error(toolError));
|
||||
} else if (toolText !== undefined) {
|
||||
finalize(undefined, toolText);
|
||||
} else {
|
||||
const errSuffix =
|
||||
stderr.length > 0 ? ` stderr: ${stderr.slice(0, 500)}` : '';
|
||||
finalize(
|
||||
new Error(`boocontext MCP call failed (exit ${code})${errSuffix}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1: initialize — establishes MCP protocol version + capabilities
|
||||
child.stdin!.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'boocode-server', version: '1.0.0' },
|
||||
},
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
// Step 2: tools/call — invoke the named boocontext tool
|
||||
child.stdin!.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: toolName, arguments: args },
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
child.stdin!.end();
|
||||
|
||||
// Safety timeout — prevent hung processes
|
||||
setTimeout(() => {
|
||||
finalize(
|
||||
new Error(
|
||||
`boocontext call timed out after ${TOOL_CALL_TIMEOUT_MS}ms`,
|
||||
),
|
||||
);
|
||||
}, TOOL_CALL_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
// ======================= Tool Definition =======================
|
||||
|
||||
const TRUNCATION_LIMIT = 32_000;
|
||||
|
||||
export const GetCodeImpactInput = z.object({
|
||||
symbol: z.string().min(1).describe('Symbol name for TSA trace_impact'),
|
||||
file: z.string().optional().describe('File path for codesight blast_radius'),
|
||||
directory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Directory (defaults to project root)'),
|
||||
depth: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(5)
|
||||
.optional()
|
||||
.describe('Max blast-radius traversal depth (default 1)'),
|
||||
});
|
||||
export type GetCodeImpactInputT = z.infer<typeof GetCodeImpactInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Impact analysis. Merges symbol-level call trace with file-level blast radius. ' +
|
||||
'Use before making changes to understand change propagation. ' +
|
||||
'Single call replaces separate get_symbol_info + get_blast_radius steps.';
|
||||
|
||||
/**
|
||||
* Standalone execute function — calls the boocontext MCP `boocontext_impact`
|
||||
* tool via a short-lived child process, then wraps the result in the standard
|
||||
* CodecontextResponse shape with inline truncation at 32 KB.
|
||||
*/
|
||||
export async function executeGetCodeImpact(
|
||||
input: GetCodeImpactInputT,
|
||||
projectPath: string,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = {
|
||||
symbol: input.symbol,
|
||||
directory: input.directory ?? projectPath,
|
||||
};
|
||||
if (input.file) args['file'] = input.file;
|
||||
|
||||
const text = await callBoocontext('boocontext_impact', args);
|
||||
|
||||
// Inline truncation matching codecontext_client.ts patterns (32 KB ceiling).
|
||||
if (text.length > TRUNCATION_LIMIT) {
|
||||
const sliced = text.slice(0, TRUNCATION_LIMIT);
|
||||
const omitted = text.length - TRUNCATION_LIMIT;
|
||||
return {
|
||||
result: `${sliced}\n\n[truncated, ${omitted} chars omitted; narrow with symbol or file parameters]`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { result: text, truncated: false };
|
||||
}
|
||||
|
||||
export const getCodeImpact: ToolDef<GetCodeImpactInputT> = {
|
||||
name: 'get_code_impact',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetCodeImpactInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_code_impact',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
symbol: {
|
||||
type: 'string',
|
||||
description: 'Symbol name for TSA trace_impact',
|
||||
},
|
||||
file: {
|
||||
type: 'string',
|
||||
description: 'File path for codesight blast_radius',
|
||||
},
|
||||
directory: {
|
||||
type: 'string',
|
||||
description: 'Directory (defaults to project root)',
|
||||
},
|
||||
depth: {
|
||||
type: 'number',
|
||||
description: 'Max blast-radius traversal depth (default 1)',
|
||||
},
|
||||
},
|
||||
required: ['symbol'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
execute(input, projectRoot) {
|
||||
return executeGetCodeImpact(input, projectRoot);
|
||||
},
|
||||
};
|
||||
192
apps/server/src/services/tools/codecontext/get_code_map.ts
Normal file
192
apps/server/src/services/tools/codecontext/get_code_map.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../types.js';
|
||||
|
||||
export const GetCodeMapInput = z.object({
|
||||
directory: z.string().optional().describe('Directory to scan (defaults to project root)'),
|
||||
compress: z.boolean().optional().describe('Apply DCP compression if payload exceeds threshold (default: true)'),
|
||||
});
|
||||
export type GetCodeMapInputT = z.infer<typeof GetCodeMapInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'DCP-compressed codebase context map. Returns filenames, sizes, import relationships in a compressed format. ' +
|
||||
'Use compress=false for full detail, compress=true (default) for token-efficient overview.';
|
||||
|
||||
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
|
||||
const TOOL_TIMEOUT_MS = 30_000;
|
||||
const MAX_RESULT_BYTES = 32_768;
|
||||
|
||||
export interface CodeMapResponse {
|
||||
result: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the boocontext MCP server over stdio JSON-RPC to invoke
|
||||
* the boocontext_map tool. Spawns the standalone binary, sends
|
||||
* initialize + tools/call, collects NDJSON responses, and kills
|
||||
* the child process.
|
||||
*/
|
||||
function callBoocontextMap(args: Record<string, unknown>): Promise<CodeMapResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [BOOCONTEXT_PATH], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdoutBuf = '';
|
||||
const lines: string[] = [];
|
||||
let timedOut = false;
|
||||
let resolved = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`boocontext MCP call timed out after ${TOOL_TIMEOUT_MS}ms`));
|
||||
}, TOOL_TIMEOUT_MS);
|
||||
|
||||
function tryParse(): void {
|
||||
if (resolved || timedOut) return;
|
||||
|
||||
// Accumulate complete NDJSON lines
|
||||
const parts = stdoutBuf.split('\n');
|
||||
stdoutBuf = parts.pop()! ?? '';
|
||||
for (const p of parts) {
|
||||
const t = p.trim();
|
||||
if (t) lines.push(t);
|
||||
}
|
||||
|
||||
// Need at least 2 responses: initialize + tools/call
|
||||
if (lines.length < 2) return;
|
||||
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
child.kill();
|
||||
|
||||
try {
|
||||
const callResponse = JSON.parse(lines[1]!);
|
||||
if (callResponse.error) {
|
||||
reject(new Error(`MCP error: ${callResponse.error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const content = callResponse.result?.content;
|
||||
if (!content?.[0]?.text) {
|
||||
reject(new Error('Unexpected MCP response shape — missing content[0].text'));
|
||||
return;
|
||||
}
|
||||
|
||||
// content[0].text is JSON-stringified VerdictEnvelope from boocontext
|
||||
const envelope = JSON.parse(content[0].text as string);
|
||||
const details = envelope.details;
|
||||
|
||||
let result: string;
|
||||
if (details && typeof details === 'object' && 'data' in details) {
|
||||
// DcpEnvelope shape: { compressed, originalLength, compressedLength, data }
|
||||
if (details.compressed) {
|
||||
// Return the full DcpEnvelope as JSON so the LLM can pass it
|
||||
// transparently to a decompression step
|
||||
result = JSON.stringify(details);
|
||||
} else {
|
||||
// Uncompressed — data is the raw output
|
||||
result = details.data;
|
||||
}
|
||||
} else {
|
||||
result = JSON.stringify(details ?? envelope);
|
||||
}
|
||||
|
||||
const truncated = Buffer.byteLength(result, 'utf-8') > MAX_RESULT_BYTES;
|
||||
if (truncated) {
|
||||
result = result.substring(0, MAX_RESULT_BYTES);
|
||||
}
|
||||
|
||||
resolve({ result, truncated });
|
||||
} catch (e: any) {
|
||||
reject(new Error(`Failed to parse boocontext response: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout!.on('data', (chunk: Buffer) => {
|
||||
if (timedOut) return;
|
||||
stdoutBuf += chunk.toString('utf-8');
|
||||
tryParse();
|
||||
});
|
||||
|
||||
child.stderr!.on('data', (_chunk: Buffer) => {
|
||||
// Captured but not surfaced — logged only on parse failure
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`boocontext spawn failed: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
if (!resolved && !timedOut) {
|
||||
tryParse();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error('boocontext process closed without producing a valid response'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1: initialize
|
||||
child.stdin!.write(
|
||||
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }) + '\n',
|
||||
);
|
||||
|
||||
// Step 2: tools/call for boocontext_map
|
||||
child.stdin!.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'boocontext_map', arguments: args },
|
||||
}) + '\n',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const getCodeMap: ToolDef<GetCodeMapInputT> = {
|
||||
name: 'get_code_map',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetCodeMapInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_code_map',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
directory: { type: 'string', description: 'Directory to scan (defaults to project root)' },
|
||||
compress: {
|
||||
type: 'boolean',
|
||||
description: 'Apply DCP compression if payload exceeds threshold (default: true)',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot): Promise<CodeMapResponse> {
|
||||
return callBoocontextMap({
|
||||
directory: input.directory ?? projectRoot,
|
||||
compress: input.compress ?? true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export async function executeGetCodeMap(
|
||||
input: GetCodeMapInputT,
|
||||
projectRoot: string,
|
||||
): Promise<CodeMapResponse> {
|
||||
return callBoocontextMap({
|
||||
directory: input.directory ?? projectRoot,
|
||||
compress: input.compress ?? true,
|
||||
});
|
||||
}
|
||||
262
apps/server/src/services/tools/codecontext/get_type_info.ts
Normal file
262
apps/server/src/services/tools/codecontext/get_type_info.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { z } from 'zod';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { ToolDef } from '../types.js';
|
||||
import type { CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
|
||||
const TRUNCATION_LIMIT = 32_000;
|
||||
|
||||
export const GetTypeInfoInput = z.object({
|
||||
file: z.string().min(1).describe('File path to resolve types in'),
|
||||
symbol: z.string().optional().describe('Symbol name to resolve (supports regex)'),
|
||||
directory: z.string().optional().describe('Project directory for type resolution context'),
|
||||
});
|
||||
export type GetTypeInfoInputT = z.infer<typeof GetTypeInfoInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'TypeScript type recovery. Returns type signatures, interface definitions, ' +
|
||||
'generic constraints, and JSDoc for symbols in a file. Uses type-inject MCP server.';
|
||||
|
||||
// ---- JSON-RPC-over-stdio MCP caller for boocontext --------------------------
|
||||
|
||||
async function callBoocontext(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<CodecontextResponse> {
|
||||
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
let stderrBuf = '';
|
||||
child.stderr!.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString('utf-8');
|
||||
});
|
||||
|
||||
let killed = false;
|
||||
const killChild = () => {
|
||||
if (killed) return;
|
||||
killed = true;
|
||||
child.kill();
|
||||
};
|
||||
|
||||
try {
|
||||
// Read one complete JSON-RPC response from stdout (handles both
|
||||
// Content-Length framed and newline-delimited transport).
|
||||
async function readResponse(timeoutMs = 30_000): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Timeout reading boocontext response'));
|
||||
}, timeoutMs);
|
||||
|
||||
let buf = '';
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
child.stdout!.removeListener('data', onData);
|
||||
child.stdout!.removeListener('end', onEnd);
|
||||
child.stdout!.removeListener('error', onError);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString('utf-8');
|
||||
|
||||
const msg = tryExtractMessage(buf);
|
||||
if (msg !== null) {
|
||||
cleanup();
|
||||
resolve(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (buf.length > 1_024 * 1_024) {
|
||||
cleanup();
|
||||
reject(new Error('Boocontext response exceeded 1 MB'));
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
cleanup();
|
||||
if (buf.trim()) {
|
||||
try {
|
||||
resolve(JSON.parse(buf.trim()));
|
||||
} catch {
|
||||
reject(new Error('Boocontext stream ended with incomplete data'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error('Boocontext stream ended unexpectedly'));
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
child.stdout!.on('data', onData);
|
||||
child.stdout!.on('end', onEnd);
|
||||
child.stdout!.on('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for the process to be fully spawned.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('spawn', () => resolve());
|
||||
});
|
||||
|
||||
// Step 1 — MCP initialize
|
||||
let reqId = 0;
|
||||
reqId++;
|
||||
child.stdin!.write(
|
||||
JSON.stringify({ jsonrpc: '2.0', id: reqId, method: 'initialize' }) + '\n',
|
||||
);
|
||||
|
||||
const initResp = await readResponse() as { error?: { message: string } };
|
||||
if (initResp.error) {
|
||||
throw new Error(`Boocontext init failed: ${initResp.error.message}`);
|
||||
}
|
||||
|
||||
// Step 2 — tools/call
|
||||
reqId++;
|
||||
child.stdin!.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: reqId,
|
||||
method: 'tools/call',
|
||||
params: { name: toolName, arguments: args },
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
const callResp = await readResponse() as {
|
||||
error?: { message: string };
|
||||
result?: { content?: Array<{ type: string; text: string }> };
|
||||
};
|
||||
if (callResp.error) {
|
||||
throw new Error(`Boocontext tool call failed: ${callResp.error.message}`);
|
||||
}
|
||||
|
||||
// Extract text from the MCP tool result shape:
|
||||
// { content: [{ type: "text", text: "…" }] }
|
||||
const content = callResp.result?.content;
|
||||
let text: string;
|
||||
if (Array.isArray(content) && content.length > 0 && content[0]!.type === 'text') {
|
||||
text = content[0]!.text;
|
||||
} else {
|
||||
text = JSON.stringify(callResp.result);
|
||||
}
|
||||
|
||||
// Inline truncation at 32 KB.
|
||||
if (text.length > TRUNCATION_LIMIT) {
|
||||
const omitted = text.length - TRUNCATION_LIMIT;
|
||||
return {
|
||||
result:
|
||||
text.slice(0, TRUNCATION_LIMIT) +
|
||||
`\n\n[truncated, ${omitted} chars omitted; narrow with file or symbol filter]`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { result: text, truncated: false };
|
||||
} finally {
|
||||
killChild();
|
||||
// Give the process a moment to release resources.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 2_000);
|
||||
child.on('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to extract one complete JSON-RPC message from the head of a
|
||||
* buffer. Handles both Content-Length framed and newline-delimited
|
||||
* formats. Returns `null` when more data is needed.
|
||||
*/
|
||||
function tryExtractMessage(buf: string): unknown | null {
|
||||
// --- Content-Length framed ---
|
||||
const headerEnd = buf.indexOf('\r\n\r\n');
|
||||
if (headerEnd !== -1) {
|
||||
const header = buf.substring(0, headerEnd);
|
||||
const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
|
||||
if (lengthMatch) {
|
||||
const contentLength = parseInt(lengthMatch[1]!, 10);
|
||||
const bodyStart = headerEnd + 4;
|
||||
if (buf.length >= bodyStart + contentLength) {
|
||||
const jsonStr = buf.substring(bodyStart, bodyStart + contentLength);
|
||||
return JSON.parse(jsonStr);
|
||||
}
|
||||
return null; // need more data
|
||||
}
|
||||
// Has \r\n\r\n but no Content-Length — junk segment; skip and retry.
|
||||
return tryExtractMessage(buf.substring(headerEnd + 4));
|
||||
}
|
||||
|
||||
// --- Newline-delimited ---
|
||||
const nlIndex = buf.indexOf('\n');
|
||||
if (nlIndex !== -1) {
|
||||
const line = buf.substring(0, nlIndex).trim();
|
||||
if (line && line.startsWith('{')) {
|
||||
return JSON.parse(line);
|
||||
}
|
||||
// Non-JSON line (e.g. stderr echo), skip and continue.
|
||||
return tryExtractMessage(buf.substring(nlIndex + 1));
|
||||
}
|
||||
|
||||
return null; // need more data
|
||||
}
|
||||
|
||||
// ---- ToolDef ----------------------------------------------------------------
|
||||
|
||||
export const getTypeInfo: ToolDef<GetTypeInfoInputT> = {
|
||||
name: 'get_type_info',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetTypeInfoInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_type_info',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', description: 'File path to resolve types in' },
|
||||
symbol: {
|
||||
type: 'string',
|
||||
description: 'Symbol name to resolve (supports regex)',
|
||||
},
|
||||
directory: {
|
||||
type: 'string',
|
||||
description: 'Project directory for type resolution context',
|
||||
},
|
||||
},
|
||||
required: ['file'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = { file: input.file };
|
||||
if (input.symbol) args['symbol'] = input.symbol;
|
||||
return callBoocontext('boocontext_types', args);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Standalone execute function matching the `execute` shape returned by
|
||||
* `makeCodecontextTool` — useful for direct callers and tests.
|
||||
*
|
||||
* Note: unlike the HTTP-backed codecontext tools this does NOT accept a
|
||||
* `fetcher` override because it communicates over stdio rather than HTTP.
|
||||
*/
|
||||
export async function executeGetTypeInfo(
|
||||
input: GetTypeInfoInputT,
|
||||
_projectPath?: string,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = { file: input.file };
|
||||
if (input.symbol) args['symbol'] = input.symbol;
|
||||
return callBoocontext('boocontext_types', args);
|
||||
}
|
||||
@@ -13,3 +13,8 @@ export { getBlastRadius } from './get_blast_radius.js';
|
||||
export { getHotFiles } from './get_hot_files.js';
|
||||
export { getRoutes } from './get_routes.js';
|
||||
export { getMiddleware } from './get_middleware.js';
|
||||
// v2.8.14-domain2-phase1: boocontext-backed tools.
|
||||
export { getCodeHealth } from './get_code_health.js';
|
||||
export { getCodeImpact } from './get_code_impact.js';
|
||||
export { getTypeInfo } from './get_type_info.js';
|
||||
export { getCodeMap } from './get_code_map.js';
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
getHotFiles,
|
||||
getRoutes,
|
||||
getMiddleware,
|
||||
getCodeHealth,
|
||||
getCodeImpact,
|
||||
getTypeInfo,
|
||||
getCodeMap,
|
||||
} from './codecontext/index.js';
|
||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||
@@ -75,6 +79,12 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
||||
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
||||
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
||||
readTabByNumber as ToolDef<unknown>,
|
||||
// v2.8.14-domain2-phase1: boocontext-backed tools. Backed by the boocontext
|
||||
// MCP server. All read-only. Health, impact, types, map analysis.
|
||||
getCodeHealth as ToolDef<unknown>,
|
||||
getCodeImpact as ToolDef<unknown>,
|
||||
getTypeInfo as ToolDef<unknown>,
|
||||
getCodeMap as ToolDef<unknown>,
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||
|
||||
@@ -17,18 +17,22 @@ COPY go.mod ./
|
||||
COPY shim.go ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /build/shim-bin ./
|
||||
|
||||
# Stage 2: boocontext MCP builder
|
||||
# Stage 2: boocontext MCP builder (pnpm project)
|
||||
FROM node:20-alpine AS boocontext-builder
|
||||
WORKDIR /build/boocontext
|
||||
RUN apk add --no-cache git python3 make g++ ca-certificates
|
||||
RUN npm install -g pnpm@9 --silent
|
||||
COPY fork.tar.gz /build/fork.tar.gz
|
||||
RUN mkdir -p /build/boocontext && tar -xzf /build/fork.tar.gz -C /build/boocontext
|
||||
WORKDIR /build/boocontext
|
||||
RUN npm ci && npm run build
|
||||
RUN pnpm install --frozen-lockfile && pnpm run build
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates nodejs uv
|
||||
# uv intentionally not installed — container network blocks astral.sh.
|
||||
# tree-sitter-analyzer child server (uvx) won't start in-container, but
|
||||
# boocontext logs a graceful warning; TSA-backed tools fall through.
|
||||
RUN apk add --no-cache ca-certificates nodejs
|
||||
COPY --from=shim-builder /build/shim-bin /usr/local/bin/shim
|
||||
COPY --from=boocontext-builder /build/boocontext/dist /usr/local/lib/boocontext/dist
|
||||
COPY --from=boocontext-builder /build/boocontext/node_modules /usr/local/lib/boocontext/node_modules
|
||||
|
||||
90
codecontext/openspec/codesight-merge.md
Normal file
90
codecontext/openspec/codesight-merge.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# codecontext — codesight feature merge
|
||||
|
||||
Port codesight's highest-value analysis capabilities into codecontext as 4 new MCP tools. All work in `/opt/forks/codecontext` (Go). BooCode wrapper tools in a follow-up batch.
|
||||
|
||||
## New tools
|
||||
|
||||
### 1. `get_blast_radius` (Tier 1)
|
||||
|
||||
**Input:** `file_path` (required), `target_dir` (optional)
|
||||
**Output:** markdown listing all files, routes, and symbols that depend (transitively) on the given file.
|
||||
|
||||
Algorithm: build a reverse adjacency map from `s.graph.Edges` (filter by `type == "imports"`), then BFS outward from the target file's node. Report each affected file with its symbol count and distance from the source.
|
||||
|
||||
Codesight reference: `detectors/blast-radius.ts` (128 lines). The Go port is simpler — codecontext already has the edge graph; codesight had to build its own.
|
||||
|
||||
~50 lines of Go (handler + BFS).
|
||||
|
||||
### 2. `get_hot_files` (Tier 1)
|
||||
|
||||
**Input:** `target_dir` (optional), `limit` (optional, default 20)
|
||||
**Output:** ranked list of most-imported files with import count.
|
||||
|
||||
Algorithm: count incoming `"imports"` edges per file node. Sort descending. Return top N.
|
||||
|
||||
Codesight reference: `detectors/graph.ts` hot-files metric. codecontext's `identifyHotspotFiles()` at `relationships.go:286` already computes this — the tool just needs to expose it.
|
||||
|
||||
~30 lines of Go (handler + sort).
|
||||
|
||||
### 3. `get_routes` (Tier 2)
|
||||
|
||||
**Input:** `target_dir` (optional), `framework` (optional filter — "fastify", "express", etc.)
|
||||
**Output:** structured list of HTTP routes with method, path, file, line number, middleware, tags.
|
||||
|
||||
Algorithm: for each TypeScript/JavaScript file in the graph, re-parse the AST via `gb.parser.ParseFile()` and walk the tree for call expressions matching framework-specific patterns:
|
||||
|
||||
**Fastify patterns** (primary — Sam's stack):
|
||||
- `app.get('/path', handler)` / `app.post(...)` / etc.
|
||||
- `app.route({ method: 'GET', url: '/path', handler })` (object form)
|
||||
- `app.register(plugin)` (plugin registration — note but don't trace into)
|
||||
|
||||
**Express patterns** (secondary — common in analyzed projects):
|
||||
- `router.get('/path', ...middleware, handler)`
|
||||
- `app.use('/prefix', router)`
|
||||
|
||||
Tag inference: scan handler body for common patterns (SQL queries → `db` tag, auth checks → `auth` tag, cache reads → `cache` tag). Simplified version of codesight's 30-framework tagger — only Fastify + Express for now.
|
||||
|
||||
Codesight reference: `detectors/routes.ts` (1969 lines) + `ast/extract-routes.ts` (14690 lines). The Go port is ~200 lines targeting only 2 frameworks.
|
||||
|
||||
### 4. `get_middleware` (Tier 2)
|
||||
|
||||
**Input:** `target_dir` (optional)
|
||||
**Output:** list of detected middleware with type (auth, cors, rate-limit, validation, error-handler, logging), file, line.
|
||||
|
||||
Algorithm: for each file, scan for common middleware registration patterns:
|
||||
- `app.register(fastifyCors, ...)` → CORS
|
||||
- `app.addHook('preHandler', authCheck)` → auth
|
||||
- `app.setErrorHandler(...)` → error-handler
|
||||
- Import-name heuristics: `@fastify/cors` → CORS, `@fastify/rate-limit` → rate-limit
|
||||
|
||||
Codesight reference: `detectors/middleware.ts` (217 lines). Go port: ~80 lines, Fastify-focused.
|
||||
|
||||
## Architecture
|
||||
|
||||
All 4 tools register in `internal/mcp/server.go:registerTools()` following the existing pattern (`mcp.AddTool`).
|
||||
|
||||
Tools 1-2 (blast radius, hot files) operate on the existing `CodeGraph` — no re-parsing needed. They read `s.graph.Edges` and `s.graph.Files` under `s.graphMu.RLock()`.
|
||||
|
||||
Tools 3-4 (routes, middleware) need AST access. The current pipeline discards ASTs after symbol extraction. Two options:
|
||||
- **(a) Re-parse on demand:** when `get_routes` is called, iterate TypeScript files in `s.graph.Files`, call `s.analyzer.parser.ParseFile()` for each, walk the AST. Slower but no structural change.
|
||||
- **(b) Cache route/middleware data during analysis:** modify `processFile()` in `graph_analysis.go` to extract routes alongside symbols, store in a new `FileNode.Routes` field. Faster on repeated calls but requires graph-builder changes.
|
||||
|
||||
**Recommendation: (a) for this batch.** Re-parse is acceptable because route extraction runs on human timescale (one tool call, not per-token), and most projects have <50 route files. Optimize to (b) later if needed.
|
||||
|
||||
New Go files:
|
||||
- `internal/mcp/blast_radius.go` — handler + BFS
|
||||
- `internal/mcp/hot_files.go` — handler + sort
|
||||
- `internal/mcp/routes.go` — handler + AST route extraction for Fastify + Express
|
||||
- `internal/mcp/middleware.go` — handler + middleware pattern detection
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Go code. Tree-sitter for AST parsing (already in the project).
|
||||
- No new Go deps (tree-sitter + MCP SDK already present).
|
||||
- `go build ./...` clean. `go test ./...` passing.
|
||||
- Test coverage: at least one test per new tool exercising the happy path.
|
||||
- Don't modify existing tool behavior.
|
||||
|
||||
## Estimate
|
||||
|
||||
~400 lines of Go across 4 new files + registration in server.go. Blast radius and hot files are trivial (graph queries). Routes and middleware are the bulk (AST walking + pattern matching).
|
||||
@@ -17,7 +17,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
|
||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||
---
|
||||
You review code. Find real problems, not style nits.
|
||||
@@ -56,7 +56,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||
---
|
||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||
@@ -82,7 +82,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 5
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||
---
|
||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||
@@ -125,7 +125,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 1.5
|
||||
steps: 20
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes, web_fetch, web_search]
|
||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||
---
|
||||
You design. You produce build plans, not code.
|
||||
@@ -167,7 +167,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
|
||||
description: Audits code for security vulnerabilities. Read-only.
|
||||
---
|
||||
You audit for security issues. Concrete findings only, no generic warnings.
|
||||
@@ -212,7 +212,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
tools: [find_files, get_codebase_overview, grep, list_dir, view_file]
|
||||
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
|
||||
---
|
||||
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
|
||||
@@ -250,7 +250,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
||||
---
|
||||
You map codebases. Start broad, then drill into specifics.
|
||||
@@ -278,7 +278,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 10
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, watch_changes]
|
||||
description: Produces actionable step plans from requirements. Read-only — never modifies files.
|
||||
---
|
||||
You produce actionable step plans. You do not modify files.
|
||||
|
||||
@@ -8,16 +8,11 @@
|
||||
},
|
||||
"enabled": false
|
||||
},
|
||||
"boocontext": {
|
||||
"type-inject": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": ["/opt/forks/boocontext/dist/index.js"],
|
||||
"env": {
|
||||
"TYPE_INJECT_MCP_PATH": "/opt/forks/type-inject/packages/mcp/dist/index.js",
|
||||
"TREE_SITTER_MCP_CMD": "uvx",
|
||||
"TREE_SITTER_MCP_ARGS": "--from tree-sitter-analyzer[mcp] tree-sitter-analyzer-mcp"
|
||||
},
|
||||
"enabled": false
|
||||
"command": "npx",
|
||||
"args": ["-y", "@nick-vi/type-inject-mcp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
287
data/skills/boocode/self-healing/SKILL.md
Normal file
287
data/skills/boocode/self-healing/SKILL.md
Normal file
@@ -0,0 +1,287 @@
|
||||
---
|
||||
name: self-healing
|
||||
description: "Active runtime recovery for coding agents: when something breaks mid-task, diagnose the root cause, write a fix, VERIFY by re-running the broken thing, then file a `HEAL-` entry to `.learnings/HEALS.md` with proof. Use whenever a command, test, build, or lint fails or exits non-zero; on missing tooling, dependency/lockfile mismatch, wrong runtime version, venv or permission errors, port conflicts, dirty git state, or a missing `.env`; when the agent needs a helper or one-off script that doesn't exist yet; when an external API, tool, or MCP errors or rate-limits; or when a test flakes. Search `HEALS.md` by `Pattern-Key` first — most heals are recurrences, so increment `Recurrence-Count` instead of duplicating. Verify is mandatory: mark `pending-verify` honestly if sandboxed, `abandoned` if the fix can't be made to work. Pairs with `self-improvement` (which promotes recurring heals to durable memory) but owns the verify-before-persist discipline self-improvement doesn't."
|
||||
---
|
||||
|
||||
# Self-Healing
|
||||
|
||||
Active runtime recovery for coding agents. When something breaks, run the loop: **diagnose → patch → verify → file**. Leave behind a reusable, verified artifact instead of a swept-under-the-rug failure.
|
||||
|
||||
The premise mirrors [browser-use/browser-harness](https://github.com/browser-use/browser-harness): *the harness improves itself every run*. An agent that hits a gap doesn't fail — it writes the fix during execution, verifies it works, and files the durable artifact for future runs. Coding tasks deserve the same loop.
|
||||
|
||||
## What this skill is for
|
||||
|
||||
When a coding agent hits a wall mid-task, the default failure modes are:
|
||||
|
||||
1. **Paper over it** — "let me try a different approach" — and lose the recovery
|
||||
2. **Pretend the fix worked** — without re-running the broken thing
|
||||
3. **Symptom-fix** — skip the test, swallow the error, retry until green
|
||||
|
||||
All three turn a one-time failure into a recurrence. The next agent on the same project hits the same wall.
|
||||
|
||||
This skill enforces one discipline: **verify before persist**. A patch isn't real until you've re-run the failing operation and watched it succeed. When it does, file the verified fix so the next run benefits.
|
||||
|
||||
## Relationship to self-improvement
|
||||
|
||||
These two skills are deliberately split. Run both — they feed each other but don't overlap.
|
||||
|
||||
| Aspect | `self-healing` (this skill) | `self-improvement` |
|
||||
| ----------- | -------------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| **When** | During execution, failure is live | After the fact, at natural breakpoints |
|
||||
| **Verb** | Heal now — restore working state | Remember for later — accumulate knowledge |
|
||||
| **Outcome** | Verified patch + (optional) reusable artifact | Logged learning, correction, request |
|
||||
| **Verify** | **Mandatory** — no persist without proof | Not required |
|
||||
| **Files** | `.learnings/HEALS.md` + `.learnings/heals/<HEAL-ID>/` (lazy) | `.learnings/ERRORS.md`, `LEARNINGS.md`, `FEATURE_REQUESTS.md` |
|
||||
| **Trigger** | Failure observed mid-task | Correction, knowledge gap, feature request, recurrence |
|
||||
|
||||
**Boundary rule:** if you're capturing a fact, a correction, or a wish — that's `self-improvement`. If you're applying and verifying a fix to a live failure — that's `self-healing`.
|
||||
|
||||
## The Heal Loop
|
||||
|
||||
```
|
||||
● failure observed
|
||||
│
|
||||
● 1. DIAGNOSE capture context — command, error, env, what was attempted
|
||||
│ search HEALS.md for the same Pattern-Key first
|
||||
│ (most heals are recurrences; don't reinvent)
|
||||
│
|
||||
● 2. PATCH write the fix — script, helper, env tweak, alt command
|
||||
│ artifacts → .learnings/heals/<HEAL-ID>/ (only if needed)
|
||||
│
|
||||
● 3. VERIFY re-run the failing op — must succeed
|
||||
│ ↻ if still failing: refine and retry, cap at 3 attempts
|
||||
│ ✗ if uncrackable: file Status: abandoned with notes
|
||||
│
|
||||
● 4. FILE write HEAL-YYYYMMDD-XXX to .learnings/HEALS.md
|
||||
│ with Pattern-Key, status, verification proof
|
||||
│
|
||||
✓ working state restored, heal persisted
|
||||
|
||||
(conditional) PROMOTE if Pattern-Key recurrence ≥ 3 across distinct tasks,
|
||||
append a Handoff block → self-improvement promotes to memory
|
||||
```
|
||||
|
||||
If you abandon a heal mid-loop, don't pretend it succeeded. File a `HEAL-` entry with `Status: abandoned` and notes on what didn't work. The next agent learns from the dead end too.
|
||||
|
||||
## When to trigger
|
||||
|
||||
Self-healing fires on **active failures during execution** — the agent has just observed something not working and needs to make it work to continue. Five shapes:
|
||||
|
||||
### 1. Tool failure (command / test / build / lint)
|
||||
Any invocation exits non-zero or produces wrong output. Don't acknowledge and retry verbatim — diagnose, patch, verify.
|
||||
|
||||
*Examples:* `npm install` errors when a `pnpm-lock.yaml` is present (switch tool); `pytest` fails with `ModuleNotFoundError` (activate the venv); `tsc` flags a stale type (regenerate the client); `eslint` reports a config error (install the missing parser).
|
||||
|
||||
### 2. Missing capability / tool gap
|
||||
The agent needs something that doesn't exist yet — a script, a helper, a wrapper, a glue function. Write it in the moment. This is the closest analog to browser-harness's `agent_helpers.py`.
|
||||
|
||||
*Examples:* dedupe a CSV by custom key (write a small Python helper); bootstrap 12 microservices the same way (write `scripts/bootstrap-all.sh`); bulk-rename branches matching a pattern (write a `gh`-based shell helper).
|
||||
|
||||
### 3. Environment issue
|
||||
The local environment isn't what the project expects. Detect, patch, verify.
|
||||
|
||||
*Examples:* runtime version mismatch (`nvm use`, `pyenv local`, `rustup override`); stale dependency cache after a branch switch; dirty git state blocking a checkout; missing `.env` (copy from `.env.example` and surface gaps).
|
||||
|
||||
### 4. External service / API change
|
||||
A service the agent depends on returns something unexpected. Find a workaround and capture it.
|
||||
|
||||
*Examples:* an MCP tool returns `InputValidationError` because the schema changed (patch the call shape); a public API hits a rate limit (back off, switch endpoint, batch); an upstream lib bumped a default and broke a script (pin the version).
|
||||
|
||||
### 5. About-to-retry-the-same-broken-approach
|
||||
The agent catches itself about to redo the failing step. That self-recognition is a heal forming — capture the alternate approach as the patch.
|
||||
|
||||
### Detection signals to watch for
|
||||
|
||||
- Non-zero exit codes
|
||||
- Stack traces in tool output
|
||||
- The same operation failing twice with the same error
|
||||
- "I'll try a different approach" — capture it as a heal
|
||||
- `command not found` / `module not found` / `permission denied`
|
||||
- Stale assertions, snapshot mismatches, type errors that weren't there before
|
||||
- "Weird" output that suggests environmental rather than logical bugs
|
||||
|
||||
## HEAL Entry Format
|
||||
|
||||
Append to `.learnings/HEALS.md` (create if missing):
|
||||
|
||||
```markdown
|
||||
## [HEAL-YYYYMMDD-XXX] short_kebab_name
|
||||
|
||||
**Logged**: ISO-8601 timestamp
|
||||
**Status**: verified | pending-verify | abandoned
|
||||
**Trigger**: tool-failure | missing-capability | env-issue | external-change | <free-form>
|
||||
**Active-Context**: (optional) — current skill, task phase, or workflow stage; omit if not applicable
|
||||
**Area**: free-form tag — what part of the system (`build`, `tests`, `ci`, `auth`, `data-pipeline`, `mobile`, ...)
|
||||
**Priority**: low | medium | high | critical
|
||||
|
||||
### Failure
|
||||
What broke — concrete: the command, the error message, the action that was blocked. Include exit codes and verbatim error lines.
|
||||
|
||||
### Diagnosis
|
||||
The root cause as understood after investigation. Why the obvious approach didn't work. Not a guess — what was actually verified during the heal.
|
||||
|
||||
### Fix
|
||||
The patch that was applied. Verbatim commands, code snippets, or pointers to files under `.learnings/heals/<HEAL-ID>/`. Keep it minimal — just enough to reproduce.
|
||||
|
||||
### Verification
|
||||
What was run after the fix and what it returned. Exit code, output snippet, test pass count. **This is the proof.** Without it, the entry is `pending-verify` or `abandoned`.
|
||||
|
||||
### Artifacts
|
||||
(omit this section if no files were generated; otherwise list relative paths under `.learnings/heals/<HEAL-ID>/`)
|
||||
|
||||
### Metadata
|
||||
- Related Files: path/to/file.ext
|
||||
- See Also: HEAL-... | LRN-... | ERR-... (related entries)
|
||||
- Pattern-Key: lower.snake.case key for recurrence detection (e.g. `env.lockfile_mismatch`)
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen / Last-Seen: YYYY-MM-DD
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
### Field guidance
|
||||
|
||||
- **Status** — `verified` = the verify step passed. `pending-verify` = patch applied but couldn't be fully proven (sandboxed/offline/CI-only) — surface to the user. `abandoned` = patch didn't work or diagnosis was wrong — document what was tried.
|
||||
- **Trigger** — free-form is fine. The listed values are common shapes; what matters is that the failure shape is described enough for future agents to match against.
|
||||
- **Active-Context** — optional. Use it if your environment has a meaningful "what was I doing" tag (an active skill, a current task phase, a build stage, an agent role). Skip if not applicable. The browser-harness analog is the per-domain scoping of `domain-skills/<site>/`.
|
||||
- **Area** — free-form. Pick whatever helps future agents find this. `frontend`, `data-pipeline`, `ci`, `auth`, `terraform`, `mobile`, `embedded` — anything that fits your project shape.
|
||||
- **Pattern-Key** — lower.snake.case, stable, reusable across projects. Two heals with the same key are recurrences. `env.lockfile_mismatch` is good; `fixed_thing_tuesday` isn't.
|
||||
|
||||
## ID generation
|
||||
|
||||
Format: `HEAL-YYYYMMDD-XXX`. `XXX` is sequential 3-digit or 3-char random alphanumeric. Examples: `HEAL-20260524-001`, `HEAL-20260524-A7B`.
|
||||
|
||||
## Artifacts directory (lazy)
|
||||
|
||||
Only create `.learnings/heals/<HEAL-ID>/` when the heal generated something worth preserving. One-line fixes don't need a folder; the HEAL entry text is enough. Abandoned heals with no applied patch also skip the folder.
|
||||
|
||||
```
|
||||
.learnings/
|
||||
├── HEALS.md
|
||||
├── ERRORS.md / LEARNINGS.md / FEATURE_REQUESTS.md (self-improvement)
|
||||
└── heals/
|
||||
└── HEAL-20260524-001/
|
||||
├── helper.sh
|
||||
├── patch.diff
|
||||
└── notes.md
|
||||
```
|
||||
|
||||
**Put here:** generated scripts/helpers, patch files, supplementary notes, output captures that document the diagnosis.
|
||||
**Don't put here:** project source changes (those go in the project tree, referenced via Related Files); secrets; output already captured in the HEAL text.
|
||||
|
||||
## Verification rules
|
||||
|
||||
Verify is the load-bearing wall. The whole point of self-healing over self-improvement is that the fix is *proven*, not theorized.
|
||||
|
||||
### What counts as proof
|
||||
|
||||
| Failure shape | Verification |
|
||||
| ------------------------------------- | ------------------------------------------------------------------ |
|
||||
| Tool / command / test / build / lint | Re-run the original invocation; expect exit 0 / pass |
|
||||
| Missing capability | Invoke the helper end-to-end on a real input; expect the intent |
|
||||
| Environment drift | Re-run the operation that triggered the diagnosis |
|
||||
| External service workaround | Re-run the failed call with the patch; expect a usable response |
|
||||
|
||||
### Sandboxed / offline / CI-only failures
|
||||
|
||||
When you genuinely can't run the verify step (no network, no real remote, sandboxed shell, CI-only reproduction), file `Status: pending-verify` with:
|
||||
|
||||
- The exact command the user / CI should run
|
||||
- The acceptance criteria — what counts as proof
|
||||
- A simulated proof if you can construct one (e.g. a dry-run mode, a stub of the failing call, a sandbox script)
|
||||
|
||||
`pending-verify` is honest. Faking `verified` is the failure mode this skill exists to prevent.
|
||||
|
||||
### When to invest in a proof script
|
||||
|
||||
Most heals don't need a separate proof script — the verify step is just re-running the failing thing. Build a proper proof script when:
|
||||
|
||||
- The heal generates a reusable helper that needs to be exercised across cases
|
||||
- The failure can't be reproduced live but can be reproduced in a sandbox (clean git repo, mock service, fake input)
|
||||
- You expect the heal to be re-applied across projects — the proof script then doubles as a regression check
|
||||
|
||||
### If verification fails
|
||||
|
||||
1. **Once** — refine the patch and retry. First diagnosis is often wrong.
|
||||
2. **Twice** — step back and reconsider the diagnosis. Maybe the root cause is elsewhere.
|
||||
3. **Three times** — stop. File `Status: abandoned` with notes on what you tried. Surface to the user. Don't flail.
|
||||
|
||||
### What does NOT count as verification
|
||||
|
||||
- "It looks right" / "I think this should work"
|
||||
- Re-running a *different* command than the one that originally failed
|
||||
- Suppressing the failure (`|| true`, `--ignore-errors`) — that's hiding
|
||||
- Skipping or deleting the failing test — that's regression
|
||||
- Passing because the cache was warm from before the fix
|
||||
|
||||
### Reversibility
|
||||
|
||||
Prefer reversible patches. If your heal modifies project files, capture the diff in `patch.diff`. If the heal is destructive (deletes generated files, rewrites locks), note it explicitly — a future agent reading the HEAL needs to know what was destroyed.
|
||||
|
||||
## Recurrence and promotion
|
||||
|
||||
Most heals are recurrences. Before filing a new HEAL, search:
|
||||
|
||||
```bash
|
||||
grep -n "Pattern-Key: <your-pattern-key>" .learnings/HEALS.md
|
||||
```
|
||||
|
||||
If found:
|
||||
|
||||
- Increment `Recurrence-Count`
|
||||
- Update `Last-Seen`
|
||||
- Add the current occurrence as a See Also link
|
||||
- **Do not** create a duplicate entry
|
||||
|
||||
### Promotion threshold
|
||||
|
||||
Add a `Handoff` block to an existing entry when **all** are true:
|
||||
|
||||
- `Recurrence-Count >= 3`
|
||||
- Seen across at least 2 distinct tasks
|
||||
- The fix is generalizable (not project-specific in a way that's already in a memory file)
|
||||
|
||||
```markdown
|
||||
### Handoff
|
||||
- **Promoted To**: self-improvement at YYYY-MM-DD
|
||||
- **Promotion Target**: CLAUDE.md | AGENTS.md | .github/copilot-instructions.md | new-skill
|
||||
- **Distilled Rule**: One-line prevention guidance derived from the heal
|
||||
```
|
||||
|
||||
Then `self-improvement` (or a learning aggregator) takes over: distills the rule, writes it into the right context file, or extracts a reusable skill. The HEAL stays for traceability.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
1. **Logging without verifying.** A HEAL filed before the fix is proven turns this into noisier self-improvement. If verify hasn't passed, the entry is `pending-verify` or `abandoned`.
|
||||
2. **Healing the symptom, not the cause.** A failing test isn't healed by skipping it (`pytest.skip`, `it.skip`, `xit`). A flaky CI isn't healed by `--retry`. Find the root cause; if you can't, abandon honestly.
|
||||
3. **Generating a new fix without trying existing ones first.** Search `HEALS.md` by Pattern-Key. Most heals are recurrences.
|
||||
4. **Inventing helpers when the project already has them.** Look in `scripts/`, `Makefile`, `justfile`, `package.json`, `pyproject.toml` first. Heal = write what's missing, not what's there.
|
||||
5. **Scope creep.** A heal is scoped to one failure. Cleanup belongs in a quality pass; refactors are features. Scope creep makes heals unreviewable.
|
||||
6. **Empty artifact folders.** Don't create `.learnings/heals/<HEAL-ID>/` if nothing goes in it.
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Heal eagerly, file always.** Even abandoned heals teach the next agent what doesn't work.
|
||||
2. **Verify before persist.** The non-negotiable rule.
|
||||
3. **Minimal and reversible patches.** A 3-line fix is a heal; a 300-line refactor is a feature.
|
||||
4. **Stable Pattern-Keys.** `env.node_version_mismatch` is reusable; `fixed_the_thing_on_tuesday` isn't.
|
||||
5. **Reference, don't duplicate.** Cross-link related HEAL/LRN/ERR via See Also.
|
||||
6. **Hand off recurrences.** A heal seen 3 times deserves to be in the project's permanent memory.
|
||||
7. **Don't gate the main tree on heal artifacts.** Files under `.learnings/heals/` are reference material; if a script becomes load-bearing, promote it to `scripts/`.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
mkdir -p .learnings # heals/ is lazy — created only when artifacts exist
|
||||
touch .learnings/HEALS.md
|
||||
```
|
||||
|
||||
Gitignore choices match `self-improvement`. Keep heals local (`.learnings/` in `.gitignore`) or share them as team knowledge (don't gitignore — they become reviewable durable context).
|
||||
|
||||
## Multi-agent use
|
||||
|
||||
The skill is agent-agnostic. The `.learnings/HEALS.md` format is plain markdown — any agent (Claude Code, BooCode agents, OpenCode, Copilot, Cursor, Aider, ...) can read and write it.
|
||||
|
||||
## See also
|
||||
|
||||
- [`references/examples.md`](references/examples.md) — canonical HEAL entry shapes (command failure, missing capability, env drift, external API workaround, abandoned heal)
|
||||
35
data/skills/boocode/self-healing/eval.yaml
Normal file
35
data/skills/boocode/self-healing/eval.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
skill: self-healing
|
||||
tasks:
|
||||
- prompt: "I'm in a project root that has pnpm-lock.yaml present but no package-lock.json. I just tried to run `npm install` and it failed. Get me to a working state so I can keep working — I have other things to do, just unblock me. After fixing it, make sure future agents in this project know what happened."
|
||||
grader:
|
||||
- the response invokes the self-healing skill
|
||||
- the response diagnoses pnpm vs npm mismatch as the root cause
|
||||
- the response runs pnpm install successfully
|
||||
- the response files a HEAL entry to .learnings/HEALS.md with Status: verified
|
||||
- the HEAL entry has Trigger: tool-failure
|
||||
- the HEAL entry has a Pattern-Key resembling env.lockfile_mismatch
|
||||
- the HEAL entry includes the verification output
|
||||
- prompt: "I need to bulk-rename 8 git branches in this repo from `feat-XXX-name` to `feat/XXX-name`. There's no existing script for this and `gh` doesn't have a bulk-rename. Write what's needed, prove it works on a dry run, and capture the work so it's not lost if I need it again."
|
||||
grader:
|
||||
- the response invokes the self-healing skill
|
||||
- the response recognizes this as a missing-capability heal
|
||||
- the response writes a helper script under .learnings/heals/HEAL-<date>-<seq>/
|
||||
- the response runs a dry-run verification
|
||||
- the response files a HEAL entry with Status: verified and Trigger: missing-capability
|
||||
- the HEAL entry references the helper script in the Artifacts section
|
||||
- prompt: "I just ran `pytest` and got `ModuleNotFoundError: No module named 'pydantic'`. There's already a `.learnings/HEALS.md` in this project with a prior heal for a similar venv-not-activated issue. Fix this, and do the right thing with the heal records."
|
||||
grader:
|
||||
- the response invokes the self-healing skill
|
||||
- the response searches HEALS.md first (using find-similar-heals.sh or grep) before writing a new fix
|
||||
- the response finds the existing HEAL entry and applies its fix (activate venv)
|
||||
- the response increments Recurrence-Count on the existing entry
|
||||
- the response updates Last-Seen on the existing entry
|
||||
- the response does NOT create a duplicate HEAL entry
|
||||
- prompt: "A test in this repo is failing intermittently — the snapshot for `Card.test.tsx` flakes. I've already tried fixing it once by stubbing the date; it passes twice then flakes again because there's a UUID that's also non-deterministic. I don't have time to refactor the Card component to inject dependencies. Just do the right thing — get me to a state that's honest about what's known and not known, and don't pretend the heal worked."
|
||||
grader:
|
||||
- the response invokes the self-healing skill
|
||||
- the response diagnoses that the initial patch attempt was incomplete
|
||||
- the response files a HEAL entry with Status: abandoned
|
||||
- the HEAL entry documents what was tried and why it failed
|
||||
- the response does NOT mark anything as verified
|
||||
- the response surfaces the situation honestly to the user
|
||||
248
data/skills/boocode/self-healing/references/examples.md
Normal file
248
data/skills/boocode/self-healing/references/examples.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Self-Healing Examples
|
||||
|
||||
Concrete HEAL entries showing the format applied to real failure shapes. Use these as templates when filing your own heals. All examples use the iteration-2 schema (free-form `Trigger` / `Area`, optional `Active-Context`, no `Source` field, lazy artifact folders).
|
||||
|
||||
---
|
||||
|
||||
## Example 1 — Tool failure (lockfile mismatch)
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-001] npm_install_pnpm_lockfile
|
||||
|
||||
**Logged**: 2026-05-24T14:22:01Z
|
||||
**Status**: verified
|
||||
**Trigger**: tool-failure
|
||||
**Area**: build
|
||||
**Priority**: medium
|
||||
|
||||
### Failure
|
||||
`npm install` exited 1 with `npm ERR! code EUSAGE` and a notice that `pnpm-lock.yaml` is present but `package-lock.json` is missing. The project uses pnpm workspaces; npm refuses to install against a pnpm lockfile.
|
||||
|
||||
### Diagnosis
|
||||
Project root contains `pnpm-lock.yaml`. The README and CI both invoke `pnpm`. `npm` was a habit from previous projects, not the actual project's package manager.
|
||||
|
||||
### Fix
|
||||
Use pnpm instead:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Verification
|
||||
```
|
||||
$ pnpm install
|
||||
Lockfile is up to date, resolution step is skipped
|
||||
Already up to date
|
||||
✓ Done in 1.4s
|
||||
```
|
||||
Exit 0.
|
||||
|
||||
### Metadata
|
||||
- Related Files: package.json, pnpm-lock.yaml
|
||||
- See Also: (none yet)
|
||||
- Pattern-Key: env.lockfile_mismatch
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Pattern-Key `env.lockfile_mismatch` is reusable across projects (yarn.lock, bun.lockb, etc.). At Recurrence ≥ 3, this should be promoted to `CLAUDE.md` or `AGENTS.md` as a verification step.
|
||||
|
||||
No Artifacts section — the fix is a tool swap, no files generated. Lazy folder pattern: nothing to put in `.learnings/heals/HEAL-20260524-001/`, so the folder isn't created.
|
||||
|
||||
---
|
||||
|
||||
## Example 2 — Missing capability (helper written on the fly)
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-002] bulk_rename_branches_helper
|
||||
|
||||
**Logged**: 2026-05-24T15:10:44Z
|
||||
**Status**: verified
|
||||
**Trigger**: missing-capability
|
||||
**Area**: ci
|
||||
**Priority**: low
|
||||
|
||||
### Failure
|
||||
Need to rename 12 feature branches from `feat-XXX-name` to `feat/XXX-name`. No existing project script handles this; `gh` doesn't have a bulk-rename primitive.
|
||||
|
||||
### Diagnosis
|
||||
This is glue work, not a project bug. A small shell helper using `gh api` per branch is the right level — not worth a top-level script, but worth keeping the file for the next time someone asks.
|
||||
|
||||
### Fix
|
||||
Wrote `.learnings/heals/HEAL-20260524-002/rename-branches.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
git fetch --all
|
||||
for branch in $(git branch -r | grep 'origin/feat-' | sed 's|origin/||'); do
|
||||
new="${branch/feat-/feat/}"
|
||||
echo "$branch → $new"
|
||||
gh api -X POST "repos/{owner}/{repo}/git/refs" \
|
||||
-f "ref=refs/heads/$new" \
|
||||
-f "sha=$(git rev-parse "origin/$branch")"
|
||||
gh api -X DELETE "repos/{owner}/{repo}/git/refs/heads/$branch"
|
||||
done
|
||||
```
|
||||
|
||||
### Verification
|
||||
Dry-run (commented out the API calls) printed the 12 expected mappings.
|
||||
Live run renamed all 12; `git branch -r | grep 'feat-' | wc -l` returns 0.
|
||||
|
||||
### Artifacts
|
||||
- `.learnings/heals/HEAL-20260524-002/rename-branches.sh`
|
||||
|
||||
### Metadata
|
||||
- Related Files: (none — operates on git refs)
|
||||
- See Also: (none)
|
||||
- Pattern-Key: tool.gh.bulk_branch_rename
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Helper script lives under `.learnings/heals/<HEAL-ID>/` — referenceable, but not assumed to be load-bearing. If it gets reused frequently, promote to `scripts/`.
|
||||
|
||||
---
|
||||
|
||||
## Example 3 — Environment issue (runtime version)
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-003] nvm_use_project_node
|
||||
|
||||
**Logged**: 2026-05-24T16:01:12Z
|
||||
**Status**: verified
|
||||
**Trigger**: env-issue
|
||||
**Active-Context**: verify-gate
|
||||
**Area**: tests
|
||||
**Priority**: medium
|
||||
|
||||
### Failure
|
||||
`pnpm test` exited 1 with `engine "node" is incompatible with this module. Expected version "^20.10.0". Got "18.19.0"`.
|
||||
|
||||
### Diagnosis
|
||||
`.nvmrc` requests node 20.10.0; current shell has 18.19.0 from a previous project context. The shell's nvm wasn't switched after `cd`-ing into the repo.
|
||||
|
||||
### Fix
|
||||
```bash
|
||||
nvm use # reads .nvmrc
|
||||
```
|
||||
|
||||
### Verification
|
||||
```
|
||||
$ node --version
|
||||
v20.10.0
|
||||
$ pnpm test
|
||||
✓ 47 tests passed
|
||||
```
|
||||
|
||||
### Metadata
|
||||
- Related Files: .nvmrc, package.json
|
||||
- See Also: (none)
|
||||
- Pattern-Key: env.node_version_mismatch
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
`Active-Context: verify-gate` because that's the workflow phase the agent was in when the test step blew up. An upstream context loader could surface this entry next time `verify-gate` runs in a node project. If you don't have an analogous concept in your pipeline, omit the field.
|
||||
|
||||
---
|
||||
|
||||
## Example 4 — External service workaround
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-004] gh_api_rate_limit_backoff
|
||||
|
||||
**Logged**: 2026-05-24T17:33:08Z
|
||||
**Status**: verified
|
||||
**Trigger**: external-change
|
||||
**Area**: ci
|
||||
**Priority**: high
|
||||
|
||||
### Failure
|
||||
Looping `gh api repos/.../issues` over 200 issues started returning `403 rate limit exceeded` after ~60 calls. Unauthenticated burst limit (abuse detection on rapid successive calls).
|
||||
|
||||
### Diagnosis
|
||||
Script was using `gh api` REST without batching. `gh` is authenticated but the secondary rate limit fires on rapid successive calls — not the primary 5000/hour limit. Switching to a single paginated GraphQL query bypasses the secondary limit entirely.
|
||||
|
||||
### Fix
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$cursor:String) {
|
||||
repository(owner:$owner,name:$repo) {
|
||||
issues(first:100,after:$cursor) { ... }
|
||||
}
|
||||
}' -F owner=... -F repo=...
|
||||
```
|
||||
Took ~3 calls total instead of 200.
|
||||
|
||||
### Verification
|
||||
Full run completed in 4.8s, no 403s, all 200 issues retrieved. Compared output against a sample of the original per-issue calls — fields match.
|
||||
|
||||
### Artifacts
|
||||
- `.learnings/heals/HEAL-20260524-004/fetch-issues.sh`
|
||||
|
||||
### Metadata
|
||||
- Related Files: (none — ad-hoc query)
|
||||
- See Also: (none)
|
||||
- Pattern-Key: api.gh.rate_limit
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 5 — Abandoned heal (diagnosis was wrong)
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-005] vitest_flaky_snapshot
|
||||
|
||||
**Logged**: 2026-05-24T18:14:22Z
|
||||
**Status**: abandoned
|
||||
**Trigger**: tool-failure
|
||||
**Active-Context**: verify-gate
|
||||
**Area**: tests
|
||||
**Priority**: medium
|
||||
|
||||
### Failure
|
||||
`vitest` snapshot test `Card > renders default` flaked twice in three runs. Diff showed a timestamp string differing by ~3 seconds.
|
||||
|
||||
### Diagnosis (initial — wrong)
|
||||
Assumed flake was timezone drift in the snapshot fixture. Patched the fixture to use a fixed `Date.now()` stub.
|
||||
|
||||
### Diagnosis (current — correct)
|
||||
The snapshot depends on multiple non-deterministic values: timestamp AND a `crypto.randomUUID()`. The clock stub addressed only one of them. The UUID is still random per render, so the snapshot keeps drifting on subsequent runs.
|
||||
|
||||
### Fix (attempted)
|
||||
Added `vi.useFakeTimers({ now: 1700000000000 })` to the test setup.
|
||||
|
||||
### Verification
|
||||
Test passed twice, then flaked again on the third run — same `Card > renders default`, different diff (this time the UUID changed). Original diagnosis was incomplete.
|
||||
|
||||
### Abandonment notes
|
||||
The right fix is to make the component deterministic via dependency injection (pass a `clock` and `idGen` prop), not to stub globally. That's a real change to the component contract — out of scope for a heal. Filed `FEAT-20260524-001` via self-improvement; surfaced to the user.
|
||||
|
||||
### Metadata
|
||||
- Related Files: src/components/Card.tsx, src/components/Card.test.tsx
|
||||
- See Also: FEAT-20260524-001
|
||||
- Pattern-Key: tests.flaky_snapshot_multi_nondeterminism
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Abandoned heals are first-class. They document a dead end so the next agent doesn't re-walk it. The handoff to a `FEAT-` entry via self-improvement is the right next step when the real fix is a feature, not a heal.
|
||||
|
||||
No Artifacts section — the attempted patch was reverted; nothing reusable was generated.
|
||||
54
data/skills/boocode/self-healing/scripts/detect-failure.sh
Executable file
54
data/skills/boocode/self-healing/scripts/detect-failure.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# detect-failure.sh — PostToolUse hook for Bash invocations.
|
||||
# Reads the tool result JSON on stdin (per Claude Code hook spec); if exit_code != 0,
|
||||
# emits a system reminder pointing the agent at self-healing.
|
||||
#
|
||||
# Wire up in .claude/settings.json:
|
||||
# "hooks": {
|
||||
# "PostToolUse": [{ "matcher": "Bash",
|
||||
# "hooks": [{ "type": "command",
|
||||
# "command": "./data/skills/boocode/self-healing/scripts/detect-failure.sh" }] }]
|
||||
# }
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Hook payload arrives on stdin. We tolerate either jq-style JSON or raw text.
|
||||
PAYLOAD="$(cat || true)"
|
||||
|
||||
# Try to parse exit_code; fall through silently on parse failure.
|
||||
EXIT_CODE=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
data = json.loads(sys.stdin.read() or "{}")
|
||||
# Common shapes: {"tool_result": {"exit_code": N}}, {"exit_code": N}, {"output": "...", "exit_code": N}
|
||||
for path in (("tool_result","exit_code"), ("exit_code",), ("result","exit_code")):
|
||||
d = data
|
||||
ok = True
|
||||
for k in path:
|
||||
if isinstance(d, dict) and k in d:
|
||||
d = d[k]
|
||||
else:
|
||||
ok = False
|
||||
break
|
||||
if ok and isinstance(d, int):
|
||||
print(d)
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass
|
||||
print(0)
|
||||
' 2>/dev/null || echo 0)
|
||||
|
||||
if [[ "$EXIT_CODE" != "0" ]]; then
|
||||
cat <<'EOF'
|
||||
<self-healing-trigger>
|
||||
A Bash command just exited non-zero. This is a heal opportunity.
|
||||
|
||||
Before retrying the same command verbatim:
|
||||
1. DIAGNOSE — read the error; identify the root cause (env? missing dep? wrong tool?)
|
||||
2. Search .learnings/HEALS.md for a matching Pattern-Key (don't re-solve a solved problem)
|
||||
3. PATCH — write the fix (or apply a known one)
|
||||
4. VERIFY — re-run the command; require exit 0
|
||||
5. FILE — append a HEAL entry to .learnings/HEALS.md via data/skills/boocode/self-healing/scripts/new-heal.sh
|
||||
</self-healing-trigger>
|
||||
EOF
|
||||
fi
|
||||
52
data/skills/boocode/self-healing/scripts/find-similar-heals.sh
Executable file
52
data/skills/boocode/self-healing/scripts/find-similar-heals.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# find-similar-heals.sh — Search existing heals before generating a new fix.
|
||||
# Usage: ./find-similar-heals.sh <pattern-key-or-keyword>
|
||||
#
|
||||
# Prints matching HEAL entries with their Pattern-Key, Status, and Recurrence-Count
|
||||
# so the agent can decide whether to re-apply an existing fix or write a new one.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
QUERY="${1:-}"
|
||||
HEALS_FILE="$(pwd)/.learnings/HEALS.md"
|
||||
|
||||
if [[ -z "$QUERY" ]]; then
|
||||
echo "usage: $0 <pattern-key-or-keyword>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HEALS_FILE" ]]; then
|
||||
echo "(no .learnings/HEALS.md yet — no prior heals to consult)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find HEAL section headers that contain the query in their body (Pattern-Key, name, or text).
|
||||
python3 - <<PY "$QUERY" "$HEALS_FILE"
|
||||
import sys, re
|
||||
query, path = sys.argv[1].lower(), sys.argv[2]
|
||||
with open(path) as f:
|
||||
text = f.read()
|
||||
# Split into entries by ^## [HEAL-...]
|
||||
entries = re.split(r"(?m)^## \[HEAL-", text)[1:]
|
||||
hits = []
|
||||
for body in entries:
|
||||
if query in body.lower():
|
||||
head = body.splitlines()[0]
|
||||
pk = re.search(r"Pattern-Key:\s*(\S+)", body)
|
||||
status = re.search(r"Status\*\*:\s*(\S+)", body) or re.search(r"Status:\s*(\S+)", body)
|
||||
rc = re.search(r"Recurrence-Count:\s*(\d+)", body)
|
||||
hits.append({
|
||||
"id": "HEAL-" + head.split("]")[0],
|
||||
"name": head.split("]", 1)[1].strip() if "]" in head else head,
|
||||
"pattern_key": pk.group(1) if pk else "?",
|
||||
"status": status.group(1) if status else "?",
|
||||
"recurrence": rc.group(1) if rc else "1",
|
||||
})
|
||||
if not hits:
|
||||
print(f"(no heals match '{query}')")
|
||||
else:
|
||||
print(f"Found {len(hits)} matching heal(s):\n")
|
||||
for h in hits:
|
||||
print(f" {h['id']} {h['name']}")
|
||||
print(f" pattern={h['pattern_key']} status={h['status']} recurrence={h['recurrence']}")
|
||||
PY
|
||||
74
data/skills/boocode/self-healing/scripts/new-heal.sh
Executable file
74
data/skills/boocode/self-healing/scripts/new-heal.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# new-heal.sh — Initialize a new HEAL-<date>-<seq> entry skeleton.
|
||||
# Usage: ./new-heal.sh <short_kebab_name> [trigger]
|
||||
# trigger: tool-failure | missing-capability | env-issue | external-change | <free-form>
|
||||
#
|
||||
# Appends a templated HEAL entry to .learnings/HEALS.md and prints the HEAL-ID.
|
||||
# Does NOT create .learnings/heals/<HEAL-ID>/ — that folder is lazy, created
|
||||
# only when artifacts are written.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${1:-}"
|
||||
TRIGGER="${2:-tool-failure}"
|
||||
|
||||
if [[ -z "$NAME" ]]; then
|
||||
echo "usage: $0 <short_kebab_name> [trigger]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
LEARNINGS_DIR="$(pwd)/.learnings"
|
||||
HEALS_FILE="$LEARNINGS_DIR/HEALS.md"
|
||||
mkdir -p "$LEARNINGS_DIR"
|
||||
|
||||
DATE="$(date +%Y%m%d)"
|
||||
SEQ=$(grep -c "^## \[HEAL-${DATE}-" "$HEALS_FILE" 2>/dev/null || echo 0)
|
||||
NEXT=$(printf "%03d" $((SEQ + 1)))
|
||||
HEAL_ID="HEAL-${DATE}-${NEXT}"
|
||||
|
||||
# Active-Context is optional. The agent / harness can set ACTIVE_CONTEXT in env.
|
||||
ACTIVE_CONTEXT="${ACTIVE_CONTEXT:-}"
|
||||
ACTIVE_LINE=""
|
||||
if [[ -n "$ACTIVE_CONTEXT" ]]; then
|
||||
ACTIVE_LINE="**Active-Context**: $ACTIVE_CONTEXT
|
||||
"
|
||||
fi
|
||||
|
||||
cat >> "$HEALS_FILE" <<EOF
|
||||
|
||||
## [$HEAL_ID] $NAME
|
||||
|
||||
**Logged**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
**Status**: pending-verify
|
||||
**Trigger**: $TRIGGER
|
||||
${ACTIVE_LINE}**Area**: TODO
|
||||
**Priority**: medium
|
||||
|
||||
### Failure
|
||||
TODO — concrete error, command, exit code
|
||||
|
||||
### Diagnosis
|
||||
TODO — root cause after investigation
|
||||
|
||||
### Fix
|
||||
TODO — patch applied (commands, snippets, or pointers to .learnings/heals/$HEAL_ID/ if files were generated)
|
||||
|
||||
### Verification
|
||||
TODO — what was run after the fix, what it returned. **Update Status to "verified" only after this passes.**
|
||||
|
||||
### Metadata
|
||||
- Related Files: TODO
|
||||
- See Also: TODO
|
||||
- Pattern-Key: TODO
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: $(date +%Y-%m-%d)
|
||||
- Last-Seen: $(date +%Y-%m-%d)
|
||||
|
||||
---
|
||||
EOF
|
||||
|
||||
# stdout = the HEAL-ID alone, so `ID=$(new-heal.sh ...)` captures it cleanly.
|
||||
# Human guidance goes to stderr.
|
||||
echo "$HEAL_ID"
|
||||
echo "$HEALS_FILE" >&2
|
||||
echo "(create .learnings/heals/$HEAL_ID/ only if you generate artifacts to put there)" >&2
|
||||
178
data/skills/boocode/verify-gate/SKILL.md
Normal file
178
data/skills/boocode/verify-gate/SKILL.md
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: verify-gate
|
||||
description: "Runs project compile, test, and lint commands between implementation and quality review. Gates simplify-and-harden behind machine verification. If checks fail, enters a fix loop with diagnostics. If checks pass, signals ready for quality pass. Use after any implementation work completes and before signaling done. Essential for the inner loop's verify step."
|
||||
---
|
||||
|
||||
# Verify Gate
|
||||
|
||||
Machine verification gate between implementation and quality review. Runs the project's compile, test, and lint commands. If any fail, enters a fix loop. If all pass, unblocks the quality pass.
|
||||
|
||||
This is the inner loop's **verify** step. Without it, the agent hands off code with zero machine signal about whether it actually works.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After any implementation work completes, before signaling "done"
|
||||
- Before running simplify-and-harden or quality review
|
||||
- After fixing audit findings from code review
|
||||
- Any time you want a machine-verified green signal
|
||||
|
||||
## Pipeline Position
|
||||
|
||||
```
|
||||
[implementation] → verify-gate → [quality review / simplify-and-harden]
|
||||
↻ fix loop — on failure, diagnose and retry
|
||||
```
|
||||
|
||||
## Step 1: Discover Project Commands
|
||||
|
||||
Read the project's configuration to find verification commands. Check these sources in order:
|
||||
|
||||
1. **Project instruction files** (`CLAUDE.md`, `data/AGENTS.md`) — look for a `## Verification` or `## Test Commands` section
|
||||
2. **package.json** — `scripts.test`, `scripts.lint`, `scripts.typecheck`, `scripts.build`. BooCode uses pnpm, so prefer `pnpm run <script>` when `pnpm-lock.yaml` is present.
|
||||
3. **Makefile** / **Justfile** — `test`, `lint`, `check`, `build` targets
|
||||
4. **Cargo.toml** — `cargo build`, `cargo test`, `cargo clippy`
|
||||
5. **pyproject.toml** / **setup.cfg** — `pytest`, `mypy`, `ruff`
|
||||
6. **go.mod** — `go build ./...`, `go test ./...`, `go vet ./...`
|
||||
7. **deno.json** / **deno.jsonc** — `deno task <name>` for any defined tasks
|
||||
|
||||
If no commands are discoverable, ask the user once and suggest they add a `## Verification` section to `CLAUDE.md` for future sessions:
|
||||
|
||||
```markdown
|
||||
## Verification
|
||||
|
||||
- Build: `pnpm run build`
|
||||
- Test: `pnpm test`
|
||||
- Lint: `pnpm run lint`
|
||||
- Type check: `npx tsc -p apps/server/tsconfig.json --noEmit`
|
||||
```
|
||||
|
||||
## Step 2: Run Verification
|
||||
|
||||
Run discovered commands in this order. Stop at the first failure category.
|
||||
|
||||
### Phase 1: Compile / Type Check
|
||||
Run the build or type-check command. These catch structural errors before wasting time on tests.
|
||||
|
||||
```
|
||||
Exit 0 → proceed to Phase 2
|
||||
Exit non-zero → enter fix loop with compiler output
|
||||
```
|
||||
|
||||
### Phase 2: Tests
|
||||
Run the test command. Scope to changed files if the test runner supports it.
|
||||
|
||||
```
|
||||
Exit 0 → proceed to Phase 3
|
||||
Exit non-zero → enter fix loop with test output
|
||||
```
|
||||
|
||||
### Phase 3: Lint (optional, skippable with --skip-lint)
|
||||
Run the lint command. Lint failures are lower severity but still worth catching.
|
||||
|
||||
```
|
||||
Exit 0 → all phases green, gate passes
|
||||
Exit non-zero → enter fix loop with lint output
|
||||
```
|
||||
|
||||
## Step 3: Fix Loop
|
||||
|
||||
When a phase fails:
|
||||
|
||||
1. **Read the output.** Parse the error output for actionable diagnostics — file paths, line numbers, error messages.
|
||||
2. **Scope the fix.** Only fix what the verification caught. Do not refactor, improve, or touch unrelated code.
|
||||
3. **Apply the fix.** Make the minimal change to resolve the failure.
|
||||
4. **Re-run the failed phase.** Not all phases — just the one that failed.
|
||||
5. **If it passes**, continue to the next phase.
|
||||
6. **If it fails again**, increment the attempt counter.
|
||||
|
||||
### Fix Loop Limits
|
||||
|
||||
- **Default max attempts:** 3 per phase (configurable via `--fix-limit N`)
|
||||
- **Counter increments on every attempt**, even if the error changes. Fixing Error A and uncovering Error B counts as attempt 2, not attempt 1. The counter tracks fix attempts, not unique errors.
|
||||
- **If limit reached:** Stop. Report what failed, what was tried, and the remaining error output. Do not guess further — signal to the user that manual intervention is needed.
|
||||
- **Total budget:** The fix loop should not exceed 20% of the original implementation effort. If fixes are snowballing, stop and report.
|
||||
|
||||
## Step 4: Gate Signal
|
||||
|
||||
When all phases pass:
|
||||
|
||||
```markdown
|
||||
## Verify Gate: PASSED
|
||||
|
||||
- Build: passed
|
||||
- Tests: passed (N tests, M suites)
|
||||
- Lint: passed (or skipped)
|
||||
|
||||
Ready for quality review.
|
||||
```
|
||||
|
||||
When the fix loop is exhausted:
|
||||
|
||||
```markdown
|
||||
## Verify Gate: BLOCKED
|
||||
|
||||
- Build: passed
|
||||
- Tests: FAILED (attempt 3/3)
|
||||
- [file:line] error description
|
||||
- [file:line] error description
|
||||
- Lint: not reached
|
||||
|
||||
Fix loop exhausted. Manual intervention needed before quality review.
|
||||
```
|
||||
|
||||
## Integration with Other Skills
|
||||
|
||||
### boocode simplify-and-harden / quality review
|
||||
verify-gate should gate any quality pass. Run verify-gate first; only proceed to review if the gate passes.
|
||||
|
||||
### self-healing (if available)
|
||||
On any failure during the verify run, consider handing the diagnostics to a self-healing loop (diagnose → patch → verify → persist). Verify-gate then re-runs the checks. Up to 3 heal attempts per phase before abandoning.
|
||||
|
||||
### self-improvement
|
||||
If a recurring error pattern emerges across verify runs, capture it in `CLAUDE.md` or as a new skill under `data/skills/boocode/` so future verify-gate runs don't rediscover the same fix.
|
||||
|
||||
## What This Skill Does NOT Do
|
||||
|
||||
- Does not review code quality (that's a separate review pass)
|
||||
- Does not check security
|
||||
- Does not verify spec compliance
|
||||
- Does not modify test files or add new tests
|
||||
- Does not run tests for code it didn't change (unless the test runner doesn't support scoping)
|
||||
|
||||
## Configuration
|
||||
|
||||
If the project has a `verify-gate` section in `CLAUDE.md` or `data/AGENTS.md`:
|
||||
|
||||
```yaml
|
||||
## Verify Gate Config
|
||||
|
||||
build: pnpm run build
|
||||
test: pnpm test
|
||||
lint: pnpm run lint
|
||||
type_check: npx tsc -p apps/server/tsconfig.json --noEmit
|
||||
fix_limit: 3
|
||||
skip_lint: false
|
||||
test_scope: changed # changed | all
|
||||
```
|
||||
|
||||
If no configuration exists, discover commands automatically (Step 1) and suggest persisting them.
|
||||
|
||||
### Custom Verification Steps
|
||||
|
||||
Projects with custom invariants can define additional verification phases. These run as extra phases after the standard compile/test/lint checks.
|
||||
|
||||
Example — a project that needs API schema validation:
|
||||
|
||||
```yaml
|
||||
## Verify Gate Config
|
||||
|
||||
custom_checks:
|
||||
- name: validate-schema
|
||||
command: python scripts/validate_schema.py --strict
|
||||
- name: check-no-legacy-imports
|
||||
command: grep -r "from legacy" src/ --include="*.py" && exit 1 || exit 0
|
||||
```
|
||||
|
||||
When custom checks are defined, verify-gate runs them as **Phase 4** after lint. Each check's exit code determines pass/fail. Failed checks enter the same fix loop as standard phases.
|
||||
|
||||
This moves project-specific invariants from "knowledge in your head" to "knowledge in the harness" — exactly where the agent can reach it.
|
||||
@@ -110,7 +110,7 @@ services:
|
||||
- "127.0.0.1:8080:8080"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CODECONTEXT_CHILD: node /usr/local/lib/boocontext/dist/index.js
|
||||
CODECONTEXT_CHILD: node /usr/local/lib/boocontext/dist/index.js --mcp
|
||||
TYPE_INJECT_MCP_PATH: /opt/type-inject/packages/mcp/dist/index.js
|
||||
TREE_SITTER_MCP_CMD: uvx
|
||||
TREE_SITTER_MCP_ARGS: --from tree-sitter-analyzer[mcp] tree-sitter-analyzer-mcp
|
||||
|
||||
1734
packages/ion/package-lock.json
generated
Normal file
1734
packages/ion/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
packages/ion/package.json
Normal file
58
packages/ion/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@boocode/ion",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./dist/schema/index.d.ts",
|
||||
"default": "./dist/schema/index.js"
|
||||
},
|
||||
"./engine": {
|
||||
"types": "./dist/engine/index.d.ts",
|
||||
"default": "./dist/engine/index.js"
|
||||
},
|
||||
"./store": {
|
||||
"types": "./dist/store/index.d.ts",
|
||||
"default": "./dist/store/index.js"
|
||||
},
|
||||
"./format": {
|
||||
"types": "./dist/format/index.d.ts",
|
||||
"default": "./dist/format/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"default": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^25.9.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"ulid": "^2.3.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"postgres": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
55
packages/ion/src/cli/commands/abandon.ts
Normal file
55
packages/ion/src/cli/commands/abandon.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `workflow abandon` — Cancel a non-terminal workflow run.
|
||||
*
|
||||
* Marks the run as cancelled. Only works on runs that are not
|
||||
* already in a terminal state (completed, failed, cancelled).
|
||||
*
|
||||
* @example
|
||||
* workflow abandon abc123
|
||||
* workflow abandon abc123 --json
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printJson } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AbandonResult {
|
||||
runId: string;
|
||||
abandoned: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function abandonWorkflowRun(_runId: string): Promise<AbandonResult> {
|
||||
throw new Error('not implemented yet: abandonWorkflowRun');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function abandonCommand(
|
||||
args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow abandon <run-id> [--json]');
|
||||
}
|
||||
|
||||
const runId = args[0]!;
|
||||
|
||||
const result = await abandonWorkflowRun(runId);
|
||||
|
||||
if (options.json) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.abandoned) {
|
||||
console.log(`⊘ Run ${result.runId} abandoned (cancelled).`);
|
||||
} else {
|
||||
console.log(`Failed to abandon run ${result.runId}: ${result.message}`);
|
||||
}
|
||||
}
|
||||
60
packages/ion/src/cli/commands/approve.ts
Normal file
60
packages/ion/src/cli/commands/approve.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* `workflow approve` — Approve a paused workflow run.
|
||||
*
|
||||
* @example
|
||||
* workflow approve abc123
|
||||
* workflow approve abc123 "Looks good" --json
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printJson } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ApproveResult {
|
||||
runId: string;
|
||||
approved: boolean;
|
||||
comment?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function approveWorkflowRun(
|
||||
_runId: string,
|
||||
_comment?: string,
|
||||
): Promise<ApproveResult> {
|
||||
throw new Error('not implemented yet: approveWorkflowRun');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function approveCommand(
|
||||
args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow approve <run-id> [comment] [--json]');
|
||||
}
|
||||
|
||||
const runId = args[0]!;
|
||||
const comment = args.length > 1 ? args.slice(1).join(' ') : undefined;
|
||||
|
||||
const result = await approveWorkflowRun(runId, comment);
|
||||
|
||||
if (options.json) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.approved) {
|
||||
console.log(`✓ Run ${result.runId} approved.`);
|
||||
if (result.comment) {
|
||||
console.log(` Comment: ${result.comment}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`✗ Failed to approve run ${result.runId}: ${result.message}`);
|
||||
}
|
||||
}
|
||||
74
packages/ion/src/cli/commands/cleanup.ts
Normal file
74
packages/ion/src/cli/commands/cleanup.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* `workflow cleanup` — Remove old workflow run artifacts.
|
||||
*
|
||||
* Default retention: 7 days. Removes run data older than the specified
|
||||
* number of days.
|
||||
*
|
||||
* @example
|
||||
* workflow cleanup
|
||||
* workflow cleanup 30 --json
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printJson } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CleanupResult {
|
||||
removedRuns: number;
|
||||
removedEvents: number;
|
||||
freedBytes: number;
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
async function cleanupWorkflowRuns(
|
||||
_days: number,
|
||||
_cwd?: string,
|
||||
): Promise<CleanupResult> {
|
||||
throw new Error('not implemented yet: cleanupWorkflowRuns');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function cleanupCommand(
|
||||
args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
// First positional arg is the number of days (default 7).
|
||||
const days = args.length > 0 ? parseInt(args[0]!, 10) : 7;
|
||||
|
||||
if (isNaN(days) || days < 1) {
|
||||
throw new Error(`Invalid retention days: ${args[0]}. Must be a positive integer.`);
|
||||
}
|
||||
|
||||
const result = await cleanupWorkflowRuns(days, options.cwd);
|
||||
|
||||
if (options.json) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Cleanup complete (retention: ${result.retentionDays} days).`);
|
||||
console.log(` Runs removed: ${result.removedRuns}`);
|
||||
console.log(` Events removed: ${result.removedEvents}`);
|
||||
console.log(` Space freed: ${formatBytes(result.freedBytes)}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
const value = bytes / Math.pow(1024, i);
|
||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
62
packages/ion/src/cli/commands/convert.ts
Normal file
62
packages/ion/src/cli/commands/convert.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* `workflow convert` — Convert a .sop.md file to a YAML workflow definition.
|
||||
*
|
||||
* Reads the SOP markdown file, parses its structure, and outputs
|
||||
* a corresponding YAML workflow definition.
|
||||
*
|
||||
* @example
|
||||
* workflow convert deploy.sop.md
|
||||
* workflow convert deploy.sop.md --output workflows/deploy.yaml
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printJson } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConvertResult {
|
||||
inputFile: string;
|
||||
outputFile: string;
|
||||
workflowName: string;
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
async function convertSopToYaml(
|
||||
_inputPath: string,
|
||||
_outputPath?: string,
|
||||
): Promise<ConvertResult> {
|
||||
throw new Error('not implemented yet: convertSopToYaml');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function convertCommand(
|
||||
args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
throw new Error('Missing required argument: <file.sop.md>\n\nUsage: workflow convert <file.sop.md> [--output <path>]');
|
||||
}
|
||||
|
||||
const inputPath = args[0]!;
|
||||
|
||||
if (!inputPath.endsWith('.sop.md')) {
|
||||
throw new Error(`Input file must end with .sop.md, got: ${inputPath}`);
|
||||
}
|
||||
|
||||
const result = await convertSopToYaml(inputPath, options.output);
|
||||
|
||||
if (options.json) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Converted: ${result.inputFile}`);
|
||||
console.log(` Output: ${result.outputFile}`);
|
||||
console.log(` Workflow: ${result.workflowName}`);
|
||||
console.log(` Nodes: ${result.nodeCount}`);
|
||||
}
|
||||
59
packages/ion/src/cli/commands/list.ts
Normal file
59
packages/ion/src/cli/commands/list.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* `workflow list` — List all available workflows.
|
||||
*
|
||||
* Discovers workflows from both bundled and project sources and displays
|
||||
* them in a formatted table (or JSON with --json).
|
||||
*
|
||||
* @example
|
||||
* workflow list
|
||||
* workflow list --json
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printTable, printJson } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WorkflowEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
source: 'bundled' | 'project';
|
||||
}
|
||||
|
||||
async function discoverWorkflows(_cwd?: string): Promise<WorkflowEntry[]> {
|
||||
throw new Error('not implemented yet: discoverWorkflows');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listCommand(
|
||||
_args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
const workflows = await discoverWorkflows(options.cwd);
|
||||
|
||||
if (options.json) {
|
||||
printJson(workflows);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Available workflows:');
|
||||
console.log('');
|
||||
|
||||
printTable(
|
||||
workflows.map((w) => ({
|
||||
name: w.name,
|
||||
description: w.description,
|
||||
source: w.source,
|
||||
})),
|
||||
[
|
||||
{ header: 'Name', field: 'name', minWidth: 20 },
|
||||
{ header: 'Description', field: 'description', minWidth: 30 },
|
||||
{ header: 'Source', field: 'source', minWidth: 10 },
|
||||
],
|
||||
);
|
||||
}
|
||||
62
packages/ion/src/cli/commands/reject.ts
Normal file
62
packages/ion/src/cli/commands/reject.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* `workflow reject` — Reject a paused workflow run.
|
||||
*
|
||||
* Sets $REJECTION_REASON with the provided reason string.
|
||||
*
|
||||
* @example
|
||||
* workflow reject abc123
|
||||
* workflow reject abc123 "Not compliant" --json
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printJson } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RejectResult {
|
||||
runId: string;
|
||||
rejected: boolean;
|
||||
reason?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function rejectWorkflowRun(
|
||||
_runId: string,
|
||||
_reason?: string,
|
||||
): Promise<RejectResult> {
|
||||
throw new Error('not implemented yet: rejectWorkflowRun');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function rejectCommand(
|
||||
args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow reject <run-id> [reason] [--json]');
|
||||
}
|
||||
|
||||
const runId = args[0]!;
|
||||
const reason = args.length > 1 ? args.slice(1).join(' ') : undefined;
|
||||
|
||||
const result = await rejectWorkflowRun(runId, reason);
|
||||
|
||||
if (options.json) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.rejected) {
|
||||
console.log(`✗ Run ${result.runId} rejected.`);
|
||||
if (result.reason) {
|
||||
console.log(` Reason: ${result.reason}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Failed to reject run ${result.runId}: ${result.message}`);
|
||||
}
|
||||
}
|
||||
55
packages/ion/src/cli/commands/resume.ts
Normal file
55
packages/ion/src/cli/commands/resume.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `workflow resume` — Resume a failed workflow run.
|
||||
*
|
||||
* Skips completed nodes and re-executes from the failure point.
|
||||
*
|
||||
* @example
|
||||
* workflow resume abc123
|
||||
* workflow resume abc123 --json
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printJson } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ResumeResult {
|
||||
runId: string;
|
||||
resumed: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function resumeWorkflowRun(_runId: string): Promise<ResumeResult> {
|
||||
throw new Error('not implemented yet: resumeWorkflowRun');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function resumeCommand(
|
||||
args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow resume <run-id> [--json]');
|
||||
}
|
||||
|
||||
const runId = args[0]!;
|
||||
|
||||
const result = await resumeWorkflowRun(runId);
|
||||
|
||||
if (options.json) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.resumed) {
|
||||
console.log(`↻ Run ${result.runId} resumed.`);
|
||||
console.log(` ${result.message}`);
|
||||
} else {
|
||||
console.log(`Failed to resume run ${result.runId}: ${result.message}`);
|
||||
}
|
||||
}
|
||||
94
packages/ion/src/cli/commands/run.ts
Normal file
94
packages/ion/src/cli/commands/run.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* `workflow run` — Execute a workflow by name.
|
||||
*
|
||||
* Resolves the workflow, passes message args, and shows real-time progress.
|
||||
* With --detach, runs in background and returns the run ID immediately.
|
||||
*
|
||||
* @example
|
||||
* workflow run deploy
|
||||
* workflow run deploy --cwd /tmp/project --json
|
||||
* workflow run deploy --detach
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printJson } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WorkflowRunResult {
|
||||
id: string;
|
||||
workflowName: string;
|
||||
status: string;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
async function resolveWorkflow(
|
||||
_name: string,
|
||||
_cwd?: string,
|
||||
): Promise<unknown> {
|
||||
throw new Error('not implemented yet: resolveWorkflow');
|
||||
}
|
||||
|
||||
async function executeWorkflow(
|
||||
_workflow: unknown,
|
||||
_messageArgs: string[],
|
||||
_options: { cwd?: string; detach?: boolean },
|
||||
): Promise<WorkflowRunResult> {
|
||||
throw new Error('not implemented yet: executeWorkflow');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runCommand(
|
||||
args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
throw new Error('Missing required argument: <name>\n\nUsage: workflow run <name> [args...] [--cwd <path>] [--detach] [--json]');
|
||||
}
|
||||
|
||||
const workflowName = args[0]!;
|
||||
const messageArgs = args.slice(1);
|
||||
const detach = options.json ? false : false; // --detach is a flag, not in CliOptions yet
|
||||
|
||||
// Parse --detach from raw args (it's a boolean flag).
|
||||
// This is handled by the arg parser in the main entry point,
|
||||
// but since CliOptions doesn't have detach, we check process.argv.
|
||||
const isDetach = process.argv.includes('--detach');
|
||||
|
||||
const workflow = await resolveWorkflow(workflowName, options.cwd);
|
||||
const result = await executeWorkflow(workflow, messageArgs, {
|
||||
cwd: options.cwd,
|
||||
detach: isDetach,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDetach) {
|
||||
console.log(`Workflow started in background.`);
|
||||
console.log(`Run ID: ${result.id}`);
|
||||
console.log(`Workflow: ${result.workflowName}`);
|
||||
console.log(`Status: ${result.status}`);
|
||||
} else {
|
||||
console.log(`Workflow run completed.`);
|
||||
console.log(` Run ID: ${result.id}`);
|
||||
console.log(` Workflow: ${result.workflowName}`);
|
||||
console.log(` Status: ${result.status}`);
|
||||
if (result.output) {
|
||||
console.log(` Output: ${JSON.stringify(result.output)}`);
|
||||
}
|
||||
if (result.error) {
|
||||
console.log(` Error: ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
packages/ion/src/cli/commands/runs.ts
Normal file
91
packages/ion/src/cli/commands/runs.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* `workflow runs` — List recent workflow runs with filters.
|
||||
*
|
||||
* @example
|
||||
* workflow runs
|
||||
* workflow runs --status failed --limit 10 --json
|
||||
* workflow runs --all
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printTable, printJson, formatTimestamp, formatDuration } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RunRecord {
|
||||
id: string;
|
||||
workflowName: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
duration?: number; // ms, absent if still running
|
||||
currentNode?: string;
|
||||
}
|
||||
|
||||
async function listWorkflowRuns(_filters: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
all?: boolean;
|
||||
cwd?: string;
|
||||
}): Promise<RunRecord[]> {
|
||||
throw new Error('not implemented yet: listWorkflowRuns');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runsCommand(
|
||||
args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
// Parse --status, --limit, --all from args/options.
|
||||
// These are already extracted by parseArgs into options.
|
||||
const status = typeof (options as Record<string, unknown>).status === 'string'
|
||||
? (options as Record<string, unknown>).status as string
|
||||
: undefined;
|
||||
const limit = typeof (options as Record<string, unknown>).limit === 'string'
|
||||
? parseInt((options as Record<string, unknown>).limit as string, 10)
|
||||
: 50;
|
||||
const all = (options as Record<string, unknown>).all === true;
|
||||
|
||||
const runs = await listWorkflowRuns({
|
||||
status,
|
||||
limit,
|
||||
all,
|
||||
cwd: options.cwd,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
printJson(runs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
console.log('No workflow runs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Showing ${runs.length} run(s):`);
|
||||
console.log('');
|
||||
|
||||
printTable(
|
||||
runs.map((r) => ({
|
||||
id: r.id,
|
||||
workflow: r.workflowName,
|
||||
status: r.status,
|
||||
started: formatTimestamp(new Date(r.startedAt)),
|
||||
duration: r.duration != null ? formatDuration(r.duration) : '-',
|
||||
currentNode: r.currentNode ?? '-',
|
||||
})),
|
||||
[
|
||||
{ header: 'ID', field: 'id', minWidth: 26 },
|
||||
{ header: 'Workflow', field: 'workflow', minWidth: 20 },
|
||||
{ header: 'Status', field: 'status', minWidth: 10 },
|
||||
{ header: 'Started', field: 'started', minWidth: 19 },
|
||||
{ header: 'Duration', field: 'duration', minWidth: 10 },
|
||||
{ header: 'Node', field: 'currentNode', minWidth: 15 },
|
||||
],
|
||||
);
|
||||
}
|
||||
67
packages/ion/src/cli/commands/status.ts
Normal file
67
packages/ion/src/cli/commands/status.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* `workflow status` — Show active (running + paused) workflow runs.
|
||||
*
|
||||
* @example
|
||||
* workflow status
|
||||
* workflow status --json
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printTable, printJson, formatDuration } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ActiveRun {
|
||||
id: string;
|
||||
workflowName: string;
|
||||
status: string;
|
||||
duration: number; // ms
|
||||
currentNode?: string;
|
||||
}
|
||||
|
||||
async function getActiveRuns(_cwd?: string): Promise<ActiveRun[]> {
|
||||
throw new Error('not implemented yet: getActiveRuns');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function statusCommand(
|
||||
_args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
const runs = await getActiveRuns(options.cwd);
|
||||
|
||||
if (options.json) {
|
||||
printJson(runs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
console.log('No active workflow runs.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Active workflow runs:');
|
||||
console.log('');
|
||||
|
||||
printTable(
|
||||
runs.map((r) => ({
|
||||
id: r.id,
|
||||
workflow: r.workflowName,
|
||||
status: r.status,
|
||||
duration: formatDuration(r.duration),
|
||||
currentNode: r.currentNode ?? '-',
|
||||
})),
|
||||
[
|
||||
{ header: 'ID', field: 'id', minWidth: 26 },
|
||||
{ header: 'Workflow', field: 'workflow', minWidth: 20 },
|
||||
{ header: 'Status', field: 'status', minWidth: 10 },
|
||||
{ header: 'Duration', field: 'duration', minWidth: 10 },
|
||||
{ header: 'Current Node', field: 'currentNode', minWidth: 15 },
|
||||
],
|
||||
);
|
||||
}
|
||||
66
packages/ion/src/cli/commands/validate.ts
Normal file
66
packages/ion/src/cli/commands/validate.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* `workflow validate` — Validate a workflow definition without executing.
|
||||
*
|
||||
* Loads the workflow, runs schema validation, and reports any errors.
|
||||
*
|
||||
* @example
|
||||
* workflow validate deploy
|
||||
* workflow validate deploy --json
|
||||
*/
|
||||
|
||||
import type { CliOptions } from '../utils.js';
|
||||
import { printJson } from '../utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub: engine integration (not implemented yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ValidationError {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ValidateResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
workflowName: string;
|
||||
}
|
||||
|
||||
async function validateWorkflow(
|
||||
_name: string,
|
||||
_cwd?: string,
|
||||
): Promise<ValidateResult> {
|
||||
throw new Error('not implemented yet: validateWorkflow');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function validateCommand(
|
||||
args: string[],
|
||||
options: CliOptions,
|
||||
): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
throw new Error('Missing required argument: <name>\n\nUsage: workflow validate <name> [--json]');
|
||||
}
|
||||
|
||||
const workflowName = args[0]!;
|
||||
|
||||
const result = await validateWorkflow(workflowName, options.cwd);
|
||||
|
||||
if (options.json) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.valid) {
|
||||
console.log(`✓ Workflow "${result.workflowName}" is valid.`);
|
||||
} else {
|
||||
console.log(`✗ Workflow "${result.workflowName}" has ${result.errors.length} error(s):`);
|
||||
console.log('');
|
||||
for (const err of result.errors) {
|
||||
console.log(` ${err.path}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
207
packages/ion/src/cli/index.ts
Normal file
207
packages/ion/src/cli/index.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Ion workflow engine CLI entry point.
|
||||
*
|
||||
* Pure Node.js CLI using process.argv parsing — no external argparse library.
|
||||
* Routes subcommands to their respective handler modules.
|
||||
*
|
||||
* @example
|
||||
* node dist/cli/index.js workflow list --json
|
||||
* node dist/cli/index.js workflow run deploy --cwd /tmp/project
|
||||
*/
|
||||
|
||||
import { parseArgs, buildCliOptions, printJson } from './utils.js';
|
||||
import type { CliOptions } from './utils.js';
|
||||
|
||||
import { listCommand } from './commands/list.js';
|
||||
import { runCommand } from './commands/run.js';
|
||||
import { statusCommand } from './commands/status.js';
|
||||
import { runsCommand } from './commands/runs.js';
|
||||
import { approveCommand } from './commands/approve.js';
|
||||
import { rejectCommand } from './commands/reject.js';
|
||||
import { resumeCommand } from './commands/resume.js';
|
||||
import { abandonCommand } from './commands/abandon.js';
|
||||
import { cleanupCommand } from './commands/cleanup.js';
|
||||
import { validateCommand } from './commands/validate.js';
|
||||
import { convertCommand } from './commands/convert.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommandEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
usage: string;
|
||||
handler: (args: string[], options: CliOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
const COMMANDS: CommandEntry[] = [
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all available workflows',
|
||||
usage: 'workflow list [--json]',
|
||||
handler: listCommand,
|
||||
},
|
||||
{
|
||||
name: 'run',
|
||||
description: 'Execute a workflow by name',
|
||||
usage: 'workflow run <name> [args...] [--cwd <path>] [--detach] [--json]',
|
||||
handler: runCommand,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
description: 'Show active (running + paused) workflow runs',
|
||||
usage: 'workflow status [--json]',
|
||||
handler: statusCommand,
|
||||
},
|
||||
{
|
||||
name: 'runs',
|
||||
description: 'List recent workflow runs with filters',
|
||||
usage: 'workflow runs [--status <status>] [--limit N] [--all] [--json]',
|
||||
handler: runsCommand,
|
||||
},
|
||||
{
|
||||
name: 'approve',
|
||||
description: 'Approve a paused workflow run',
|
||||
usage: 'workflow approve <run-id> [comment] [--json]',
|
||||
handler: approveCommand,
|
||||
},
|
||||
{
|
||||
name: 'reject',
|
||||
description: 'Reject a paused workflow run',
|
||||
usage: 'workflow reject <run-id> [reason] [--json]',
|
||||
handler: rejectCommand,
|
||||
},
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a failed workflow run',
|
||||
usage: 'workflow resume <run-id> [--json]',
|
||||
handler: resumeCommand,
|
||||
},
|
||||
{
|
||||
name: 'abandon',
|
||||
description: 'Cancel a non-terminal workflow run',
|
||||
usage: 'workflow abandon <run-id> [--json]',
|
||||
handler: abandonCommand,
|
||||
},
|
||||
{
|
||||
name: 'cleanup',
|
||||
description: 'Remove old workflow run artifacts',
|
||||
usage: 'workflow cleanup [days] [--json]',
|
||||
handler: cleanupCommand,
|
||||
},
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate a workflow definition without executing',
|
||||
usage: 'workflow validate <name> [--json]',
|
||||
handler: validateCommand,
|
||||
},
|
||||
{
|
||||
name: 'convert',
|
||||
description: 'Convert a .sop.md file to a YAML workflow definition',
|
||||
usage: 'workflow convert <file.sop.md> [--output <path>]',
|
||||
handler: convertCommand,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Help output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function printHelp(): void {
|
||||
console.log('');
|
||||
console.log('Ion — Workflow Engine CLI');
|
||||
console.log('');
|
||||
console.log('Usage:');
|
||||
console.log(' workflow <command> [options]');
|
||||
console.log('');
|
||||
console.log('Commands:');
|
||||
|
||||
const maxNameLen = Math.max(...COMMANDS.map((c) => c.name.length));
|
||||
for (const cmd of COMMANDS) {
|
||||
const padded = cmd.name.padEnd(maxNameLen + 2);
|
||||
console.log(` ${padded}${cmd.description}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Global options:');
|
||||
console.log(' --json Output as JSON (suppresses all other output)');
|
||||
console.log(' --cwd <path> Set working directory');
|
||||
console.log(' --store <path> Path to workflow store');
|
||||
console.log(' --db-path <p> Path to database file');
|
||||
console.log('');
|
||||
console.log('Run "workflow <command> --help" for command-specific usage.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function printCommandHelp(cmd: CommandEntry): void {
|
||||
console.log('');
|
||||
console.log(`workflow ${cmd.name}`);
|
||||
console.log('');
|
||||
console.log(` ${cmd.description}`);
|
||||
console.log('');
|
||||
console.log('Usage:');
|
||||
console.log(` ${cmd.usage}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
||||
const { args, options } = parseArgs(argv);
|
||||
const cliOptions = buildCliOptions(options);
|
||||
|
||||
// --help with no command → general help
|
||||
if (args.length === 0 || options.help === true) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const commandName = args[0];
|
||||
const commandArgs = args.slice(1);
|
||||
|
||||
// --help after a command name → command-specific help
|
||||
if (options.help) {
|
||||
const cmd = COMMANDS.find((c) => c.name === commandName);
|
||||
if (cmd) {
|
||||
printCommandHelp(cmd);
|
||||
} else {
|
||||
console.error(`Unknown command: ${commandName}`);
|
||||
printHelp();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const command = COMMANDS.find((c) => c.name === commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`Unknown command: ${commandName}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
return; // unreachable, but satisfies TS control flow
|
||||
}
|
||||
|
||||
try {
|
||||
await command.handler(commandArgs, cliOptions);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (cliOptions.json) {
|
||||
printJson({ error: message });
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run when executed directly (not imported).
|
||||
// In ESM, check import.meta.url to detect direct execution.
|
||||
const _directRun = typeof import.meta !== 'undefined' && import.meta.url;
|
||||
if (_directRun) {
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
239
packages/ion/src/cli/utils.ts
Normal file
239
packages/ion/src/cli/utils.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* CLI utility functions for the Ion workflow engine.
|
||||
*
|
||||
* Provides formatting, table rendering, and JSON output helpers
|
||||
* used across all CLI commands.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CliOptions {
|
||||
/** Working directory override. */
|
||||
cwd?: string;
|
||||
/** Output as JSON (suppresses all other output). */
|
||||
json?: boolean;
|
||||
/** Path to the workflow store database. */
|
||||
store?: string;
|
||||
/** Path to the database file. */
|
||||
dbPath?: string;
|
||||
/** Output file path (for convert command). */
|
||||
output?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Duration formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a duration in milliseconds into a human-readable string.
|
||||
*
|
||||
* @example
|
||||
* formatDuration(90500) // "1m 30s"
|
||||
* formatDuration(3661000) // "1h 1m"
|
||||
* formatDuration(500) // "0s"
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 0) ms = 0;
|
||||
|
||||
const seconds = Math.floor(ms / 1000) % 60;
|
||||
const minutes = Math.floor(ms / 60000) % 60;
|
||||
const hours = Math.floor(ms / 3600000);
|
||||
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timestamp formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a Date into an ISO-like timestamp suitable for CLI display.
|
||||
*
|
||||
* @example
|
||||
* formatTimestamp(new Date('2025-06-07T14:30:00Z'))
|
||||
* // "2025-06-07 14:30:00"
|
||||
*/
|
||||
export function formatTimestamp(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const mo = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const h = String(date.getHours()).padStart(2, '0');
|
||||
const mi = String(date.getMinutes()).padStart(2, '0');
|
||||
const s = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${y}-${mo}-${d} ${h}:${mi}:${s}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String truncation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Truncate a string to `max` characters, appending an ellipsis if truncated.
|
||||
*
|
||||
* @example
|
||||
* truncate('hello world', 8) // "hello..."
|
||||
* truncate('hi', 8) // "hi"
|
||||
*/
|
||||
export function truncate(str: string, max: number): string {
|
||||
if (str.length <= max) return str;
|
||||
if (max <= 3) return str.slice(0, max);
|
||||
return str.slice(0, max - 3) + '...';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TableColumn {
|
||||
/** Column header label. */
|
||||
header: string;
|
||||
/** Minimum column width. */
|
||||
minWidth?: number;
|
||||
/** Field name to extract from each row object. */
|
||||
field: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a formatted table to stdout.
|
||||
*
|
||||
* @param rows - Array of row objects.
|
||||
* @param columns - Column definitions with header labels and field names.
|
||||
*
|
||||
* @example
|
||||
* printTable(
|
||||
* [{ name: 'deploy', desc: 'Deploy app' }],
|
||||
* [{ header: 'Name', field: 'name' }, { header: 'Description', field: 'desc' }],
|
||||
* )
|
||||
*/
|
||||
export function printTable(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: TableColumn[],
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
console.log('(no results)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute column widths.
|
||||
const widths: number[] = columns.map((col) => {
|
||||
const headerLen = col.header.length;
|
||||
const dataLen = Math.max(
|
||||
...rows.map((row) => {
|
||||
const val = row[col.field];
|
||||
const str = val === undefined || val === null ? '' : String(val);
|
||||
return str.length;
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const min = col.minWidth ?? 0;
|
||||
return Math.max(headerLen, dataLen, min);
|
||||
});
|
||||
|
||||
// Header row.
|
||||
const headerLine = columns
|
||||
.map((col, i) => col.header.padEnd(widths[i]!))
|
||||
.join(' ');
|
||||
console.log(headerLine);
|
||||
|
||||
// Separator.
|
||||
const sepLine = widths.map((w) => '-'.repeat(w)).join(' ');
|
||||
console.log(sepLine);
|
||||
|
||||
// Data rows.
|
||||
for (const row of rows) {
|
||||
const line = columns
|
||||
.map((col, i) => {
|
||||
const val = row[col.field];
|
||||
const str = val === undefined || val === null ? '' : String(val);
|
||||
return str.padEnd(widths[i]!);
|
||||
})
|
||||
.join(' ');
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Print a data structure as formatted JSON to stdout.
|
||||
* Uses 2-space indentation.
|
||||
*/
|
||||
export function printJson(data: unknown): void {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse CLI arguments into positional args and named options.
|
||||
*
|
||||
* Supports `--flag` (boolean) and `--key value` (string) formats.
|
||||
* Everything after `--` is treated as positional.
|
||||
*
|
||||
* @example
|
||||
* parseArgs(['run', 'deploy', '--json', '--cwd', '/tmp'])
|
||||
* // { args: ['run', 'deploy'], options: { json: true, cwd: '/tmp' } }
|
||||
*/
|
||||
export function parseArgs(argv: string[]): {
|
||||
args: string[];
|
||||
options: Record<string, string | boolean>;
|
||||
} {
|
||||
const args: string[] = [];
|
||||
const options: Record<string, string | boolean> = {};
|
||||
let i = 0;
|
||||
|
||||
while (i < argv.length) {
|
||||
const token = argv[i]!;
|
||||
|
||||
if (token === '--') {
|
||||
args.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.startsWith('--')) {
|
||||
const key = token.slice(2);
|
||||
const next = argv[i + 1];
|
||||
|
||||
if (next && !next.startsWith('--')) {
|
||||
options[key] = next;
|
||||
i += 2;
|
||||
} else {
|
||||
options[key] = true;
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
args.push(token);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { args, options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CliOptions object from parsed options.
|
||||
* Extracts known CLI flags into their typed fields.
|
||||
*/
|
||||
export function buildCliOptions(
|
||||
options: Record<string, string | boolean>,
|
||||
): CliOptions {
|
||||
return {
|
||||
cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
|
||||
json: options.json === true,
|
||||
store: typeof options.store === 'string' ? options.store : undefined,
|
||||
dbPath: typeof options['db-path'] === 'string' ? options['db-path'] : undefined,
|
||||
output: typeof options.output === 'string' ? options.output : undefined,
|
||||
};
|
||||
}
|
||||
70
packages/ion/src/engine/__tests__/command-validation.test.ts
Normal file
70
packages/ion/src/engine/__tests__/command-validation.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidCommandName } from '../command-validation.js';
|
||||
|
||||
describe('isValidCommandName', () => {
|
||||
describe('valid command names', () => {
|
||||
it('accepts simple lowercase names', () => {
|
||||
expect(isValidCommandName('assist')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts kebab-case names', () => {
|
||||
expect(isValidCommandName('code-review')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts names with numbers', () => {
|
||||
expect(isValidCommandName('deploy-v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts single character names', () => {
|
||||
expect(isValidCommandName('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts names with only numbers', () => {
|
||||
expect(isValidCommandName('123')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts names with mixed alphanumeric and hyphens', () => {
|
||||
expect(isValidCommandName('a1-b2-c3')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts names starting with numbers', () => {
|
||||
expect(isValidCommandName('2fa-verify')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid command names', () => {
|
||||
it('rejects uppercase letters', () => {
|
||||
expect(isValidCommandName('Assist')).toBe(false);
|
||||
expect(isValidCommandName('CODE-REVIEW')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects leading hyphens', () => {
|
||||
expect(isValidCommandName('-assist')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects trailing hyphens', () => {
|
||||
expect(isValidCommandName('assist-')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects double hyphens', () => {
|
||||
expect(isValidCommandName('code--review')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty strings', () => {
|
||||
expect(isValidCommandName('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects underscores', () => {
|
||||
expect(isValidCommandName('code_review')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects spaces', () => {
|
||||
expect(isValidCommandName('code review')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects special characters', () => {
|
||||
expect(isValidCommandName('code.review')).toBe(false);
|
||||
expect(isValidCommandName('code@review')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
149
packages/ion/src/engine/__tests__/condition-evaluator.test.ts
Normal file
149
packages/ion/src/engine/__tests__/condition-evaluator.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluateCondition, ConditionError } from '../condition-evaluator.js';
|
||||
|
||||
describe('evaluateCondition', () => {
|
||||
describe('simple boolean conditions', () => {
|
||||
it('evaluates boolean true in a comparison', () => {
|
||||
expect(evaluateCondition('true == true', {})).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates boolean false in a comparison', () => {
|
||||
expect(evaluateCondition('false == false', {})).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates true != false', () => {
|
||||
expect(evaluateCondition('true != false', {})).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates boolean via node reference', () => {
|
||||
const outputs = { flag: { output: true } };
|
||||
expect(evaluateCondition('$flag.output == true', outputs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('string equality with node references', () => {
|
||||
it('evaluates $nodeId.output == "value" as true when matching', () => {
|
||||
const outputs = { analysis: { output: 'done' } };
|
||||
expect(evaluateCondition('$analysis.output == "done"', outputs)).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates $nodeId.output == "value" as false when not matching', () => {
|
||||
const outputs = { analysis: { output: 'pending' } };
|
||||
expect(evaluateCondition('$analysis.output == "done"', outputs)).toBe(false);
|
||||
});
|
||||
|
||||
it('evaluates $nodeId.output != "value" correctly', () => {
|
||||
const outputs = { analysis: { output: 'pending' } };
|
||||
expect(evaluateCondition('$analysis.output != "done"', outputs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('numeric comparisons', () => {
|
||||
it('evaluates $score.output > 5 as true', () => {
|
||||
const outputs = { score: { output: 10 } };
|
||||
expect(evaluateCondition('$score.output > 5', outputs)).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates $score.output > 5 as false when score is lower', () => {
|
||||
const outputs = { score: { output: 3 } };
|
||||
expect(evaluateCondition('$score.output > 5', outputs)).toBe(false);
|
||||
});
|
||||
|
||||
it('evaluates >= comparison', () => {
|
||||
const outputs = { score: { output: 5 } };
|
||||
expect(evaluateCondition('$score.output >= 5', outputs)).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates < comparison', () => {
|
||||
const outputs = { score: { output: 3 } };
|
||||
expect(evaluateCondition('$score.output < 5', outputs)).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates <= comparison', () => {
|
||||
const outputs = { score: { output: 5 } };
|
||||
expect(evaluateCondition('$score.output <= 5', outputs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AND/OR compounds', () => {
|
||||
it('evaluates AND compound: both true', () => {
|
||||
const outputs = { a: { output: 'x' }, b: { output: 'y' } };
|
||||
expect(evaluateCondition('$a.output == "x" AND $b.output == "y"', outputs)).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates AND compound: one false', () => {
|
||||
const outputs = { a: { output: 'x' }, b: { output: 'z' } };
|
||||
expect(evaluateCondition('$a.output == "x" AND $b.output == "y"', outputs)).toBe(false);
|
||||
});
|
||||
|
||||
it('evaluates OR compound: one true', () => {
|
||||
const outputs = { a: { output: 'x' }, b: { output: 'z' } };
|
||||
expect(evaluateCondition('$a.output == "x" OR $b.output == "y"', outputs)).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates OR compound: both false', () => {
|
||||
const outputs = { a: { output: 'z' }, b: { output: 'z' } };
|
||||
expect(evaluateCondition('$a.output == "x" OR $b.output == "y"', outputs)).toBe(false);
|
||||
});
|
||||
|
||||
it('evaluates mixed AND/OR with correct precedence', () => {
|
||||
const outputs = { a: { output: 'x' }, b: { output: 'y' }, c: { output: 'z' } };
|
||||
// false AND true OR true => false OR true => true (AND binds tighter)
|
||||
expect(evaluateCondition('$a.output == "wrong" AND $b.output == "y" OR $c.output == "z"', outputs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parenthesized expressions', () => {
|
||||
it('evaluates parenthesized expressions', () => {
|
||||
const outputs = { a: { output: 'x' }, b: { output: 'y' } };
|
||||
expect(evaluateCondition('($a.output == "x" OR $a.output == "z") AND $b.output == "y"', outputs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws ConditionError on invalid expressions', () => {
|
||||
expect(() => evaluateCondition('!!!invalid', {})).toThrow(ConditionError);
|
||||
});
|
||||
|
||||
it('throws ConditionError on missing node reference', () => {
|
||||
expect(() => evaluateCondition('$missing.output == "x"', {})).toThrow(ConditionError);
|
||||
});
|
||||
|
||||
it('throws ConditionError on node reference without field', () => {
|
||||
expect(() => evaluateCondition('$analysis == "x"', {})).toThrow(ConditionError);
|
||||
});
|
||||
|
||||
it('throws ConditionError on unterminated string', () => {
|
||||
expect(() => evaluateCondition('"unterminated', {})).toThrow(ConditionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('handles extra whitespace around operators', () => {
|
||||
const outputs = { a: { output: 'x' } };
|
||||
expect(evaluateCondition(' $a.output == "x" ', outputs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quoted strings with special characters', () => {
|
||||
it('handles double-quoted strings with spaces', () => {
|
||||
const outputs = { msg: { output: 'hello world' } };
|
||||
expect(evaluateCondition('$msg.output == "hello world"', outputs)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles single-quoted strings', () => {
|
||||
const outputs = { msg: { output: 'hello' } };
|
||||
expect(evaluateCondition("$msg.output == 'hello'", outputs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty condition', () => {
|
||||
it('returns true for empty string', () => {
|
||||
expect(evaluateCondition('', {})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for whitespace-only string', () => {
|
||||
expect(evaluateCondition(' ', {})).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
132
packages/ion/src/engine/__tests__/output-ref.test.ts
Normal file
132
packages/ion/src/engine/__tests__/output-ref.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolveNodeOutputField,
|
||||
declaredFieldsFromSchema,
|
||||
OutputRefError,
|
||||
} from '../output-ref.js';
|
||||
|
||||
describe('resolveNodeOutputField', () => {
|
||||
describe('with declared schema match', () => {
|
||||
it('returns value when field exists in output and schema', () => {
|
||||
const declaredFields = new Set(['name', 'status']);
|
||||
const output = { name: 'test-result', status: 'completed' };
|
||||
const result = resolveNodeOutputField(output, 'node-1', 'name', declaredFields);
|
||||
expect(result).toEqual({ kind: 'value', value: 'test-result' });
|
||||
});
|
||||
|
||||
it('returns JSON-serialized value for non-string fields', () => {
|
||||
const declaredFields = new Set(['count']);
|
||||
const output = { count: 42 };
|
||||
const result = resolveNodeOutputField(output, 'node-1', 'count', declaredFields);
|
||||
expect(result).toEqual({ kind: 'value', value: '42' });
|
||||
});
|
||||
|
||||
it('returns JSON-serialized value for object fields', () => {
|
||||
const declaredFields = new Set(['data']);
|
||||
const output = { data: { key: 'val' } };
|
||||
const result = resolveNodeOutputField(output, 'node-1', 'data', declaredFields);
|
||||
expect(result).toEqual({ kind: 'value', value: '{"key":"val"}' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('with schemaless JSON output', () => {
|
||||
it('returns value when field exists in output without schema', () => {
|
||||
const output = { dynamic_field: 'hello' };
|
||||
const result = resolveNodeOutputField(output, 'node-1', 'dynamic_field');
|
||||
expect(result).toEqual({ kind: 'value', value: 'hello' });
|
||||
});
|
||||
|
||||
it('returns JSON-serialized number without schema', () => {
|
||||
const output = { score: 99 };
|
||||
const result = resolveNodeOutputField(output, 'node-1', 'score');
|
||||
expect(result).toEqual({ kind: 'value', value: '99' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing optional field', () => {
|
||||
it('returns empty when field is declared in schema but missing from output', () => {
|
||||
const declaredFields = new Set(['name', 'optional_field']);
|
||||
const output = { name: 'test' };
|
||||
const result = resolveNodeOutputField(output, 'node-1', 'optional_field', declaredFields);
|
||||
expect(result).toEqual({ kind: 'empty', value: '' });
|
||||
});
|
||||
|
||||
it('returns empty when field exists but value is null', () => {
|
||||
const declaredFields = new Set(['name']);
|
||||
const output = { name: null };
|
||||
const result = resolveNodeOutputField(output, 'node-1', 'name', declaredFields);
|
||||
expect(result).toEqual({ kind: 'empty', value: '' });
|
||||
});
|
||||
|
||||
it('returns empty when field exists but value is undefined', () => {
|
||||
const declaredFields = new Set(['name']);
|
||||
const output = { name: undefined };
|
||||
const result = resolveNodeOutputField(output, 'node-1', 'name', declaredFields);
|
||||
expect(result).toEqual({ kind: 'empty', value: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing required field', () => {
|
||||
it('throws OutputRefError when field is not declared and not in output', () => {
|
||||
const output = { name: 'test' };
|
||||
expect(() => resolveNodeOutputField(output, 'node-1', 'nonexistent')).toThrow(OutputRefError);
|
||||
});
|
||||
|
||||
it('throws OutputRefError with nodeId and field info', () => {
|
||||
const output = { name: 'test' };
|
||||
try {
|
||||
resolveNodeOutputField(output, 'my-node', 'missing_field');
|
||||
expect.unreachable('Should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OutputRefError);
|
||||
if (err instanceof OutputRefError) {
|
||||
expect(err.nodeId).toBe('my-node');
|
||||
expect(err.field).toBe('missing_field');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('includes available fields in error message', () => {
|
||||
const output = { name: 'test', status: 'ok' };
|
||||
try {
|
||||
resolveNodeOutputField(output, 'node-1', 'nonexistent');
|
||||
expect.unreachable('Should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OutputRefError);
|
||||
if (err instanceof OutputRefError) {
|
||||
expect(err.message).toContain('name');
|
||||
expect(err.message).toContain('status');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('declaredFieldsFromSchema', () => {
|
||||
it('extracts fields from a valid JSON Schema object', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
},
|
||||
};
|
||||
const fields = declaredFieldsFromSchema(schema);
|
||||
expect(fields).toEqual(new Set(['name', 'count']));
|
||||
});
|
||||
|
||||
it('returns empty set for undefined schema', () => {
|
||||
const fields = declaredFieldsFromSchema(undefined);
|
||||
expect(fields).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('returns empty set for string schema', () => {
|
||||
const fields = declaredFieldsFromSchema('just a string description');
|
||||
expect(fields).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('returns empty set for schema without properties', () => {
|
||||
const fields = declaredFieldsFromSchema({ type: 'object' });
|
||||
expect(fields).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
27
packages/ion/src/engine/command-validation.ts
Normal file
27
packages/ion/src/engine/command-validation.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Command name validation for the Ion workflow engine.
|
||||
*
|
||||
* Command names must be lowercase kebab-case: lowercase alphanumeric
|
||||
* segments separated by single hyphens.
|
||||
*/
|
||||
|
||||
/** Pattern for valid command names: lowercase kebab-case. */
|
||||
const COMMAND_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
|
||||
/**
|
||||
* Validate a command name.
|
||||
*
|
||||
* Valid names match the pattern: `^[a-z0-9]+(-[a-z0-9]+)*$`
|
||||
* - Lowercase alphanumeric segments
|
||||
* - Segments separated by single hyphens
|
||||
* - No leading or trailing hyphens
|
||||
* - No consecutive hyphens
|
||||
*
|
||||
* @returns `true` if the name is valid, `false` otherwise.
|
||||
*/
|
||||
export function isValidCommandName(name: string): boolean {
|
||||
if (name.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return COMMAND_NAME_PATTERN.test(name);
|
||||
}
|
||||
427
packages/ion/src/engine/condition-evaluator.ts
Normal file
427
packages/ion/src/engine/condition-evaluator.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Condition evaluator for the Ion workflow engine.
|
||||
*
|
||||
* Parses and evaluates `when:` conditions that reference node outputs.
|
||||
* Supports comparison operators, AND/OR compounds, and literal values.
|
||||
*
|
||||
* Grammar (informal):
|
||||
* condition = orExpr
|
||||
* orExpr = andExpr ( "OR" andExpr )*
|
||||
* andExpr = comparison ( "AND" comparison )*
|
||||
* comparison = value operator value
|
||||
* value = nodeRef | literal
|
||||
* nodeRef = "$" nodeId "." field
|
||||
* literal = number | boolean | quotedString
|
||||
* operator = "==" | "!=" | "<" | ">" | "<=" | ">="
|
||||
*/
|
||||
|
||||
import { resolveNodeOutputField, OutputRefError } from './output-ref.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ConditionError extends Error {
|
||||
public readonly expression: string;
|
||||
|
||||
constructor(expression: string, message: string) {
|
||||
super(`Condition evaluation error in "${expression}": ${message}`);
|
||||
this.name = 'ConditionError';
|
||||
this.expression = expression;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TokenType =
|
||||
| 'NODE_REF' // $nodeId.field
|
||||
| 'NUMBER' // 42, 3.14
|
||||
| 'BOOLEAN' // true, false
|
||||
| 'STRING' // "hello" or 'hello'
|
||||
| 'OPERATOR' // ==, !=, <, >, <=, >=
|
||||
| 'AND' // AND keyword
|
||||
| 'OR' // OR keyword
|
||||
| 'LPAREN' // (
|
||||
| 'RPAREN' // )
|
||||
| 'EOF';
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokenizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OPERATORS = new Set(['==', '!=', '<=', '>=', '<', '>']);
|
||||
|
||||
function tokenize(expression: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (pos < expression.length) {
|
||||
// Skip whitespace.
|
||||
if (/\s/.test(expression[pos]!)) {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parentheses.
|
||||
if (expression[pos] === '(') {
|
||||
tokens.push({ type: 'LPAREN', value: '(' });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
if (expression[pos] === ')') {
|
||||
tokens.push({ type: 'RPAREN', value: ')' });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Node reference: $nodeId.field
|
||||
if (expression[pos] === '$') {
|
||||
const start = pos;
|
||||
pos++; // skip $
|
||||
let field = '';
|
||||
// Read the nodeId (alphanumeric, underscores, hyphens).
|
||||
while (pos < expression.length && /[\w-]/.test(expression[pos]!)) {
|
||||
pos++;
|
||||
}
|
||||
const nodeId = expression.slice(start + 1, pos);
|
||||
if (nodeId.length === 0) {
|
||||
throw new ConditionError(expression, `Expected node identifier after $ at position ${start}`);
|
||||
}
|
||||
// Expect a dot then field name.
|
||||
if (expression[pos] !== '.') {
|
||||
throw new ConditionError(expression, `Expected "." after node reference $${nodeId} at position ${pos}`);
|
||||
}
|
||||
pos++; // skip dot
|
||||
const fieldStart = pos;
|
||||
while (pos < expression.length && /[\w-]/.test(expression[pos]!)) {
|
||||
pos++;
|
||||
}
|
||||
field = expression.slice(fieldStart, pos);
|
||||
if (field.length === 0) {
|
||||
throw new ConditionError(expression, `Expected field name after $${nodeId}. at position ${fieldStart}`);
|
||||
}
|
||||
tokens.push({ type: 'NODE_REF', value: `${nodeId}.${field}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Quoted string.
|
||||
if (expression[pos] === '"' || expression[pos] === "'") {
|
||||
const quote = expression[pos]!;
|
||||
const start = pos;
|
||||
pos++;
|
||||
let str = '';
|
||||
while (pos < expression.length && expression[pos] !== quote) {
|
||||
if (expression[pos] === '\\' && pos + 1 < expression.length) {
|
||||
pos++; // skip escape
|
||||
str += expression[pos]!;
|
||||
} else {
|
||||
str += expression[pos]!;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
if (pos >= expression.length) {
|
||||
throw new ConditionError(expression, `Unterminated string starting at position ${start}`);
|
||||
}
|
||||
pos++; // skip closing quote
|
||||
tokens.push({ type: 'STRING', value: str });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Two-character operators.
|
||||
if (pos + 1 < expression.length) {
|
||||
const twoChar = expression.slice(pos, pos + 2);
|
||||
if (OPERATORS.has(twoChar)) {
|
||||
tokens.push({ type: 'OPERATOR', value: twoChar });
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-character operators.
|
||||
const oneChar = expression[pos]!;
|
||||
if (OPERATORS.has(oneChar)) {
|
||||
tokens.push({ type: 'OPERATOR', value: oneChar });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// AND / OR keywords.
|
||||
const remaining = expression.slice(pos);
|
||||
const andMatch = remaining.match(/^AND(?=\s|\(|$)/i);
|
||||
if (andMatch) {
|
||||
tokens.push({ type: 'AND', value: 'AND' });
|
||||
pos += 3;
|
||||
continue;
|
||||
}
|
||||
const orMatch = remaining.match(/^OR(?=\s|\(|$)/i);
|
||||
if (orMatch) {
|
||||
tokens.push({ type: 'OR', value: 'OR' });
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Boolean literals.
|
||||
const trueMatch = remaining.match(/^true(?=\s|\)|$)/i);
|
||||
if (trueMatch) {
|
||||
tokens.push({ type: 'BOOLEAN', value: 'true' });
|
||||
pos += 4;
|
||||
continue;
|
||||
}
|
||||
const falseMatch = remaining.match(/^false(?=\s|\)|$)/i);
|
||||
if (falseMatch) {
|
||||
tokens.push({ type: 'BOOLEAN', value: 'false' });
|
||||
pos += 5;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number literal.
|
||||
const numMatch = remaining.match(/^(-?\d+\.?\d*)/);
|
||||
if (numMatch && numMatch[1] !== undefined) {
|
||||
tokens.push({ type: 'NUMBER', value: numMatch[1] });
|
||||
pos += numMatch[1].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ConditionError(
|
||||
expression,
|
||||
`Unexpected character "${expression[pos]}" at position ${pos}`,
|
||||
);
|
||||
}
|
||||
|
||||
tokens.push({ type: 'EOF', value: '' });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser (recursive descent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ConditionParser {
|
||||
private pos = 0;
|
||||
|
||||
constructor(
|
||||
private tokens: Token[],
|
||||
private expression: string,
|
||||
private nodeOutputs: Record<string, Record<string, unknown>>,
|
||||
) {}
|
||||
|
||||
parse(): boolean {
|
||||
const result = this.parseOr();
|
||||
if (this.tokens[this.pos]!.type !== 'EOF') {
|
||||
throw new ConditionError(
|
||||
this.expression,
|
||||
`Unexpected token "${this.tokens[this.pos]!.value}" after expression`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// orExpr = andExpr ( "OR" andExpr )*
|
||||
private parseOr(): boolean {
|
||||
let result = this.parseAnd();
|
||||
while (this.tokens[this.pos]!.type === 'OR') {
|
||||
this.pos++; // consume OR
|
||||
const right = this.parseAnd();
|
||||
result = result || right;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// andExpr = comparison ( "AND" comparison )*
|
||||
private parseAnd(): boolean {
|
||||
let result = this.parseComparison();
|
||||
while (this.tokens[this.pos]!.type === 'AND') {
|
||||
this.pos++; // consume AND
|
||||
const right = this.parseComparison();
|
||||
result = result && right;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// comparison = value operator value | "(" orExpr ")"
|
||||
private parseComparison(): boolean {
|
||||
// Parenthesized expression.
|
||||
if (this.tokens[this.pos]!.type === 'LPAREN') {
|
||||
this.pos++; // consume (
|
||||
const result = this.parseOr();
|
||||
if (this.tokens[this.pos]!.type !== 'RPAREN') {
|
||||
throw new ConditionError(this.expression, 'Expected closing ")"');
|
||||
}
|
||||
this.pos++; // consume )
|
||||
return result;
|
||||
}
|
||||
|
||||
// value operator value
|
||||
const left = this.resolveValue();
|
||||
const opToken = this.tokens[this.pos]!;
|
||||
|
||||
if (opToken.type !== 'OPERATOR') {
|
||||
throw new ConditionError(
|
||||
this.expression,
|
||||
`Expected comparison operator, got "${opToken.value}" (${opToken.type})`,
|
||||
);
|
||||
}
|
||||
|
||||
this.pos++; // consume operator
|
||||
const right = this.resolveValue();
|
||||
|
||||
return this.compare(left, opToken.value, right);
|
||||
}
|
||||
|
||||
private resolveValue(): string | number | boolean {
|
||||
const token = this.tokens[this.pos]!;
|
||||
|
||||
switch (token.type) {
|
||||
case 'NODE_REF': {
|
||||
this.pos++;
|
||||
const dotIndex = token.value.indexOf('.');
|
||||
if (dotIndex === -1) {
|
||||
throw new ConditionError(
|
||||
this.expression,
|
||||
`Invalid node reference: ${token.value}`,
|
||||
);
|
||||
}
|
||||
const nodeId = token.value.slice(0, dotIndex);
|
||||
const field = token.value.slice(dotIndex + 1);
|
||||
|
||||
const output = this.nodeOutputs[nodeId];
|
||||
if (!output) {
|
||||
throw new ConditionError(
|
||||
this.expression,
|
||||
`Node "${nodeId}" has no output available. Available nodes: ${Object.keys(this.nodeOutputs).join(', ') || '(none)'}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = resolveNodeOutputField(output, nodeId, field);
|
||||
// For comparison, we need the raw value, not the stringified version.
|
||||
const rawValue = output[field];
|
||||
if (typeof rawValue === 'number') return rawValue;
|
||||
if (typeof rawValue === 'boolean') return rawValue;
|
||||
return result.value;
|
||||
} catch (err) {
|
||||
if (err instanceof OutputRefError) {
|
||||
throw new ConditionError(this.expression, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
case 'NUMBER': {
|
||||
this.pos++;
|
||||
const num = Number(token.value);
|
||||
if (Number.isNaN(num)) {
|
||||
throw new ConditionError(
|
||||
this.expression,
|
||||
`Invalid number literal: ${token.value}`,
|
||||
);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
case 'BOOLEAN': {
|
||||
this.pos++;
|
||||
return token.value.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
case 'STRING': {
|
||||
this.pos++;
|
||||
return token.value;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ConditionError(
|
||||
this.expression,
|
||||
`Expected value (node reference, number, boolean, or string), got "${token.value}" (${token.type})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private compare(
|
||||
left: string | number | boolean,
|
||||
op: string,
|
||||
right: string | number | boolean,
|
||||
): boolean {
|
||||
// Coerce types for comparison.
|
||||
const leftNum = typeof left === 'number' ? left : Number(left);
|
||||
const rightNum = typeof right === 'number' ? right : Number(right);
|
||||
|
||||
switch (op) {
|
||||
case '==':
|
||||
return left === right;
|
||||
case '!=':
|
||||
return left !== right;
|
||||
case '<':
|
||||
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
|
||||
return leftNum < rightNum;
|
||||
}
|
||||
return String(left) < String(right);
|
||||
case '>':
|
||||
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
|
||||
return leftNum > rightNum;
|
||||
}
|
||||
return String(left) > String(right);
|
||||
case '<=':
|
||||
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
|
||||
return leftNum <= rightNum;
|
||||
}
|
||||
return String(left) <= String(right);
|
||||
case '>=':
|
||||
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
|
||||
return leftNum >= rightNum;
|
||||
}
|
||||
return String(left) >= String(right);
|
||||
default:
|
||||
throw new ConditionError(this.expression, `Unknown operator: ${op}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate a `when:` condition expression against node outputs.
|
||||
*
|
||||
* Supports:
|
||||
* - Node output references: `$nodeId.field`
|
||||
* - Comparison operators: `==`, `!=`, `<`, `>`, `<=`, `>=`
|
||||
* - Logical compounds: `AND`, `OR`
|
||||
* - Parenthesized sub-expressions
|
||||
* - Literal values: numbers, booleans, quoted strings
|
||||
*
|
||||
* Returns `true` or `false`. Throws `ConditionError` on parse failure (fail-closed).
|
||||
*/
|
||||
export function evaluateCondition(
|
||||
expression: string,
|
||||
nodeOutputs: Record<string, Record<string, unknown>>,
|
||||
): boolean {
|
||||
if (!expression || expression.trim().length === 0) {
|
||||
// Empty condition is always true (no guard = proceed).
|
||||
return true;
|
||||
}
|
||||
|
||||
const trimmed = expression.trim();
|
||||
const tokens = tokenize(trimmed);
|
||||
const parser = new ConditionParser(tokens, trimmed, nodeOutputs);
|
||||
|
||||
try {
|
||||
return parser.parse();
|
||||
} catch (err) {
|
||||
if (err instanceof ConditionError) {
|
||||
throw err;
|
||||
}
|
||||
throw new ConditionError(
|
||||
trimmed,
|
||||
`Unexpected error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
1149
packages/ion/src/engine/dag-executor.ts
Normal file
1149
packages/ion/src/engine/dag-executor.ts
Normal file
File diff suppressed because it is too large
Load Diff
199
packages/ion/src/engine/deps.ts
Normal file
199
packages/ion/src/engine/deps.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Dependency injection interfaces for the Ion workflow engine.
|
||||
*
|
||||
* These interfaces define the contracts that the engine requires from its
|
||||
* runtime environment. Concrete implementations are provided by the
|
||||
* platform layer (CLI, server, etc.).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow platform — messaging back to the conversation channel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IWorkflowPlatform {
|
||||
/** Send a text message to the conversation channel. */
|
||||
sendMessage(
|
||||
conversationId: string,
|
||||
message: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
|
||||
/** Whether the platform supports streaming responses. */
|
||||
getStreamingMode(): boolean;
|
||||
|
||||
/** Optional structured event emission (e.g. approval requests). */
|
||||
sendStructuredEvent?(
|
||||
conversationId: string,
|
||||
event: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow configuration — per-workflow settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Configuration for a single AI provider. */
|
||||
export interface ProviderConfig {
|
||||
/** Provider identifier (e.g. "openai", "anthropic"). */
|
||||
provider: string;
|
||||
/** Model name (e.g. "gpt-4o", "claude-sonnet-4-20250514"). */
|
||||
model?: string;
|
||||
/** Additional provider-specific options. */
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Folder-level command configuration. */
|
||||
export interface CommandFolderConfig {
|
||||
/** Path to the commands folder. */
|
||||
path: string;
|
||||
/** Whether commands are enabled by default. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowConfig {
|
||||
/** Default assistant identifier. */
|
||||
assistant: string;
|
||||
/** Named provider configurations keyed by provider id. */
|
||||
assistants: Record<string, ProviderConfig>;
|
||||
/** Environment variables available to the workflow. */
|
||||
envVars: Record<string, string>;
|
||||
/** Command folder configuration. */
|
||||
commands: CommandFolderConfig;
|
||||
/** Base git branch for the workflow (optional). */
|
||||
baseBranch?: string;
|
||||
/** Path to documentation directory (optional). */
|
||||
docsPath?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow store — persistence interface (will move to store/ later)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal data required to create a workflow run. */
|
||||
export interface CreateWorkflowRunData {
|
||||
workflowPath: string;
|
||||
workflowName: string;
|
||||
trigger: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Status of a workflow run. */
|
||||
export type WorkflowRunStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
/** A persisted workflow run record. */
|
||||
export interface WorkflowRun {
|
||||
id: string;
|
||||
workflowPath: string;
|
||||
workflowName: string;
|
||||
status: WorkflowRunStatus;
|
||||
trigger: string;
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** A single event within a workflow run (node start/complete/etc.). */
|
||||
export interface WorkflowEvent {
|
||||
id: string;
|
||||
runId: string;
|
||||
nodeId?: string;
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface IWorkflowStore {
|
||||
// -- Run lifecycle -------------------------------------------------------
|
||||
|
||||
/** Create a new workflow run. */
|
||||
createWorkflowRun(data: CreateWorkflowRunData): Promise<WorkflowRun>;
|
||||
|
||||
/** Retrieve a workflow run by id. */
|
||||
getWorkflowRun(id: string): Promise<WorkflowRun | null>;
|
||||
|
||||
/** Update a workflow run. */
|
||||
updateWorkflowRun(
|
||||
id: string,
|
||||
data: Partial<WorkflowRun>,
|
||||
): Promise<WorkflowRun>;
|
||||
|
||||
/** Mark a workflow run as failed. */
|
||||
failWorkflowRun(id: string, error: string): Promise<WorkflowRun>;
|
||||
|
||||
/** Get the current status of a workflow run. */
|
||||
getWorkflowRunStatus(id: string): Promise<WorkflowRunStatus | null>;
|
||||
|
||||
// -- Events --------------------------------------------------------------
|
||||
|
||||
/** Record a workflow event. */
|
||||
createWorkflowEvent(event: Omit<WorkflowEvent, 'id' | 'createdAt'>): Promise<WorkflowEvent>;
|
||||
|
||||
/** Get completed DAG node outputs for a run. */
|
||||
getCompletedDagNodeOutputs(
|
||||
runId: string,
|
||||
): Promise<Record<string, Record<string, unknown>>>;
|
||||
|
||||
// -- Active runs ---------------------------------------------------------
|
||||
|
||||
/** Find an active (non-terminal) run for a given workflow path. */
|
||||
getActiveWorkflowRunByPath(
|
||||
path: string,
|
||||
opts?: { excludeId?: string },
|
||||
): Promise<WorkflowRun | null>;
|
||||
|
||||
// -- Codebase ------------------------------------------------------------
|
||||
|
||||
/** Get a codebase record by id. */
|
||||
getCodebase(id: string): Promise<Record<string, unknown> | null>;
|
||||
|
||||
/** Get environment variables for a codebase. */
|
||||
getCodebaseEnvVars(id: string): Promise<Record<string, string>>;
|
||||
|
||||
// -- Resumption ----------------------------------------------------------
|
||||
|
||||
/** Resume a paused workflow run. */
|
||||
resumeWorkflowRun(id: string): Promise<WorkflowRun>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent provider — creates AI agent instances
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IAgentProvider {
|
||||
/** Provider identifier. */
|
||||
readonly providerId: string;
|
||||
|
||||
/** Send a prompt and return the response. */
|
||||
sendPrompt(prompt: string, options?: Record<string, unknown>): Promise<string>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow dependencies — the full DI container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorkflowDeps {
|
||||
/** Persistence store. */
|
||||
store: IWorkflowStore;
|
||||
|
||||
/** Load workflow config from a working directory. */
|
||||
loadConfig(cwd: string): Promise<WorkflowConfig>;
|
||||
|
||||
/** Get an agent provider by its provider id. */
|
||||
getAgentProvider(providerId: string): IAgentProvider;
|
||||
|
||||
/** Resolve a bot-level GitHub token (optional). */
|
||||
resolveBotGitHubToken?(): Promise<string | undefined>;
|
||||
|
||||
/** Get the user-level GitHub token (optional). */
|
||||
getUserGithubToken?(): Promise<string | undefined>;
|
||||
|
||||
/** Whether per-user GitHub token resolution is enabled (optional). */
|
||||
isPerUserGitHubEnabled?(): boolean;
|
||||
}
|
||||
214
packages/ion/src/engine/event-emitter.ts
Normal file
214
packages/ion/src/engine/event-emitter.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Typed event emitter for the Ion workflow engine.
|
||||
*
|
||||
* Provides a singleton event bus for workflow lifecycle events.
|
||||
* Supports both global and run-scoped subscriptions.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WorkflowEventType =
|
||||
| 'workflow_started'
|
||||
| 'workflow_completed'
|
||||
| 'workflow_failed'
|
||||
| 'workflow_cancelled'
|
||||
| 'node_started'
|
||||
| 'node_completed'
|
||||
| 'node_failed'
|
||||
| 'node_skipped'
|
||||
| 'loop_iteration_started'
|
||||
| 'loop_iteration_completed'
|
||||
| 'approval_pending';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorkflowEventBase {
|
||||
/** Discriminator for the event type. */
|
||||
type: WorkflowEventType;
|
||||
/** The workflow run id. */
|
||||
runId: string;
|
||||
/** The node id (when applicable). */
|
||||
nodeId?: string;
|
||||
/** The workflow name. */
|
||||
workflowName?: string;
|
||||
/** Error message (for failure events). */
|
||||
error?: string;
|
||||
/** Human-readable step name. */
|
||||
stepName?: string;
|
||||
/** Arbitrary metadata attached to the event. */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Specific shapes for strongly-typed event handling. */
|
||||
export interface WorkflowStartedEvent extends WorkflowEventBase {
|
||||
type: 'workflow_started';
|
||||
workflowName: string;
|
||||
}
|
||||
|
||||
export interface WorkflowCompletedEvent extends WorkflowEventBase {
|
||||
type: 'workflow_completed';
|
||||
workflowName: string;
|
||||
}
|
||||
|
||||
export interface WorkflowFailedEvent extends WorkflowEventBase {
|
||||
type: 'workflow_failed';
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface WorkflowCancelledEvent extends WorkflowEventBase {
|
||||
type: 'workflow_cancelled';
|
||||
}
|
||||
|
||||
export interface NodeStartedEvent extends WorkflowEventBase {
|
||||
type: 'node_started';
|
||||
nodeId: string;
|
||||
stepName?: string;
|
||||
}
|
||||
|
||||
export interface NodeCompletedEvent extends WorkflowEventBase {
|
||||
type: 'node_completed';
|
||||
nodeId: string;
|
||||
stepName?: string;
|
||||
}
|
||||
|
||||
export interface NodeFailedEvent extends WorkflowEventBase {
|
||||
type: 'node_failed';
|
||||
nodeId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface NodeSkippedEvent extends WorkflowEventBase {
|
||||
type: 'node_skipped';
|
||||
nodeId: string;
|
||||
stepName?: string;
|
||||
}
|
||||
|
||||
export interface LoopIterationStartedEvent extends WorkflowEventBase {
|
||||
type: 'loop_iteration_started';
|
||||
nodeId: string;
|
||||
metadata?: { iteration: number };
|
||||
}
|
||||
|
||||
export interface LoopIterationCompletedEvent extends WorkflowEventBase {
|
||||
type: 'loop_iteration_completed';
|
||||
nodeId: string;
|
||||
metadata?: { iteration: number };
|
||||
}
|
||||
|
||||
export interface ApprovalPendingEvent extends WorkflowEventBase {
|
||||
type: 'approval_pending';
|
||||
nodeId: string;
|
||||
metadata?: { approver?: string; reason?: string };
|
||||
}
|
||||
|
||||
export type WorkflowEvent =
|
||||
| WorkflowStartedEvent
|
||||
| WorkflowCompletedEvent
|
||||
| WorkflowFailedEvent
|
||||
| WorkflowCancelledEvent
|
||||
| NodeStartedEvent
|
||||
| NodeCompletedEvent
|
||||
| NodeFailedEvent
|
||||
| NodeSkippedEvent
|
||||
| LoopIterationStartedEvent
|
||||
| LoopIterationCompletedEvent
|
||||
| ApprovalPendingEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handler type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WorkflowEventHandler = (event: WorkflowEvent) => void;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorkflowEventEmitter — singleton event bus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class WorkflowEventEmitter {
|
||||
private listeners: Set<WorkflowEventHandler> = new Set();
|
||||
private runListeners: Map<string, Set<WorkflowEventHandler>> = new Map();
|
||||
|
||||
/** Subscribe to all workflow events. */
|
||||
subscribe(handler: WorkflowEventHandler): () => void {
|
||||
this.listeners.add(handler);
|
||||
return () => {
|
||||
this.listeners.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/** Unsubscribe a handler from all events. */
|
||||
unsubscribe(handler: WorkflowEventHandler): void {
|
||||
this.listeners.delete(handler);
|
||||
}
|
||||
|
||||
/** Emit an event to all subscribers (global + run-scoped). */
|
||||
emit(event: WorkflowEvent): void {
|
||||
for (const handler of this.listeners) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
// Swallow handler errors to prevent cascading failures.
|
||||
}
|
||||
}
|
||||
|
||||
// Also notify run-scoped listeners.
|
||||
const runHandlers = this.runListeners.get(event.runId);
|
||||
if (runHandlers) {
|
||||
for (const handler of runHandlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
// Swallow handler errors to prevent cascading failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove all global listeners and run-scoped listeners. */
|
||||
clear(): void {
|
||||
this.listeners.clear();
|
||||
this.runListeners.clear();
|
||||
}
|
||||
|
||||
// -- Run-scoped subscriptions ------------------------------------------------
|
||||
|
||||
/** Register a run-scoped event listener. */
|
||||
registerRun(runId: string, handler: WorkflowEventHandler): () => void {
|
||||
let handlers = this.runListeners.get(runId);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
this.runListeners.set(runId, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
|
||||
return () => {
|
||||
handlers!.delete(handler);
|
||||
if (handlers!.size === 0) {
|
||||
this.runListeners.delete(runId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Unregister all listeners for a specific run. */
|
||||
unregisterRun(runId: string): void {
|
||||
this.runListeners.delete(runId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let instance: WorkflowEventEmitter | undefined;
|
||||
|
||||
/** Get the singleton WorkflowEventEmitter instance. */
|
||||
export function getWorkflowEventEmitter(): WorkflowEventEmitter {
|
||||
if (!instance) {
|
||||
instance = new WorkflowEventEmitter();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
251
packages/ion/src/engine/executor-shared.ts
Normal file
251
packages/ion/src/engine/executor-shared.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Shared executor utilities for the Ion workflow engine.
|
||||
*
|
||||
* Pure functions for variable substitution, error classification,
|
||||
* message helpers, and completion signal detection.
|
||||
*/
|
||||
|
||||
import type { IWorkflowPlatform } from './deps.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable substitution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Well-known workflow variable names. */
|
||||
const WORKFLOW_VARIABLES = [
|
||||
'$WORKFLOW_ID',
|
||||
'$USER_MESSAGE',
|
||||
'$ARGUMENTS',
|
||||
'$ARTIFACTS_DIR',
|
||||
'$BASE_BRANCH',
|
||||
'$DOCS_DIR',
|
||||
'$CONTEXT',
|
||||
'$EXTERNAL_CONTEXT',
|
||||
'$LOOP_USER_INPUT',
|
||||
'$REJECTION_REASON',
|
||||
] as const;
|
||||
|
||||
export type WorkflowVariableName = (typeof WORKFLOW_VARIABLES)[number];
|
||||
|
||||
/** Context object providing values for workflow variable substitution. */
|
||||
export interface VariableContext {
|
||||
WORKFLOW_ID?: string;
|
||||
USER_MESSAGE?: string;
|
||||
ARGUMENTS?: string;
|
||||
ARTIFACTS_DIR?: string;
|
||||
BASE_BRANCH?: string;
|
||||
DOCS_DIR?: string;
|
||||
CONTEXT?: string;
|
||||
EXTERNAL_CONTEXT?: string;
|
||||
LOOP_USER_INPUT?: string;
|
||||
REJECTION_REASON?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute workflow variables in a string.
|
||||
*
|
||||
* Replaces known `$VARIABLE` tokens with values from the context.
|
||||
* Unknown `$VARIABLE` tokens are left as-is (not removed).
|
||||
*/
|
||||
export function substituteWorkflowVariables(
|
||||
template: string,
|
||||
context: VariableContext,
|
||||
): string {
|
||||
let result = template;
|
||||
|
||||
// Map variable names to their context keys.
|
||||
const mapping: Record<string, string | undefined> = {
|
||||
$WORKFLOW_ID: context.WORKFLOW_ID,
|
||||
$USER_MESSAGE: context.USER_MESSAGE,
|
||||
$ARGUMENTS: context.ARGUMENTS,
|
||||
$ARTIFACTS_DIR: context.ARTIFACTS_DIR,
|
||||
$BASE_BRANCH: context.BASE_BRANCH,
|
||||
$DOCS_DIR: context.DOCS_DIR,
|
||||
$CONTEXT: context.CONTEXT,
|
||||
$EXTERNAL_CONTEXT: context.EXTERNAL_CONTEXT,
|
||||
$LOOP_USER_INPUT: context.LOOP_USER_INPUT,
|
||||
$REJECTION_REASON: context.REJECTION_REASON,
|
||||
};
|
||||
|
||||
for (const [variable, value] of Object.entries(mapping)) {
|
||||
if (value !== undefined) {
|
||||
// Replace all occurrences of the variable.
|
||||
result = result.split(variable).join(value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a prompt by substituting workflow variables and appending
|
||||
* issue context if provided.
|
||||
*/
|
||||
export function buildPromptWithContext(
|
||||
template: string,
|
||||
context: VariableContext,
|
||||
issueContext?: string,
|
||||
): string {
|
||||
let prompt = substituteWorkflowVariables(template, context);
|
||||
|
||||
if (issueContext && issueContext.length > 0) {
|
||||
prompt += `\n\n---\nIssue Context:\n${issueContext}`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ErrorClassification = 'FATAL' | 'TRANSIENT' | 'UNKNOWN';
|
||||
|
||||
/** Patterns that indicate a fatal (non-retryable) error. */
|
||||
const FATAL_PATTERNS: readonly RegExp[] = [
|
||||
/auth(?:entication|orization)?\s*(?:fail|error|denied|invalid)/i,
|
||||
/permission\s*(?:denied|error|fail)/i,
|
||||
/insufficient\s*(?:credit|quota|permission)/i,
|
||||
/api\s*key\s*(?:invalid|expired|missing)/i,
|
||||
/forbidden/i,
|
||||
/unauthorized/i,
|
||||
/account\s*(?:suspended|disabled|deactivated)/i,
|
||||
];
|
||||
|
||||
/** Patterns that indicate a transient (retryable) error. */
|
||||
const TRANSIENT_PATTERNS: readonly RegExp[] = [
|
||||
/timeout/i,
|
||||
/timed?\s*out/i,
|
||||
/network\s*(?:error|fail|unreachable)/i,
|
||||
/connection\s*(?:reset|refused|dropped|lost)/i,
|
||||
/econnreset/i,
|
||||
/econnrefused/i,
|
||||
/rate\s*limit/i,
|
||||
/too\s*many\s*requests/i,
|
||||
/service\s*(?:unavailable|temporarily\s*unavailable)/i,
|
||||
/503/i,
|
||||
/502/i,
|
||||
/retry/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Classify an error as FATAL, TRANSIENT, or UNKNOWN based on its message.
|
||||
*
|
||||
* FATAL errors should not be retried (auth, permission, credit issues).
|
||||
* TRANSIENT errors may succeed on retry (network, timeout, rate limit).
|
||||
* UNKNOWN errors have no recognized pattern.
|
||||
*/
|
||||
export function classifyError(error: Error | string): ErrorClassification {
|
||||
const message = typeof error === 'string' ? error : error.message;
|
||||
|
||||
for (const pattern of FATAL_PATTERNS) {
|
||||
if (pattern.test(message)) {
|
||||
return 'FATAL';
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of TRANSIENT_PATTERNS) {
|
||||
if (pattern.test(message)) {
|
||||
return 'TRANSIENT';
|
||||
}
|
||||
}
|
||||
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform message helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Safely send a message via the platform interface.
|
||||
*
|
||||
* Returns `true` if the message was sent successfully, `false` otherwise.
|
||||
* Logs failures but does not throw.
|
||||
*/
|
||||
export async function safeSendMessage(
|
||||
platform: IWorkflowPlatform,
|
||||
conversationId: string,
|
||||
message: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await platform.sendMessage(conversationId, message, metadata);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Completion signal detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect whether an output contains the expected completion signal.
|
||||
*
|
||||
* The `until` string is the marker that signals the node has finished
|
||||
* producing its output. Returns `true` if the marker is found.
|
||||
*/
|
||||
export function detectCompletionSignal(
|
||||
output: string,
|
||||
until: string,
|
||||
): boolean {
|
||||
if (!until || until.length === 0) {
|
||||
// No completion marker configured — always consider complete.
|
||||
return true;
|
||||
}
|
||||
return output.includes(until);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the completion marker from the output.
|
||||
*
|
||||
* Removes the `until` string from the output if present.
|
||||
* Does not trim or modify whitespace beyond removing the marker.
|
||||
*/
|
||||
export function stripCompletionTags(output: string, until: string): string {
|
||||
if (!until || until.length === 0) {
|
||||
return output;
|
||||
}
|
||||
return output.split(until).join('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subprocess failure formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SubprocessFailure {
|
||||
exitCode: number | null;
|
||||
stderr: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a subprocess failure into a human-readable error string.
|
||||
*
|
||||
* Includes the command (if provided), exit code, and stderr output.
|
||||
*/
|
||||
export function formatSubprocessFailure(failure: SubprocessFailure): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (failure.command) {
|
||||
parts.push(`Command: ${failure.command}`);
|
||||
}
|
||||
|
||||
if (failure.exitCode !== null) {
|
||||
parts.push(`Exit code: ${failure.exitCode}`);
|
||||
}
|
||||
|
||||
if (failure.stderr && failure.stderr.length > 0) {
|
||||
// Truncate very long stderr to keep error messages manageable.
|
||||
const maxStderr = 2048;
|
||||
const stderr =
|
||||
failure.stderr.length > maxStderr
|
||||
? failure.stderr.slice(0, maxStderr) + '... (truncated)'
|
||||
: failure.stderr;
|
||||
parts.push(`Stderr:\n${stderr}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('\n') : 'Subprocess failed with no details';
|
||||
}
|
||||
373
packages/ion/src/engine/executor.ts
Normal file
373
packages/ion/src/engine/executor.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Top-level workflow executor for the Ion engine.
|
||||
*
|
||||
* Orchestrates the full lifecycle of a workflow run:
|
||||
* - Load configuration and resolve provider/model
|
||||
* - Create or resume a WorkflowRun
|
||||
* - Path-lock guard (prevent concurrent runs on the same working path)
|
||||
* - Pre-create artifacts directory
|
||||
* - Delegate to executeDagWorkflow for DAG traversal
|
||||
* - Update run status on completion/failure
|
||||
* - Emit events and notify the user
|
||||
*/
|
||||
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import type { WorkflowDefinition } from '../schema/index.js';
|
||||
import type {
|
||||
IWorkflowPlatform,
|
||||
IWorkflowStore,
|
||||
WorkflowDeps,
|
||||
WorkflowConfig,
|
||||
WorkflowRun,
|
||||
CreateWorkflowRunData,
|
||||
} from './deps.js';
|
||||
import { executeDagWorkflow, type DagWorkflowResult } from './dag-executor.js';
|
||||
import { safeSendMessage } from './utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for workflow execution. */
|
||||
export interface WorkflowExecutionOptions {
|
||||
/** Whether to resume a previously failed/paused run. */
|
||||
resume?: boolean;
|
||||
/** Additional input variables for the workflow. */
|
||||
input?: Record<string, unknown>;
|
||||
/** Override the provider id. */
|
||||
provider?: string;
|
||||
/** Override the model. */
|
||||
model?: string;
|
||||
/** Codebase id for scoped paths. */
|
||||
codebaseId?: string;
|
||||
}
|
||||
|
||||
/** Result of a workflow execution. */
|
||||
export interface WorkflowExecutionResult {
|
||||
/** The workflow run record. */
|
||||
run: WorkflowRun;
|
||||
/** The DAG execution result. */
|
||||
dagResult?: DagWorkflowResult;
|
||||
/** Whether the execution was successful. */
|
||||
success: boolean;
|
||||
/** Error message if execution failed. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Hydrated resumable run data. */
|
||||
export interface HydratedResumableRun {
|
||||
/** The pre-created or resumed workflow run. */
|
||||
preCreatedRun: WorkflowRun;
|
||||
/** Prior completed node outputs from the previous run. */
|
||||
priorCompletedNodes: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Resolved project paths for a workflow run. */
|
||||
export interface ProjectPaths {
|
||||
/** Directory for workflow artifacts. */
|
||||
artifactsDir: string;
|
||||
/** Directory for workflow logs. */
|
||||
logDir: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main executor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Execute a workflow from start to finish.
|
||||
*
|
||||
* This is the primary entry point for running a workflow. It handles:
|
||||
* 1. Loading configuration
|
||||
* 2. Resolving provider and model
|
||||
* 3. Creating or resuming a WorkflowRun
|
||||
* 4. Path-lock guard
|
||||
* 5. Pre-creating the artifacts directory
|
||||
* 6. Delegating to executeDagWorkflow
|
||||
* 7. Updating run status on completion/failure
|
||||
*
|
||||
* @param deps - Dependency injection container.
|
||||
* @param platform - Platform interface for messaging.
|
||||
* @param conversationId - Conversation channel id.
|
||||
* @param cwd - Working directory for the workflow.
|
||||
* @param workflow - The workflow definition.
|
||||
* @param userMessage - The triggering user message (stored as input).
|
||||
* @param opts - Execution options.
|
||||
* @returns WorkflowExecutionResult with run, dag result, and success status.
|
||||
*/
|
||||
export async function executeWorkflow(
|
||||
deps: WorkflowDeps,
|
||||
platform: IWorkflowPlatform,
|
||||
conversationId: string,
|
||||
cwd: string,
|
||||
workflow: WorkflowDefinition,
|
||||
userMessage: string,
|
||||
opts: WorkflowExecutionOptions = {},
|
||||
): Promise<WorkflowExecutionResult> {
|
||||
// 1. Load configuration
|
||||
let config: WorkflowConfig;
|
||||
try {
|
||||
config = await deps.loadConfig(cwd);
|
||||
} catch (err) {
|
||||
return {
|
||||
run: createFailedRun(workflow, err),
|
||||
success: false,
|
||||
error: `Failed to load configuration: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Resolve provider and model
|
||||
const providerId = opts.provider ?? workflow.provider ?? config.assistant;
|
||||
const model = opts.model ?? workflow.model ?? config.assistants[providerId]?.model;
|
||||
|
||||
// 3. Create or resume a workflow run
|
||||
let workflowRun: WorkflowRun;
|
||||
let priorCompletedNodes: Record<string, Record<string, unknown>> | undefined;
|
||||
|
||||
if (opts.resume) {
|
||||
// Try to find an existing run to resume
|
||||
const existingRun = await deps.store.getActiveWorkflowRunByPath(workflow.name);
|
||||
if (existingRun) {
|
||||
const hydrated = await hydrateResumableRun(deps, existingRun);
|
||||
workflowRun = hydrated.preCreatedRun;
|
||||
priorCompletedNodes = hydrated.priorCompletedNodes;
|
||||
} else {
|
||||
// No existing run — create a new one
|
||||
workflowRun = await createNewRun(deps, workflow, userMessage, opts);
|
||||
}
|
||||
} else {
|
||||
// 4. Path-lock guard: check no other run is active
|
||||
const activeRun = await deps.store.getActiveWorkflowRunByPath(workflow.name);
|
||||
if (activeRun) {
|
||||
const errorMsg = `Workflow "${workflow.name}" already has an active run (${activeRun.id}). Wait for it to complete or cancel it first.`;
|
||||
await safeSendMessage(platform, conversationId, `❌ ${errorMsg}`);
|
||||
return {
|
||||
run: createFailedRun(workflow, new Error(errorMsg)),
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
|
||||
workflowRun = await createNewRun(deps, workflow, userMessage, opts);
|
||||
}
|
||||
|
||||
// 5. Pre-create artifacts directory
|
||||
const paths = resolveProjectPaths(deps, cwd, workflowRun.id, opts.codebaseId);
|
||||
try {
|
||||
await mkdir(paths.artifactsDir, { recursive: true });
|
||||
await mkdir(paths.logDir, { recursive: true });
|
||||
} catch (err) {
|
||||
// Artifacts dir creation is best-effort
|
||||
}
|
||||
|
||||
// 6. Set status to running
|
||||
try {
|
||||
workflowRun = await deps.store.updateWorkflowRun(workflowRun.id, {
|
||||
status: 'running',
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
run: workflowRun,
|
||||
success: false,
|
||||
error: `Failed to set workflow run status to running: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 7. Notify user
|
||||
await safeSendMessage(
|
||||
platform,
|
||||
conversationId,
|
||||
`🚀 Starting workflow "${workflow.name}" (run ${workflowRun.id})`,
|
||||
);
|
||||
|
||||
// 8. Execute the DAG
|
||||
let dagResult: DagWorkflowResult | undefined;
|
||||
try {
|
||||
dagResult = await executeDagWorkflow(
|
||||
deps,
|
||||
platform,
|
||||
conversationId,
|
||||
cwd,
|
||||
workflow,
|
||||
workflowRun,
|
||||
priorCompletedNodes,
|
||||
);
|
||||
|
||||
// 9. Update run status on completion
|
||||
if (dagResult.success) {
|
||||
workflowRun = await deps.store.updateWorkflowRun(workflowRun.id, {
|
||||
status: 'completed',
|
||||
output: Object.fromEntries(dagResult.nodeOutputs),
|
||||
});
|
||||
|
||||
await safeSendMessage(
|
||||
platform,
|
||||
conversationId,
|
||||
`✅ Workflow "${workflow.name}" completed successfully\n${dagResult.summary}`,
|
||||
);
|
||||
} else {
|
||||
workflowRun = await deps.store.failWorkflowRun(
|
||||
workflowRun.id,
|
||||
dagResult.error ?? 'Workflow failed',
|
||||
);
|
||||
|
||||
await safeSendMessage(
|
||||
platform,
|
||||
conversationId,
|
||||
`❌ Workflow "${workflow.name}" failed: ${dagResult.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
run: workflowRun,
|
||||
dagResult,
|
||||
success: dagResult.success,
|
||||
error: dagResult.error,
|
||||
};
|
||||
} catch (err) {
|
||||
// Unhandled error — update DB and notify
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
try {
|
||||
workflowRun = await deps.store.failWorkflowRun(workflowRun.id, errorMsg);
|
||||
} catch {
|
||||
// Best-effort DB update
|
||||
}
|
||||
|
||||
await safeSendMessage(
|
||||
platform,
|
||||
conversationId,
|
||||
`❌ Workflow "${workflow.name}" failed with error: ${errorMsg}`,
|
||||
);
|
||||
|
||||
// Emit error event
|
||||
try {
|
||||
await deps.store.createWorkflowEvent({
|
||||
runId: workflowRun.id,
|
||||
type: 'workflow_failed',
|
||||
data: { error: errorMsg },
|
||||
});
|
||||
} catch {
|
||||
// Best-effort event emission
|
||||
}
|
||||
|
||||
return {
|
||||
run: workflowRun,
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resume support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hydrate a resumable workflow run.
|
||||
*
|
||||
* Loads completed node outputs from the previous run and sets the
|
||||
* run status back to 'running' so execution can continue.
|
||||
*
|
||||
* @param deps - Dependency injection container.
|
||||
* @param candidate - The existing workflow run to resume.
|
||||
* @returns Hydrated run with prior completed nodes.
|
||||
*/
|
||||
export async function hydrateResumableRun(
|
||||
deps: WorkflowDeps,
|
||||
candidate: WorkflowRun,
|
||||
): Promise<HydratedResumableRun> {
|
||||
// Load completed node outputs from the previous run
|
||||
const priorCompletedNodes = await deps.store.getCompletedDagNodeOutputs(candidate.id);
|
||||
|
||||
// Resume the workflow run (set status back to 'running')
|
||||
const preCreatedRun = await deps.store.resumeWorkflowRun(candidate.id);
|
||||
|
||||
return {
|
||||
preCreatedRun,
|
||||
priorCompletedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve project paths for a workflow run.
|
||||
*
|
||||
* Uses codebase-scoped paths if a codebaseId is provided,
|
||||
* otherwise falls back to cwd-based paths.
|
||||
*
|
||||
* @param deps - Dependency injection container.
|
||||
* @param cwd - Working directory.
|
||||
* @param workflowRunId - The workflow run id.
|
||||
* @param codebaseId - Optional codebase id for scoped paths.
|
||||
* @returns Resolved artifacts and log directories.
|
||||
*/
|
||||
export function resolveProjectPaths(
|
||||
_deps: WorkflowDeps,
|
||||
cwd: string,
|
||||
workflowRunId: string,
|
||||
codebaseId?: string,
|
||||
): ProjectPaths {
|
||||
if (codebaseId) {
|
||||
// Codebase-scoped paths
|
||||
return {
|
||||
artifactsDir: resolve(cwd, '.ion', 'codebases', codebaseId, 'artifacts', workflowRunId),
|
||||
logDir: resolve(cwd, '.ion', 'codebases', codebaseId, 'logs', workflowRunId),
|
||||
};
|
||||
}
|
||||
|
||||
// Cwd-based paths (default)
|
||||
return {
|
||||
artifactsDir: resolve(cwd, '.ion', 'artifacts', workflowRunId),
|
||||
logDir: resolve(cwd, '.ion', 'logs', workflowRunId),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new workflow run in the store.
|
||||
*/
|
||||
async function createNewRun(
|
||||
deps: WorkflowDeps,
|
||||
workflow: WorkflowDefinition,
|
||||
userMessage: string,
|
||||
opts: WorkflowExecutionOptions,
|
||||
): Promise<WorkflowRun> {
|
||||
const data: CreateWorkflowRunData = {
|
||||
workflowPath: workflow.name,
|
||||
workflowName: workflow.name,
|
||||
trigger: 'manual',
|
||||
input: {
|
||||
message: userMessage,
|
||||
...(opts.input ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
return deps.store.createWorkflowRun(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal failed run object for error cases where
|
||||
* the store is not available.
|
||||
*/
|
||||
function createFailedRun(workflow: WorkflowDefinition, error: unknown): WorkflowRun {
|
||||
return {
|
||||
id: 'error',
|
||||
workflowPath: workflow.name,
|
||||
workflowName: workflow.name,
|
||||
status: 'failed',
|
||||
trigger: 'manual',
|
||||
input: {},
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
63
packages/ion/src/engine/index.ts
Normal file
63
packages/ion/src/engine/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Engine barrel exports.
|
||||
*
|
||||
* Re-exports everything from the engine submodules.
|
||||
*/
|
||||
|
||||
// Dependencies and types
|
||||
export type {
|
||||
IWorkflowPlatform,
|
||||
IWorkflowStore,
|
||||
IAgentProvider,
|
||||
WorkflowDeps,
|
||||
WorkflowConfig,
|
||||
ProviderConfig,
|
||||
CommandFolderConfig,
|
||||
CreateWorkflowRunData,
|
||||
WorkflowEvent,
|
||||
} from './deps.js';
|
||||
|
||||
// DAG executor
|
||||
export {
|
||||
buildTopologicalLayers,
|
||||
checkTriggerRule,
|
||||
executeNodeInternal,
|
||||
executeScriptNode,
|
||||
handleApprovalNode,
|
||||
handleLoopNode,
|
||||
executeDagWorkflow,
|
||||
} from './dag-executor.js';
|
||||
export type { DagWorkflowResult } from './dag-executor.js';
|
||||
|
||||
// Top-level executor
|
||||
export {
|
||||
executeWorkflow,
|
||||
hydrateResumableRun,
|
||||
resolveProjectPaths,
|
||||
} from './executor.js';
|
||||
export type {
|
||||
WorkflowExecutionOptions,
|
||||
WorkflowExecutionResult,
|
||||
HydratedResumableRun,
|
||||
ProjectPaths,
|
||||
} from './executor.js';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
substituteWorkflowVariables,
|
||||
substituteNodeOutputRefs,
|
||||
buildPromptWithContext,
|
||||
evaluateCondition,
|
||||
classifyError,
|
||||
safeSendMessage,
|
||||
formatSubprocessFailure,
|
||||
sleep,
|
||||
retryWithBackoff,
|
||||
resolveNodeOutputField,
|
||||
OutputRefError,
|
||||
DagCycleError,
|
||||
NodeTimeoutError,
|
||||
ApprovalRejectedError,
|
||||
LoopMaxIterationsError,
|
||||
} from './utils.js';
|
||||
export type { ErrorCategory } from './utils.js';
|
||||
228
packages/ion/src/engine/model-validation.ts
Normal file
228
packages/ion/src/engine/model-validation.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Provider/model resolution for the Ion workflow engine.
|
||||
*
|
||||
* Maps model references (aliases, tier presets, or literal specs)
|
||||
* to concrete AI model configurations.
|
||||
*/
|
||||
|
||||
import type { ProviderConfig } from './deps.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A concrete model specification with all fields resolved. */
|
||||
export interface LiteralModelSpec {
|
||||
/** The AI provider (e.g. "openai", "anthropic"). */
|
||||
provider: string;
|
||||
/** The model identifier (e.g. "gpt-4o", "claude-sonnet-4-20250514"). */
|
||||
model: string;
|
||||
/** Optional effort level (e.g. "low", "medium", "high"). */
|
||||
effort?: string;
|
||||
/** Optional thinking/reasoning configuration. */
|
||||
thinking?: {
|
||||
type: string;
|
||||
budgetTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** A preset that maps an alias to a concrete model configuration. */
|
||||
export interface ModelAliasPreset {
|
||||
/** The provider for this preset. */
|
||||
provider: string;
|
||||
/** The model identifier. */
|
||||
model: string;
|
||||
/** Optional effort level. */
|
||||
effort?: string;
|
||||
/** Optional thinking configuration. */
|
||||
thinking?: {
|
||||
type: string;
|
||||
budgetTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Tier definitions for an AI profile. */
|
||||
export interface AiProfileTiers {
|
||||
/** Fast/cheap tier. */
|
||||
fast?: ModelAliasPreset;
|
||||
/** Balanced tier. */
|
||||
balanced?: ModelAliasPreset;
|
||||
/** Powerful/expensive tier. */
|
||||
powerful?: ModelAliasPreset;
|
||||
}
|
||||
|
||||
/** An AI profile with tiers and named aliases. */
|
||||
export interface AiProfile {
|
||||
/** The default provider. */
|
||||
defaultProvider: string;
|
||||
/** Named provider configurations. */
|
||||
providers: Record<string, ProviderConfig>;
|
||||
/** Tier presets. */
|
||||
tiers: AiProfileTiers;
|
||||
/** Named aliases mapping to presets. */
|
||||
aliases: Record<string, ModelAliasPreset>;
|
||||
}
|
||||
|
||||
/** Options for building an AI profile. */
|
||||
export interface BuildAiProfileOptions {
|
||||
/** The default assistant/provider id. */
|
||||
assistant: string;
|
||||
/** Named provider configurations. */
|
||||
assistants: Record<string, ProviderConfig>;
|
||||
/** Optional model overrides from workflow config. */
|
||||
modelOverrides?: Record<string, ModelAliasPreset>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if a model spec is a literal (fully resolved) spec.
|
||||
*
|
||||
* A literal spec has a `provider` and `model` field directly.
|
||||
*/
|
||||
export function isLiteralSpec(
|
||||
spec: unknown,
|
||||
): spec is LiteralModelSpec {
|
||||
if (typeof spec !== 'object' || spec === null) {
|
||||
return false;
|
||||
}
|
||||
const obj = spec as Record<string, unknown>;
|
||||
return typeof obj['provider'] === 'string' && typeof obj['model'] === 'string';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default tier presets for common providers. */
|
||||
const DEFAULT_TIERS: Record<string, AiProfileTiers> = {
|
||||
openai: {
|
||||
fast: { provider: 'openai', model: 'gpt-4o-mini' },
|
||||
balanced: { provider: 'openai', model: 'gpt-4o' },
|
||||
powerful: { provider: 'openai', model: 'o1' },
|
||||
},
|
||||
anthropic: {
|
||||
fast: { provider: 'anthropic', model: 'claude-haiku-4-20250414' },
|
||||
balanced: { provider: 'anthropic', model: 'claude-sonnet-4-20250514' },
|
||||
powerful: {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-opus-4-20250514',
|
||||
thinking: { type: 'enabled', budgetTokens: 10000 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an AI profile from workflow configuration.
|
||||
*
|
||||
* Merges default tier presets with any model overrides from the config.
|
||||
*/
|
||||
export function buildAiProfile(
|
||||
opts: BuildAiProfileOptions,
|
||||
): AiProfile {
|
||||
const providers = { ...opts.assistants };
|
||||
|
||||
// Determine the default provider from the assistant config.
|
||||
const defaultProviderConfig = providers[opts.assistant];
|
||||
const defaultProvider = defaultProviderConfig?.provider ?? opts.assistant;
|
||||
|
||||
// Start with default tiers for the default provider.
|
||||
const baseTiers = DEFAULT_TIERS[defaultProvider] ?? {};
|
||||
|
||||
// Apply model overrides if provided.
|
||||
const tiers: AiProfileTiers = { ...baseTiers };
|
||||
if (opts.modelOverrides) {
|
||||
for (const [key, preset] of Object.entries(opts.modelOverrides)) {
|
||||
if (key === 'fast' || key === 'balanced' || key === 'powerful') {
|
||||
tiers[key] = preset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build aliases from overrides and tiers.
|
||||
const aliases: Record<string, ModelAliasPreset> = {};
|
||||
|
||||
// Tier-based aliases.
|
||||
if (tiers.fast) aliases['fast'] = tiers.fast;
|
||||
if (tiers.balanced) aliases['balanced'] = tiers.balanced;
|
||||
if (tiers.powerful) aliases['powerful'] = tiers.powerful;
|
||||
|
||||
// Custom overrides as aliases.
|
||||
if (opts.modelOverrides) {
|
||||
for (const [key, preset] of Object.entries(opts.modelOverrides)) {
|
||||
if (key !== 'fast' && key !== 'balanced' && key !== 'powerful') {
|
||||
aliases[key] = preset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultProvider,
|
||||
providers,
|
||||
tiers,
|
||||
aliases,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a model reference to a literal model spec.
|
||||
*
|
||||
* A model reference can be:
|
||||
* - A literal spec (has `provider` and `model` fields) → returned as-is
|
||||
* - A tier name ("fast", "balanced", "powerful") → resolved from profile tiers
|
||||
* - A named alias → resolved from profile aliases
|
||||
* - A provider-prefixed reference ("openai/gpt-4o") → parsed into a spec
|
||||
* - A bare model name → resolved using the default provider
|
||||
*
|
||||
* Throws if the reference cannot be resolved.
|
||||
*/
|
||||
export function resolveModelSpec(
|
||||
profile: AiProfile,
|
||||
modelRef: string | LiteralModelSpec,
|
||||
): LiteralModelSpec {
|
||||
// Already a literal spec.
|
||||
if (typeof modelRef !== 'string') {
|
||||
if (isLiteralSpec(modelRef)) {
|
||||
return modelRef;
|
||||
}
|
||||
throw new Error(`Invalid model spec: ${JSON.stringify(modelRef)}`);
|
||||
}
|
||||
|
||||
// Check aliases first (includes tier aliases).
|
||||
const alias = profile.aliases[modelRef];
|
||||
if (alias) {
|
||||
return {
|
||||
provider: alias.provider,
|
||||
model: alias.model,
|
||||
effort: alias.effort,
|
||||
thinking: alias.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
// Provider-prefixed reference: "provider/model"
|
||||
if (modelRef.includes('/')) {
|
||||
const slashIndex = modelRef.indexOf('/');
|
||||
const provider = modelRef.slice(0, slashIndex)!;
|
||||
const model = modelRef.slice(slashIndex + 1);
|
||||
|
||||
if (!provider || !model) {
|
||||
throw new Error(
|
||||
`Invalid provider-prefixed model reference: "${modelRef}". Expected format "provider/model".`,
|
||||
);
|
||||
}
|
||||
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
// Bare model name — use default provider.
|
||||
return {
|
||||
provider: profile.defaultProvider,
|
||||
model: modelRef,
|
||||
};
|
||||
}
|
||||
122
packages/ion/src/engine/output-ref.ts
Normal file
122
packages/ion/src/engine/output-ref.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Node output reference resolution for the Ion workflow engine.
|
||||
*
|
||||
* Resolves `$nodeId.field` references in workflow conditions and prompts,
|
||||
* with strict schema-aware validation and descriptive errors.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output reference result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OutputRefKind = 'value' | 'empty';
|
||||
|
||||
export interface OutputRefResult {
|
||||
/** Whether the field had a value or was empty. */
|
||||
kind: OutputRefKind;
|
||||
/** The resolved value (empty string for missing optional fields). */
|
||||
value: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OutputRefError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OutputRefError extends Error {
|
||||
public readonly nodeId: string;
|
||||
public readonly field: string;
|
||||
|
||||
constructor(nodeId: string, field: string, message: string) {
|
||||
super(`Output reference error for node "${nodeId}".${field}: ${message}`);
|
||||
this.name = 'OutputRefError';
|
||||
this.nodeId = nodeId;
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract declared field names from an output_format schema.
|
||||
*
|
||||
* The output_format can be:
|
||||
* - A JSON Schema object with `properties` (standard)
|
||||
* - A string describing the format (treated as having no declared fields)
|
||||
* - Undefined (no schema)
|
||||
*
|
||||
* Returns a Set of field names that are declared in the schema.
|
||||
*/
|
||||
export function declaredFieldsFromSchema(
|
||||
outputFormat: Record<string, unknown> | string | undefined,
|
||||
): Set<string> {
|
||||
if (!outputFormat || typeof outputFormat === 'string') {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const properties = outputFormat['properties'];
|
||||
if (properties && typeof properties === 'object' && properties !== null) {
|
||||
return new Set(Object.keys(properties as Record<string, unknown>));
|
||||
}
|
||||
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node output resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a specific field from a node's output.
|
||||
*
|
||||
* Behavior:
|
||||
* - If the field is present in the output, returns `{ kind: 'value', value }`.
|
||||
* - If the field is declared in the schema but not present in the output,
|
||||
* returns `{ kind: 'empty', value: '' }` (optional field not set).
|
||||
* - If the field is NOT declared in the schema AND not in the output,
|
||||
* throws `OutputRefError` (undeclared reference).
|
||||
* - If the field is NOT declared in the schema but IS in the output,
|
||||
* returns `{ kind: 'value', value }` (dynamic output).
|
||||
*
|
||||
* The `declaredFields` parameter should come from `declaredFieldsFromSchema()`.
|
||||
*/
|
||||
export function resolveNodeOutputField(
|
||||
nodeOutput: Record<string, unknown>,
|
||||
nodeId: string,
|
||||
field: string,
|
||||
declaredFields?: Set<string>,
|
||||
): OutputRefResult {
|
||||
// Check if the field exists in the output.
|
||||
if (field in nodeOutput) {
|
||||
const rawValue = nodeOutput[field];
|
||||
|
||||
// Convert the value to a string.
|
||||
if (rawValue === null || rawValue === undefined) {
|
||||
// Field key exists but value is nullish — treat as empty.
|
||||
return { kind: 'empty', value: '' };
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
return { kind: 'value', value: rawValue };
|
||||
}
|
||||
|
||||
// Non-string values are JSON-serialized.
|
||||
return { kind: 'value', value: JSON.stringify(rawValue) };
|
||||
}
|
||||
|
||||
// Field is not in the output. Check if it was declared in the schema.
|
||||
const isDeclared = declaredFields?.has(field) ?? false;
|
||||
|
||||
if (isDeclared) {
|
||||
// Declared but not present — optional field not set.
|
||||
return { kind: 'empty', value: '' };
|
||||
}
|
||||
|
||||
// Not declared and not present — this is an error.
|
||||
throw new OutputRefError(
|
||||
nodeId,
|
||||
field,
|
||||
`Field "${field}" is not declared in the output schema and is not present in the node output. Available fields: ${Object.keys(nodeOutput).join(', ') || '(none)'}`,
|
||||
);
|
||||
}
|
||||
372
packages/ion/src/engine/utils.ts
Normal file
372
packages/ion/src/engine/utils.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Utility functions for the Ion workflow engine.
|
||||
*
|
||||
* Provides variable substitution, condition evaluation, error classification,
|
||||
* and safe messaging helpers used by the DAG executor and top-level executor.
|
||||
*/
|
||||
|
||||
import type { NodeOutput } from '../schema/index.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable substitution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Substitute workflow-level variables in a string.
|
||||
*
|
||||
* Replaces `${VAR_NAME}` patterns with values from the provided variables map.
|
||||
* Supports nested dot-notation access (e.g. `${env.API_KEY}`).
|
||||
*/
|
||||
export function substituteWorkflowVariables(
|
||||
template: string,
|
||||
variables: Record<string, unknown>,
|
||||
): string {
|
||||
return template.replace(/\$\{([^}]+)\}/g, (_match, path: string) => {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = variables;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return '';
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current != null ? String(current) : '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex pattern for node output references: `$nodeId.output` or `$nodeId.output.field`.
|
||||
*/
|
||||
const NODE_OUTPUT_REF_REGEX =
|
||||
/\$([a-zA-Z_][\w-]*)\.output(?:\.([a-zA-Z_][\w]*))?/g;
|
||||
|
||||
/**
|
||||
* Substitute node output references in a prompt string.
|
||||
*
|
||||
* Resolves `$nodeId.output` → full text output, and
|
||||
* `$nodeId.output.field` → specific structured field.
|
||||
*
|
||||
* @param prompt - The prompt template containing references.
|
||||
* @param nodeOutputs - Map of node id → NodeOutput.
|
||||
* @param escapedForBash - If true, escapes special bash characters in the output.
|
||||
*/
|
||||
export function substituteNodeOutputRefs(
|
||||
prompt: string,
|
||||
nodeOutputs: Map<string, NodeOutput>,
|
||||
escapedForBash = false,
|
||||
): string {
|
||||
return prompt.replace(NODE_OUTPUT_REF_REGEX, (_match, nodeId: string, field?: string) => {
|
||||
const output = nodeOutputs.get(nodeId);
|
||||
if (!output) {
|
||||
throw new OutputRefError(
|
||||
`Node output reference $${nodeId}.output not found. ` +
|
||||
`Available nodes: ${[...nodeOutputs.keys()].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
let value: string;
|
||||
if (field) {
|
||||
value = resolveNodeOutputField(output, field);
|
||||
} else {
|
||||
value = output.text ?? '';
|
||||
}
|
||||
|
||||
if (escapedForBash) {
|
||||
value = value.replace(/(["'$`\\!])/g, '\\$1');
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a specific field from a node's structured output.
|
||||
*
|
||||
* @throws OutputRefError if the field doesn't exist or output has no fields.
|
||||
*/
|
||||
export function resolveNodeOutputField(output: NodeOutput, field: string): string {
|
||||
if (!output.fields || !(field in output.fields)) {
|
||||
throw new OutputRefError(
|
||||
`Node ${output.nodeId} output does not have field "${field}". ` +
|
||||
`Available fields: ${output.fields ? Object.keys(output.fields).join(', ') : '(none)'}`,
|
||||
);
|
||||
}
|
||||
const value = output.fields[field];
|
||||
return value != null ? String(value) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete prompt string with context injection.
|
||||
*
|
||||
* Applies workflow variable substitution and node output reference
|
||||
* substitution to produce the final prompt sent to the AI provider.
|
||||
*/
|
||||
export function buildPromptWithContext(
|
||||
prompt: string,
|
||||
variables: Record<string, unknown>,
|
||||
nodeOutputs: Map<string, NodeOutput>,
|
||||
escapedForBash = false,
|
||||
): string {
|
||||
let result = substituteWorkflowVariables(prompt, variables);
|
||||
result = substituteNodeOutputRefs(result, nodeOutputs, escapedForBash);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Condition evaluation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate a condition expression against the current workflow context.
|
||||
*
|
||||
* Supports simple expressions:
|
||||
* - Truthy/falsy string check (empty string = false)
|
||||
* - Comparison: `==`, `!=`, `>`, `<`, `>=`, `<=`
|
||||
* - Boolean literals: `true`, `false`
|
||||
* - Variable references: `${var}` resolved before evaluation
|
||||
*
|
||||
* @param condition - The condition string to evaluate.
|
||||
* @param variables - Workflow variables for substitution.
|
||||
* @returns Whether the condition is truthy.
|
||||
*/
|
||||
export function evaluateCondition(
|
||||
condition: string | undefined,
|
||||
variables: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (condition === undefined || condition === '') return true;
|
||||
|
||||
// Substitute variables first
|
||||
const resolved = substituteWorkflowVariables(condition, variables).trim();
|
||||
|
||||
// Boolean literals
|
||||
if (resolved === 'true') return true;
|
||||
if (resolved === 'false') return false;
|
||||
|
||||
// Empty after substitution = falsy
|
||||
if (resolved === '') return false;
|
||||
|
||||
// Comparison operators
|
||||
const comparisonMatch = resolved.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/);
|
||||
if (comparisonMatch) {
|
||||
const [, left, op, right] = comparisonMatch;
|
||||
const leftVal = left!.trim();
|
||||
const rightVal = right!.trim();
|
||||
|
||||
switch (op) {
|
||||
case '==':
|
||||
return leftVal === rightVal;
|
||||
case '!=':
|
||||
return leftVal !== rightVal;
|
||||
case '>=':
|
||||
return parseFloat(leftVal) >= parseFloat(rightVal);
|
||||
case '<=':
|
||||
return parseFloat(leftVal) <= parseFloat(rightVal);
|
||||
case '>':
|
||||
return parseFloat(leftVal) > parseFloat(rightVal);
|
||||
case '<':
|
||||
return parseFloat(leftVal) < parseFloat(rightVal);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: non-empty string is truthy
|
||||
return resolved.length > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Error categories for classification. */
|
||||
export type ErrorCategory = 'transient' | 'permanent' | 'timeout' | 'rate_limit' | 'unknown';
|
||||
|
||||
/**
|
||||
* Classify an error into a category for retry decisions.
|
||||
*/
|
||||
export function classifyError(error: unknown): ErrorCategory {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
|
||||
// Timeout errors
|
||||
if (
|
||||
msg.includes('timeout') ||
|
||||
msg.includes('timed out') ||
|
||||
msg.includes('aborted') ||
|
||||
error.name === 'AbortError' ||
|
||||
error.name === 'TimeoutError'
|
||||
) {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (
|
||||
msg.includes('rate limit') ||
|
||||
msg.includes('429') ||
|
||||
msg.includes('too many requests')
|
||||
) {
|
||||
return 'rate_limit';
|
||||
}
|
||||
|
||||
// Permanent errors
|
||||
if (
|
||||
msg.includes('authentication') ||
|
||||
msg.includes('unauthorized') ||
|
||||
msg.includes('forbidden') ||
|
||||
msg.includes('401') ||
|
||||
msg.includes('403') ||
|
||||
msg.includes('invalid api key') ||
|
||||
msg.includes('permission denied')
|
||||
) {
|
||||
return 'permanent';
|
||||
}
|
||||
|
||||
// Transient errors
|
||||
if (
|
||||
msg.includes('network') ||
|
||||
msg.includes('econnreset') ||
|
||||
msg.includes('econnrefused') ||
|
||||
msg.includes('enotfound') ||
|
||||
msg.includes('socket hang up') ||
|
||||
msg.includes('500') ||
|
||||
msg.includes('502') ||
|
||||
msg.includes('503') ||
|
||||
msg.includes('504') ||
|
||||
msg.includes('internal server error') ||
|
||||
msg.includes('bad gateway') ||
|
||||
msg.includes('service unavailable') ||
|
||||
msg.includes('gateway timeout')
|
||||
) {
|
||||
return 'transient';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe messaging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Safely send a message to the platform, swallowing errors.
|
||||
*
|
||||
* Used for non-critical notifications where failure should not
|
||||
* abort the workflow.
|
||||
*/
|
||||
export async function safeSendMessage(
|
||||
platform: { sendMessage: (convId: string, msg: string, meta?: Record<string, unknown>) => Promise<void> },
|
||||
conversationId: string,
|
||||
message: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await platform.sendMessage(conversationId, message, metadata);
|
||||
} catch {
|
||||
// Swallow — this is a best-effort notification
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Thrown when a node output reference cannot be resolved. */
|
||||
export class OutputRefError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'OutputRefError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a cycle is detected in the DAG. */
|
||||
export class DagCycleError extends Error {
|
||||
constructor(nodeCount: number, layerSum: number) {
|
||||
super(
|
||||
`Cycle detected in DAG: ${nodeCount} nodes but only ${layerSum} reachable via topological sort`,
|
||||
);
|
||||
this.name = 'DagCycleError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a node execution times out. */
|
||||
export class NodeTimeoutError extends Error {
|
||||
constructor(nodeId: string, timeoutMs: number) {
|
||||
super(`Node "${nodeId}" timed out after ${timeoutMs}ms`);
|
||||
this.name = 'NodeTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when an approval is rejected. */
|
||||
export class ApprovalRejectedError extends Error {
|
||||
constructor(nodeId: string, reason?: string) {
|
||||
super(`Approval rejected for node "${nodeId}"${reason ? `: ${reason}` : ''}`);
|
||||
this.name = 'ApprovalRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a loop exceeds its maximum iterations. */
|
||||
export class LoopMaxIterationsError extends Error {
|
||||
constructor(nodeId: string, iterations: number) {
|
||||
super(`Loop node "${nodeId}" exceeded max iterations (${iterations})`);
|
||||
this.name = 'LoopMaxIterationsError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subprocess formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a subprocess failure into a human-readable error message.
|
||||
*/
|
||||
export function formatSubprocessFailure(
|
||||
command: string,
|
||||
exitCode: number | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`Command failed: ${command}`);
|
||||
if (exitCode != null) parts.push(`Exit code: ${exitCode}`);
|
||||
if (stderr.trim()) parts.push(`stderr: ${stderr.trim()}`);
|
||||
if (stdout.trim()) parts.push(`stdout: ${stdout.trim()}`);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Misc helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sleep for a given number of milliseconds.
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff.
|
||||
*
|
||||
* @param fn - The function to retry.
|
||||
* @param maxAttempts - Maximum number of attempts.
|
||||
* @param baseDelayMs - Base delay between retries in ms.
|
||||
* @param shouldRetry - Optional predicate to decide if a retry is warranted.
|
||||
*/
|
||||
export async function retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxAttempts: number,
|
||||
baseDelayMs = 1000,
|
||||
shouldRetry?: (error: unknown) => boolean,
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (shouldRetry && !shouldRetry(error)) throw error;
|
||||
if (attempt < maxAttempts) {
|
||||
const delay = baseDelayMs * Math.pow(2, attempt - 1);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
22
packages/ion/src/format/index.ts
Normal file
22
packages/ion/src/format/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Ion workflow engine — format module.
|
||||
*
|
||||
* Re-exports the SOP markdown parser, YAML converter, and file discovery
|
||||
* utilities so consumers can import from a single entry point:
|
||||
*
|
||||
* ```ts
|
||||
* import { parseSopContent, convertSopToWorkflowYaml, discoverSopFiles } from '@boocode/ion/format';
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
parseSopContent,
|
||||
type SopDocument,
|
||||
type SopParameter,
|
||||
type SopStep,
|
||||
} from './sop-parser.js';
|
||||
|
||||
export { convertSopToWorkflowYaml } from './sop-to-yaml.js';
|
||||
|
||||
export { discoverSopFiles } from './sop-discovery.js';
|
||||
export type { GlobFn } from './sop-discovery.js';
|
||||
78
packages/ion/src/format/sop-discovery.ts
Normal file
78
packages/ion/src/format/sop-discovery.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* SOP file discovery for the Ion workflow engine.
|
||||
*
|
||||
* Locates `.sop.md` files by delegating file-system traversal to a
|
||||
* caller-provided glob function. This keeps the module pure (no Node
|
||||
* dependency) and easily testable.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A function that resolves a glob pattern to an array of absolute paths.
|
||||
*
|
||||
* The caller typically provides an implementation backed by `node:fs/promises`
|
||||
* or a test double.
|
||||
*/
|
||||
export type GlobFn = (pattern: string) => Promise<string[]>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default search directories (in priority order, relative to cwd). */
|
||||
const SEARCH_DIRS = ['.archon/workflows', '.'];
|
||||
|
||||
/** Glob pattern for SOP markdown files. */
|
||||
const SOP_GLOB = '**/*.sop.md';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all `.sop.md` files in the given working directory.
|
||||
*
|
||||
* Searches `.archon/workflows/` first, then the project root, and returns
|
||||
* absolute paths to every matching file. Duplicate paths are de-duplicated.
|
||||
*
|
||||
* @param cwd - The working directory to search from.
|
||||
* @param globFn - A function that resolves a glob pattern to file paths.
|
||||
* Typically backed by `glob` from `node:fs/promises` or a
|
||||
* similar library.
|
||||
* @returns An array of absolute file paths to discovered `.sop.md` files,
|
||||
* sorted deterministically.
|
||||
*/
|
||||
export async function discoverSopFiles(
|
||||
cwd: string,
|
||||
globFn: GlobFn,
|
||||
): Promise<string[]> {
|
||||
const seen = new Set<string>();
|
||||
const results: string[] = [];
|
||||
|
||||
for (const dir of SEARCH_DIRS) {
|
||||
const pattern =
|
||||
dir === '.' ? `${cwd}/${SOP_GLOB}` : `${cwd}/${dir}/${SOP_GLOB}`;
|
||||
|
||||
let paths: string[];
|
||||
try {
|
||||
paths = await globFn(pattern);
|
||||
} catch {
|
||||
// Glob errors (e.g. directory doesn't exist) are non-fatal.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort for deterministic output and de-duplicate
|
||||
paths.sort();
|
||||
for (const p of paths) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
results.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
203
packages/ion/src/format/sop-parser.ts
Normal file
203
packages/ion/src/format/sop-parser.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* SOP Markdown parser for the Ion workflow engine.
|
||||
*
|
||||
* Parses `.sop.md` files (Agent SOP format) into structured `SopDocument`
|
||||
* objects that can be converted to YAML workflow definitions.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single parameter declared in the SOP's Parameters section. */
|
||||
export interface SopParameter {
|
||||
/** Parameter name (camelCase by convention). */
|
||||
name: string;
|
||||
/** Whether the parameter is required or optional. */
|
||||
type: 'required' | 'optional';
|
||||
/** Default value (only present when type is 'optional'). */
|
||||
default?: string;
|
||||
/** Human-readable description of the parameter. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** A single step declared in the SOP's Steps section. */
|
||||
export interface SopStep {
|
||||
/** Step number (1-based). */
|
||||
number: number;
|
||||
/** Short human-readable step name. */
|
||||
name: string;
|
||||
/** Full body text of the step (may be multi-line). */
|
||||
body: string;
|
||||
/** Constraints text extracted from the step, if any. */
|
||||
constraints?: string;
|
||||
}
|
||||
|
||||
/** The fully-parsed SOP document. */
|
||||
export interface SopDocument {
|
||||
/** Title extracted from the first `# heading`. */
|
||||
title: string;
|
||||
/** Overview section content. */
|
||||
overview: string;
|
||||
/** Parsed parameters (empty array if section absent). */
|
||||
parameters: SopParameter[];
|
||||
/** Parsed steps (empty array if section absent). */
|
||||
steps: SopStep[];
|
||||
/** Optional examples section content. */
|
||||
examples?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract a section body from markdown text.
|
||||
*
|
||||
* A section starts with `## <Title>` and ends at the next `## ` or `# `
|
||||
* heading (or end of string).
|
||||
*/
|
||||
function extractSection(markdown: string, heading: string): string | null {
|
||||
const pattern = new RegExp(
|
||||
`^##\\s+${escapeRegex(heading)}\\s*\\n([\\s\\S]*?)(?=\\n##|\\n#|$)`,
|
||||
'm',
|
||||
);
|
||||
const match = markdown.match(pattern);
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
/** Escape special regex characters in a literal string. */
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parse the Parameters section into structured `SopParameter` objects. */
|
||||
function parseParameters(raw: string): SopParameter[] {
|
||||
const parameters: SopParameter[] = [];
|
||||
// Match lines like: - **paramName** (required): Description here
|
||||
// - **paramName** (optional, default: value): Description here
|
||||
const paramRegex =
|
||||
/^-\s+\*\*(\w+)\*\*\s+\((required|optional)(?:,\s*default:\s*([^)]+))?\):\s+(.+)$/gm;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = paramRegex.exec(raw)) !== null) {
|
||||
const name = match[1]!;
|
||||
const type = match[2]! as 'required' | 'optional';
|
||||
const defaultVal = match[3]; // may be undefined (optional group)
|
||||
const description = match[4]!;
|
||||
|
||||
const param: SopParameter = {
|
||||
name,
|
||||
type,
|
||||
description,
|
||||
};
|
||||
if (defaultVal !== undefined) {
|
||||
param.default = defaultVal.trim();
|
||||
}
|
||||
parameters.push(param);
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/** Parse the Steps section into structured `SopStep` objects. */
|
||||
function parseSteps(raw: string): SopStep[] {
|
||||
const steps: SopStep[] = [];
|
||||
|
||||
// Find all ### sub-headings like "### 1. Step Name"
|
||||
const stepHeadingRegex = /^###\s+(\d+)\.\s+(.+)$/gm;
|
||||
|
||||
// Collect heading positions: [startIndex, endIndex, number, name]
|
||||
const headings: { number: number; name: string; start: number; end: number }[] = [];
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = stepHeadingRegex.exec(raw)) !== null) {
|
||||
headings.push({
|
||||
number: parseInt(match[1]!, 10),
|
||||
name: match[2]!.trim(),
|
||||
start: match.index,
|
||||
end: -1, // filled in below
|
||||
});
|
||||
}
|
||||
|
||||
// Set end positions: each heading ends where the next one starts, or at EOF
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const heading = headings[i]!;
|
||||
heading.end =
|
||||
i + 1 < headings.length ? headings[i + 1]!.start : raw.length;
|
||||
}
|
||||
|
||||
for (const heading of headings) {
|
||||
// The body starts after the heading line itself
|
||||
const headingLineEnd = raw.indexOf('\n', heading.start);
|
||||
const bodyStart = headingLineEnd === -1 ? raw.length : headingLineEnd + 1;
|
||||
const sectionText = raw.slice(bodyStart, heading.end).trim();
|
||||
|
||||
// Extract constraints if present
|
||||
const constraintsMatch = sectionText.match(
|
||||
/\*\*Constraints:\*\*\s*\n([\s\S]*?)(?=\n###|\n##|$)/,
|
||||
);
|
||||
const constraints = constraintsMatch?.[1]?.trim();
|
||||
|
||||
// Body is everything before the Constraints heading (or the whole text)
|
||||
let body: string;
|
||||
if (constraintsMatch?.index !== undefined) {
|
||||
body = sectionText.slice(0, constraintsMatch.index).trim();
|
||||
} else {
|
||||
body = sectionText;
|
||||
}
|
||||
|
||||
steps.push({
|
||||
number: heading.number,
|
||||
name: heading.name,
|
||||
body,
|
||||
...(constraints ? { constraints } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a `.sop.md` markdown string into a structured `SopDocument`.
|
||||
*
|
||||
* @param markdown - The raw markdown content of a `.sop.md` file.
|
||||
* @returns A parsed `SopDocument` with title, overview, parameters, steps,
|
||||
* and optional examples.
|
||||
*/
|
||||
export function parseSopContent(markdown: string): SopDocument {
|
||||
// --- Title (first h1) ---
|
||||
const titleMatch = markdown.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch?.[1]?.trim() ?? 'Untitled SOP';
|
||||
|
||||
// --- Overview ---
|
||||
const overviewRaw = extractSection(markdown, 'Overview');
|
||||
const overview = overviewRaw ?? '';
|
||||
|
||||
// --- Parameters ---
|
||||
const parametersRaw = extractSection(markdown, 'Parameters');
|
||||
const parameters = parametersRaw ? parseParameters(parametersRaw) : [];
|
||||
|
||||
// --- Steps ---
|
||||
const stepsRaw = extractSection(markdown, 'Steps');
|
||||
const steps = stepsRaw ? parseSteps(stepsRaw) : [];
|
||||
|
||||
// --- Examples (optional) ---
|
||||
const examplesRaw = extractSection(markdown, 'Examples');
|
||||
|
||||
return {
|
||||
title,
|
||||
overview,
|
||||
parameters,
|
||||
steps,
|
||||
...(examplesRaw !== null ? { examples: examplesRaw } : {}),
|
||||
};
|
||||
}
|
||||
102
packages/ion/src/format/sop-to-yaml.ts
Normal file
102
packages/ion/src/format/sop-to-yaml.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* SOP-to-YAML converter for the Ion workflow engine.
|
||||
*
|
||||
* Converts a parsed `SopDocument` into a YAML workflow definition string
|
||||
* that can be fed back into the Ion YAML loader.
|
||||
*/
|
||||
|
||||
import type { SopDocument } from './sop-parser.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert a title string to kebab-case for use as a YAML identifier. */
|
||||
function toKebabCase(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphen
|
||||
.replace(/^-+|-+$/g, ''); // strip leading/trailing hyphens
|
||||
}
|
||||
|
||||
/**
|
||||
* Indent every line of a multi-line string by the given number of spaces.
|
||||
* Empty lines are preserved without extra indentation.
|
||||
*/
|
||||
function indentBlock(text: string, spaces: number): string {
|
||||
const prefix = ' '.repeat(spaces);
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => (line.length > 0 ? prefix + line : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a parsed `SopDocument` into a YAML workflow definition string.
|
||||
*
|
||||
* The output is valid YAML that can be loaded by the Ion YAML loader.
|
||||
* Steps become sequential prompt nodes with `depends_on` linking.
|
||||
* Constraints are appended to the prompt body as plain text.
|
||||
* Only `prompt:` nodes are emitted — SOP is conversation-only.
|
||||
*
|
||||
* @param sop - The parsed SOP document.
|
||||
* @returns A YAML string representing the workflow.
|
||||
*/
|
||||
export function convertSopToWorkflowYaml(sop: SopDocument): string {
|
||||
const name = toKebabCase(sop.title);
|
||||
const lines: string[] = [];
|
||||
|
||||
// --- Header comment with parameter info ---
|
||||
if (sop.parameters.length > 0) {
|
||||
lines.push('# Parameters:');
|
||||
for (const param of sop.parameters) {
|
||||
const tag = param.type === 'required' ? 'required' : 'optional';
|
||||
const defaultPart = param.default ? `, default: ${param.default}` : '';
|
||||
lines.push(`# ${param.name} (${tag}${defaultPart}): ${param.description}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- Top-level fields ---
|
||||
lines.push(`name: ${name}`);
|
||||
lines.push(`description: |`);
|
||||
lines.push(indentBlock(sop.overview || 'No description provided.', 2));
|
||||
lines.push('');
|
||||
|
||||
// --- Nodes ---
|
||||
lines.push('nodes:');
|
||||
|
||||
for (let i = 0; i < sop.steps.length; i++) {
|
||||
const step = sop.steps[i]!;
|
||||
const stepId = `step_${step.number}`;
|
||||
const isFirst = i === 0;
|
||||
|
||||
// Build the prompt body: step body + constraints (if any)
|
||||
let promptBody = step.body;
|
||||
if (step.constraints) {
|
||||
promptBody += `\n\nConstraints:\n${step.constraints}`;
|
||||
}
|
||||
|
||||
lines.push(` - id: ${stepId}`);
|
||||
lines.push(` prompt: |`);
|
||||
lines.push(indentBlock(promptBody, 6));
|
||||
|
||||
if (isFirst) {
|
||||
lines.push(` depends_on: []`);
|
||||
} else {
|
||||
const prevStep = sop.steps[i - 1]!;
|
||||
lines.push(` depends_on: [step_${prevStep.number}]`);
|
||||
}
|
||||
|
||||
// Blank line between nodes (but not after the last one)
|
||||
if (i < sop.steps.length - 1) {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
152
packages/ion/src/index.ts
Normal file
152
packages/ion/src/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Schema layer — types and validation
|
||||
export type {
|
||||
StepRetryConfig,
|
||||
LoopNodeConfig,
|
||||
TriggerRule,
|
||||
EffortLevel,
|
||||
ThinkingConfig,
|
||||
ApprovalOnReject,
|
||||
DagNodeBase,
|
||||
CommandNode,
|
||||
PromptNode,
|
||||
BashNode,
|
||||
ScriptNode,
|
||||
LoopNode,
|
||||
ApprovalNode,
|
||||
CancelNode,
|
||||
DagNode,
|
||||
ModelReasoningEffort,
|
||||
WebSearchMode,
|
||||
WorkflowRequirement,
|
||||
WorkflowWorktreePolicy,
|
||||
SandboxConfig,
|
||||
ProviderOverrides,
|
||||
WorkflowBase,
|
||||
WorkflowDefinition,
|
||||
WorkflowSource,
|
||||
WorkflowExecutionResult as SchemaWorkflowExecutionResult,
|
||||
WorkflowWithSource,
|
||||
WorkflowLoadError,
|
||||
WorkflowLoadResult,
|
||||
LoadCommandResult,
|
||||
WorkflowRunStatus,
|
||||
NodeState,
|
||||
NodeOutput,
|
||||
ApprovalContext,
|
||||
WorkflowRun,
|
||||
NodeExecutionResult,
|
||||
} from './schema/index.js';
|
||||
|
||||
export {
|
||||
stepRetryConfigSchema,
|
||||
loopNodeConfigSchema,
|
||||
triggerRuleSchema,
|
||||
TRIGGER_RULES,
|
||||
DEFAULT_TRIGGER_RULE,
|
||||
effortLevelSchema,
|
||||
thinkingConfigSchema,
|
||||
approvalOnRejectSchema,
|
||||
dagNodeBaseSchema,
|
||||
commandNodeSchema,
|
||||
promptNodeSchema,
|
||||
bashNodeSchema,
|
||||
scriptNodeSchema,
|
||||
loopNodeSchema,
|
||||
approvalNodeSchema,
|
||||
cancelNodeSchema,
|
||||
dagNodeSchema,
|
||||
isBashNode,
|
||||
isLoopNode,
|
||||
isApprovalNode,
|
||||
isCancelNode,
|
||||
isScriptNode,
|
||||
isPromptNode,
|
||||
isCommandNode,
|
||||
modelReasoningEffortSchema,
|
||||
webSearchModeSchema,
|
||||
workflowRequirementSchema,
|
||||
workflowWorktreePolicySchema,
|
||||
sandboxConfigSchema,
|
||||
providerOverridesSchema,
|
||||
workflowBaseSchema,
|
||||
workflowDefinitionSchema,
|
||||
WorkflowSourceSchema,
|
||||
workflowExecutionResultSchema,
|
||||
workflowWithSourceSchema,
|
||||
workflowLoadErrorSchema,
|
||||
workflowLoadResultSchema,
|
||||
loadCommandResultSchema,
|
||||
WorkflowRunStatusSchema,
|
||||
TERMINAL_WORKFLOW_STATUSES,
|
||||
RESUMABLE_WORKFLOW_STATUSES,
|
||||
NodeStateSchema,
|
||||
ApprovalContextSchema,
|
||||
WorkflowRunSchema,
|
||||
nodeOutputSchema,
|
||||
} from './schema/index.js';
|
||||
|
||||
// Engine — core execution logic
|
||||
export type {
|
||||
IWorkflowPlatform,
|
||||
IWorkflowStore,
|
||||
IAgentProvider,
|
||||
WorkflowDeps,
|
||||
WorkflowConfig,
|
||||
ProviderConfig,
|
||||
CommandFolderConfig,
|
||||
CreateWorkflowRunData,
|
||||
WorkflowEvent,
|
||||
DagWorkflowResult,
|
||||
WorkflowExecutionOptions,
|
||||
HydratedResumableRun,
|
||||
ProjectPaths,
|
||||
ErrorCategory,
|
||||
} from './engine/index.js';
|
||||
|
||||
export {
|
||||
buildTopologicalLayers,
|
||||
checkTriggerRule,
|
||||
executeNodeInternal,
|
||||
executeScriptNode,
|
||||
handleApprovalNode,
|
||||
handleLoopNode,
|
||||
executeDagWorkflow,
|
||||
executeWorkflow,
|
||||
hydrateResumableRun,
|
||||
resolveProjectPaths,
|
||||
substituteWorkflowVariables,
|
||||
substituteNodeOutputRefs,
|
||||
buildPromptWithContext,
|
||||
evaluateCondition,
|
||||
classifyError,
|
||||
safeSendMessage,
|
||||
formatSubprocessFailure,
|
||||
resolveNodeOutputField,
|
||||
OutputRefError,
|
||||
DagCycleError,
|
||||
NodeTimeoutError,
|
||||
ApprovalRejectedError,
|
||||
LoopMaxIterationsError,
|
||||
} from './engine/index.js';
|
||||
|
||||
// Storage backends
|
||||
export {
|
||||
createFsStore,
|
||||
createSqliteStore,
|
||||
createPostgresStore,
|
||||
} from './store/index.js';
|
||||
export type {
|
||||
IWorkflowStore as StoreIWorkflowStore,
|
||||
} from './store/index.js';
|
||||
|
||||
// Format — markdown transpiler
|
||||
export {
|
||||
parseSopContent,
|
||||
convertSopToWorkflowYaml,
|
||||
discoverSopFiles,
|
||||
} from './format/index.js';
|
||||
export type {
|
||||
SopDocument,
|
||||
SopParameter,
|
||||
SopStep,
|
||||
} from './format/index.js';
|
||||
377
packages/ion/src/schema/__tests__/dag-node.test.ts
Normal file
377
packages/ion/src/schema/__tests__/dag-node.test.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
dagNodeSchema,
|
||||
promptNodeSchema,
|
||||
commandNodeSchema,
|
||||
bashNodeSchema,
|
||||
scriptNodeSchema,
|
||||
loopNodeSchema,
|
||||
approvalNodeSchema,
|
||||
cancelNodeSchema,
|
||||
loopNodeConfigSchema,
|
||||
stepRetryConfigSchema,
|
||||
triggerRuleSchema,
|
||||
isBashNode,
|
||||
isLoopNode,
|
||||
isApprovalNode,
|
||||
isCancelNode,
|
||||
isScriptNode,
|
||||
isPromptNode,
|
||||
isCommandNode,
|
||||
effortLevelSchema,
|
||||
thinkingConfigSchema,
|
||||
approvalOnRejectSchema,
|
||||
dagNodeBaseSchema,
|
||||
} from '../index.js';
|
||||
import type {
|
||||
DagNode,
|
||||
PromptNode,
|
||||
CommandNode,
|
||||
BashNode,
|
||||
ScriptNode,
|
||||
LoopNode,
|
||||
ApprovalNode,
|
||||
CancelNode,
|
||||
} from '../index.js';
|
||||
|
||||
const validBase = {
|
||||
id: 'test-node',
|
||||
depends_on: [],
|
||||
};
|
||||
|
||||
describe('dagNodeSchema', () => {
|
||||
it('validates a prompt node', () => {
|
||||
const node = { ...validBase, kind: 'prompt' as const, prompt: 'Do the thing' };
|
||||
const result = dagNodeSchema.safeParse(node);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates a command node', () => {
|
||||
const node = { ...validBase, kind: 'command' as const, command: 'echo hello' };
|
||||
const result = dagNodeSchema.safeParse(node);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates a bash node', () => {
|
||||
const node = { ...validBase, kind: 'bash' as const, bash: 'echo hello' };
|
||||
const result = dagNodeSchema.safeParse(node);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates a script node', () => {
|
||||
const node = { ...validBase, kind: 'script' as const, script: 'print(1)', runtime: 'bun' as const };
|
||||
const result = dagNodeSchema.safeParse(node);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates a loop node', () => {
|
||||
const node = {
|
||||
...validBase,
|
||||
kind: 'loop' as const,
|
||||
config: { prompt: 'iterate', until: 'done', max_iterations: 5 },
|
||||
};
|
||||
const result = dagNodeSchema.safeParse(node);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates an approval node', () => {
|
||||
const node = { ...validBase, kind: 'approval' as const, message: 'Approve this?' };
|
||||
const result = dagNodeSchema.safeParse(node);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates a cancel node', () => {
|
||||
const node = { ...validBase, kind: 'cancel' as const };
|
||||
const result = dagNodeSchema.safeParse(node);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a node with no kind field', () => {
|
||||
const result = dagNodeSchema.safeParse({ id: 'x', prompt: 'y' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a node with an invalid kind', () => {
|
||||
const result = dagNodeSchema.safeParse({ ...validBase, kind: 'unknown' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node type unique fields', () => {
|
||||
it('prompt node requires prompt or command_file', () => {
|
||||
const result = promptNodeSchema.safeParse({ ...validBase, kind: 'prompt' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('command node requires command', () => {
|
||||
const result = commandNodeSchema.safeParse({ ...validBase, kind: 'command' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('bash node requires bash', () => {
|
||||
const result = bashNodeSchema.safeParse({ ...validBase, kind: 'bash' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('script node requires script and runtime', () => {
|
||||
const noScript = scriptNodeSchema.safeParse({ ...validBase, kind: 'script', runtime: 'bun' });
|
||||
expect(noScript.success).toBe(false);
|
||||
|
||||
const noRuntime = scriptNodeSchema.safeParse({ ...validBase, kind: 'script', script: 'print(1)' });
|
||||
expect(noRuntime.success).toBe(false);
|
||||
|
||||
const valid = scriptNodeSchema.safeParse({
|
||||
...validBase,
|
||||
kind: 'script',
|
||||
script: 'print(1)',
|
||||
runtime: 'bun',
|
||||
});
|
||||
expect(valid.success).toBe(true);
|
||||
});
|
||||
|
||||
it('loop node requires config', () => {
|
||||
const result = loopNodeSchema.safeParse({ ...validBase, kind: 'loop' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('approval node requires message', () => {
|
||||
const result = approvalNodeSchema.safeParse({ ...validBase, kind: 'approval' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('cancel node does not require reason', () => {
|
||||
const result = cancelNodeSchema.safeParse({ ...validBase, kind: 'cancel' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loopNodeConfigSchema', () => {
|
||||
it('validates a minimal config', () => {
|
||||
const result = loopNodeConfigSchema.safeParse({
|
||||
prompt: 'iterate',
|
||||
until: 'done',
|
||||
max_iterations: 5,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty prompt', () => {
|
||||
const result = loopNodeConfigSchema.safeParse({
|
||||
prompt: '',
|
||||
until: 'done',
|
||||
max_iterations: 5,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty until', () => {
|
||||
const result = loopNodeConfigSchema.safeParse({
|
||||
prompt: 'iterate',
|
||||
until: '',
|
||||
max_iterations: 5,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-positive max_iterations', () => {
|
||||
const zeroResult = loopNodeConfigSchema.safeParse({
|
||||
prompt: 'iterate',
|
||||
until: 'done',
|
||||
max_iterations: 0,
|
||||
});
|
||||
expect(zeroResult.success).toBe(false);
|
||||
|
||||
const negResult = loopNodeConfigSchema.safeParse({
|
||||
prompt: 'iterate',
|
||||
until: 'done',
|
||||
max_iterations: -1,
|
||||
});
|
||||
expect(negResult.success).toBe(false);
|
||||
});
|
||||
|
||||
it('requires gate_message when interactive is true', () => {
|
||||
const noGate = loopNodeConfigSchema.safeParse({
|
||||
prompt: 'iterate',
|
||||
until: 'done',
|
||||
max_iterations: 5,
|
||||
interactive: true,
|
||||
});
|
||||
expect(noGate.success).toBe(false);
|
||||
|
||||
const withGate = loopNodeConfigSchema.safeParse({
|
||||
prompt: 'iterate',
|
||||
until: 'done',
|
||||
max_iterations: 5,
|
||||
interactive: true,
|
||||
gate_message: 'Approve this iteration?',
|
||||
});
|
||||
expect(withGate.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows interactive=false without gate_message', () => {
|
||||
const result = loopNodeConfigSchema.safeParse({
|
||||
prompt: 'iterate',
|
||||
until: 'done',
|
||||
max_iterations: 5,
|
||||
interactive: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepRetryConfigSchema', () => {
|
||||
it('validates a minimal retry config', () => {
|
||||
const result = stepRetryConfigSchema.safeParse({ max_attempts: 3 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects max_attempts below 1', () => {
|
||||
const result = stepRetryConfigSchema.safeParse({ max_attempts: 0 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects max_attempts above 5', () => {
|
||||
const result = stepRetryConfigSchema.safeParse({ max_attempts: 6 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts max_attempts at boundaries (1 and 5)', () => {
|
||||
expect(stepRetryConfigSchema.safeParse({ max_attempts: 1 }).success).toBe(true);
|
||||
expect(stepRetryConfigSchema.safeParse({ max_attempts: 5 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects delay_ms below 1000', () => {
|
||||
const result = stepRetryConfigSchema.safeParse({ max_attempts: 2, delay_ms: 500 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects delay_ms above 60000', () => {
|
||||
const result = stepRetryConfigSchema.safeParse({ max_attempts: 2, delay_ms: 70000 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts delay_ms at boundaries (1000 and 60000)', () => {
|
||||
expect(stepRetryConfigSchema.safeParse({ max_attempts: 2, delay_ms: 1000 }).success).toBe(true);
|
||||
expect(stepRetryConfigSchema.safeParse({ max_attempts: 2, delay_ms: 60000 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults on_error to transient', () => {
|
||||
const result = stepRetryConfigSchema.parse({ max_attempts: 2 });
|
||||
expect(result.on_error).toBe('transient');
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerRuleSchema', () => {
|
||||
it('validates all trigger rules', () => {
|
||||
const rules = ['all_success', 'one_success', 'all_done', 'none_failed_min_one_success'];
|
||||
for (const rule of rules) {
|
||||
expect(triggerRuleSchema.safeParse(rule).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid trigger rules', () => {
|
||||
const result = triggerRuleSchema.safeParse('invalid_rule');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type guards', () => {
|
||||
const nodes: DagNode[] = [
|
||||
{ ...validBase, kind: 'prompt', prompt: 'test' } as PromptNode,
|
||||
{ ...validBase, kind: 'command', command: 'echo' } as CommandNode,
|
||||
{ ...validBase, kind: 'bash', bash: 'echo' } as BashNode,
|
||||
{ ...validBase, kind: 'script', script: 'print(1)', runtime: 'bun' } as ScriptNode,
|
||||
{ ...validBase, kind: 'loop', config: { prompt: 'x', until: 'y', max_iterations: 3 } } as LoopNode,
|
||||
{ ...validBase, kind: 'approval', message: 'ok?' } as ApprovalNode,
|
||||
{ ...validBase, kind: 'cancel' } as CancelNode,
|
||||
];
|
||||
|
||||
it('isPromptNode identifies prompt nodes', () => {
|
||||
expect(isPromptNode(nodes[0])).toBe(true);
|
||||
expect(isPromptNode(nodes[1])).toBe(false);
|
||||
});
|
||||
|
||||
it('isCommandNode identifies command nodes', () => {
|
||||
expect(isCommandNode(nodes[1])).toBe(true);
|
||||
expect(isCommandNode(nodes[0])).toBe(false);
|
||||
});
|
||||
|
||||
it('isBashNode identifies bash nodes', () => {
|
||||
expect(isBashNode(nodes[2])).toBe(true);
|
||||
expect(isBashNode(nodes[0])).toBe(false);
|
||||
});
|
||||
|
||||
it('isScriptNode identifies script nodes', () => {
|
||||
expect(isScriptNode(nodes[3])).toBe(true);
|
||||
expect(isScriptNode(nodes[0])).toBe(false);
|
||||
});
|
||||
|
||||
it('isLoopNode identifies loop nodes', () => {
|
||||
expect(isLoopNode(nodes[4])).toBe(true);
|
||||
expect(isLoopNode(nodes[0])).toBe(false);
|
||||
});
|
||||
|
||||
it('isApprovalNode identifies approval nodes', () => {
|
||||
expect(isApprovalNode(nodes[5])).toBe(true);
|
||||
expect(isApprovalNode(nodes[0])).toBe(false);
|
||||
});
|
||||
|
||||
it('isCancelNode identifies cancel nodes', () => {
|
||||
expect(isCancelNode(nodes[6])).toBe(true);
|
||||
expect(isCancelNode(nodes[0])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effortLevelSchema', () => {
|
||||
it('validates all effort levels', () => {
|
||||
expect(effortLevelSchema.safeParse('low').success).toBe(true);
|
||||
expect(effortLevelSchema.safeParse('medium').success).toBe(true);
|
||||
expect(effortLevelSchema.safeParse('high').success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid effort levels', () => {
|
||||
expect(effortLevelSchema.safeParse('extreme').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thinkingConfigSchema', () => {
|
||||
it('validates a minimal thinking config', () => {
|
||||
const result = thinkingConfigSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.enabled).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates a full thinking config', () => {
|
||||
const result = thinkingConfigSchema.safeParse({ enabled: true, max_tokens: 4096 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approvalOnRejectSchema', () => {
|
||||
it('validates all on-reject actions', () => {
|
||||
expect(approvalOnRejectSchema.safeParse('retry').success).toBe(true);
|
||||
expect(approvalOnRejectSchema.safeParse('fail').success).toBe(true);
|
||||
expect(approvalOnRejectSchema.safeParse('skip').success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid on-reject actions', () => {
|
||||
expect(approvalOnRejectSchema.safeParse('restart').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dagNodeBaseSchema', () => {
|
||||
it('validates a minimal base node', () => {
|
||||
const result = dagNodeBaseSchema.safeParse({
|
||||
id: 'node-1',
|
||||
kind: 'prompt',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults depends_on to empty array', () => {
|
||||
const result = dagNodeBaseSchema.parse({ id: 'node-1', kind: 'prompt' });
|
||||
expect(result.depends_on).toEqual([]);
|
||||
});
|
||||
});
|
||||
273
packages/ion/src/schema/dag-node.ts
Normal file
273
packages/ion/src/schema/dag-node.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { z } from 'zod';
|
||||
import { stepRetryConfigSchema } from './retry.js';
|
||||
import { loopNodeConfigSchema } from './loop.js';
|
||||
import { triggerRuleSchema } from './trigger-rule.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effort level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Effort level for AI model calls. */
|
||||
export const effortLevelSchema = z.enum(['low', 'medium', 'high']);
|
||||
|
||||
export type EffortLevel = z.infer<typeof effortLevelSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thinking configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Configuration for extended thinking / chain-of-thought. */
|
||||
export const thinkingConfigSchema = z.object({
|
||||
/** Whether thinking is enabled. */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Maximum thinking tokens. */
|
||||
max_tokens: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export type ThinkingConfig = z.infer<typeof thinkingConfigSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval on-reject action
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** What to do when an approval node is rejected. */
|
||||
export const approvalOnRejectSchema = z.enum(['retry', 'fail', 'skip']);
|
||||
|
||||
export type ApprovalOnReject = z.infer<typeof approvalOnRejectSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base DAG node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The kind of a DAG node determines how it executes. */
|
||||
export const dagNodeKindSchema = z.enum([
|
||||
'prompt',
|
||||
'command',
|
||||
'bash',
|
||||
'script',
|
||||
'approval',
|
||||
'loop',
|
||||
'cancel',
|
||||
]);
|
||||
|
||||
/** Base fields shared by all DAG node types. */
|
||||
export const dagNodeBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: dagNodeKindSchema,
|
||||
name: z.string().optional(),
|
||||
when: z.string().optional(),
|
||||
depends_on: z.array(z.string()).default([]),
|
||||
trigger_rule: triggerRuleSchema.optional(),
|
||||
retry: stepRetryConfigSchema.optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type DagNodeBase = z.infer<typeof dagNodeBaseSchema>;
|
||||
|
||||
export type DagNodeKind = z.infer<typeof dagNodeKindSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt node — sends a prompt to an AI provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const promptNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal('prompt'),
|
||||
/** Human-readable name for display. */
|
||||
name: z.string().optional(),
|
||||
/** The prompt text (inline). Mutually exclusive with command_file. */
|
||||
prompt: z.string().optional(),
|
||||
/** Path to a command file containing the prompt. */
|
||||
command_file: z.string().optional(),
|
||||
/** Provider id to use (overrides workflow default). */
|
||||
provider: z.string().optional(),
|
||||
/** Model override for the provider. */
|
||||
model: z.string().optional(),
|
||||
/** Structured output format definition. */
|
||||
output_format: z
|
||||
.record(z.unknown())
|
||||
.optional(),
|
||||
/** Condition expression; node runs only when truthy. */
|
||||
when: z.string().optional(),
|
||||
/** Node ids this node depends on. */
|
||||
depends_on: z.array(z.string()).default([]),
|
||||
/** Trigger rule for evaluating dependency states. */
|
||||
trigger_rule: triggerRuleSchema.optional(),
|
||||
/** Retry configuration. */
|
||||
retry: stepRetryConfigSchema.optional(),
|
||||
/** Idle timeout in milliseconds. */
|
||||
idle_timeout_ms: z.number().positive().optional(),
|
||||
/** Environment variable overrides for this node. */
|
||||
env: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command node — runs a shell command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const commandNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal('command'),
|
||||
name: z.string().optional(),
|
||||
/** The command string to execute. */
|
||||
command: z.string(),
|
||||
/** Working directory override. */
|
||||
cwd: z.string().optional(),
|
||||
when: z.string().optional(),
|
||||
depends_on: z.array(z.string()).default([]),
|
||||
trigger_rule: triggerRuleSchema.optional(),
|
||||
retry: stepRetryConfigSchema.optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bash node — runs a bash script
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const bashNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal('bash'),
|
||||
name: z.string().optional(),
|
||||
/** Bash script content to execute. */
|
||||
bash: z.string(),
|
||||
/** Timeout in milliseconds. */
|
||||
timeout_ms: z.number().positive().optional(),
|
||||
when: z.string().optional(),
|
||||
depends_on: z.array(z.string()).default([]),
|
||||
trigger_rule: triggerRuleSchema.optional(),
|
||||
retry: stepRetryConfigSchema.optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Script node — runs a script with a specific runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const scriptNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal('script'),
|
||||
name: z.string().optional(),
|
||||
/** Script content to execute. */
|
||||
script: z.string(),
|
||||
/** Runtime: 'bun' or 'uv'. */
|
||||
runtime: z.enum(['bun', 'uv']),
|
||||
/** Dependencies to install before running. */
|
||||
deps: z.array(z.string()).default([]),
|
||||
/** Timeout in milliseconds. */
|
||||
timeout_ms: z.number().positive().optional(),
|
||||
when: z.string().optional(),
|
||||
depends_on: z.array(z.string()).default([]),
|
||||
trigger_rule: triggerRuleSchema.optional(),
|
||||
retry: stepRetryConfigSchema.optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval node — pauses for human approval
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const approvalNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal('approval'),
|
||||
name: z.string().optional(),
|
||||
/** Message shown to the approver. */
|
||||
message: z.string(),
|
||||
/** Prompt to execute if the approval is rejected. */
|
||||
on_reject: z.string().optional(),
|
||||
when: z.string().optional(),
|
||||
depends_on: z.array(z.string()).default([]),
|
||||
trigger_rule: triggerRuleSchema.optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loop node — iterates until a condition is met
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const loopNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal('loop'),
|
||||
name: z.string().optional(),
|
||||
/** Loop configuration (prompt, until, max_iterations, etc.). */
|
||||
config: loopNodeConfigSchema,
|
||||
/** Provider id to use (overrides workflow default). */
|
||||
provider: z.string().optional(),
|
||||
/** Model override for the provider. */
|
||||
model: z.string().optional(),
|
||||
when: z.string().optional(),
|
||||
depends_on: z.array(z.string()).default([]),
|
||||
trigger_rule: triggerRuleSchema.optional(),
|
||||
retry: stepRetryConfigSchema.optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cancel node — cancels the workflow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const cancelNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal('cancel'),
|
||||
name: z.string().optional(),
|
||||
/** Reason for cancellation. */
|
||||
reason: z.string().optional(),
|
||||
when: z.string().optional(),
|
||||
depends_on: z.array(z.string()).default([]),
|
||||
trigger_rule: triggerRuleSchema.optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Union type — any DAG node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const dagNodeSchema = z.discriminatedUnion('kind', [
|
||||
promptNodeSchema,
|
||||
commandNodeSchema,
|
||||
bashNodeSchema,
|
||||
scriptNodeSchema,
|
||||
approvalNodeSchema,
|
||||
loopNodeSchema,
|
||||
cancelNodeSchema,
|
||||
]);
|
||||
|
||||
export type DagNode = z.infer<typeof dagNodeSchema>;
|
||||
export type PromptNode = z.infer<typeof promptNodeSchema>;
|
||||
export type CommandNode = z.infer<typeof commandNodeSchema>;
|
||||
export type BashNode = z.infer<typeof bashNodeSchema>;
|
||||
export type ScriptNode = z.infer<typeof scriptNodeSchema>;
|
||||
export type ApprovalNode = z.infer<typeof approvalNodeSchema>;
|
||||
export type LoopNode = z.infer<typeof loopNodeSchema>;
|
||||
export type CancelNode = z.infer<typeof cancelNodeSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isBashNode(node: DagNode): node is BashNode {
|
||||
return node.kind === 'bash';
|
||||
}
|
||||
|
||||
export function isScriptNode(node: DagNode): node is ScriptNode {
|
||||
return node.kind === 'script';
|
||||
}
|
||||
|
||||
export function isLoopNode(node: DagNode): node is LoopNode {
|
||||
return node.kind === 'loop';
|
||||
}
|
||||
|
||||
export function isApprovalNode(node: DagNode): node is ApprovalNode {
|
||||
return node.kind === 'approval';
|
||||
}
|
||||
|
||||
export function isCancelNode(node: DagNode): node is CancelNode {
|
||||
return node.kind === 'cancel';
|
||||
}
|
||||
|
||||
export function isPromptNode(node: DagNode): node is PromptNode {
|
||||
return node.kind === 'prompt';
|
||||
}
|
||||
|
||||
export function isCommandNode(node: DagNode): node is CommandNode {
|
||||
return node.kind === 'command';
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user