Compare commits
9 Commits
v2.8.2-enh
...
v2.8.10-se
| Author | SHA1 | Date | |
|---|---|---|---|
| 9abc14ef82 | |||
| 7ef479639a | |||
| 89a6ffe8a0 | |||
| a8e475fdf4 | |||
| 02063072ab | |||
| ec48066a80 | |||
| 876c9bcd02 | |||
| c132215064 | |||
| a72f7954b4 |
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 { getPool, closeDb } from './db.js';
|
||||||
import { registerHealthRoutes } from './routes/health.js';
|
import { registerHealthRoutes } from './routes/health.js';
|
||||||
import { registerTerminalRoutes } from './routes/terminals.js';
|
import { registerTerminalRoutes } from './routes/terminals.js';
|
||||||
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
import { registerWsAttachRoute } from './ws/attach.js';
|
import { registerWsAttachRoute } from './ws/attach.js';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -33,6 +34,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
registerHealthRoutes(app);
|
registerHealthRoutes(app);
|
||||||
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
|
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
|
||||||
|
registerSessionRoutes(app);
|
||||||
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
|
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
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';
|
} from '../pty/manager.js';
|
||||||
import { attachPty } from '../pty/pty.js';
|
import { attachPty } from '../pty/pty.js';
|
||||||
import { getUser } from '../auth.js';
|
import { getUser } from '../auth.js';
|
||||||
|
import { register, unregister } from '../pty/registry.js';
|
||||||
|
|
||||||
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
||||||
app.get<{
|
app.get<{
|
||||||
@@ -57,6 +58,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
register(sid, pid, session.project_path);
|
||||||
|
|
||||||
let handle: IPty;
|
let handle: IPty;
|
||||||
try {
|
try {
|
||||||
handle = attachPty({
|
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
|
// teardown happens via the /kill route called from the frontend when the
|
||||||
// user closes the pane.
|
// user closes the pane.
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
|
unregister(pid);
|
||||||
try {
|
try {
|
||||||
handle.kill();
|
handle.kill();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { registerArenaRoutes } from './routes/arena.js';
|
|||||||
import { registerProviderRoutes } from './routes/providers.js';
|
import { registerProviderRoutes } from './routes/providers.js';
|
||||||
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
||||||
import { registerLifecycleRoutes } from './routes/lifecycle.js';
|
import { registerLifecycleRoutes } from './routes/lifecycle.js';
|
||||||
|
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
// Phase 4: dispatcher + agent probe
|
// Phase 4: dispatcher + agent probe
|
||||||
import { createDispatcher } from './services/dispatcher.js';
|
import { createDispatcher } from './services/dispatcher.js';
|
||||||
@@ -382,6 +383,7 @@ async function main() {
|
|||||||
registerProviderRoutes(app, sql, config);
|
registerProviderRoutes(app, sql, config);
|
||||||
registerWorktreeSafetyRoutes(app, sql);
|
registerWorktreeSafetyRoutes(app, sql);
|
||||||
registerLifecycleRoutes(app, sql);
|
registerLifecycleRoutes(app, sql);
|
||||||
|
registerAnalyticsRoutes(app, sql);
|
||||||
registerWebSocket(app, sql, broker);
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|||||||
78
apps/coder/src/routes/analytics.ts
Normal file
78
apps/coder/src/routes/analytics.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
// token-analyzer-ui: aggregate token/cost analytics across all agent_sessions.
|
||||||
|
// v1 — global view only (no per-project or per-user filtering).
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_cost: number;
|
||||||
|
session_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionAnalyticsRow {
|
||||||
|
session_id: string;
|
||||||
|
session_name: string;
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_cost: number;
|
||||||
|
last_active_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBreakdownAgg {
|
||||||
|
category: string;
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/analytics/summary — aggregate totals across all agent_sessions.
|
||||||
|
app.get('/api/analytics/summary', async () => {
|
||||||
|
const [row] = await sql<AnalyticsSummary[]>`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||||
|
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||||
|
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||||
|
COUNT(DISTINCT c.session_id)::INT AS session_count
|
||||||
|
FROM agent_sessions a
|
||||||
|
JOIN chats c ON c.id = a.chat_id
|
||||||
|
`;
|
||||||
|
return row ?? { total_input_tokens: 0, total_output_tokens: 0, total_cost: 0, session_count: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/sessions — per-session token/cost breakdown.
|
||||||
|
app.get('/api/analytics/sessions', async () => {
|
||||||
|
const rows = await sql<SessionAnalyticsRow[]>`
|
||||||
|
SELECT
|
||||||
|
c.session_id AS session_id,
|
||||||
|
s.name AS session_name,
|
||||||
|
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||||
|
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||||
|
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||||
|
MAX(a.last_active_at) AS last_active_at
|
||||||
|
FROM agent_sessions a
|
||||||
|
JOIN chats c ON c.id = a.chat_id
|
||||||
|
JOIN sessions s ON s.id = c.session_id
|
||||||
|
GROUP BY c.session_id, s.name
|
||||||
|
ORDER BY MAX(a.last_active_at) DESC NULLS LAST
|
||||||
|
`;
|
||||||
|
return { sessions: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/token-breakdown — aggregate token_breakdown categories
|
||||||
|
// across all tasks that carry the JSONB field.
|
||||||
|
app.get('/api/analytics/token-breakdown', async () => {
|
||||||
|
const rows = await sql<{ category: string; total_tokens: number }[]>`
|
||||||
|
SELECT
|
||||||
|
key AS category,
|
||||||
|
SUM((value->>0)::BIGINT)::BIGINT AS total_tokens
|
||||||
|
FROM tasks,
|
||||||
|
LATERAL jsonb_each(token_breakdown)
|
||||||
|
WHERE token_breakdown IS NOT NULL
|
||||||
|
AND jsonb_typeof(token_breakdown) = 'object'
|
||||||
|
GROUP BY key
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
`;
|
||||||
|
return { categories: rows };
|
||||||
|
});
|
||||||
|
}
|
||||||
747
apps/coder/src/services/audit-session.ts
Normal file
747
apps/coder/src/services/audit-session.ts
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
import { mkdir, readFile, writeFile, readdir, rm, appendFile } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export const RUNS_REL = '.boo/runs';
|
||||||
|
export const DAILY_REL = '.boo/runs/daily';
|
||||||
|
export const GUIDELINES_REL = '.boo/guidelines';
|
||||||
|
|
||||||
|
export interface SessionJson {
|
||||||
|
session_id: string;
|
||||||
|
task: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time?: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
expected_record_types: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditTrailEntry {
|
||||||
|
timestamp: string;
|
||||||
|
record_type: string;
|
||||||
|
action_type: string;
|
||||||
|
tool?: string;
|
||||||
|
files?: string[];
|
||||||
|
detail?: string;
|
||||||
|
input?: string;
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexEntry {
|
||||||
|
id: string;
|
||||||
|
task: string;
|
||||||
|
status: string;
|
||||||
|
record_count: number;
|
||||||
|
start_time: string;
|
||||||
|
max_anomaly_level?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexJson {
|
||||||
|
entries: IndexEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartSessionResult {
|
||||||
|
sessionId: string;
|
||||||
|
contextSummary: {
|
||||||
|
recentActivity: IndexEntry[];
|
||||||
|
userCorrections: UserCorrectionRecord[];
|
||||||
|
unfinishedSessions: SessionJson[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndSessionResult {
|
||||||
|
sessionId: string;
|
||||||
|
integrity: IntegrityCheck[];
|
||||||
|
correctionCount: number;
|
||||||
|
summaryPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrityCheck {
|
||||||
|
check: string;
|
||||||
|
passed: boolean;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoverResult {
|
||||||
|
level: number;
|
||||||
|
sessionId?: string;
|
||||||
|
task?: string;
|
||||||
|
recentActivity: IndexEntry[];
|
||||||
|
lastTrailEntries: AuditTrailEntry[];
|
||||||
|
userCorrections: UserCorrectionRecord[];
|
||||||
|
conclusions: string[];
|
||||||
|
dailyAnomalies: string[];
|
||||||
|
dailyBacklog: string[];
|
||||||
|
fullTrail?: AuditTrailEntry[];
|
||||||
|
anomalies?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyReport {
|
||||||
|
date: string;
|
||||||
|
sections: {
|
||||||
|
taskOverview: string;
|
||||||
|
operationStats: { label: string; count: number }[];
|
||||||
|
changes: { time: string; target: string; detail: string }[];
|
||||||
|
userFeedback: { feedback: string; resolution: string; persistedTo: string }[];
|
||||||
|
anomalyAlerts: string[];
|
||||||
|
backlogTracking: string[];
|
||||||
|
integritySummary: string;
|
||||||
|
};
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCorrectionRecord {
|
||||||
|
record_type: 'conversation';
|
||||||
|
action_type: 'user_correction';
|
||||||
|
priority: 'critical_for_recovery';
|
||||||
|
timestamp: string;
|
||||||
|
original_claim: string;
|
||||||
|
correction: string;
|
||||||
|
principle_extracted: string;
|
||||||
|
persisted_to: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runsDir(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), RUNS_REL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyDir(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), DAILY_REL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionDir(sessionId: string, basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSessionPath(basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), '.current_session');
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexJsonPath(basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), 'index.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditBufferPath(basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), 'audit_buffer.jsonl');
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditPendingPath(basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), 'audit_pending.jsonl');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trailPath(sessionId: string, basePath?: string): string {
|
||||||
|
return join(sessionDir(sessionId, basePath), 'audit_trail.jsonl');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionJsonPath(sessionId: string, basePath?: string): string {
|
||||||
|
return join(sessionDir(sessionId, basePath), 'session.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryPath(sessionId: string, basePath?: string): string {
|
||||||
|
return join(sessionDir(sessionId, basePath), 'session_summary.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSessionId(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
return `adhoc_${y}${m}${d}_${hh}${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(d?: Date): string {
|
||||||
|
const dt = d ?? new Date();
|
||||||
|
return `${dt.getFullYear()}${String(dt.getMonth() + 1).padStart(2, '0')}${String(dt.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTodayIso(iso: string): boolean {
|
||||||
|
return iso.startsWith(new Date().toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson<T>(raw: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(p: string): Promise<void> {
|
||||||
|
if (!existsSync(p)) {
|
||||||
|
await mkdir(p, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLines(p: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(p, 'utf-8');
|
||||||
|
return content.split('\n').filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile<T>(p: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(p, 'utf-8');
|
||||||
|
return tryParseJson<T>(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLine(p: string, line: string): Promise<void> {
|
||||||
|
return appendFile(p, line + '\n', 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearFile(p: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await writeFile(p, '', 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// File may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentSession(basePath?: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(currentSessionPath(basePath), 'utf-8');
|
||||||
|
return raw.trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionJson(sessionId: string, basePath?: string): Promise<SessionJson | null> {
|
||||||
|
return readJsonFile<SessionJson>(sessionJsonPath(sessionId, basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIndex(basePath?: string): Promise<IndexJson | null> {
|
||||||
|
return readJsonFile<IndexJson>(indexJsonPath(basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIndex(entries: IndexEntry[], basePath?: string): Promise<void> {
|
||||||
|
await ensureDir(runsDir(basePath));
|
||||||
|
await writeFile(indexJsonPath(basePath), JSON.stringify({ entries }, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendIndex(sessionId: string, task: string, basePath?: string): Promise<void> {
|
||||||
|
const existing = await getIndex(basePath);
|
||||||
|
const entry: IndexEntry = {
|
||||||
|
id: sessionId,
|
||||||
|
task,
|
||||||
|
status: 'in_progress',
|
||||||
|
record_count: 0,
|
||||||
|
start_time: isoNow(),
|
||||||
|
};
|
||||||
|
const entries = [entry, ...(existing?.entries ?? [])].slice(0, 100);
|
||||||
|
await writeIndex(entries, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateIndexStatus(sessionId: string, status: string, basePath?: string): Promise<void> {
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
if (!idx) return;
|
||||||
|
for (const e of idx.entries) {
|
||||||
|
if (e.id === sessionId) {
|
||||||
|
e.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeIndex(idx.entries, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSession(task: string, basePath?: string): Promise<StartSessionResult> {
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
const sDir = sessionDir(sessionId, basePath);
|
||||||
|
await ensureDir(sDir);
|
||||||
|
|
||||||
|
const session: SessionJson = {
|
||||||
|
session_id: sessionId,
|
||||||
|
task,
|
||||||
|
start_time: isoNow(),
|
||||||
|
status: 'in_progress',
|
||||||
|
expected_record_types: ['data', 'change', 'conversation'],
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
||||||
|
await writeFile(currentSessionPath(basePath), sessionId, 'utf-8');
|
||||||
|
await appendIndex(sessionId, task, basePath);
|
||||||
|
|
||||||
|
// L0 context recovery
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
const recentActivity = idx?.entries.slice(0, 5) ?? [];
|
||||||
|
|
||||||
|
// L2 user correction scan
|
||||||
|
const allCorrections = await scanAllTrailsForCorrections(basePath);
|
||||||
|
|
||||||
|
// Check for unfinished sessions
|
||||||
|
const unfinishedSessions = await findUnfinishedSessions(basePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
contextSummary: {
|
||||||
|
recentActivity,
|
||||||
|
userCorrections: allCorrections,
|
||||||
|
unfinishedSessions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findUnfinishedSessions(basePath?: string): Promise<SessionJson[]> {
|
||||||
|
const rDir = runsDir(basePath);
|
||||||
|
if (!existsSync(rDir)) return [];
|
||||||
|
|
||||||
|
const entries = await readdir(rDir, { withFileTypes: true });
|
||||||
|
const unfinished: SessionJson[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const sess = await getSessionJson(entry.name, basePath);
|
||||||
|
if (sess && sess.status === 'in_progress') {
|
||||||
|
unfinished.push(sess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unfinished;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanAllTrailsForCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
|
||||||
|
const rDir = runsDir(basePath);
|
||||||
|
if (!existsSync(rDir)) return [];
|
||||||
|
|
||||||
|
const entries = await readdir(rDir, { withFileTypes: true });
|
||||||
|
const corrections: UserCorrectionRecord[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const lines = await readLines(trailPath(entry.name, basePath));
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||||
|
if (record?.action_type === 'user_correction') {
|
||||||
|
corrections.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also scan audit_pending.jsonl
|
||||||
|
const pendingLines = await readLines(auditPendingPath(basePath));
|
||||||
|
for (const line of pendingLines) {
|
||||||
|
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||||
|
if (record?.action_type === 'user_correction') {
|
||||||
|
corrections.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return corrections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endSession(basePath?: string): Promise<EndSessionResult | null> {
|
||||||
|
const sessionId = await getCurrentSession(basePath);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
|
const sDir = sessionDir(sessionId, basePath);
|
||||||
|
await ensureDir(sDir);
|
||||||
|
|
||||||
|
// Collect remaining buffer data
|
||||||
|
const bufferLines = await readLines(auditBufferPath(basePath));
|
||||||
|
const pendingLines = await readLines(auditPendingPath(basePath));
|
||||||
|
const allRemaining = [...bufferLines, ...pendingLines];
|
||||||
|
|
||||||
|
// Append to audit_trail.jsonl
|
||||||
|
const trail = trailPath(sessionId, basePath);
|
||||||
|
if (allRemaining.length > 0) {
|
||||||
|
await appendFile(trail, allRemaining.join('\n') + '\n', 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear buffer files
|
||||||
|
await clearFile(auditBufferPath(basePath));
|
||||||
|
await clearFile(auditPendingPath(basePath));
|
||||||
|
|
||||||
|
// Read current trail for stats
|
||||||
|
const trailLines = await readLines(trail);
|
||||||
|
|
||||||
|
// Extract user_correction records
|
||||||
|
const corrections: UserCorrectionRecord[] = [];
|
||||||
|
for (const line of trailLines) {
|
||||||
|
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||||
|
if (record?.action_type === 'user_correction') {
|
||||||
|
corrections.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integrity checks
|
||||||
|
const integrity: IntegrityCheck[] = [
|
||||||
|
{
|
||||||
|
check: 'Audit records exist',
|
||||||
|
passed: trailLines.length > 0,
|
||||||
|
detail: trailLines.length > 0 ? `${trailLines.length} records` : 'No audit records found',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
check: 'File modifications tracked',
|
||||||
|
passed: trailLines.some((l) => {
|
||||||
|
const r = tryParseJson<AuditTrailEntry>(l);
|
||||||
|
return r && (r.tool === 'Write' || r.tool === 'Edit');
|
||||||
|
}),
|
||||||
|
detail: 'Checking for Write/Edit tool entries',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
check: 'User corrections persisted',
|
||||||
|
passed: corrections.every((c) => (c.persisted_to?.length ?? 0) > 0),
|
||||||
|
detail: corrections.length > 0
|
||||||
|
? `${corrections.length} corrections found, ${corrections.filter((c) => (c.persisted_to?.length ?? 0) > 0).length} persisted`
|
||||||
|
: 'No corrections to persist',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate session summary
|
||||||
|
const summaryContent = generateSessionSummary(sessionId, trailLines, corrections);
|
||||||
|
const summaryFile = summaryPath(sessionId, basePath);
|
||||||
|
await writeFile(summaryFile, summaryContent, 'utf-8');
|
||||||
|
|
||||||
|
// Update session.json
|
||||||
|
const session = await getSessionJson(sessionId, basePath);
|
||||||
|
if (session) {
|
||||||
|
session.status = 'completed';
|
||||||
|
session.end_time = isoNow();
|
||||||
|
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
||||||
|
await updateIndexStatus(sessionId, 'completed', basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update index.json record count
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
if (idx) {
|
||||||
|
for (const e of idx.entries) {
|
||||||
|
if (e.id === sessionId) {
|
||||||
|
e.record_count = trailLines.length;
|
||||||
|
e.status = 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeIndex(idx.entries, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear .current_session
|
||||||
|
try {
|
||||||
|
await rm(currentSessionPath(basePath));
|
||||||
|
} catch {
|
||||||
|
// Ok if already gone
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
integrity,
|
||||||
|
correctionCount: corrections.length,
|
||||||
|
summaryPath: summaryFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSessionSummary(
|
||||||
|
sessionId: string,
|
||||||
|
trailLines: string[],
|
||||||
|
corrections: UserCorrectionRecord[],
|
||||||
|
): string {
|
||||||
|
const actions: string[] = [];
|
||||||
|
const outputs: string[] = [];
|
||||||
|
|
||||||
|
for (const line of trailLines) {
|
||||||
|
const record = tryParseJson<AuditTrailEntry>(line);
|
||||||
|
if (record) {
|
||||||
|
if (record.action_type) actions.push(record.action_type);
|
||||||
|
if (record.output) outputs.push(record.output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`# Session Summary | ${sessionId}`,
|
||||||
|
'',
|
||||||
|
`## Time: ${isoNow()}`,
|
||||||
|
`## Status: completed`,
|
||||||
|
'',
|
||||||
|
'## Completed work',
|
||||||
|
...actions.map((a) => `- ${a}`),
|
||||||
|
'',
|
||||||
|
'## Key conclusions',
|
||||||
|
...outputs.map((o) => `- ${o}`),
|
||||||
|
'',
|
||||||
|
'## User corrections',
|
||||||
|
...(corrections.length > 0
|
||||||
|
? corrections.map((c) => `- ${c.original_claim} → ${c.correction} (${c.principle_extracted})`)
|
||||||
|
: ['- None']),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverSession(
|
||||||
|
level: number,
|
||||||
|
specificSessionId?: string,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<RecoverResult> {
|
||||||
|
const result: RecoverResult = { level, recentActivity: [], lastTrailEntries: [], userCorrections: [], conclusions: [], dailyAnomalies: [], dailyBacklog: [] };
|
||||||
|
|
||||||
|
// L0: index summary
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
result.recentActivity = idx?.entries.slice(0, 5) ?? [];
|
||||||
|
|
||||||
|
if (level === 0) return result;
|
||||||
|
|
||||||
|
// L1: current session + last 3 trail entries
|
||||||
|
let activeSessionId = specificSessionId ?? await getCurrentSession(basePath);
|
||||||
|
if (activeSessionId) {
|
||||||
|
result.sessionId = activeSessionId;
|
||||||
|
const session = await getSessionJson(activeSessionId, basePath);
|
||||||
|
if (session) {
|
||||||
|
result.task = session.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailLines = await readLines(trailPath(activeSessionId, basePath));
|
||||||
|
result.lastTrailEntries = trailLines.slice(-3).map((l) => {
|
||||||
|
const r = tryParseJson<AuditTrailEntry>(l);
|
||||||
|
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 1) return result;
|
||||||
|
|
||||||
|
// L2: user corrections + conclusions + daily anomalies
|
||||||
|
result.userCorrections = await scanAllTrailsForCorrections(basePath);
|
||||||
|
|
||||||
|
// Extract conclusions from trail entries
|
||||||
|
const allTrailLines = await readLines(trailPath(activeSessionId ?? '', basePath));
|
||||||
|
for (const line of allTrailLines) {
|
||||||
|
const record = tryParseJson<AuditTrailEntry>(line);
|
||||||
|
if (record?.output) {
|
||||||
|
result.conclusions.push(record.output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read daily reports for anomalies + backlog
|
||||||
|
const dDir = dailyDir(basePath);
|
||||||
|
if (existsSync(dDir)) {
|
||||||
|
const dailyFiles = (await readdir(dDir)).filter((f) => f.endsWith('_daily.md')).sort().reverse();
|
||||||
|
if (dailyFiles.length > 0) {
|
||||||
|
const latest = await readFile(join(dDir, dailyFiles[0]!), 'utf-8');
|
||||||
|
const anomalies = latest.match(/## (?:四|4).*?[\s\S]*?(?=##|$)/);
|
||||||
|
if (anomalies) result.dailyAnomalies.push(anomalies[0]);
|
||||||
|
const backlog = latest.match(/## (?:六|6).*?[\s\S]*?(?=##|$)/);
|
||||||
|
if (backlog) result.dailyBacklog.push(backlog[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 2) return result;
|
||||||
|
|
||||||
|
// L3: full trail + pending
|
||||||
|
if (level >= 3) {
|
||||||
|
if (activeSessionId) {
|
||||||
|
const fullLines = await readLines(trailPath(activeSessionId, basePath));
|
||||||
|
result.fullTrail = fullLines.map((l) => {
|
||||||
|
const r = tryParseJson<AuditTrailEntry>(l);
|
||||||
|
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDailyReport(
|
||||||
|
targetDate?: string,
|
||||||
|
review?: boolean,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<DailyReport> {
|
||||||
|
const date = targetDate ?? isoDate();
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
const rDir = runsDir(basePath);
|
||||||
|
|
||||||
|
const todayEntries = (idx?.entries ?? []).filter((e) => e.start_time.startsWith(date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8)));
|
||||||
|
|
||||||
|
let totalWriteEdit = 0;
|
||||||
|
let totalBash = 0;
|
||||||
|
let totalAuditBlocks = 0;
|
||||||
|
const changes: { time: string; target: string; detail: string }[] = [];
|
||||||
|
const feedback: { feedback: string; resolution: string; persistedTo: string }[] = [];
|
||||||
|
const anomalies: string[] = [];
|
||||||
|
|
||||||
|
for (const entry of todayEntries) {
|
||||||
|
const lines = await readLines(trailPath(entry.id, basePath));
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = tryParseJson<AuditTrailEntry>(line);
|
||||||
|
if (!record) continue;
|
||||||
|
if (record.tool === 'Write' || record.tool === 'Edit') totalWriteEdit++;
|
||||||
|
if (record.tool === 'Bash') totalBash++;
|
||||||
|
if (record.action_type === 'audit_block') totalAuditBlocks++;
|
||||||
|
if (record.tool && (record.tool === 'Write' || record.tool === 'Edit') && record.files) {
|
||||||
|
changes.push({ time: record.timestamp, target: record.files.join(', '), detail: record.detail ?? '' });
|
||||||
|
}
|
||||||
|
if (record.action_type === 'user_correction') {
|
||||||
|
const uc = record as unknown as UserCorrectionRecord;
|
||||||
|
feedback.push({ feedback: uc.original_claim, resolution: uc.correction, persistedTo: (uc.persisted_to ?? []).join(', ') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for anomalies.json
|
||||||
|
if (existsSync(rDir)) {
|
||||||
|
const sessionDirs = await readdir(rDir, { withFileTypes: true });
|
||||||
|
for (const d of sessionDirs) {
|
||||||
|
if (!d.isDirectory()) continue;
|
||||||
|
const anomPath = join(rDir, d.name, 'anomalies.json');
|
||||||
|
if (existsSync(anomPath)) {
|
||||||
|
const anomContent = await readFile(anomPath, 'utf-8');
|
||||||
|
anomalies.push(`[${d.name}] ${anomContent.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read previous day backlog
|
||||||
|
const prevDate = isoDate(new Date(Date.now() - 86400000));
|
||||||
|
let backlog: string[] = [];
|
||||||
|
const prevDailyPath = join(dailyDir(basePath), `${prevDate}_daily.md`);
|
||||||
|
if (existsSync(prevDailyPath)) {
|
||||||
|
const prevContent = await readFile(prevDailyPath, 'utf-8');
|
||||||
|
const m = prevContent.match(/## (?:六|6|明日待办)[\s\S]*?(?=##|$)/);
|
||||||
|
if (m) backlog = m[0].split('\n').filter((l) => l.trim().startsWith('-')).map((l) => l.replace(/^-\s*/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportPath = join(dailyDir(basePath), `${date}_daily.md`);
|
||||||
|
await ensureDir(dailyDir(basePath));
|
||||||
|
|
||||||
|
const sections = {
|
||||||
|
taskOverview: todayEntries.length > 0
|
||||||
|
? todayEntries.map((e) => `| ${e.id} | ${e.task} | ${e.status} | ${e.record_count} |`).join('\n')
|
||||||
|
: 'No activity',
|
||||||
|
operationStats: [
|
||||||
|
{ label: 'Write/Edit operations', count: totalWriteEdit },
|
||||||
|
{ label: 'Bash executions', count: totalBash },
|
||||||
|
{ label: 'Audit blocks', count: totalAuditBlocks },
|
||||||
|
],
|
||||||
|
changes,
|
||||||
|
userFeedback: feedback,
|
||||||
|
anomalyAlerts: anomalies,
|
||||||
|
backlogTracking: backlog,
|
||||||
|
integritySummary: [
|
||||||
|
`| All sessions have audit records | ${todayEntries.every((e) => e.record_count > 0) ? '✅' : '⚠️'} |`,
|
||||||
|
`| Audit blocks persisted | ${totalAuditBlocks > 0 ? '✅' : '⚠️'} |`,
|
||||||
|
`| User corrections persisted | ${feedback.every((f) => f.persistedTo.length > 0) ? '✅' : '⚠️'} |`,
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportContent = generateDailyReportContent(date, sections);
|
||||||
|
await writeFile(reportPath, reportContent, 'utf-8');
|
||||||
|
|
||||||
|
// If review mode, also generate morning review
|
||||||
|
if (review) {
|
||||||
|
const reviewPath = join(dailyDir(basePath), `${date}_morning_review.md`);
|
||||||
|
const reviewContent = generateMorningReview(sections, date);
|
||||||
|
await writeFile(reviewPath, reviewContent, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { date, sections, path: reportPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDailyReportContent(date: string, sections: DailyReport['sections']): string {
|
||||||
|
return [
|
||||||
|
`# Work Report | ${date}`,
|
||||||
|
'',
|
||||||
|
`> Auto-generated: ${isoNow()}`,
|
||||||
|
`> Data source: .boo/runs/index.json + session audit_trail`,
|
||||||
|
`> Coverage: ${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)} 00:00 — 23:59`,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## I. Task Overview',
|
||||||
|
'',
|
||||||
|
'| Session ID | Task | Status | Records |',
|
||||||
|
'|-----------|------|--------|---------|',
|
||||||
|
sections.taskOverview,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## II. Operation Stats',
|
||||||
|
'',
|
||||||
|
'| Metric | Count |',
|
||||||
|
'|--------|-------|',
|
||||||
|
...sections.operationStats.map((s) => `| ${s.label} | ${s.count} |`),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## III. Change Records',
|
||||||
|
'',
|
||||||
|
...(sections.changes.length > 0
|
||||||
|
? ['| Time | Target | Detail |', '|------|--------|--------|', ...sections.changes.map((c) => `| ${c.time} | ${c.target} | ${c.detail} |`)]
|
||||||
|
: ['No changes recorded today.']),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## IV. User Feedback & Corrections',
|
||||||
|
'',
|
||||||
|
...(sections.userFeedback.length > 0
|
||||||
|
? ['| Feedback | Resolution | Persisted To |', '|---------|------------|--------------|', ...sections.userFeedback.map((f) => `| ${f.feedback} | ${f.resolution} | ${f.persistedTo} |`)]
|
||||||
|
: ['None.']),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## V. Anomaly Alerts',
|
||||||
|
'',
|
||||||
|
...(sections.anomalyAlerts.length > 0 ? sections.anomalyAlerts.map((a) => `- ${a}`) : ['None.']),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## VI. Backlog Tracking',
|
||||||
|
'',
|
||||||
|
...(sections.backlogTracking.length > 0 ? sections.backlogTracking.map((b) => `- ${b}`) : ['None.']),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## VII. Integrity Summary',
|
||||||
|
'',
|
||||||
|
'| Check | Result |',
|
||||||
|
'|-------|--------|',
|
||||||
|
sections.integritySummary,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMorningReview(sections: DailyReport['sections'], date: string): string {
|
||||||
|
const anomalies = sections.anomalyAlerts;
|
||||||
|
const hasUnhandledAnomalies = anomalies.some((a) => !a.includes('resolved'));
|
||||||
|
const hasUnpersistedFeedback = sections.userFeedback.some((f) => !f.persistedTo);
|
||||||
|
const hasIncompleteBacklog = sections.backlogTracking.length > 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`# Morning Self-Review | ${date}`,
|
||||||
|
'',
|
||||||
|
`> Generated: ${isoNow()}`,
|
||||||
|
'',
|
||||||
|
'## Self-Correction Check',
|
||||||
|
'',
|
||||||
|
`- Unresolved anomalies: ${hasUnhandledAnomalies ? '⚠️ Yes — needs attention' : '✅ None'}`,
|
||||||
|
`- Unpersisted user feedback: ${hasUnpersistedFeedback ? '⚠️ Needs documentation' : '✅ All persisted'}`,
|
||||||
|
`- Outstanding backlog: ${hasIncompleteBacklog ? '⚠️ Carry-over items' : '✅ Clean slate'}`,
|
||||||
|
'',
|
||||||
|
'## Today\'s Recommended Priorities',
|
||||||
|
'',
|
||||||
|
...(sections.backlogTracking.length > 0
|
||||||
|
? sections.backlogTracking.map((b) => `- [ ] ${b} (carry-over)`)
|
||||||
|
: []),
|
||||||
|
'- [ ] Review yesterday\'s user feedback and persist any remaining corrections',
|
||||||
|
'- [ ] Continue highest-priority task from session overview',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureBooDirs(basePath?: string): Promise<void> {
|
||||||
|
await ensureDir(runsDir(basePath));
|
||||||
|
await ensureDir(dailyDir(basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAuditBuffer(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
||||||
|
await ensureDir(runsDir(basePath));
|
||||||
|
await appendLine(auditBufferPath(basePath), JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAuditPending(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
||||||
|
await ensureDir(runsDir(basePath));
|
||||||
|
await appendLine(auditPendingPath(basePath), JSON.stringify(entry));
|
||||||
|
}
|
||||||
186
apps/coder/src/services/correction-service.ts
Normal file
186
apps/coder/src/services/correction-service.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { readFile, writeFile, appendFile } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export interface UserCorrectionRecord {
|
||||||
|
id: string;
|
||||||
|
record_type: 'conversation';
|
||||||
|
action_type: 'user_correction';
|
||||||
|
priority: 'critical_for_recovery';
|
||||||
|
timestamp: string;
|
||||||
|
original_claim: string;
|
||||||
|
correction: string;
|
||||||
|
principle_extracted: string;
|
||||||
|
persisted_to: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORRECTIONS_REL = '.boo/corrections/index.json';
|
||||||
|
|
||||||
|
function correctionsDir(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), '.boo/corrections');
|
||||||
|
}
|
||||||
|
|
||||||
|
function correctionsPath(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), CORRECTIONS_REL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson<T>(raw: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CorrectionsIndex {
|
||||||
|
corrections: UserCorrectionRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCorrections(basePath?: string): Promise<CorrectionsIndex> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(correctionsPath(basePath), 'utf-8');
|
||||||
|
return tryParseJson<CorrectionsIndex>(raw) ?? { corrections: [] };
|
||||||
|
} catch {
|
||||||
|
return { corrections: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCorrections(idx: CorrectionsIndex, basePath?: string): Promise<void> {
|
||||||
|
const dir = correctionsDir(basePath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
const { mkdir } = await import('node:fs/promises');
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
await writeFile(correctionsPath(basePath), JSON.stringify(idx, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
function nextId(): string {
|
||||||
|
idCounter++;
|
||||||
|
return `uc_${Date.now()}_${idCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a user correction. Stores it in .boo/corrections/index.json
|
||||||
|
* and returns the record with the assigned id.
|
||||||
|
*/
|
||||||
|
export async function recordCorrection(
|
||||||
|
originalClaim: string,
|
||||||
|
correction: string,
|
||||||
|
principleExtracted: string,
|
||||||
|
persistedTo: string[] = [],
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<UserCorrectionRecord> {
|
||||||
|
const idx = await readCorrections(basePath);
|
||||||
|
const record: UserCorrectionRecord = {
|
||||||
|
id: nextId(),
|
||||||
|
record_type: 'conversation',
|
||||||
|
action_type: 'user_correction',
|
||||||
|
priority: 'critical_for_recovery',
|
||||||
|
timestamp: isoNow(),
|
||||||
|
original_claim: originalClaim,
|
||||||
|
correction,
|
||||||
|
principle_extracted: principleExtracted,
|
||||||
|
persisted_to: persistedTo,
|
||||||
|
};
|
||||||
|
idx.corrections.push(record);
|
||||||
|
await writeCorrections(idx, basePath);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan an audit_trail.jsonl file for user_correction records.
|
||||||
|
* Returns all matching records found in the file.
|
||||||
|
*/
|
||||||
|
export async function scanForCorrections(
|
||||||
|
auditPath: string,
|
||||||
|
): Promise<UserCorrectionRecord[]> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(auditPath, 'utf-8');
|
||||||
|
const lines = raw.split('\n').filter(Boolean);
|
||||||
|
const corrections: UserCorrectionRecord[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = tryParseJson<Record<string, unknown>>(line);
|
||||||
|
if (record?.action_type === 'user_correction') {
|
||||||
|
corrections.push(record as unknown as UserCorrectionRecord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return corrections;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a proposed action contradicts any known user correction.
|
||||||
|
* Returns an array of contradiction warnings — empty means no contradictions.
|
||||||
|
*/
|
||||||
|
export function checkContradiction(
|
||||||
|
action: string,
|
||||||
|
corrections: UserCorrectionRecord[],
|
||||||
|
): { contradicts: boolean; warnings: { correction: UserCorrectionRecord; reason: string }[] } {
|
||||||
|
const warnings: { correction: UserCorrectionRecord; reason: string }[] = [];
|
||||||
|
|
||||||
|
for (const c of corrections) {
|
||||||
|
// Check if the action mentions the original claim's topic
|
||||||
|
const actionLower = action.toLowerCase();
|
||||||
|
const claimFragments = c.original_claim.toLowerCase().split(/\s+/).filter((w) => w.length > 4);
|
||||||
|
|
||||||
|
// If any significant word from the original claim appears in the proposed action,
|
||||||
|
// flag this as a potential contradiction
|
||||||
|
const matchingFragments = claimFragments.filter((f) => actionLower.includes(f));
|
||||||
|
if (matchingFragments.length >= 2) {
|
||||||
|
warnings.push({
|
||||||
|
correction: c,
|
||||||
|
reason: `Action "${action.slice(0, 60)}" may contradict prior correction: "${c.original_claim}" → "${c.correction}" (principle: ${c.principle_extracted})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contradicts: warnings.length > 0,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a file path to a correction's persisted_to array.
|
||||||
|
*/
|
||||||
|
export async function markPersisted(
|
||||||
|
correctionId: string,
|
||||||
|
filePath: string,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<UserCorrectionRecord | null> {
|
||||||
|
const idx = await readCorrections(basePath);
|
||||||
|
const record = idx.corrections.find((c) => c.id === correctionId);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
if (!record.persisted_to.includes(filePath)) {
|
||||||
|
record.persisted_to.push(filePath);
|
||||||
|
}
|
||||||
|
await writeCorrections(idx, basePath);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all stored user corrections.
|
||||||
|
*/
|
||||||
|
export async function listCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
|
||||||
|
const idx = await readCorrections(basePath);
|
||||||
|
return idx.corrections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a correction record to an audit_trail.jsonl file (inline storage).
|
||||||
|
*/
|
||||||
|
export async function appendCorrectionToTrail(
|
||||||
|
trailPath: string,
|
||||||
|
correction: UserCorrectionRecord,
|
||||||
|
): Promise<void> {
|
||||||
|
await appendFile(trailPath, JSON.stringify(correction) + '\n', 'utf-8');
|
||||||
|
}
|
||||||
560
apps/coder/src/services/guideline-service.ts
Normal file
560
apps/coder/src/services/guideline-service.ts
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export type Criticality = 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
|
export interface GuidelineContent {
|
||||||
|
condition: string;
|
||||||
|
action: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Guideline {
|
||||||
|
id: string;
|
||||||
|
creationUtc: string;
|
||||||
|
content: GuidelineContent;
|
||||||
|
enabled: boolean;
|
||||||
|
tags: string[];
|
||||||
|
labels: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
criticality: Criticality;
|
||||||
|
title: string | null;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGuidelineParams {
|
||||||
|
condition: string;
|
||||||
|
action?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
criticality?: Criticality;
|
||||||
|
title?: string;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGuidelineParams {
|
||||||
|
condition?: string;
|
||||||
|
action?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
criticality?: Criticality;
|
||||||
|
title?: string | null;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListGuidelinesFilter {
|
||||||
|
tags?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GuidelineStoreData {
|
||||||
|
version: string;
|
||||||
|
guidelines: Guideline[];
|
||||||
|
migrationLog: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUIDELINES_REL = '.boo/guidelines';
|
||||||
|
const STORE_FILE = 'guidelines.json';
|
||||||
|
const CURRENT_VERSION = 'v0.11.0';
|
||||||
|
|
||||||
|
function storeDir(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), GUIDELINES_REL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storePath(basePath?: string): string {
|
||||||
|
return join(storeDir(basePath), STORE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson<T>(raw: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
function nextId(): string {
|
||||||
|
idCounter++;
|
||||||
|
return `gl_${Date.now()}_${idCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureStoreDir(basePath?: string): Promise<void> {
|
||||||
|
const dir = storeDir(basePath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIGRATIONS: { from: string; to: string; migrate: (data: GuidelineStoreData) => GuidelineStoreData }[] = [
|
||||||
|
{
|
||||||
|
from: 'v0.1.0',
|
||||||
|
to: 'v0.2.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.2.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
enabled: g.enabled ?? true,
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.1.0→v0.2.0: add enabled field'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.2.0',
|
||||||
|
to: 'v0.3.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.3.0',
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.2.0→v0.3.0: remove guideline_set'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.3.0',
|
||||||
|
to: 'v0.4.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.4.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
content: {
|
||||||
|
...g.content,
|
||||||
|
action: g.content.action ?? null,
|
||||||
|
description: g.content.description ?? null,
|
||||||
|
},
|
||||||
|
metadata: g.metadata ?? {},
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.3.0→v0.4.0: add optional action, description, metadata'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.4.0',
|
||||||
|
to: 'v0.5.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.5.0',
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.4.0→v0.5.0: description as optional'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.5.0',
|
||||||
|
to: 'v0.6.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.6.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
criticality: g.criticality ?? 'medium',
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.5.0→v0.6.0: add criticality'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.6.0',
|
||||||
|
to: 'v0.7.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.7.0',
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.6.0→v0.7.0: add composition_mode (optional)'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.7.0',
|
||||||
|
to: 'v0.8.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.8.0',
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.7.0→v0.8.0: add track (default true)'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.8.0',
|
||||||
|
to: 'v0.9.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.9.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
labels: g.labels ?? [],
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.8.0→v0.9.0: add labels'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.9.0',
|
||||||
|
to: 'v0.10.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.10.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
priority: g.priority ?? 0,
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.9.0→v0.10.0: add priority'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.10.0',
|
||||||
|
to: 'v0.11.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.11.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
title: g.title ?? null,
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.10.0→v0.11.0: add title'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function applyMigrations(data: GuidelineStoreData): GuidelineStoreData {
|
||||||
|
let current = { ...data };
|
||||||
|
for (const migration of MIGRATIONS) {
|
||||||
|
if (current.version === migration.from) {
|
||||||
|
current = migration.migrate(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStore(basePath?: string): Promise<GuidelineStoreData> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(storePath(basePath), 'utf-8');
|
||||||
|
const data = tryParseJson<GuidelineStoreData>(raw);
|
||||||
|
if (!data) return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
|
||||||
|
if (data.version !== CURRENT_VERSION) {
|
||||||
|
return applyMigrations(data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStore(data: GuidelineStoreData, basePath?: string): Promise<void> {
|
||||||
|
await ensureStoreDir(basePath);
|
||||||
|
await writeFile(storePath(basePath), JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGuideline(
|
||||||
|
params: CreateGuidelineParams,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
const guideline: Guideline = {
|
||||||
|
id: nextId(),
|
||||||
|
creationUtc: isoNow(),
|
||||||
|
content: {
|
||||||
|
condition: params.condition,
|
||||||
|
action: params.action ?? null,
|
||||||
|
description: params.description ?? null,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
tags: params.tags ?? [],
|
||||||
|
labels: params.labels ?? [],
|
||||||
|
metadata: {},
|
||||||
|
criticality: params.criticality ?? 'medium',
|
||||||
|
title: params.title ?? null,
|
||||||
|
priority: params.priority ?? 0,
|
||||||
|
};
|
||||||
|
data.guidelines.push(guideline);
|
||||||
|
await writeStore(data, basePath);
|
||||||
|
return guideline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGuidelines(
|
||||||
|
filter?: ListGuidelinesFilter,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline[]> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
let results = data.guidelines;
|
||||||
|
|
||||||
|
if (filter?.tags && filter.tags.length > 0) {
|
||||||
|
results = results.filter((g) => filter.tags!.some((tag) => g.tags.includes(tag)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.labels && filter.labels.length > 0) {
|
||||||
|
results = results.filter((g) => filter.labels!.every((label) => g.labels.includes(label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readGuideline(
|
||||||
|
id: string,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline | null> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
return data.guidelines.find((g) => g.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGuideline(
|
||||||
|
id: string,
|
||||||
|
params: UpdateGuidelineParams,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline | null> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
const idx = data.guidelines.findIndex((g) => g.id === id);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
|
||||||
|
const existing = data.guidelines[idx]!;
|
||||||
|
|
||||||
|
if (params.condition !== undefined) existing.content.condition = params.condition;
|
||||||
|
if (params.action !== undefined) existing.content.action = params.action;
|
||||||
|
if (params.description !== undefined) existing.content.description = params.description;
|
||||||
|
if (params.enabled !== undefined) existing.enabled = params.enabled;
|
||||||
|
if (params.tags !== undefined) existing.tags = params.tags;
|
||||||
|
if (params.labels !== undefined) existing.labels = params.labels;
|
||||||
|
if (params.metadata !== undefined) existing.metadata = params.metadata;
|
||||||
|
if (params.criticality !== undefined) existing.criticality = params.criticality;
|
||||||
|
if (params.title !== undefined) existing.title = params.title;
|
||||||
|
if (params.priority !== undefined) existing.priority = params.priority;
|
||||||
|
|
||||||
|
data.guidelines[idx] = existing;
|
||||||
|
await writeStore(data, basePath);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGuideline(
|
||||||
|
id: string,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
const lenBefore = data.guidelines.length;
|
||||||
|
data.guidelines = data.guidelines.filter((g) => g.id !== id);
|
||||||
|
if (data.guidelines.length === lenBefore) return false;
|
||||||
|
await writeStore(data, basePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findGuideline(
|
||||||
|
content: { condition: string; action?: string },
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline | null> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
return data.guidelines.find((g) => {
|
||||||
|
const condMatch = g.content.condition === content.condition;
|
||||||
|
if (!condMatch) return false;
|
||||||
|
if (content.action !== undefined) {
|
||||||
|
return g.content.action === content.action;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Journey → Guideline projection (port of Parlant's JourneyGuidelineProjection) ───
|
||||||
|
|
||||||
|
export interface JourneyNode {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneyEdge {
|
||||||
|
sourceNodeId: string;
|
||||||
|
targetNodeId: string;
|
||||||
|
condition: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Journey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nodes: JourneyNode[];
|
||||||
|
edges: JourneyEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneyProjectionResult {
|
||||||
|
guidelines: Guideline[];
|
||||||
|
followUps: Map<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a Journey into an ordered list of Guidelines.
|
||||||
|
* DFS traversal from root nodes: each (edge, node) pair → one Guideline.
|
||||||
|
* Edge condition becomes guideline condition, node action becomes guideline action.
|
||||||
|
* BFS queue avoids infinite loops via visited set.
|
||||||
|
*/
|
||||||
|
export function projectJourneyToGuidelines(
|
||||||
|
journey: Journey,
|
||||||
|
baseTags?: string[],
|
||||||
|
): JourneyProjectionResult {
|
||||||
|
const guidelines: Guideline[] = [];
|
||||||
|
const followUps = new Map<string, string[]>();
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const nodeMap = new Map<string, JourneyNode>();
|
||||||
|
|
||||||
|
for (const node of journey.nodes) {
|
||||||
|
nodeMap.set(node.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build adjacency list
|
||||||
|
const adjacency = new Map<string, JourneyEdge[]>();
|
||||||
|
for (const edge of journey.edges) {
|
||||||
|
const list = adjacency.get(edge.sourceNodeId) ?? [];
|
||||||
|
list.push(edge);
|
||||||
|
adjacency.set(edge.sourceNodeId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root nodes (no incoming edges)
|
||||||
|
const hasIncoming = new Set<string>();
|
||||||
|
for (const edge of journey.edges) {
|
||||||
|
hasIncoming.add(edge.targetNodeId);
|
||||||
|
}
|
||||||
|
const roots = journey.nodes
|
||||||
|
.filter((n) => !hasIncoming.has(n.id))
|
||||||
|
.map((n) => n.id);
|
||||||
|
|
||||||
|
const queue: { nodeId: string; fromEdge?: JourneyEdge }[] = [];
|
||||||
|
|
||||||
|
// BFS from roots
|
||||||
|
for (const rootId of roots) {
|
||||||
|
if (!visited.has(rootId)) {
|
||||||
|
queue.push({ nodeId: rootId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { nodeId, fromEdge } = queue.shift()!;
|
||||||
|
if (visited.has(nodeId)) continue;
|
||||||
|
visited.add(nodeId);
|
||||||
|
|
||||||
|
const node = nodeMap.get(nodeId);
|
||||||
|
if (!node) continue;
|
||||||
|
|
||||||
|
// If we arrived via an edge, create a guideline
|
||||||
|
if (fromEdge) {
|
||||||
|
const guideline = createGuidelineFromJourneyEdge(
|
||||||
|
journey,
|
||||||
|
node,
|
||||||
|
fromEdge,
|
||||||
|
baseTags,
|
||||||
|
);
|
||||||
|
guidelines.push(guideline);
|
||||||
|
|
||||||
|
// Track follow-ups
|
||||||
|
const sourceId = findGuidelineForNode(fromEdge.sourceNodeId, journey.nodes);
|
||||||
|
if (sourceId) {
|
||||||
|
const existing = followUps.get(sourceId) ?? [];
|
||||||
|
existing.push(guideline.id);
|
||||||
|
followUps.set(sourceId, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue downstream nodes
|
||||||
|
const outgoingEdges = adjacency.get(nodeId) ?? [];
|
||||||
|
for (const edge of outgoingEdges) {
|
||||||
|
if (!visited.has(edge.targetNodeId)) {
|
||||||
|
queue.push({ nodeId: edge.targetNodeId, fromEdge: edge });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { guidelines, followUps };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGuidelineForNode(nodeId: string, nodes: JourneyNode[]): string | null {
|
||||||
|
// Placeholder: in a full implementation, map nodeId → guideline id
|
||||||
|
// For now return null — downstream consumers handle missing follow-ups gracefully
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGuidelineFromJourneyEdge(
|
||||||
|
journey: Journey,
|
||||||
|
targetNode: JourneyNode,
|
||||||
|
edge: JourneyEdge,
|
||||||
|
baseTags?: string[],
|
||||||
|
): Guideline {
|
||||||
|
const now = isoNow();
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
creationUtc: now,
|
||||||
|
content: {
|
||||||
|
condition: edge.condition,
|
||||||
|
action: targetNode.action,
|
||||||
|
description: targetNode.description ?? null,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
tags: baseTags ?? [journey.name],
|
||||||
|
labels: [],
|
||||||
|
metadata: {
|
||||||
|
journey_id: journey.id,
|
||||||
|
journey_node: targetNode.id,
|
||||||
|
source_edge_id: `${edge.sourceNodeId}→${edge.targetNodeId}`,
|
||||||
|
},
|
||||||
|
criticality: 'medium',
|
||||||
|
title: targetNode.description
|
||||||
|
? `[${journey.name}] ${targetNode.description.slice(0, 60)}`
|
||||||
|
: null,
|
||||||
|
priority: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Backtrack detection ───
|
||||||
|
|
||||||
|
export interface BacktrackCheckInput {
|
||||||
|
journeyId: string;
|
||||||
|
currentNodeId: string;
|
||||||
|
previousNodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktrackCheckResult {
|
||||||
|
journeyId: string;
|
||||||
|
currentNodeId: string;
|
||||||
|
previousNodeId: string;
|
||||||
|
isBacktrack: boolean;
|
||||||
|
recommendation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if moving from previousNodeId to currentNodeId is a backtrack
|
||||||
|
* (regression to an already-visited node not on a forward path).
|
||||||
|
*/
|
||||||
|
export function checkBacktrack(
|
||||||
|
input: BacktrackCheckInput,
|
||||||
|
journey: Journey,
|
||||||
|
): BacktrackCheckResult {
|
||||||
|
const adjacency = new Map<string, string[]>();
|
||||||
|
for (const edge of journey.edges) {
|
||||||
|
const list = adjacency.get(edge.sourceNodeId) ?? [];
|
||||||
|
list.push(edge.targetNodeId);
|
||||||
|
adjacency.set(edge.sourceNodeId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find forward reachable nodes from the current node
|
||||||
|
const forwardReachable = new Set<string>();
|
||||||
|
const bfsQueue = [input.currentNodeId];
|
||||||
|
while (bfsQueue.length > 0) {
|
||||||
|
const nid = bfsQueue.shift()!;
|
||||||
|
if (forwardReachable.has(nid)) continue;
|
||||||
|
forwardReachable.add(nid);
|
||||||
|
const next = adjacency.get(nid) ?? [];
|
||||||
|
for (const n of next) {
|
||||||
|
if (!forwardReachable.has(n)) bfsQueue.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBacktrack = input.previousNodeId !== input.currentNodeId
|
||||||
|
&& !forwardReachable.has(input.previousNodeId)
|
||||||
|
&& input.previousNodeId !== input.currentNodeId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
journeyId: input.journeyId,
|
||||||
|
currentNodeId: input.currentNodeId,
|
||||||
|
previousNodeId: input.previousNodeId,
|
||||||
|
isBacktrack,
|
||||||
|
recommendation: isBacktrack
|
||||||
|
? `Revisiting node "${input.previousNodeId}" after "${input.currentNodeId}" — this may indicate a regression. Consider whether the forward path from "${input.currentNodeId}" is the correct one.`
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import { registerModelRoutes } from './routes/models.js';
|
|||||||
import { registerAgentRoutes } from './routes/agents.js';
|
import { registerAgentRoutes } from './routes/agents.js';
|
||||||
import { registerSkillsRoutes } from './routes/skills.js';
|
import { registerSkillsRoutes } from './routes/skills.js';
|
||||||
import { registerToolsRoutes } from './routes/tools.js';
|
import { registerToolsRoutes } from './routes/tools.js';
|
||||||
|
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
||||||
|
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
|
||||||
import { createInferenceRunner } from './services/inference/index.js';
|
import { createInferenceRunner } from './services/inference/index.js';
|
||||||
import { createBroker } from './services/broker.js';
|
import { createBroker } from './services/broker.js';
|
||||||
import { listSkills } from './services/skills.js';
|
import { listSkills } from './services/skills.js';
|
||||||
@@ -122,6 +124,8 @@ async function main() {
|
|||||||
registerSidebarRoutes(app, sql);
|
registerSidebarRoutes(app, sql);
|
||||||
registerChatRoutes(app, sql, broker);
|
registerChatRoutes(app, sql, broker);
|
||||||
registerToolsRoutes(app, sql);
|
registerToolsRoutes(app, sql);
|
||||||
|
registerAnalyticsRoutes(app, sql);
|
||||||
|
registerInferenceSettingsRoutes(app);
|
||||||
|
|
||||||
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
||||||
// missing /data/skills is non-fatal — the skill tools just return empty.
|
// missing /data/skills is non-fatal — the skill tools just return empty.
|
||||||
|
|||||||
33
apps/server/src/routes/analytics.ts
Normal file
33
apps/server/src/routes/analytics.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
// token-analyzer-ui: context window utilization and token breakdown data.
|
||||||
|
// v1 — global aggregates only.
|
||||||
|
|
||||||
|
export interface ContextWindowStats {
|
||||||
|
avg_ctx_used: number | null;
|
||||||
|
avg_ctx_max: number | null;
|
||||||
|
avg_utilization_pct: number | null;
|
||||||
|
message_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/analytics/context — average context window utilization across
|
||||||
|
// completed assistant messages that carry ctx_used/ctx_max.
|
||||||
|
app.get('/api/analytics/context', async () => {
|
||||||
|
const [row] = await sql<ContextWindowStats[]>`
|
||||||
|
SELECT
|
||||||
|
AVG(ctx_used)::DOUBLE PRECISION AS avg_ctx_used,
|
||||||
|
AVG(ctx_max)::DOUBLE PRECISION AS avg_ctx_max,
|
||||||
|
AVG(ctx_used::float / NULLIF(ctx_max, 0))::DOUBLE PRECISION AS avg_utilization_pct,
|
||||||
|
COUNT(*)::INT AS message_count
|
||||||
|
FROM messages
|
||||||
|
WHERE role = 'assistant'
|
||||||
|
AND status = 'complete'
|
||||||
|
AND ctx_used IS NOT NULL
|
||||||
|
AND ctx_max IS NOT NULL
|
||||||
|
AND ctx_max > 0
|
||||||
|
`;
|
||||||
|
return row ?? { avg_ctx_used: null, avg_ctx_max: null, avg_utilization_pct: null, message_count: 0 };
|
||||||
|
});
|
||||||
|
}
|
||||||
55
apps/server/src/routes/inference-settings.ts
Normal file
55
apps/server/src/routes/inference-settings.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
|
||||||
|
const CONFIG_PATH = resolve(process.env.BOOCODE_DATA_DIR || '/opt/boocode/data', 'inference-settings.json');
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
cache_type_k: 'q4_0',
|
||||||
|
cache_reuse: 256,
|
||||||
|
spec_type: 'ngram-mod',
|
||||||
|
spec_ngram_mod_thsh: 2,
|
||||||
|
ctx_checkpoints: 32,
|
||||||
|
sleep_idle_seconds: 600,
|
||||||
|
metrics_enabled: true,
|
||||||
|
slot_save_path: '/tmp/llama-slots',
|
||||||
|
};
|
||||||
|
|
||||||
|
function load(): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
if (existsSync(CONFIG_PATH)) {
|
||||||
|
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
}
|
||||||
|
} catch { /* corrupt file */ }
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(data: Record<string, unknown>): void {
|
||||||
|
const dir = dirname(CONFIG_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_CACHE_TYPES = ['f32', 'f16', 'q8_0', 'q4_0'] as const;
|
||||||
|
const VALID_SPEC_TYPES = ['off', 'ngram-mod', 'draft-simple'] as const;
|
||||||
|
|
||||||
|
export function registerInferenceSettingsRoutes(app: FastifyInstance): void {
|
||||||
|
app.get('/api/settings/inference', async (_req, _res) => {
|
||||||
|
return { ...DEFAULTS, ...load() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch<{ Body: Record<string, unknown> }>('/api/settings/inference', async (req, reply) => {
|
||||||
|
const current = { ...DEFAULTS, ...load() };
|
||||||
|
const merged = { ...current, ...req.body };
|
||||||
|
|
||||||
|
if (merged.cache_type_k && !(VALID_CACHE_TYPES as readonly string[]).includes(merged.cache_type_k as string)) {
|
||||||
|
return reply.status(400).send({ error: 'Invalid cache_type_k' });
|
||||||
|
}
|
||||||
|
if (merged.spec_type && !(VALID_SPEC_TYPES as readonly string[]).includes(merged.spec_type as string)) {
|
||||||
|
return reply.status(400).send({ error: 'Invalid spec_type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(merged);
|
||||||
|
return { ...DEFAULTS, ...load() };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -112,14 +112,14 @@ describe('stripShadowingFlags', () => {
|
|||||||
expect(result).toEqual(['-c', '4096']);
|
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']);
|
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']);
|
const result = stripShadowingFlags(['--spec-draft-n-max', '16']);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual(['--spec-draft-n-max', '16']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
52
apps/server/src/services/audit/corrections.ts
Normal file
52
apps/server/src/services/audit/corrections.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export interface UserCorrectionRecord {
|
||||||
|
record_type: 'conversation';
|
||||||
|
action_type: 'user_correction';
|
||||||
|
priority: 'critical_for_recovery';
|
||||||
|
timestamp: string;
|
||||||
|
original_claim: string;
|
||||||
|
correction: string;
|
||||||
|
principle_extracted: string;
|
||||||
|
persisted_to: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCorrection(params: {
|
||||||
|
originalClaim: string;
|
||||||
|
correction: string;
|
||||||
|
principleExtracted?: string;
|
||||||
|
persistedTo?: string[];
|
||||||
|
}): UserCorrectionRecord {
|
||||||
|
return {
|
||||||
|
record_type: 'conversation',
|
||||||
|
action_type: 'user_correction',
|
||||||
|
priority: 'critical_for_recovery',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
original_claim: params.originalClaim,
|
||||||
|
correction: params.correction,
|
||||||
|
principle_extracted: params.principleExtracted || '',
|
||||||
|
persisted_to: params.persistedTo || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCorrections(
|
||||||
|
records: Record<string, unknown>[],
|
||||||
|
): UserCorrectionRecord[] {
|
||||||
|
return records.filter(
|
||||||
|
r => r['action_type'] === 'user_correction',
|
||||||
|
) as unknown as UserCorrectionRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCorrectionConflict(
|
||||||
|
proposedAction: string,
|
||||||
|
corrections: UserCorrectionRecord[],
|
||||||
|
): UserCorrectionRecord | null {
|
||||||
|
for (const c of corrections) {
|
||||||
|
if (!c.original_claim) continue;
|
||||||
|
const claimKeywords = c.original_claim.toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
||||||
|
const actionLower = proposedAction.toLowerCase();
|
||||||
|
const matchCount = claimKeywords.filter(k => actionLower.includes(k)).length;
|
||||||
|
if (matchCount >= 2 && matchCount / claimKeywords.length >= 0.5) {
|
||||||
|
if (c.persisted_to.length > 0) return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
251
apps/server/src/services/audit/guideline-store.ts
Normal file
251
apps/server/src/services/audit/guideline-store.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ensureRunsDir } from './runs-dir.js';
|
||||||
|
|
||||||
|
export type GuidelineId = string;
|
||||||
|
export type TagId = string;
|
||||||
|
export type Criticality = 'low' | 'medium' | 'high';
|
||||||
|
export type GuidelineDocumentVersion = string;
|
||||||
|
|
||||||
|
export interface GuidelineContent {
|
||||||
|
condition: string;
|
||||||
|
action: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Guideline {
|
||||||
|
id: GuidelineId;
|
||||||
|
creationUtc: string;
|
||||||
|
content: GuidelineContent;
|
||||||
|
enabled: boolean;
|
||||||
|
tags: TagId[];
|
||||||
|
labels: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
criticality: Criticality;
|
||||||
|
title: string | null;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineDocument {
|
||||||
|
id: string;
|
||||||
|
version: GuidelineDocumentVersion;
|
||||||
|
creation_utc: string;
|
||||||
|
condition: string;
|
||||||
|
action: string | null;
|
||||||
|
description: string | null;
|
||||||
|
title: string | null;
|
||||||
|
criticality: string;
|
||||||
|
enabled: boolean;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
labels: string[];
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineUpdateParams {
|
||||||
|
condition?: string;
|
||||||
|
action?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
criticality?: Criticality;
|
||||||
|
enabled?: boolean;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
result += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbPath(projectRoot?: string): string {
|
||||||
|
const dir = join(ensureRunsDir(projectRoot), '..', 'guidelines');
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
return join(dir, 'guidelines.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDb(projectRoot?: string): GuidelineDocument[] {
|
||||||
|
const path = dbPath(projectRoot);
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as GuidelineDocument[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDb(docs: GuidelineDocument[], projectRoot?: string): void {
|
||||||
|
writeFileSync(dbPath(projectRoot), JSON.stringify(docs, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDocument(g: Guideline): GuidelineDocument {
|
||||||
|
return {
|
||||||
|
id: g.id,
|
||||||
|
version: '0.11.0',
|
||||||
|
creation_utc: g.creationUtc,
|
||||||
|
condition: g.content.condition,
|
||||||
|
action: g.content.action,
|
||||||
|
description: g.content.description,
|
||||||
|
title: g.title,
|
||||||
|
criticality: g.criticality,
|
||||||
|
enabled: g.enabled,
|
||||||
|
metadata: g.metadata,
|
||||||
|
labels: g.labels,
|
||||||
|
priority: g.priority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDocument(d: GuidelineDocument): Guideline {
|
||||||
|
return {
|
||||||
|
id: d.id,
|
||||||
|
creationUtc: d.creation_utc,
|
||||||
|
content: {
|
||||||
|
condition: d.condition,
|
||||||
|
action: d.action ?? null,
|
||||||
|
description: d.description ?? null,
|
||||||
|
},
|
||||||
|
title: d.title ?? null,
|
||||||
|
criticality: (d.criticality || 'medium') as Criticality,
|
||||||
|
enabled: d.enabled ?? true,
|
||||||
|
tags: [],
|
||||||
|
labels: d.labels ?? [],
|
||||||
|
metadata: d.metadata ?? {},
|
||||||
|
priority: d.priority ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GuidelineDocumentStore {
|
||||||
|
createGuideline(params: {
|
||||||
|
condition: string;
|
||||||
|
action?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
criticality?: Criticality;
|
||||||
|
enabled?: boolean;
|
||||||
|
labels?: string[];
|
||||||
|
priority?: number;
|
||||||
|
id?: GuidelineId;
|
||||||
|
}, projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const id = params.id || `gl_${generateId()}`;
|
||||||
|
|
||||||
|
if (docs.find(d => d.id === id)) {
|
||||||
|
throw new Error(`Guideline with id '${id}' already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guideline: Guideline = {
|
||||||
|
id,
|
||||||
|
creationUtc: new Date().toISOString(),
|
||||||
|
content: {
|
||||||
|
condition: params.condition,
|
||||||
|
action: params.action ?? null,
|
||||||
|
description: params.description ?? null,
|
||||||
|
},
|
||||||
|
title: params.title ?? null,
|
||||||
|
criticality: params.criticality ?? 'medium',
|
||||||
|
enabled: params.enabled ?? true,
|
||||||
|
tags: [],
|
||||||
|
labels: params.labels ?? [],
|
||||||
|
metadata: {},
|
||||||
|
priority: params.priority ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
docs.push(toDocument(guideline));
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
return guideline;
|
||||||
|
}
|
||||||
|
|
||||||
|
listGuidelines(params?: {
|
||||||
|
tags?: TagId[];
|
||||||
|
labels?: string[];
|
||||||
|
}, projectRoot?: string): Guideline[] {
|
||||||
|
let docs = readDb(projectRoot);
|
||||||
|
|
||||||
|
if (params?.tags && params.tags.length > 0) {
|
||||||
|
const tagSet = new Set(params.tags);
|
||||||
|
docs = docs.filter(d => d.metadata['tags'] &&
|
||||||
|
Array.isArray(d.metadata['tags']) &&
|
||||||
|
(d.metadata['tags'] as string[]).some(t => tagSet.has(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.labels && params.labels.length > 0) {
|
||||||
|
const labelSet = new Set(params.labels);
|
||||||
|
docs = docs.filter(d => {
|
||||||
|
const gl = fromDocument(d);
|
||||||
|
return params.labels!.every(l => gl.labels.includes(l));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs.map(fromDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
readGuideline(id: GuidelineId, projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const doc = docs.find(d => d.id === id);
|
||||||
|
if (!doc) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGuideline(id: GuidelineId, params: GuidelineUpdateParams, projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const idx = docs.findIndex(d => d.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
|
||||||
|
const doc = docs[idx]!;
|
||||||
|
if (params.condition !== undefined) doc.condition = params.condition;
|
||||||
|
if (params.action !== undefined) doc.action = params.action;
|
||||||
|
if (params.description !== undefined) doc.description = params.description;
|
||||||
|
if (params.title !== undefined) doc.title = params.title;
|
||||||
|
if (params.criticality !== undefined) doc.criticality = params.criticality;
|
||||||
|
if (params.enabled !== undefined) doc.enabled = params.enabled;
|
||||||
|
if (params.priority !== undefined) doc.priority = params.priority;
|
||||||
|
|
||||||
|
docs[idx] = doc;
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGuideline(id: GuidelineId, projectRoot?: string): void {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const idx = docs.findIndex(d => d.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
docs.splice(idx, 1);
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
findGuideline(content: GuidelineContent, projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const doc = docs.find(d =>
|
||||||
|
d.condition === content.condition &&
|
||||||
|
(content.action === undefined || d.action === content.action),
|
||||||
|
);
|
||||||
|
if (!doc) throw new Error(`Guideline not found for condition='${content.condition}'`);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const idx = docs.findIndex(d => d.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
|
||||||
|
const doc = docs[idx]!;
|
||||||
|
const current = new Set(doc.labels || []);
|
||||||
|
for (const l of labels) current.add(l);
|
||||||
|
doc.labels = [...current];
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const idx = docs.findIndex(d => d.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
|
||||||
|
const doc = docs[idx]!;
|
||||||
|
const removeSet = new Set(labels);
|
||||||
|
doc.labels = (doc.labels || []).filter(l => !removeSet.has(l));
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/server/src/services/audit/index.ts
Normal file
68
apps/server/src/services/audit/index.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export {
|
||||||
|
findRunsDir,
|
||||||
|
ensureRunsDir,
|
||||||
|
readCurrentSession,
|
||||||
|
writeCurrentSession,
|
||||||
|
clearCurrentSession,
|
||||||
|
readIndex,
|
||||||
|
writeIndex,
|
||||||
|
updateIndexEntry,
|
||||||
|
findInProgressSessions,
|
||||||
|
INDEX_SCHEMA_VERSION,
|
||||||
|
GITIGNORE_CONTENT,
|
||||||
|
} from './runs-dir.js';
|
||||||
|
export type { IndexEntry, IndexFile } from './runs-dir.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
generateSessionId,
|
||||||
|
isoNow,
|
||||||
|
createSession,
|
||||||
|
getSessionDir,
|
||||||
|
getActiveSession,
|
||||||
|
readSession,
|
||||||
|
updateSession,
|
||||||
|
endSession,
|
||||||
|
appendToTrail,
|
||||||
|
readTrail,
|
||||||
|
recoverContext,
|
||||||
|
checkUnfinishedSessions,
|
||||||
|
generateSessionSummary,
|
||||||
|
} from './session-manager.js';
|
||||||
|
export type { SessionJson, RecoverySummary } from './session-manager.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createCorrection,
|
||||||
|
findCorrections,
|
||||||
|
checkCorrectionConflict,
|
||||||
|
} from './corrections.js';
|
||||||
|
export type { UserCorrectionRecord } from './corrections.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
GuidelineDocumentStore,
|
||||||
|
} from './guideline-store.js';
|
||||||
|
export type {
|
||||||
|
GuidelineId,
|
||||||
|
GuidelineContent,
|
||||||
|
Guideline,
|
||||||
|
Criticality,
|
||||||
|
GuidelineUpdateParams,
|
||||||
|
GuidelineDocument,
|
||||||
|
} from './guideline-store.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
JourneyStore,
|
||||||
|
} from './journey-store.js';
|
||||||
|
export type {
|
||||||
|
JourneyId,
|
||||||
|
JourneyNodeId,
|
||||||
|
JourneyEdgeId,
|
||||||
|
Journey,
|
||||||
|
JourneyNode,
|
||||||
|
JourneyEdge,
|
||||||
|
} from './journey-store.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
projectJourneyToGuidelines,
|
||||||
|
detectJourneyBacktrack,
|
||||||
|
} from './journey-projection.js';
|
||||||
|
export type { ProjectedGuideline, BacktrackCheck } from './journey-projection.js';
|
||||||
189
apps/server/src/services/audit/journey-projection.ts
Normal file
189
apps/server/src/services/audit/journey-projection.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import type {
|
||||||
|
Journey,
|
||||||
|
JourneyNode,
|
||||||
|
JourneyEdge,
|
||||||
|
JourneyNodeId,
|
||||||
|
JourneyEdgeId,
|
||||||
|
} from './journey-store.js';
|
||||||
|
import type { Guideline, GuidelineId, Criticality } from './guideline-store.js';
|
||||||
|
|
||||||
|
export interface ProjectedGuideline {
|
||||||
|
id: GuidelineId;
|
||||||
|
content: {
|
||||||
|
condition: string;
|
||||||
|
action: string | null;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
criticality: Criticality;
|
||||||
|
creationUtc: string;
|
||||||
|
enabled: boolean;
|
||||||
|
tags: string[];
|
||||||
|
labels: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNodeGuidelineId(nodeId: JourneyNodeId, edgeId?: JourneyEdgeId | null): GuidelineId {
|
||||||
|
return `journey_node:${nodeId}${edgeId ? `:${edgeId}` : ''}` as GuidelineId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectJourneyToGuidelines(
|
||||||
|
journey: Journey,
|
||||||
|
nodes: JourneyNode[],
|
||||||
|
edges: JourneyEdge[],
|
||||||
|
): ProjectedGuideline[] {
|
||||||
|
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
|
||||||
|
for (const n of nodes) nodeMap.set(n.id, n);
|
||||||
|
|
||||||
|
const edgeMap = new Map<JourneyEdgeId, JourneyEdge>();
|
||||||
|
for (const e of edges) edgeMap.set(e.id, e);
|
||||||
|
|
||||||
|
const nodeEdges = new Map<JourneyNodeId, JourneyEdge[]>();
|
||||||
|
for (const e of edges) {
|
||||||
|
const list = nodeEdges.get(e.source) || [];
|
||||||
|
list.push(e);
|
||||||
|
nodeEdges.set(e.source, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guidelines: Map<GuidelineId, ProjectedGuideline> = new Map();
|
||||||
|
const nodeIndexes = new Map<JourneyNodeId, number>();
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const queue: Array<{ edgeId: JourneyEdgeId | null; nodeId: JourneyNodeId }> = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
queue.push({ edgeId: null, nodeId: journey.rootId });
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { edgeId, nodeId } = queue.shift()!;
|
||||||
|
const visitKey = `${edgeId || ''}:${nodeId}`;
|
||||||
|
if (visited.has(visitKey)) continue;
|
||||||
|
visited.add(visitKey);
|
||||||
|
|
||||||
|
const node = nodeMap.get(nodeId);
|
||||||
|
if (!node) continue;
|
||||||
|
|
||||||
|
if (!nodeIndexes.has(nodeId)) {
|
||||||
|
index++;
|
||||||
|
nodeIndexes.set(nodeId, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const edge = edgeId ? edgeMap.get(edgeId) : undefined;
|
||||||
|
|
||||||
|
const baseJourneyNode: Record<string, unknown> = {
|
||||||
|
follow_ups: [],
|
||||||
|
index: String(nodeIndexes.get(nodeId)),
|
||||||
|
journey_id: journey.id,
|
||||||
|
labels: node.labels,
|
||||||
|
tool_ids: node.tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
const edgeJourneyNode = (edge?.metadata?.['journey_node'] as Record<string, unknown>) || {};
|
||||||
|
const nodeJourneyNode = (node.metadata?.['journey_node'] as Record<string, unknown>) || {};
|
||||||
|
|
||||||
|
const mergedJourneyNode = { ...baseJourneyNode, ...nodeJourneyNode, ...edgeJourneyNode };
|
||||||
|
|
||||||
|
const metadata: Record<string, unknown> = {
|
||||||
|
journey_node: mergedJourneyNode,
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(node.metadata)) {
|
||||||
|
if (k !== 'journey_node') metadata[k] = v;
|
||||||
|
}
|
||||||
|
if (edge) {
|
||||||
|
for (const [k, v] of Object.entries(edge.metadata)) {
|
||||||
|
if (k !== 'journey_node') metadata[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gid = formatNodeGuidelineId(nodeId, edgeId);
|
||||||
|
const guideline: ProjectedGuideline = {
|
||||||
|
id: gid,
|
||||||
|
content: {
|
||||||
|
condition: (edge?.condition) || '',
|
||||||
|
action: node.action,
|
||||||
|
description: node.description,
|
||||||
|
},
|
||||||
|
criticality: 'high' as Criticality,
|
||||||
|
creationUtc: new Date().toISOString(),
|
||||||
|
enabled: true,
|
||||||
|
tags: journey.tags,
|
||||||
|
labels: [...(node.labels || [])],
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
guidelines.set(gid, guideline);
|
||||||
|
|
||||||
|
const childEdges = nodeEdges.get(nodeId) || [];
|
||||||
|
for (const childEdge of childEdges) {
|
||||||
|
if (visited.has(`${childEdge.id}:${childEdge.target}`)) continue;
|
||||||
|
queue.push({ edgeId: childEdge.id, nodeId: childEdge.target });
|
||||||
|
|
||||||
|
const childGid = formatNodeGuidelineId(childEdge.target, childEdge.id);
|
||||||
|
const followUps = (guideline.metadata['journey_node'] as Record<string, unknown>)['follow_ups'] as string[];
|
||||||
|
if (!followUps.includes(childGid)) {
|
||||||
|
followUps.push(childGid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...guidelines.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktrackCheck {
|
||||||
|
journeyId: string;
|
||||||
|
currentNodeId: JourneyNodeId;
|
||||||
|
previousNodeId: JourneyNodeId;
|
||||||
|
isBacktrack: boolean;
|
||||||
|
recommendation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectJourneyBacktrack(
|
||||||
|
journey: Journey,
|
||||||
|
nodes: JourneyNode[],
|
||||||
|
edges: JourneyEdge[],
|
||||||
|
currentNodeId: JourneyNodeId,
|
||||||
|
previousNodeId: JourneyNodeId,
|
||||||
|
): BacktrackCheck {
|
||||||
|
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
|
||||||
|
for (const n of nodes) nodeMap.set(n.id, n);
|
||||||
|
|
||||||
|
const adjacency = new Map<JourneyNodeId, JourneyNodeId[]>();
|
||||||
|
for (const e of edges) {
|
||||||
|
const list = adjacency.get(e.source) || [];
|
||||||
|
list.push(e.target);
|
||||||
|
adjacency.set(e.source, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInForwardPath = (from: JourneyNodeId, target: JourneyNodeId): boolean => {
|
||||||
|
const visitedInner = new Set<JourneyNodeId>();
|
||||||
|
const queueInner: JourneyNodeId[] = [from];
|
||||||
|
while (queueInner.length > 0) {
|
||||||
|
const current = queueInner.shift()!;
|
||||||
|
if (current === target) return true;
|
||||||
|
if (visitedInner.has(current)) continue;
|
||||||
|
visitedInner.add(current);
|
||||||
|
for (const next of adjacency.get(current) || []) {
|
||||||
|
if (!visitedInner.has(next)) queueInner.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromCurToPrev = isInForwardPath(currentNodeId, previousNodeId);
|
||||||
|
const fromPrevToCur = isInForwardPath(previousNodeId, currentNodeId);
|
||||||
|
|
||||||
|
const isBacktrack = !fromPrevToCur && !fromCurToPrev;
|
||||||
|
|
||||||
|
let recommendation: string | null = null;
|
||||||
|
if (isBacktrack && nodeMap.has(previousNodeId)) {
|
||||||
|
const prevNode = nodeMap.get(previousNodeId)!;
|
||||||
|
recommendation = `Detected potential backtrack from '${currentNodeId}' to '${previousNodeId}' (${prevNode.action || 'no action'}). Consider whether this regression is intentional.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
journeyId: journey.id,
|
||||||
|
currentNodeId,
|
||||||
|
previousNodeId,
|
||||||
|
isBacktrack,
|
||||||
|
recommendation,
|
||||||
|
};
|
||||||
|
}
|
||||||
360
apps/server/src/services/audit/journey-store.ts
Normal file
360
apps/server/src/services/audit/journey-store.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ensureRunsDir } from './runs-dir.js';
|
||||||
|
import type { GuidelineId } from './guideline-store.js';
|
||||||
|
|
||||||
|
export type JourneyId = string;
|
||||||
|
export type JourneyNodeId = string;
|
||||||
|
export type JourneyEdgeId = string;
|
||||||
|
|
||||||
|
export interface JourneyNode {
|
||||||
|
id: JourneyNodeId;
|
||||||
|
creationUtc: string;
|
||||||
|
action: string | null;
|
||||||
|
tools: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
description: string | null;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneyEdge {
|
||||||
|
id: JourneyEdgeId;
|
||||||
|
creationUtc: string;
|
||||||
|
source: JourneyNodeId;
|
||||||
|
target: JourneyNodeId;
|
||||||
|
condition: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Journey {
|
||||||
|
id: JourneyId;
|
||||||
|
creationUtc: string;
|
||||||
|
description: string;
|
||||||
|
triggers: GuidelineId[];
|
||||||
|
title: string;
|
||||||
|
rootId: JourneyNodeId;
|
||||||
|
tags: string[];
|
||||||
|
labels: string[];
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JourneyDocument {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
creation_utc: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
root_id: JourneyNodeId;
|
||||||
|
labels: string[];
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeDocument {
|
||||||
|
id: string;
|
||||||
|
node_id: JourneyNodeId;
|
||||||
|
journey_id: JourneyId;
|
||||||
|
creation_utc: string;
|
||||||
|
action: string | null;
|
||||||
|
tools: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
description: string | null;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EdgeDocument {
|
||||||
|
id: string;
|
||||||
|
journey_id: JourneyId;
|
||||||
|
creation_utc: string;
|
||||||
|
source: JourneyNodeId;
|
||||||
|
target: JourneyNodeId;
|
||||||
|
condition: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerDocument {
|
||||||
|
id: string;
|
||||||
|
journey_id: JourneyId;
|
||||||
|
trigger: GuidelineId;
|
||||||
|
creation_utc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
result += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbPath(name: string, projectRoot?: string): string {
|
||||||
|
const dir = join(ensureRunsDir(projectRoot), '..', 'journeys');
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
return join(dir, `${name}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCollection<T>(name: string, projectRoot?: string): T[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(dbPath(name, projectRoot), 'utf-8')) as T[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCollection<T>(name: string, data: T[], projectRoot?: string): void {
|
||||||
|
writeFileSync(dbPath(name, projectRoot), JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JourneyStore {
|
||||||
|
createJourney(params: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
triggers?: GuidelineId[];
|
||||||
|
labels?: string[];
|
||||||
|
priority?: number;
|
||||||
|
}, projectRoot?: string): Journey {
|
||||||
|
const id = `jny_${generateId()}`;
|
||||||
|
const rootId = `node_${generateId()}`;
|
||||||
|
const creationUtc = new Date().toISOString();
|
||||||
|
|
||||||
|
const journey: Journey = {
|
||||||
|
id,
|
||||||
|
creationUtc,
|
||||||
|
description: params.description,
|
||||||
|
triggers: params.triggers || [],
|
||||||
|
title: params.title,
|
||||||
|
rootId,
|
||||||
|
tags: [],
|
||||||
|
labels: params.labels || [],
|
||||||
|
priority: params.priority || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||||
|
journeys.push({
|
||||||
|
id,
|
||||||
|
version: '0.7.0',
|
||||||
|
creation_utc: creationUtc,
|
||||||
|
title: params.title,
|
||||||
|
description: params.description,
|
||||||
|
root_id: rootId,
|
||||||
|
labels: params.labels || [],
|
||||||
|
priority: params.priority || 0,
|
||||||
|
});
|
||||||
|
writeCollection('journeys', journeys, projectRoot);
|
||||||
|
|
||||||
|
const root: JourneyNode = {
|
||||||
|
id: rootId,
|
||||||
|
creationUtc,
|
||||||
|
action: null,
|
||||||
|
tools: [],
|
||||||
|
metadata: {},
|
||||||
|
description: null,
|
||||||
|
labels: [],
|
||||||
|
};
|
||||||
|
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
|
||||||
|
nodes.push({
|
||||||
|
id: `nd_${generateId()}`,
|
||||||
|
node_id: rootId,
|
||||||
|
journey_id: id,
|
||||||
|
creation_utc: creationUtc,
|
||||||
|
action: null,
|
||||||
|
tools: [],
|
||||||
|
metadata: {},
|
||||||
|
description: null,
|
||||||
|
labels: [],
|
||||||
|
});
|
||||||
|
writeCollection('nodes', nodes, projectRoot);
|
||||||
|
|
||||||
|
return journey;
|
||||||
|
}
|
||||||
|
|
||||||
|
readJourney(id: JourneyId, projectRoot?: string): Journey {
|
||||||
|
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||||
|
const doc = journeys.find(j => j.id === id);
|
||||||
|
if (!doc) throw new Error(`Journey '${id}' not found`);
|
||||||
|
|
||||||
|
const triggers = readCollection<TriggerDocument>('triggers', projectRoot)
|
||||||
|
.filter(t => t.journey_id === id)
|
||||||
|
.map(t => t.trigger);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
creationUtc: doc.creation_utc,
|
||||||
|
description: doc.description,
|
||||||
|
triggers,
|
||||||
|
title: doc.title,
|
||||||
|
rootId: doc.root_id,
|
||||||
|
tags: [],
|
||||||
|
labels: doc.labels || [],
|
||||||
|
priority: doc.priority || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteJourney(id: JourneyId, projectRoot?: string): void {
|
||||||
|
let journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||||
|
const idx = journeys.findIndex(j => j.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Journey '${id}' not found`);
|
||||||
|
journeys.splice(idx, 1);
|
||||||
|
writeCollection('journeys', journeys, projectRoot);
|
||||||
|
|
||||||
|
let nodes = readCollection<NodeDocument>('nodes', projectRoot);
|
||||||
|
nodes = nodes.filter(n => n.journey_id !== id);
|
||||||
|
writeCollection('nodes', nodes, projectRoot);
|
||||||
|
|
||||||
|
let edges = readCollection<EdgeDocument>('edges', projectRoot);
|
||||||
|
edges = edges.filter(e => e.journey_id !== id);
|
||||||
|
writeCollection('edges', edges, projectRoot);
|
||||||
|
|
||||||
|
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
|
||||||
|
triggers = triggers.filter(t => t.journey_id !== id);
|
||||||
|
writeCollection('triggers', triggers, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
listJourneys(projectRoot?: string): Journey[] {
|
||||||
|
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||||
|
return journeys.map(j => this.readJourney(j.id, projectRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
createNode(journeyId: JourneyId, params: {
|
||||||
|
action?: string | null;
|
||||||
|
tools?: string[];
|
||||||
|
description?: string | null;
|
||||||
|
labels?: string[];
|
||||||
|
id?: JourneyNodeId;
|
||||||
|
}, projectRoot?: string): JourneyNode {
|
||||||
|
const nodeId = params.id || `node_${generateId()}`;
|
||||||
|
const creationUtc = new Date().toISOString();
|
||||||
|
|
||||||
|
const node: JourneyNode = {
|
||||||
|
id: nodeId,
|
||||||
|
creationUtc,
|
||||||
|
action: params.action ?? null,
|
||||||
|
tools: params.tools || [],
|
||||||
|
metadata: {},
|
||||||
|
description: params.description ?? null,
|
||||||
|
labels: params.labels || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
|
||||||
|
nodes.push({
|
||||||
|
id: `nd_${generateId()}`,
|
||||||
|
node_id: nodeId,
|
||||||
|
journey_id: journeyId,
|
||||||
|
creation_utc: creationUtc,
|
||||||
|
action: node.action,
|
||||||
|
tools: node.tools,
|
||||||
|
metadata: node.metadata,
|
||||||
|
description: node.description,
|
||||||
|
labels: node.labels,
|
||||||
|
});
|
||||||
|
writeCollection('nodes', nodes, projectRoot);
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
listNodes(journeyId: JourneyId, projectRoot?: string): JourneyNode[] {
|
||||||
|
const docs = readCollection<NodeDocument>('nodes', projectRoot)
|
||||||
|
.filter(n => n.journey_id === journeyId);
|
||||||
|
|
||||||
|
const nodes = docs.map(d => ({
|
||||||
|
id: d.node_id,
|
||||||
|
creationUtc: d.creation_utc,
|
||||||
|
action: d.action,
|
||||||
|
tools: d.tools,
|
||||||
|
metadata: d.metadata,
|
||||||
|
description: d.description,
|
||||||
|
labels: d.labels || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: 'end' as JourneyNodeId,
|
||||||
|
creationUtc: new Date().toISOString(),
|
||||||
|
action: null,
|
||||||
|
tools: [],
|
||||||
|
metadata: {},
|
||||||
|
description: null,
|
||||||
|
labels: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
createEdge(journeyId: JourneyId, params: {
|
||||||
|
source: JourneyNodeId;
|
||||||
|
target: JourneyNodeId;
|
||||||
|
condition?: string | null;
|
||||||
|
}, projectRoot?: string): JourneyEdge {
|
||||||
|
const creationUtc = new Date().toISOString();
|
||||||
|
const edge: JourneyEdge = {
|
||||||
|
id: `edge_${generateId()}`,
|
||||||
|
creationUtc,
|
||||||
|
source: params.source,
|
||||||
|
target: params.target,
|
||||||
|
condition: params.condition ?? null,
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const edges = readCollection<EdgeDocument>('edges', projectRoot);
|
||||||
|
edges.push({
|
||||||
|
id: edge.id,
|
||||||
|
journey_id: journeyId,
|
||||||
|
creation_utc: creationUtc,
|
||||||
|
source: params.source,
|
||||||
|
target: params.target,
|
||||||
|
condition: params.condition ?? null,
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
writeCollection('edges', edges, projectRoot);
|
||||||
|
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEdges(journeyId: JourneyId, nodeId?: JourneyNodeId, projectRoot?: string): JourneyEdge[] {
|
||||||
|
let docs = readCollection<EdgeDocument>('edges', projectRoot)
|
||||||
|
.filter(e => e.journey_id === journeyId);
|
||||||
|
|
||||||
|
if (nodeId) {
|
||||||
|
docs = docs.filter(e => e.source === nodeId || e.target === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs.map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
creationUtc: d.creation_utc,
|
||||||
|
source: d.source,
|
||||||
|
target: d.target,
|
||||||
|
condition: d.condition,
|
||||||
|
metadata: d.metadata,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEdge(edgeId: JourneyEdgeId, projectRoot?: string): void {
|
||||||
|
let edges = readCollection<EdgeDocument>('edges', projectRoot);
|
||||||
|
const idx = edges.findIndex(e => e.id === edgeId);
|
||||||
|
if (idx === -1) throw new Error(`Edge '${edgeId}' not found`);
|
||||||
|
edges.splice(idx, 1);
|
||||||
|
writeCollection('edges', edges, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
addTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
|
||||||
|
const triggers = readCollection<TriggerDocument>('triggers', projectRoot);
|
||||||
|
if (triggers.find(t => t.journey_id === journeyId && t.trigger === trigger)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
triggers.push({
|
||||||
|
id: `trg_${generateId()}`,
|
||||||
|
journey_id: journeyId,
|
||||||
|
trigger,
|
||||||
|
creation_utc: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
writeCollection('triggers', triggers, projectRoot);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
|
||||||
|
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
|
||||||
|
const len = triggers.length;
|
||||||
|
triggers = triggers.filter(t => !(t.journey_id === journeyId && t.trigger === trigger));
|
||||||
|
writeCollection('triggers', triggers, projectRoot);
|
||||||
|
return triggers.length < len;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
apps/server/src/services/audit/runs-dir.ts
Normal file
111
apps/server/src/services/audit/runs-dir.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export const INDEX_SCHEMA_VERSION = '1.1';
|
||||||
|
export const GITIGNORE_CONTENT = `# boocode audit runs
|
||||||
|
/*
|
||||||
|
!index.json
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface IndexEntry {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
task?: string;
|
||||||
|
skill?: string;
|
||||||
|
created?: string;
|
||||||
|
last_updated?: string;
|
||||||
|
record_count?: number;
|
||||||
|
anomaly_count?: number;
|
||||||
|
max_anomaly_level?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexFile {
|
||||||
|
schema_version: string;
|
||||||
|
entries: IndexEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRunsDirFrom(start: string): string {
|
||||||
|
const explicit = process.env['AUDIT_DOT_DIR']?.trim();
|
||||||
|
const candidates = explicit ? [explicit] : ['.boo'];
|
||||||
|
let cur = resolve(start);
|
||||||
|
while (true) {
|
||||||
|
for (const basename of candidates) {
|
||||||
|
const candidate = join(cur, basename, 'runs');
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
const parent = resolve(cur, '..');
|
||||||
|
if (parent === cur) break;
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
const defaultBasename = explicit || '.boo';
|
||||||
|
return join(resolve(start), defaultBasename, 'runs');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRunsDir(projectRoot?: string): string {
|
||||||
|
return findRunsDirFrom(projectRoot || process.cwd());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureRunsDir(projectRoot?: string): string {
|
||||||
|
const dir = findRunsDir(projectRoot);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const gitignorePath = join(dir, '.gitignore');
|
||||||
|
if (!existsSync(gitignorePath)) {
|
||||||
|
writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCurrentSession(projectRoot?: string): string | null {
|
||||||
|
const path = join(ensureRunsDir(projectRoot), '.current_session');
|
||||||
|
try {
|
||||||
|
return readFileSync(path, 'utf-8').trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCurrentSession(sessionId: string, projectRoot?: string): void {
|
||||||
|
writeFileSync(join(ensureRunsDir(projectRoot), '.current_session'), sessionId, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCurrentSession(projectRoot?: string): void {
|
||||||
|
const path = join(ensureRunsDir(projectRoot), '.current_session');
|
||||||
|
try {
|
||||||
|
writeFileSync(path, '', 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readIndex(projectRoot?: string): IndexFile {
|
||||||
|
const path = join(ensureRunsDir(projectRoot), 'index.json');
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as IndexFile;
|
||||||
|
} catch {
|
||||||
|
return { schema_version: INDEX_SCHEMA_VERSION, entries: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeIndex(index: IndexFile, projectRoot?: string): void {
|
||||||
|
const runsDir = ensureRunsDir(projectRoot);
|
||||||
|
writeFileSync(join(runsDir, 'index.json'), JSON.stringify(index, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateIndexEntry(entry: IndexEntry, projectRoot?: string): void {
|
||||||
|
const idx = readIndex(projectRoot);
|
||||||
|
const existing = idx.entries.find(e => e.id === entry.id);
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, entry);
|
||||||
|
} else {
|
||||||
|
idx.entries.push({ ...entry });
|
||||||
|
}
|
||||||
|
writeIndex(idx, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findInProgressSessions(projectRoot?: string): IndexEntry[] {
|
||||||
|
const idx = readIndex(projectRoot);
|
||||||
|
return idx.entries.filter(e => e.status === 'in_progress');
|
||||||
|
}
|
||||||
236
apps/server/src/services/audit/session-manager.ts
Normal file
236
apps/server/src/services/audit/session-manager.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
ensureRunsDir,
|
||||||
|
readCurrentSession,
|
||||||
|
writeCurrentSession,
|
||||||
|
clearCurrentSession,
|
||||||
|
updateIndexEntry,
|
||||||
|
findInProgressSessions,
|
||||||
|
readIndex,
|
||||||
|
type IndexEntry,
|
||||||
|
} from './runs-dir.js';
|
||||||
|
|
||||||
|
export interface SessionJson {
|
||||||
|
session_id: string;
|
||||||
|
task: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time?: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
expected_record_types?: string[];
|
||||||
|
total_records?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSessionId(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const h = String(now.getHours()).padStart(2, '0');
|
||||||
|
const min = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
return `adhoc_${y}${m}${d}_${h}${min}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(
|
||||||
|
task: string,
|
||||||
|
sessionId?: string,
|
||||||
|
projectRoot?: string,
|
||||||
|
): string {
|
||||||
|
const sid = sessionId || generateSessionId();
|
||||||
|
const runsDir = ensureRunsDir(projectRoot);
|
||||||
|
const sessionDir = join(runsDir, sid);
|
||||||
|
mkdirSync(sessionDir, { recursive: true });
|
||||||
|
|
||||||
|
const session: SessionJson = {
|
||||||
|
session_id: sid,
|
||||||
|
task,
|
||||||
|
start_time: isoNow(),
|
||||||
|
status: 'in_progress',
|
||||||
|
expected_record_types: ['data', 'change', 'conversation'],
|
||||||
|
};
|
||||||
|
|
||||||
|
writeFileSync(join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), 'utf-8');
|
||||||
|
writeCurrentSession(sid, projectRoot);
|
||||||
|
|
||||||
|
updateIndexEntry({
|
||||||
|
id: sid,
|
||||||
|
type: 'adhoc',
|
||||||
|
status: 'in_progress',
|
||||||
|
task,
|
||||||
|
created: session.start_time,
|
||||||
|
last_updated: session.start_time,
|
||||||
|
}, projectRoot);
|
||||||
|
|
||||||
|
return sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionDir(sessionId: string, projectRoot?: string): string {
|
||||||
|
return join(ensureRunsDir(projectRoot), sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveSession(projectRoot?: string): SessionJson | null {
|
||||||
|
const sid = readCurrentSession(projectRoot);
|
||||||
|
if (!sid) return null;
|
||||||
|
return readSession(sid, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSession(sessionId: string, projectRoot?: string): SessionJson | null {
|
||||||
|
const path = join(getSessionDir(sessionId, projectRoot), 'session.json');
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as SessionJson;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSession(
|
||||||
|
sessionId: string,
|
||||||
|
updates: Partial<SessionJson>,
|
||||||
|
projectRoot?: string,
|
||||||
|
): void {
|
||||||
|
const session = readSession(sessionId, projectRoot) || { session_id: sessionId, task: '', start_time: isoNow(), status: 'in_progress' as const };
|
||||||
|
Object.assign(session, updates);
|
||||||
|
writeFileSync(
|
||||||
|
join(getSessionDir(sessionId, projectRoot), 'session.json'),
|
||||||
|
JSON.stringify(session, null, 2),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endSession(sessionId: string, projectRoot?: string): void {
|
||||||
|
updateSession(sessionId, { status: 'completed', end_time: isoNow() }, projectRoot);
|
||||||
|
updateIndexEntry({ id: sessionId, type: 'adhoc', status: 'completed', last_updated: isoNow() }, projectRoot);
|
||||||
|
clearCurrentSession(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendToTrail(sessionId: string, records: Record<string, unknown>[], projectRoot?: string): void {
|
||||||
|
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
||||||
|
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
|
||||||
|
appendFileSync(trailPath, lines, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTrail(sessionId: string, projectRoot?: string): Record<string, unknown>[] {
|
||||||
|
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
||||||
|
try {
|
||||||
|
const content = readFileSync(trailPath, 'utf-8').trim();
|
||||||
|
if (!content) return [];
|
||||||
|
return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoverySummary {
|
||||||
|
sessionId: string;
|
||||||
|
task: string;
|
||||||
|
recentActivity: IndexEntry[];
|
||||||
|
userCorrections: Record<string, unknown>[];
|
||||||
|
unresolvedIssues: string[];
|
||||||
|
recommendedPriorities: string[];
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoverContext(
|
||||||
|
sessionId: string,
|
||||||
|
level: number,
|
||||||
|
projectRoot?: string,
|
||||||
|
): RecoverySummary {
|
||||||
|
const session = readSession(sessionId, projectRoot);
|
||||||
|
const idx = readIndex(projectRoot);
|
||||||
|
const recentActivity = idx.entries.slice(-5);
|
||||||
|
const trail = readTrail(sessionId, projectRoot);
|
||||||
|
const userCorrections = trail.filter(r => r['action_type'] === 'user_correction');
|
||||||
|
|
||||||
|
const summary: RecoverySummary = {
|
||||||
|
sessionId,
|
||||||
|
task: session?.task || '(unknown)',
|
||||||
|
recentActivity,
|
||||||
|
userCorrections,
|
||||||
|
unresolvedIssues: [],
|
||||||
|
recommendedPriorities: [],
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (level >= 1) {
|
||||||
|
const last = trail.slice(-3);
|
||||||
|
if (last.length > 0) {
|
||||||
|
summary.recommendedPriorities.push(`Last action: ${JSON.stringify(last[last.length - 1]?.['action'] || 'none')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level >= 3) {
|
||||||
|
summary.recommendedPriorities.push(`Full trail: ${trail.length} records`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let checkCount = 0;
|
||||||
|
for (const entry of recentActivity) {
|
||||||
|
if (entry.status === 'in_progress' && entry.id !== sessionId) {
|
||||||
|
summary.unresolvedIssues.push(`Unfinished session: ${entry.id} (${entry.task || 'no task'})`);
|
||||||
|
checkCount++;
|
||||||
|
if (checkCount >= 3) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkUnfinishedSessions(projectRoot?: string): IndexEntry[] {
|
||||||
|
return findInProgressSessions(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSessionSummary(sessionId: string, projectRoot?: string): string {
|
||||||
|
const session = readSession(sessionId, projectRoot);
|
||||||
|
const trail = readTrail(sessionId, projectRoot);
|
||||||
|
const corrections = trail.filter(r => r['action_type'] === 'user_correction');
|
||||||
|
const changes = trail.filter(r => r['action'] === 'edit_file' || r['action'] === 'create_file' || r['action'] === 'delete_file');
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`# Session Summary | ${sessionId}`,
|
||||||
|
'',
|
||||||
|
`## Task: ${session?.task || '(unknown)'}`,
|
||||||
|
`## Time: ${session?.start_time || '?'} → ${session?.end_time || 'in_progress'}`,
|
||||||
|
`## Status: ${session?.status || 'unknown'}`,
|
||||||
|
'',
|
||||||
|
'## Completed Work',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const r of trail) {
|
||||||
|
if (r['action']) {
|
||||||
|
lines.push(`- ${r['action']}: ${r['detail'] || r['reason'] || '(no detail)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (corrections.length > 0) {
|
||||||
|
lines.push('', '## User Corrections');
|
||||||
|
for (const c of corrections) {
|
||||||
|
lines.push(`- Original: ${c['original_claim']}`);
|
||||||
|
lines.push(` Correction: ${c['correction']}`);
|
||||||
|
if (c['principle_extracted']) {
|
||||||
|
lines.push(` Principle: ${c['principle_extracted']}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.length > 0) {
|
||||||
|
lines.push('', '## Files Changed');
|
||||||
|
const fileSet = new Set<string>();
|
||||||
|
for (const c of changes) {
|
||||||
|
const files = c['files'];
|
||||||
|
if (Array.isArray(files)) {
|
||||||
|
for (const f of files) fileSet.add(String(f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const f of fileSet) lines.push(`- ${f}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '## Stats');
|
||||||
|
lines.push(`- Total records: ${trail.length}`);
|
||||||
|
lines.push(`- Corrections: ${corrections.length}`);
|
||||||
|
lines.push(`- File changes: ${changes.length}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -131,23 +131,13 @@ export function isManagedFlag(flag: string): boolean {
|
|||||||
|
|
||||||
const SHADOW_CONTEXT = ['-c', '--ctx-size'];
|
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 = [
|
// Empty: ngram speculative decoding is a performance feature agents should
|
||||||
'--spec-default',
|
// be able to enable.
|
||||||
'--spec-type',
|
const SHADOW_SPEC: string[] = [];
|
||||||
'--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',
|
|
||||||
];
|
|
||||||
|
|
||||||
const SHADOW_TEMPLATE = [
|
const SHADOW_TEMPLATE = [
|
||||||
'--chat-template',
|
'--chat-template',
|
||||||
@@ -160,7 +150,6 @@ const SHADOW_TEMPLATE = [
|
|||||||
// Shadowing flags that take no value — a boolean switch — so the stripper must
|
// Shadowing flags that take no value — a boolean switch — so the stripper must
|
||||||
// not also drop the following token.
|
// not also drop the following token.
|
||||||
const VALUELESS_SHADOW_FLAGS: ReadonlySet<string> = new Set([
|
const VALUELESS_SHADOW_FLAGS: ReadonlySet<string> = new Set([
|
||||||
'--spec-default',
|
|
||||||
'--jinja',
|
'--jinja',
|
||||||
'--no-jinja',
|
'--no-jinja',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -57,11 +57,21 @@ interface ConfigLike {
|
|||||||
LLAMA_SIDECAR_URL?: string;
|
LLAMA_SIDECAR_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveRoute(agent: AgentLike | null): RoutingInfo {
|
export function resolveRoute(
|
||||||
|
agent: AgentLike | null,
|
||||||
|
config?: ConfigLike,
|
||||||
|
): RoutingInfo {
|
||||||
|
// When llama_extra_args are explicitly set, route through sidecar with them.
|
||||||
const flags = agent?.llama_extra_args;
|
const flags = agent?.llama_extra_args;
|
||||||
if (flags && flags.length > 0) {
|
if (flags && flags.length > 0) {
|
||||||
return { route: 'sidecar', flags };
|
return { route: 'sidecar', flags };
|
||||||
}
|
}
|
||||||
|
// When LLAMA_SIDECAR_URL is configured (even without per-agent flags),
|
||||||
|
// route through sidecar to pick up the default base args (cache quant,
|
||||||
|
// spec decoding, slot save, etc.). Fall back to llama-swap otherwise.
|
||||||
|
if (config?.LLAMA_SIDECAR_URL) {
|
||||||
|
return { route: 'sidecar', flags: [] };
|
||||||
|
}
|
||||||
return { route: 'swap', flags: null };
|
return { route: 'swap', flags: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,15 +80,13 @@ export function upstreamModel(
|
|||||||
modelId: string,
|
modelId: string,
|
||||||
agent?: AgentLike | null,
|
agent?: AgentLike | null,
|
||||||
): LanguageModel {
|
): LanguageModel {
|
||||||
const { route, flags } = resolveRoute(agent ?? null);
|
const { route, flags } = resolveRoute(agent ?? null, config);
|
||||||
if (route === 'sidecar') {
|
if (route === 'sidecar') {
|
||||||
const url = config.LLAMA_SIDECAR_URL;
|
const url = config.LLAMA_SIDECAR_URL;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error(
|
throw new Error(`Sidecar route selected but LLAMA_SIDECAR_URL is not set`);
|
||||||
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return sidecarProvider(url, flags!).chatModel(modelId);
|
return sidecarProvider(url, (flags ?? [])).chatModel(modelId);
|
||||||
}
|
}
|
||||||
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
|
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Home } from '@/pages/Home';
|
|||||||
import { Project } from '@/pages/Project';
|
import { Project } from '@/pages/Project';
|
||||||
import { Session } from '@/pages/Session';
|
import { Session } from '@/pages/Session';
|
||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
|
import { Analytics } from '@/pages/Analytics';
|
||||||
|
import { Results } from '@/pages/Results';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { useUserEvents } from '@/hooks/useUserEvents';
|
import { useUserEvents } from '@/hooks/useUserEvents';
|
||||||
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
|
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
|
||||||
@@ -95,6 +97,8 @@ function AppShell() {
|
|||||||
<Route path="/project/:id" element={<Project />} />
|
<Route path="/project/:id" element={<Project />} />
|
||||||
<Route path="/session/:id" element={<Session />} />
|
<Route path="/session/:id" element={<Session />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
|
<Route path="/results" element={<Results />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<MobileRightRailBackdrop />
|
<MobileRightRailBackdrop />
|
||||||
|
|||||||
271
apps/web/src/components/InferenceSettings.tsx
Normal file
271
apps/web/src/components/InferenceSettings.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Database, Zap, Clock, BarChart3, Folder } from 'lucide-react';
|
||||||
|
|
||||||
|
interface InferenceConfig {
|
||||||
|
cache_type_k: string;
|
||||||
|
cache_reuse: number;
|
||||||
|
spec_type: string;
|
||||||
|
spec_ngram_mod_thsh: number;
|
||||||
|
ctx_checkpoints: number;
|
||||||
|
sleep_idle_seconds: number;
|
||||||
|
metrics_enabled: boolean;
|
||||||
|
slot_save_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: InferenceConfig = {
|
||||||
|
cache_type_k: 'q4_0',
|
||||||
|
cache_reuse: 256,
|
||||||
|
spec_type: 'ngram-mod',
|
||||||
|
spec_ngram_mod_thsh: 2,
|
||||||
|
ctx_checkpoints: 32,
|
||||||
|
sleep_idle_seconds: 600,
|
||||||
|
metrics_enabled: true,
|
||||||
|
slot_save_path: '/tmp/llama-slots',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Switch({ checked, onCheckedChange, id }: {
|
||||||
|
checked: boolean;
|
||||||
|
onCheckedChange: (v: boolean) => void;
|
||||||
|
id?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onCheckedChange(!checked)}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors ${
|
||||||
|
checked ? 'bg-primary' : 'bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||||
|
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loader() {
|
||||||
|
return <div className="text-sm text-muted-foreground py-8 text-center">Loading inference settings...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InferenceSettings() {
|
||||||
|
const [config, setConfig] = useState<InferenceConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/settings/inference')
|
||||||
|
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||||
|
.then((data) => setConfig(data as InferenceConfig))
|
||||||
|
.catch(() => {
|
||||||
|
setConfig({ ...DEFAULTS });
|
||||||
|
toast.error('Could not load inference config — loading defaults');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function update<K extends keyof InferenceConfig>(key: K, value: InferenceConfig[K]) {
|
||||||
|
setConfig((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!config || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/inference', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Save failed');
|
||||||
|
const updated = (await res.json()) as InferenceConfig;
|
||||||
|
setConfig(updated);
|
||||||
|
toast.success('Inference settings saved');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Loader />;
|
||||||
|
if (!config) return <div className="text-sm text-destructive py-8 text-center">Failed to load</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="size-3.5 text-muted-foreground" />
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
KV Cache Quantization
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={config.cache_type_k}
|
||||||
|
onChange={(e) => update('cache_type_k', e.target.value)}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
>
|
||||||
|
<option value="f32">f32 (full precision)</option>
|
||||||
|
<option value="f16">f16 (half)</option>
|
||||||
|
<option value="q8_0">q8_0 (8-bit)</option>
|
||||||
|
<option value="q4_0">q4_0 (4-bit) — recommended</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Format for the attention KV cache. Lower = less VRAM. q4_0 gives ~4x savings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="size-3.5 text-muted-foreground" />
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Prompt Caching
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={4096}
|
||||||
|
value={config.cache_reuse}
|
||||||
|
onChange={(e) => update('cache_reuse', Number(e.target.value))}
|
||||||
|
className="w-32 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{config.cache_reuse > 0 ? 'On (min chunk size in tokens)' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Reuses KV cache across turns when prompt prefix matches. 256 is a good default.
|
||||||
|
0 = disabled. The local equivalent of prompt caching.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="size-3.5 text-muted-foreground" />
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Speculative Decoding
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={config.spec_type}
|
||||||
|
onChange={(e) => update('spec_type', e.target.value)}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
<option value="ngram-mod">N-gram (lightweight, ~16MB)</option>
|
||||||
|
<option value="draft-simple">Draft model (requires separate model)</option>
|
||||||
|
</select>
|
||||||
|
{config.spec_type === 'ngram-mod' && (
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={config.spec_ngram_mod_thsh}
|
||||||
|
onChange={(e) => update('spec_ngram_mod_thsh', Number(e.target.value))}
|
||||||
|
className="w-24 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">Match threshold (2 = default)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Predicts tokens ahead with a small model; main model verifies in batch.
|
||||||
|
2-3x speedup on repetitive/code tasks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Context Checkpoints
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={128}
|
||||||
|
value={config.ctx_checkpoints}
|
||||||
|
onChange={(e) => update('ctx_checkpoints', Number(e.target.value))}
|
||||||
|
className="w-24 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{config.ctx_checkpoints > 0 ? `Max ${config.ctx_checkpoints} checkpoints per slot` : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Prevents context overflow on long conversations. Default: 32.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="size-3.5 text-muted-foreground" />
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Auto-sleep Timeout
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={-1}
|
||||||
|
max={86400}
|
||||||
|
value={config.sleep_idle_seconds}
|
||||||
|
onChange={(e) => update('sleep_idle_seconds', Number(e.target.value))}
|
||||||
|
className="w-24 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
GPU auto-sleeps after N seconds idle. -1 = disabled. 600 = 10 min.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="size-3.5 text-muted-foreground" />
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Prometheus Metrics
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.metrics_enabled}
|
||||||
|
onCheckedChange={(v) => update('metrics_enabled', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Enable /metrics endpoint for Prometheus monitoring (token rates, latency).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="size-3.5 text-muted-foreground" />
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Slot KV Cache Path
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.slot_save_path}
|
||||||
|
onChange={(e) => update('slot_save_path', e.target.value)}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm font-mono outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Directory for disk-persistent KV cache. Idle slot caches are saved here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t pt-4">
|
||||||
|
<Button onClick={() => void save()} disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
import { BarChart3, ChevronRight, ExternalLink, Folder, MessageSquare, Plus, ScrollText, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import mascot from '@/assets/brand/banner-mascot.png';
|
import mascot from '@/assets/brand/banner-mascot.png';
|
||||||
@@ -519,11 +519,40 @@ export function ProjectSidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* bottom-pinned nav buttons. Results → Analytics → Settings. */}
|
||||||
|
<div className="border-t shrink-0 p-2 space-y-0.5">
|
||||||
|
<NavLink
|
||||||
|
to="/results"
|
||||||
|
onClick={() => { if (isMobile) setDrawerOpen(false); }}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${
|
||||||
|
isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
aria-label="Results"
|
||||||
|
>
|
||||||
|
<ScrollText className="size-3.5 shrink-0 opacity-70" />
|
||||||
|
<span className="flex-1 text-left">Results</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/analytics"
|
||||||
|
onClick={() => { if (isMobile) setDrawerOpen(false); }}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${
|
||||||
|
isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
aria-label="Token Analytics"
|
||||||
|
>
|
||||||
|
<BarChart3 className="size-3.5 shrink-0 opacity-70" />
|
||||||
|
<span className="flex-1 text-left">Token Analytics</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
||||||
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
||||||
the panesHook). Outside a session there's no workspace to mount the
|
the panesHook). Outside a session there's no workspace to mount the
|
||||||
pane in, so we navigate to /settings (themes page) instead. */}
|
pane in, so we navigate to /settings (themes page) instead. */}
|
||||||
<div className="border-t shrink-0 p-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ export function ArenaPane({ state, onClose }: Props) {
|
|||||||
duration_ms: null,
|
duration_ms: null,
|
||||||
tokens_per_sec: null,
|
tokens_per_sec: null,
|
||||||
cost_tokens: null,
|
cost_tokens: null,
|
||||||
|
token_breakdown: null,
|
||||||
result_path: null,
|
result_path: null,
|
||||||
error: null,
|
error: null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Archive, FolderOpen, Maximize2, Minimize2, Trash2, X } from 'lucide-react';
|
import { Archive, FolderOpen, Maximize2, Minimize2, Trash2, X, Database, Zap, Clock, BarChart3, Folder } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Project, Session } from '@/api/types';
|
import type { Project, Session } from '@/api/types';
|
||||||
@@ -15,10 +15,11 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { ModelPicker } from '@/components/ModelPicker';
|
import { ModelPicker } from '@/components/ModelPicker';
|
||||||
import { ThemePicker } from '@/components/ThemePicker';
|
import { ThemePicker } from '@/components/ThemePicker';
|
||||||
|
import { InferenceSettings as InferenceSettingsComponent } from '@/components/InferenceSettings';
|
||||||
import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
|
import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Section = 'session' | 'project' | 'theme' | 'providers';
|
type Section = 'session' | 'project' | 'theme' | 'providers' | 'inference';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
session: Session;
|
session: Session;
|
||||||
@@ -74,7 +75,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
|||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
{(['session', 'project', 'theme', 'providers'] as const).map((s) => (
|
{(['session', 'project', 'theme', 'providers', 'inference'] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -118,6 +119,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
|||||||
{activeSection === 'project' && <ProjectSection project={project} />}
|
{activeSection === 'project' && <ProjectSection project={project} />}
|
||||||
{activeSection === 'theme' && <ThemePicker />}
|
{activeSection === 'theme' && <ThemePicker />}
|
||||||
{activeSection === 'providers' && <ProvidersSettings />}
|
{activeSection === 'providers' && <ProvidersSettings />}
|
||||||
|
{activeSection === 'inference' && <InferenceSettingsComponent />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -599,3 +601,249 @@ function ProjectSection({ project }: { project: Project }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InferenceSettings {
|
||||||
|
cacheTypeK: string;
|
||||||
|
cacheReuse: number;
|
||||||
|
specType: string;
|
||||||
|
ctxCheckpoints: number;
|
||||||
|
sleepIdleSeconds: number;
|
||||||
|
metrics: boolean;
|
||||||
|
slotSavePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INFERENCE_DEFAULTS: InferenceSettings = {
|
||||||
|
cacheTypeK: 'q4_0',
|
||||||
|
cacheReuse: 256,
|
||||||
|
specType: 'ngram-mod',
|
||||||
|
ctxCheckpoints: 32,
|
||||||
|
sleepIdleSeconds: 600,
|
||||||
|
metrics: true,
|
||||||
|
slotSavePath: '/tmp/llama-slots',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'boocode-inference-settings';
|
||||||
|
|
||||||
|
function InferenceSettings() {
|
||||||
|
const [settings, setSettings] = useState<InferenceSettings>(INFERENCE_DEFAULTS);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
setSettings({ ...INFERENCE_DEFAULTS, ...parsed });
|
||||||
|
}
|
||||||
|
} catch { /* ignore corrupt storage */ }
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dirty = loaded && JSON.stringify(settings) !== (() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored ? JSON.stringify({ ...INFERENCE_DEFAULTS, ...JSON.parse(stored) }) : JSON.stringify(INFERENCE_DEFAULTS);
|
||||||
|
} catch { return JSON.stringify(INFERENCE_DEFAULTS); }
|
||||||
|
})();
|
||||||
|
|
||||||
|
function update<K extends keyof InferenceSettings>(key: K, value: InferenceSettings[K]) {
|
||||||
|
setSettings(prev => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
// Simulate API delay
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
toast.success('Inference settings saved. Restart sidecar to apply.');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'save failed');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetDefaults() {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
setSettings(INFERENCE_DEFAULTS);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(INFERENCE_DEFAULTS));
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
toast.success('Reset to defaults');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'reset failed');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loaded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="size-3.5 text-muted-foreground" />
|
||||||
|
<label htmlFor="cache-type-k" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
KV Cache Quantization
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="cache-type-k"
|
||||||
|
value={settings.cacheTypeK}
|
||||||
|
onChange={(e) => update('cacheTypeK', e.target.value)}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
>
|
||||||
|
<option value="f32">f32 — 32-bit (max quality)</option>
|
||||||
|
<option value="f16">f16 — 16-bit (balanced)</option>
|
||||||
|
<option value="q8_0">q8_0 — 8-bit (efficient)</option>
|
||||||
|
<option value="q4_0">q4_0 — 4-bit (max efficiency)</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Compresses the attention cache. Lower = less VRAM usage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="size-3.5 text-muted-foreground" />
|
||||||
|
<label htmlFor="cache-reuse" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Cache Reuse (Prompt Caching)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="cache-reuse"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={64}
|
||||||
|
value={settings.cacheReuse}
|
||||||
|
onChange={(e) => update('cacheReuse', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Minimum chunk size in tokens to reuse across turns. 0 = disabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="size-3.5 text-muted-foreground" />
|
||||||
|
<label htmlFor="spec-type" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Speculative Decoding
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="spec-type"
|
||||||
|
value={settings.specType}
|
||||||
|
onChange={(e) => update('specType', e.target.value)}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
<option value="ngram-mod">ngram-mod — Lightweight (~16MB, no draft model)</option>
|
||||||
|
<option value="draft-simple">draft-simple — Requires separate draft model</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Predicts tokens ahead using a small model. Main model verifies in batch for 2-3x speedup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="size-3.5 text-muted-foreground" />
|
||||||
|
<label htmlFor="ctx-checkpoints" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Context Checkpoints
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="ctx-checkpoints"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={256}
|
||||||
|
value={settings.ctxCheckpoints}
|
||||||
|
onChange={(e) => update('ctxCheckpoints', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Max checkpoints per slot. 0 = disabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="size-3.5 text-muted-foreground" />
|
||||||
|
<label htmlFor="sleep-idle" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Sleep Idle
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="sleep-idle"
|
||||||
|
type="number"
|
||||||
|
min={-1}
|
||||||
|
step={60}
|
||||||
|
value={settings.sleepIdleSeconds}
|
||||||
|
onChange={(e) => update('sleepIdleSeconds', parseInt(e.target.value) || -1)}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Auto-sleep after N seconds idle. -1 = disabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="size-3.5 text-muted-foreground" />
|
||||||
|
<label htmlFor="metrics" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Metrics Endpoint
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="metrics"
|
||||||
|
checked={settings.metrics}
|
||||||
|
onCheckedChange={(v) => update('metrics', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Exposes Prometheus /metrics endpoint for observability.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="size-3.5 text-muted-foreground" />
|
||||||
|
<label htmlFor="slot-save-path" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Slot Save Path
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="slot-save-path"
|
||||||
|
type="text"
|
||||||
|
value={settings.slotSavePath}
|
||||||
|
onChange={(e) => update('slotSavePath', e.target.value)}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm font-mono outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Directory for disk-persistent KV cache. Must be writable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between gap-2 border-t pt-4">
|
||||||
|
<Button variant="outline" onClick={() => void resetDefaults()} disabled={saving}>
|
||||||
|
Reset to defaults
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void save()} disabled={!dirty || saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground border-t pt-4">
|
||||||
|
Changes apply to new llama-server processes. Restart the sidecar to apply.
|
||||||
|
These settings are stored locally in your browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
454
apps/web/src/pages/Analytics.tsx
Normal file
454
apps/web/src/pages/Analytics.tsx
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ArrowLeft, BarChart3, Wifi, Wrench, Layers } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type {
|
||||||
|
AnalyticsSummary,
|
||||||
|
SessionAnalyticsRow,
|
||||||
|
ToolCostStat,
|
||||||
|
ContextWindowStats,
|
||||||
|
TokenBreakdownAgg,
|
||||||
|
} from '@/api/types';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// --- Independent section data fetcher ---
|
||||||
|
// Each section manages its own loading/error/data state so one failure doesn't
|
||||||
|
// block the rest of the page.
|
||||||
|
|
||||||
|
function useFetch<T>(fetcher: () => Promise<T>): {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
retry: () => void;
|
||||||
|
} {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetcher()
|
||||||
|
.then(setData)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to load data');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { data, loading, error, retry: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Skeleton pulse placeholder ---
|
||||||
|
function SkeletonBar({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Number formatting ---
|
||||||
|
function formatNumber(n: number | null | undefined): string {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(n: number | null | undefined): string {
|
||||||
|
if (n == null) return '—';
|
||||||
|
if (n < 0.001) return `$${(n * 1000).toFixed(2)}m`;
|
||||||
|
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||||
|
return `$${n.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(n: number | null | undefined): string {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return `${(n * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary Cards ---
|
||||||
|
function SummaryCards({ summary }: { summary: AnalyticsSummary }) {
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: 'Total Input Tokens',
|
||||||
|
value: formatNumber(summary.total_input_tokens),
|
||||||
|
icon: BarChart3,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Output Tokens',
|
||||||
|
value: formatNumber(summary.total_output_tokens),
|
||||||
|
icon: BarChart3,
|
||||||
|
color: 'text-green-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Cost',
|
||||||
|
value: formatCost(summary.total_cost),
|
||||||
|
icon: Wifi,
|
||||||
|
color: 'text-amber-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sessions Tracked',
|
||||||
|
value: formatNumber(summary.session_count),
|
||||||
|
icon: Layers,
|
||||||
|
color: 'text-purple-500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Card key={c.label} size="sm">
|
||||||
|
<CardContent className="flex items-start gap-3 pt-3">
|
||||||
|
<c.icon className={cn('size-5 shrink-0 mt-0.5', c.color)} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-lg font-semibold tabular-nums">{c.value}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{c.label}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCardsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} size="sm">
|
||||||
|
<CardContent className="pt-3">
|
||||||
|
<SkeletonBar className="h-5 w-20 mb-2" />
|
||||||
|
<SkeletonBar className="h-3 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Section wrappers ---
|
||||||
|
function SectionCard({
|
||||||
|
title,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SkeletonBar className="h-4 w-full" />
|
||||||
|
<SkeletonBar className="h-4 w-3/4" />
|
||||||
|
<SkeletonBar className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return <p className="text-sm text-muted-foreground py-2">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-Session Token Table ---
|
||||||
|
function SessionTable({ sessions }: { sessions: SessionAnalyticsRow[] }) {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return <EmptyState message="No session token data available yet. Token data is collected as agent sessions run." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<th className="py-2 pr-4 font-medium">Session</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Input</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Output</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Cost</th>
|
||||||
|
<th className="py-2 font-medium tabular-nums text-right">Last Active</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="py-2 pr-4 truncate max-w-[200px]" title={s.session_name}>
|
||||||
|
{s.session_name || 'Untitled'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_input_tokens)}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_output_tokens)}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatCost(s.total_cost)}</td>
|
||||||
|
<td className="py-2 tabular-nums text-right text-muted-foreground">{formatDate(s.last_active_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-Tool Cost Table ---
|
||||||
|
function ToolTable({ stats }: { stats: ToolCostStat[] }) {
|
||||||
|
if (stats.length === 0) {
|
||||||
|
return <EmptyState message="No tool cost data available yet. Stats accumulate after tool calls are made." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<th className="py-2 pr-4 font-medium">Tool</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Calls</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Prompt</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Completion</th>
|
||||||
|
<th className="py-2 font-medium tabular-nums text-right">Avg Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.map((t) => (
|
||||||
|
<tr key={t.tool_name} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="py-2 pr-4 flex items-center gap-2">
|
||||||
|
<Wrench className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate max-w-[200px]" title={t.tool_name}>{t.tool_name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{t.n_calls}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens)}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_completion_tokens)}</td>
|
||||||
|
<td className="py-2 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens + t.mean_completion_tokens)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Context Window Utilization ---
|
||||||
|
function ContextSection({ stats }: { stats: ContextWindowStats }) {
|
||||||
|
if (stats.message_count === 0) {
|
||||||
|
return <EmptyState message="No context window data available yet. Data is captured during inference." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Context Used</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_used ?? 0))}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Context Limit</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_max ?? 0))}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Utilization</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums mt-1">{formatPct(stats.avg_utilization_pct)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Based on {formatNumber(stats.message_count)} completed assistant messages</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: stats.avg_utilization_pct != null
|
||||||
|
? `${Math.min(stats.avg_utilization_pct * 100, 100)}%`
|
||||||
|
: '0%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Token Category Breakdown (CSS stacked bar) ---
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
system: 'bg-blue-500',
|
||||||
|
user: 'bg-green-500',
|
||||||
|
assistant: 'bg-amber-500',
|
||||||
|
tools: 'bg-purple-500',
|
||||||
|
reasoning: 'bg-rose-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
system: 'System',
|
||||||
|
user: 'User',
|
||||||
|
assistant: 'Assistant',
|
||||||
|
tools: 'Tools',
|
||||||
|
reasoning: 'Reasoning',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TokenBreakdownSection({ categories }: { categories: TokenBreakdownAgg[] }) {
|
||||||
|
if (categories.length === 0) {
|
||||||
|
return <EmptyState message="No token breakdown data available. Breakdown is captured for arena contestants and certain task types." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = categories.reduce((sum, c) => sum + c.total_tokens, 0);
|
||||||
|
if (total === 0) return <EmptyState message="Token breakdown totals are zero." />;
|
||||||
|
|
||||||
|
// Sort in a consistent order
|
||||||
|
const order = ['system', 'user', 'assistant', 'tools', 'reasoning'];
|
||||||
|
const sorted = [...categories].sort(
|
||||||
|
(a, b) => order.indexOf(a.category) - order.indexOf(b.category),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-4 rounded-full bg-muted overflow-hidden flex">
|
||||||
|
{sorted.map((c) => {
|
||||||
|
const pct = (c.total_tokens / total) * 100;
|
||||||
|
if (pct < 1) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.category}
|
||||||
|
className={cn('h-full first:rounded-l-full last:rounded-r-full', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
title={`${CATEGORY_LABELS[c.category] ?? c.category}: ${formatNumber(c.total_tokens)} (${pct.toFixed(1)}%)`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
|
{sorted.map((c) => {
|
||||||
|
const pct = (c.total_tokens / total) * 100;
|
||||||
|
return (
|
||||||
|
<div key={c.category} className="flex items-center gap-1.5">
|
||||||
|
<span className={cn('size-2.5 rounded-sm', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')} />
|
||||||
|
<span className="text-muted-foreground">{CATEGORY_LABELS[c.category] ?? c.category}</span>
|
||||||
|
<span className="font-medium tabular-nums">{pct.toFixed(1)}%</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">({formatNumber(c.total_tokens)})</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Page ---
|
||||||
|
export function Analytics() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const summary = useFetch(() => api.analytics.summary());
|
||||||
|
const sessions = useFetch(() => api.analytics.sessions().then((r) => r.sessions));
|
||||||
|
const tools = useFetch(() => api.tools.costStats().then((r) => r.stats));
|
||||||
|
const context = useFetch(() => api.analytics.context());
|
||||||
|
const breakdown = useFetch(() => api.analytics.tokenBreakdown().then((r) => r.categories));
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Token Analytics</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Aggregate token usage, cost, and context window data across all sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summary.loading ? (
|
||||||
|
<SummaryCardsSkeleton />
|
||||||
|
) : summary.error ? (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-destructive">{summary.error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={summary.retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : summary.data ? (
|
||||||
|
<SummaryCards summary={summary.data} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Per-Session Token Breakdown */}
|
||||||
|
<SectionCard
|
||||||
|
title="Per-Session Token Usage"
|
||||||
|
loading={sessions.loading}
|
||||||
|
error={sessions.error}
|
||||||
|
onRetry={sessions.retry}
|
||||||
|
>
|
||||||
|
{sessions.data && <SessionTable sessions={sessions.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Per-Tool Cost Breakdown */}
|
||||||
|
<SectionCard
|
||||||
|
title="Per-Tool Token Cost"
|
||||||
|
loading={tools.loading}
|
||||||
|
error={tools.error}
|
||||||
|
onRetry={tools.retry}
|
||||||
|
>
|
||||||
|
{tools.data && <ToolTable stats={tools.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Context Window Utilization */}
|
||||||
|
<SectionCard
|
||||||
|
title="Context Window Utilization"
|
||||||
|
loading={context.loading}
|
||||||
|
error={context.error}
|
||||||
|
onRetry={context.retry}
|
||||||
|
>
|
||||||
|
{context.data && <ContextSection stats={context.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Token Category Breakdown */}
|
||||||
|
<SectionCard
|
||||||
|
title="Token Breakdown by Category"
|
||||||
|
loading={breakdown.loading}
|
||||||
|
error={breakdown.error}
|
||||||
|
onRetry={breakdown.retry}
|
||||||
|
>
|
||||||
|
{breakdown.data && <TokenBreakdownSection categories={breakdown.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
510
apps/web/src/pages/Results.tsx
Normal file
510
apps/web/src/pages/Results.tsx
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ArrowLeft, Beaker, CheckCircle2, FileText, ScrollText, Swords, XCircle } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { BattleShape, FlowRunRow } from '@/api/types';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useSidebar } from '@/hooks/useSidebar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
|
||||||
|
|
||||||
|
function useFetch<T>(fetcher: () => Promise<T>): {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
retry: () => void;
|
||||||
|
} {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetcher()
|
||||||
|
.then(setData)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to load data');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { data, loading, error, retry: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Skeleton ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SkeletonBar({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatters ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(startIso: string, endIso?: string | null): string {
|
||||||
|
const start = new Date(startIso).getTime();
|
||||||
|
const end = endIso ? new Date(endIso).getTime() : Date.now();
|
||||||
|
const ms = end - start;
|
||||||
|
if (ms < 0) return '—';
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
|
||||||
|
return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str: string, max: number): string {
|
||||||
|
if (str.length <= max) return str;
|
||||||
|
return str.slice(0, max) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status dot (shared visual language with OrchestratorPane/ArenaPane) ──────
|
||||||
|
|
||||||
|
type DotStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'pending';
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: DotStatus }) {
|
||||||
|
if (status === 'running') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label="running"
|
||||||
|
className="inline-block w-2.5 h-2.5 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cls =
|
||||||
|
status === 'completed'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: status === 'failed'
|
||||||
|
? 'bg-destructive'
|
||||||
|
: status === 'cancelled'
|
||||||
|
? 'bg-muted-foreground/20'
|
||||||
|
: 'bg-muted-foreground/40'; // pending
|
||||||
|
return <span aria-label={status} className={cn('inline-block w-2 h-2 rounded-full shrink-0', cls)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab bar ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TabId = 'runs' | 'battles';
|
||||||
|
|
||||||
|
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 border-b pb-px">
|
||||||
|
{[
|
||||||
|
{ id: 'runs' as TabId, label: 'Analysis Runs', icon: FileText },
|
||||||
|
{ id: 'battles' as TabId, label: 'Arena Battles', icon: Swords },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
|
||||||
|
active === tab.id
|
||||||
|
? 'bg-background border-border text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tab.icon className="size-3.5" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Empty state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project selector ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProjectSelector({
|
||||||
|
projects,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
projects: Array<{ id: string; name: string }>;
|
||||||
|
value: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="text-sm bg-muted/30 border border-border rounded px-2 py-1 text-foreground"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Analysis Runs tab ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AnalysisRunsTab({ projectId }: { projectId: string }) {
|
||||||
|
const { data, loading, error, retry } = useFetch(() => api.runs.list(projectId).then((r) => r.runs));
|
||||||
|
|
||||||
|
const [selectedRun, setSelectedRun] = useState<FlowRunRow | null>(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<SkeletonBar key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm pt-4">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <EmptyState message="No analysis runs yet. Start one from the Workflow button in any chat." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-2">
|
||||||
|
{data.map((run) => (
|
||||||
|
<div key={run.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedRun(selectedRun?.id === run.id ? null : run)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
|
||||||
|
selectedRun?.id === run.id && 'bg-muted/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusDot status={run.status as DotStatus} />
|
||||||
|
<span className="font-medium min-w-0 flex-1 truncate">
|
||||||
|
{run.flow_name}
|
||||||
|
<span className="text-muted-foreground font-normal ml-1.5 text-xs uppercase">
|
||||||
|
{run.band}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
||||||
|
{run.model ? run.model.split('/').pop() : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{formatDuration(run.created_at, run.updated_at)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDate(run.created_at)}
|
||||||
|
</span>
|
||||||
|
{run.error && (
|
||||||
|
<span className="text-destructive" title={run.error}>
|
||||||
|
<XCircle className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{run.status === 'completed' && run.report && (
|
||||||
|
<FileText className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded detail — report preview */}
|
||||||
|
{selectedRun?.id === run.id && run.status === 'completed' && run.report && (
|
||||||
|
<div className="ml-8 mr-2 mb-2 p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
|
||||||
|
{truncate(run.report, 3000)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Arena Battles tab ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ArenaBattlesTab({ projectId }: { projectId: string }) {
|
||||||
|
const { data, loading, error, retry } = useFetch(() => api.battles.list(projectId).then((r) => r.battles));
|
||||||
|
|
||||||
|
const [selectedBattle, setSelectedBattle] = useState<BattleShape | null>(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<SkeletonBar key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm pt-4">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <EmptyState message="No arena battles yet. Start one from the Arena button in any chat." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-2">
|
||||||
|
{data.map((battle) => {
|
||||||
|
const hasAnalysis = battle.status === 'completed' && battle.results_path;
|
||||||
|
return (
|
||||||
|
<div key={battle.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedBattle(selectedBattle?.id === battle.id ? null : battle)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
|
||||||
|
selectedBattle?.id === battle.id && 'bg-muted/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusDot status={
|
||||||
|
battle.status === 'completed' ? 'completed'
|
||||||
|
: battle.status === 'failed' ? 'failed'
|
||||||
|
: battle.status === 'cancelled' ? 'cancelled'
|
||||||
|
: 'running'
|
||||||
|
} />
|
||||||
|
<span className="font-medium min-w-0 flex-1 truncate">
|
||||||
|
{battle.battle_type === 'coding' ? 'Coding Battle' : 'Q&A Battle'}
|
||||||
|
<span className="text-muted-foreground font-normal ml-1.5 text-xs">
|
||||||
|
{truncate(battle.prompt, 60)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{battle.winner_contestant_id && (
|
||||||
|
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="size-3" />
|
||||||
|
Winner
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{battle.error && (
|
||||||
|
<span className="text-destructive" title={battle.error}>
|
||||||
|
<XCircle className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap hidden sm:block">
|
||||||
|
{formatDate(battle.created_at)}
|
||||||
|
</span>
|
||||||
|
{hasAnalysis && (
|
||||||
|
<Beaker className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded detail — analysis preview */}
|
||||||
|
{selectedBattle?.id === battle.id && hasAnalysis && (
|
||||||
|
<div className="ml-8 mr-2 mb-2">
|
||||||
|
<AnalysisPreview battleId={battle.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Battle analysis preview (fetches analysis.md on expand) ─────────────────
|
||||||
|
|
||||||
|
function AnalysisPreview({ battleId }: { battleId: string }) {
|
||||||
|
const { data, loading, error, retry } = useFetch(() => api.battles.getAnalysis(battleId).then((r) => r.text));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-3 rounded-md bg-muted/20 border border-border/50">
|
||||||
|
<SkeletonBar className="h-3 w-full" />
|
||||||
|
<SkeletonBar className="h-3 w-3/4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-md bg-muted/20 border border-border/50 text-xs">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
|
||||||
|
{data ? truncate(data, 3000) : 'No analysis available.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary strip ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SummaryCards({
|
||||||
|
runs,
|
||||||
|
battles,
|
||||||
|
}: {
|
||||||
|
runs: FlowRunRow[] | null;
|
||||||
|
battles: BattleShape[] | null;
|
||||||
|
}) {
|
||||||
|
const totalRuns = runs?.length ?? 0;
|
||||||
|
const completedRuns = runs?.filter((r) => r.status === 'completed').length ?? 0;
|
||||||
|
const totalBattles = battles?.length ?? 0;
|
||||||
|
const completedBattles = battles?.filter((b) => b.status === 'completed').length ?? 0;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: 'Total Runs', value: totalRuns, icon: FileText, color: 'text-blue-500' },
|
||||||
|
{ label: 'Completed Runs', value: completedRuns, icon: CheckCircle2, color: 'text-emerald-500' },
|
||||||
|
{ label: 'Total Battles', value: totalBattles, icon: Swords, color: 'text-violet-500' },
|
||||||
|
{ label: 'Completed Battles', value: completedBattles, icon: CheckCircle2, color: 'text-emerald-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Card key={c.label} size="sm">
|
||||||
|
<CardContent className="flex items-start gap-3 pt-3">
|
||||||
|
<c.icon className={cn('size-4 shrink-0 mt-0.5', c.color)} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-lg font-semibold tabular-nums">{c.value}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{c.label}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCardsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} size="sm">
|
||||||
|
<CardContent className="pt-3">
|
||||||
|
<SkeletonBar className="h-5 w-16 mb-2" />
|
||||||
|
<SkeletonBar className="h-3 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Results() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: sidebar, activeSession } = useSidebar();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<TabId>('runs');
|
||||||
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Derive default project from active session or first project.
|
||||||
|
const projects = useMemo(() => {
|
||||||
|
return sidebar?.projects?.map((p: { id: string; name: string }) => p) ?? [];
|
||||||
|
}, [sidebar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId && projects.length > 0) {
|
||||||
|
// Prefer active session's project, else first project.
|
||||||
|
const defaultId = activeSession?.project_id ?? projects[0]!.id;
|
||||||
|
setProjectId(defaultId);
|
||||||
|
}
|
||||||
|
}, [projects, activeSession, projectId]);
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runsFetch = useFetch(
|
||||||
|
projectId ? () => api.runs.list(projectId).then((r) => r.runs) : () => Promise.resolve([] as FlowRunRow[]),
|
||||||
|
);
|
||||||
|
const battlesFetch = useFetch(
|
||||||
|
projectId ? () => api.battles.list(projectId).then((r) => r.battles) : () => Promise.resolve([] as BattleShape[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryLoading = runsFetch.loading && battlesFetch.loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<ScrollText className="size-5" />
|
||||||
|
Results
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Completed orchestrator runs and arena battles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{projects.length > 0 && projectId && (
|
||||||
|
<ProjectSelector
|
||||||
|
projects={projects}
|
||||||
|
value={projectId}
|
||||||
|
onChange={setProjectId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summaryLoading ? (
|
||||||
|
<SummaryCardsSkeleton />
|
||||||
|
) : (
|
||||||
|
<SummaryCards runs={runsFetch.data} battles={battlesFetch.data} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<TabBar active={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{!projectId ? (
|
||||||
|
<EmptyState message="Select a project to view results." />
|
||||||
|
) : tab === 'runs' ? (
|
||||||
|
<AnalysisRunsTab projectId={projectId} />
|
||||||
|
) : (
|
||||||
|
<ArenaBattlesTab projectId={projectId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,18 +17,22 @@ COPY go.mod ./
|
|||||||
COPY shim.go ./
|
COPY shim.go ./
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /build/shim-bin ./
|
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
|
FROM node:20-alpine AS boocontext-builder
|
||||||
WORKDIR /build/boocontext
|
WORKDIR /build/boocontext
|
||||||
RUN apk add --no-cache git python3 make g++ ca-certificates
|
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
|
COPY fork.tar.gz /build/fork.tar.gz
|
||||||
RUN mkdir -p /build/boocontext && tar -xzf /build/fork.tar.gz -C /build/boocontext
|
RUN mkdir -p /build/boocontext && tar -xzf /build/fork.tar.gz -C /build/boocontext
|
||||||
WORKDIR /build/boocontext
|
WORKDIR /build/boocontext
|
||||||
RUN npm ci && npm run build
|
RUN pnpm install --frozen-lockfile && pnpm run build
|
||||||
|
|
||||||
# Stage 3: Runtime
|
# Stage 3: Runtime
|
||||||
FROM alpine:3.20
|
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=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/dist /usr/local/lib/boocontext/dist
|
||||||
COPY --from=boocontext-builder /build/boocontext/node_modules /usr/local/lib/boocontext/node_modules
|
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).
|
||||||
@@ -8,16 +8,11 @@
|
|||||||
},
|
},
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"boocontext": {
|
"type-inject": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "node",
|
"command": "npx",
|
||||||
"args": ["/opt/forks/boocontext/dist/index.js"],
|
"args": ["-y", "@nick-vi/type-inject-mcp"],
|
||||||
"env": {
|
"enabled": true
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
data/skills/boocode/audit-end/SKILL.md
Normal file
104
data/skills/boocode/audit-end/SKILL.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: audit-end
|
||||||
|
description: End an audit session with integrity checks and summary. Use when the user says "/end", "done", "pause", or when the current task is complete.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /end — Audit Session End + Integrity Check
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
/end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Determine current session
|
||||||
|
|
||||||
|
Read `.boo/runs/.current_session` for session_id.
|
||||||
|
|
||||||
|
If missing:
|
||||||
|
- Check for `auto_` sessions (hook-created)
|
||||||
|
- If none, report "No active session"
|
||||||
|
|
||||||
|
### 2. Collect audit data
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- `.boo/runs/audit_buffer.jsonl` — hook-recorded Write/Edit/Bash ops
|
||||||
|
- `.boo/runs/audit_pending.jsonl` — agent [AUDIT] blocks
|
||||||
|
- `.boo/runs/{session_id}/audit_trail.jsonl` — previously flushed records
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Read buffer + pending remaining data
|
||||||
|
2. Append to `audit_trail.jsonl`
|
||||||
|
3. Clear buffer + pending files
|
||||||
|
|
||||||
|
### 3. Extract user corrections
|
||||||
|
|
||||||
|
Scan audit_trail for `user_correction` records:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"record_type": "conversation",
|
||||||
|
"action_type": "user_correction",
|
||||||
|
"priority": "critical_for_recovery",
|
||||||
|
"timestamp": "<ISO 8601>",
|
||||||
|
"original_claim": "<what agent said>",
|
||||||
|
"correction": "<what user corrected>",
|
||||||
|
"principle_extracted": "<general principle>",
|
||||||
|
"persisted_to": ["CLAUDE.md", ".boo/guidelines/..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Integrity checks
|
||||||
|
|
||||||
|
| Check | Condition | Fail |
|
||||||
|
|-------|-----------|------|
|
||||||
|
| Has records | audit_trail lines > 0 | ⚠️ "Zero audit records" |
|
||||||
|
| Files covered | Write/Edit entries exist for modified files | ⚠️ List uncovered files |
|
||||||
|
| Corrections persisted | persisted_to is non-empty for each correction | ⚠️ Remind to persist |
|
||||||
|
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
=== Session Audit Check ===
|
||||||
|
Session: <id>
|
||||||
|
Task: <task>
|
||||||
|
Duration: <start → end>
|
||||||
|
|
||||||
|
[✅] Records: N
|
||||||
|
[⚠️] Files not in audit: <list>
|
||||||
|
[✅] Corrections persisted: M
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Generate session summary
|
||||||
|
|
||||||
|
Write `.boo/runs/{session_id}/session_summary.md`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Session Summary | <id>
|
||||||
|
## Task: <description>
|
||||||
|
## Time: <start → end>
|
||||||
|
## Status: completed
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
- <action list>
|
||||||
|
|
||||||
|
## User Corrections
|
||||||
|
- <correction records>
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
- Records: N
|
||||||
|
- Corrections: M
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Update state
|
||||||
|
|
||||||
|
- Set `session.json status = "completed", end_time = now()`
|
||||||
|
- Update `index.json` entry
|
||||||
|
- Clear `.current_session`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Save even if checks find problems — recording > perfection
|
||||||
|
- ⚠️ = don't block save; ❌ = warn user, still save
|
||||||
|
- /end itself may trigger one more Stop hook flush — normal
|
||||||
84
data/skills/boocode/audit-recover/SKILL.md
Normal file
84
data/skills/boocode/audit-recover/SKILL.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
name: audit-recover
|
||||||
|
description: Restore lost context from audit trail. Use when unsure of prior decisions, can't remember what was discussed, or the user says "/recover". Do not guess — check the records.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /recover — Context Recovery
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
/recover # L0+L1+L2 (current session)
|
||||||
|
/recover full # L3 (full trail)
|
||||||
|
/recover {session_id} # specific session
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core principle
|
||||||
|
|
||||||
|
**When uncertain, check the audit trail. Do not work from memory.**
|
||||||
|
Recovering from records is the only reliable way to avoid repeating corrected mistakes.
|
||||||
|
|
||||||
|
## When to trigger
|
||||||
|
|
||||||
|
| Signal | What to do |
|
||||||
|
|--------|-----------|
|
||||||
|
| Can't recall session details | Run /recover |
|
||||||
|
| Unsure about current task | Run /recover |
|
||||||
|
| About to propose something possibly corrected | Run /recover, check corrections |
|
||||||
|
| Answer is vague, missing specifics | Run /recover full |
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Graded loading
|
||||||
|
|
||||||
|
**Level 0 — Index (~200t)**
|
||||||
|
|
||||||
|
Read `.boo/runs/index.json` → last 5 entries (id, task, status)
|
||||||
|
|
||||||
|
**Level 1 — Task state (~500t)**
|
||||||
|
|
||||||
|
Read `.current_session` → session_id
|
||||||
|
Read `session.json` → task, start_time
|
||||||
|
Read last 3 `audit_trail.jsonl` entries → "where am I"
|
||||||
|
|
||||||
|
**Level 2 — User corrections (~1000t) ⚠️ HIGHEST PRIORITY**
|
||||||
|
|
||||||
|
Scan all audit_trail files for `user_correction` records
|
||||||
|
Scan for `conclusion` entries
|
||||||
|
Read latest daily report §4 (anomalies) + §6 (backlog)
|
||||||
|
|
||||||
|
**Level 3 — Full context (~3000t, /recover full only)**
|
||||||
|
|
||||||
|
Full `audit_trail.jsonl`
|
||||||
|
Full `audit_pending.jsonl`
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Context Recovery Report ===
|
||||||
|
Source: .boo/runs/<session_id>/
|
||||||
|
Level: L2
|
||||||
|
|
||||||
|
Task: <session.task>
|
||||||
|
Status: <last action>
|
||||||
|
|
||||||
|
⚠️ User corrections (must follow):
|
||||||
|
1. <timestamp> Original: "..."
|
||||||
|
Correction: "..."
|
||||||
|
Principle: <principle>
|
||||||
|
|
||||||
|
Key conclusions:
|
||||||
|
- <...>
|
||||||
|
|
||||||
|
Open issues:
|
||||||
|
- <...>
|
||||||
|
|
||||||
|
⚠️ Recovered from audit trail, not memory.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Corrections have highest priority — don't contradict them
|
||||||
|
- If current plan contradicts corrections, correct the plan
|
||||||
|
- Keep output concise — don't copy entire trail into context
|
||||||
|
- Recover "why" and "don't" before "what was done"
|
||||||
100
data/skills/boocode/audit-report-daily/SKILL.md
Normal file
100
data/skills/boocode/audit-report-daily/SKILL.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
name: audit-report-daily
|
||||||
|
description: Generate a daily work report from audit data. Every number traces to a source file. Use when user says "/report-daily", "daily report", "what did I do today".
|
||||||
|
---
|
||||||
|
|
||||||
|
# /report-daily — Audit-Driven Daily Report
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
/report-daily # today
|
||||||
|
/report-daily 20260319 # specific date
|
||||||
|
/report-daily review # with morning self-review
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
| Section | Source |
|
||||||
|
|---------|--------|
|
||||||
|
| Task overview | `.boo/runs/index.json` |
|
||||||
|
| Operation stats | `*/audit_trail.jsonl` tool records |
|
||||||
|
| Changes | trail entries with edit/create/delete |
|
||||||
|
| User feedback | `user_correction` entries in trail |
|
||||||
|
| Anomalies | `*/anomalies.json` |
|
||||||
|
| Backlog | previous day's daily report §6 |
|
||||||
|
|
||||||
|
Every number must trace to a file. Do not fill from memory.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Collect data
|
||||||
|
|
||||||
|
1. Read index.json, filter sessions for target date
|
||||||
|
2. Read each session's audit_trail.jsonl
|
||||||
|
3. Read pending (unflushed data)
|
||||||
|
4. Read previous day's report §6 (backlog) if exists
|
||||||
|
|
||||||
|
### 2. Generate report
|
||||||
|
|
||||||
|
Write to `.boo/runs/daily/{YYYYMMDD}_daily.md`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Daily Report | <DATE>
|
||||||
|
|
||||||
|
> Source: .boo/runs/index.json + audit_trails
|
||||||
|
|
||||||
|
## 1. Task Overview
|
||||||
|
| # | Type | Session | Task | Status | Records |
|
||||||
|
|
||||||
|
## 2. Operation Stats
|
||||||
|
| Metric | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| Write/Edit | N |
|
||||||
|
| Bash | N |
|
||||||
|
| AUDIT blocks | N |
|
||||||
|
|
||||||
|
## 3. Changes
|
||||||
|
| Time | File | Change |
|
||||||
|
|
||||||
|
## 4. User Feedback & Corrections
|
||||||
|
| Feedback | Persisted To |
|
||||||
|
|
||||||
|
## 5. Anomaly Alerts
|
||||||
|
- <alerts from anomalies.json>
|
||||||
|
|
||||||
|
## 6. Backlog
|
||||||
|
- previous day's todos
|
||||||
|
- current status
|
||||||
|
|
||||||
|
## 7. Integrity
|
||||||
|
- All sessions have records: ✅/❌
|
||||||
|
- Corrections persisted: ✅/❌
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. If /report-daily review
|
||||||
|
|
||||||
|
After report, additionally:
|
||||||
|
1. Check: yesterday's anomalies all addressed?
|
||||||
|
2. Check: user feedback converted to improvements?
|
||||||
|
3. Check: backlog items completed?
|
||||||
|
4. Write `.boo/runs/daily/{YYYYMMDD}_morning_review.md`
|
||||||
|
5. Output recommended priorities for today
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Morning Self-Review ===
|
||||||
|
Trend: <up/down/flat compared to last 3 days>
|
||||||
|
Anomalies resolved: N/M
|
||||||
|
Backlog cleared: N/M
|
||||||
|
|
||||||
|
Recommended priorities:
|
||||||
|
1. <...>
|
||||||
|
2. <...>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- If no sessions today, generate empty report with "No activity"
|
||||||
|
- Report itself should write one [AUDIT] block
|
||||||
|
- Historical reports are append-only — corrections go in new report
|
||||||
|
- Every number must cite its source file
|
||||||
85
data/skills/boocode/audit-start/SKILL.md
Normal file
85
data/skills/boocode/audit-start/SKILL.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: audit-start
|
||||||
|
description: Create an audit session with context recovery. Use when beginning a new task, before making changes, or when the user says "/start". Ensures all subsequent work is tracked in a recoverable session.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /start — Audit Session + Context Recovery
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
/start "task description"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Every work session should be tracked. Without a session:
|
||||||
|
- Hooks output to an auto_ session with no task description
|
||||||
|
- /end can't run integrity checks
|
||||||
|
- Daily reports lack task context
|
||||||
|
|
||||||
|
/start costs one directory + one JSON file. The return is traceability.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Create the session
|
||||||
|
|
||||||
|
1. Generate `session_id = adhoc_YYYYMMDD_HHMM`
|
||||||
|
2. `mkdir -p .boo/runs/{session_id}`
|
||||||
|
3. Write `session.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "<id>",
|
||||||
|
"task": "<user description>",
|
||||||
|
"start_time": "<ISO 8601>",
|
||||||
|
"status": "in_progress",
|
||||||
|
"expected_record_types": ["data", "change", "conversation"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Write `.boo/runs/.current_session` with session_id (hook handshake)
|
||||||
|
|
||||||
|
### 2. Context recovery
|
||||||
|
|
||||||
|
**Level 0 — Index**:
|
||||||
|
- Read `.boo/runs/index.json` → last 5 entries (id, task, status)
|
||||||
|
|
||||||
|
**Level 2 — User corrections (critical)**:
|
||||||
|
- Scan recent `audit_trail.jsonl` files for `user_correction` records
|
||||||
|
- These must be surfaced first — repeating corrected mistakes wastes effort
|
||||||
|
|
||||||
|
**Level 1 — Task state**:
|
||||||
|
- Read latest `.boo/runs/daily/*_daily.md` if it exists (§4 anomalies, §6 backlog)
|
||||||
|
- Read latest `*_morning_review.md` if it exists
|
||||||
|
|
||||||
|
### 3. Check unfinished sessions
|
||||||
|
|
||||||
|
- Scan `.boo/runs/` session dirs for `session.json` with `status: "in_progress"`
|
||||||
|
- If found, propose: continue existing session or start fresh
|
||||||
|
|
||||||
|
### 4. Output recovery summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Audit session: adhoc_20260320_1400
|
||||||
|
Task: <description>
|
||||||
|
|
||||||
|
Context recovery:
|
||||||
|
|
||||||
|
Recent activity:
|
||||||
|
- <last 3 completed tasks>
|
||||||
|
|
||||||
|
⚠️ User corrections (must follow):
|
||||||
|
- <all user_correction records>
|
||||||
|
|
||||||
|
Unresolved:
|
||||||
|
- <unfinished sessions, open alerts>
|
||||||
|
|
||||||
|
Today's priorities:
|
||||||
|
- <recommendations>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- If `.boo/runs/` doesn't exist, create it
|
||||||
|
- If no history, start clean — no errors
|
||||||
|
- session_id stays constant for the whole session; all [AUDIT] blocks share it
|
||||||
|
- If `.current_session` already points at an active session, ask before replacing
|
||||||
61
data/skills/boocode/command-end/SKILL.md
Normal file
61
data/skills/boocode/command-end/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: command-end
|
||||||
|
description: End the current audit session, flush remaining buffer data, run integrity checks, and generate a session summary. Use when finishing a task, taking a break, or ending work. Examples: "end", "finish session", "end session", "wrap up", "/end".
|
||||||
|
allowed-tools:
|
||||||
|
- Read
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- Bash
|
||||||
|
- Write
|
||||||
|
---
|
||||||
|
|
||||||
|
# /end — Audit Session End + Integrity Check
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
/end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Find current session
|
||||||
|
|
||||||
|
Read `.boo/runs/.current_session` for the session_id.
|
||||||
|
|
||||||
|
If absent, check for auto-created sessions. If none, report "No active session."
|
||||||
|
|
||||||
|
### 2. Collect remaining audit data
|
||||||
|
|
||||||
|
Read `.boo/runs/audit_buffer.jsonl` and `audit_pending.jsonl` for any data the Stop hook hasn't flushed yet. Append both to `.boo/runs/{session_id}/audit_trail.jsonl`, then clear the buffer files.
|
||||||
|
|
||||||
|
### 3. Extract user corrections
|
||||||
|
|
||||||
|
Scan `audit_trail.jsonl` for `user_correction` entries. Each should have a non-empty `persisted_to` array. If any are unpersisted, flag them.
|
||||||
|
|
||||||
|
### 4. Integrity checks
|
||||||
|
|
||||||
|
| Check | Source | Pass | Fail |
|
||||||
|
|-------|--------|------|------|
|
||||||
|
| Has records | trail line count | > 0 | Warn |
|
||||||
|
| Files tracked | tool=Write/Edit entries | Every changed file has an entry | Warn |
|
||||||
|
| Corrections persisted | user_correction entries | persisted_to non-empty | Warn |
|
||||||
|
|
||||||
|
### 5. Generate summary
|
||||||
|
|
||||||
|
Write `.boo/runs/{session_id}/session_summary.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Session Summary | {session_id}
|
||||||
|
## Task: {description}
|
||||||
|
## Time: {start} → {end}
|
||||||
|
## Status: completed
|
||||||
|
|
||||||
|
Completed work: {action list}
|
||||||
|
Key conclusions: {output entries}
|
||||||
|
User corrections: {correction records}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Close
|
||||||
|
|
||||||
|
Update `session.json`: status=completed, end_time=now. Update `index.json`. Delete `.current_session`.
|
||||||
61
data/skills/boocode/command-recover/SKILL.md
Normal file
61
data/skills/boocode/command-recover/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: command-recover
|
||||||
|
description: Recover lost context from audit session records. Use when you can't remember earlier discussion, aren't sure about task progress, or need to check what the user has corrected before. Also use when your answers feel vague — don't guess, recover. Examples: "recover", "what was I doing", "recap", "what did we discuss", "/recover".
|
||||||
|
allowed-tools:
|
||||||
|
- Read
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
---
|
||||||
|
|
||||||
|
# /recover — Context Recovery
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
/recover # L0+L1+L2 (current session)
|
||||||
|
/recover full # L3 (full audit_trail)
|
||||||
|
/recover {session_id} # Specific session
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
**Do not work from memory — query the audit trail when:**
|
||||||
|
- You can't recall what was decided earlier
|
||||||
|
- Unsure what phase the task is in
|
||||||
|
- About to propose something the user may have already corrected
|
||||||
|
- Answers feel generic (missing file names, specific numbers)
|
||||||
|
|
||||||
|
## Graded loading
|
||||||
|
|
||||||
|
### L0 — Index (~200t)
|
||||||
|
Read `.boo/runs/index.json` → last 5 entries (id, task, status)
|
||||||
|
|
||||||
|
### L1 — Task state (~500t)
|
||||||
|
Read `.current_session` → session.json → last 3 audit_trail entries
|
||||||
|
|
||||||
|
### L2 — User corrections + decisions (~1000t) ⚠️ MOST IMPORTANT
|
||||||
|
Scan ALL audit_trails for `user_correction` records + conclusions
|
||||||
|
Read daily report §4 (anomalies) + §6 (backlog)
|
||||||
|
|
||||||
|
### L3 — Full context (~3000t, /recover full only)
|
||||||
|
Complete audit_trail.jsonl + audit_pending.jsonl
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Context Recovery Report ===
|
||||||
|
Level: L2
|
||||||
|
Source: .boo/runs/{session_id}/
|
||||||
|
|
||||||
|
Current task: {description}
|
||||||
|
Progress: {last action}
|
||||||
|
|
||||||
|
USER CORRECTIONS (must follow):
|
||||||
|
1. [{time}] {original claim} → {correction}
|
||||||
|
Principle: {principle_extracted}
|
||||||
|
|
||||||
|
Key conclusions: ...
|
||||||
|
Unresolved: ...
|
||||||
|
|
||||||
|
Source: audit records (not memory)
|
||||||
|
```
|
||||||
70
data/skills/boocode/command-report-daily/SKILL.md
Normal file
70
data/skills/boocode/command-report-daily/SKILL.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
name: command-report-daily
|
||||||
|
description: Generate a data-driven daily work report from audit session records. Every number is traceable to `.boo/runs/` files. Use for daily standup, progress tracking, or morning review. Examples: "daily report", "report today", "what did I do today", "generate report", "/report-daily".
|
||||||
|
allowed-tools:
|
||||||
|
- Read
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- Bash
|
||||||
|
- Write
|
||||||
|
---
|
||||||
|
|
||||||
|
# /report-daily — Audit-Driven Work Report
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
/report-daily # Today
|
||||||
|
/report-daily 20260319 # Specific date
|
||||||
|
/report-daily review # Report + morning self-review
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data sources (every number must be traceable)
|
||||||
|
|
||||||
|
| Section | Source |
|
||||||
|
|---------|--------|
|
||||||
|
| Task overview | `.boo/runs/index.json` |
|
||||||
|
| Operation stats | `*/audit_trail.jsonl` tool records |
|
||||||
|
| Changes | trail entries with Write/Edit |
|
||||||
|
| User feedback | user_correction entries |
|
||||||
|
| Anomalies | `*/anomalies.json` (if any) |
|
||||||
|
| Backlog | previous day's daily report |
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
### I. Task Overview
|
||||||
|
Table of today's sessions with status and record count.
|
||||||
|
|
||||||
|
### II. Operation Stats
|
||||||
|
Write/Edit count, Bash count, Audit block count.
|
||||||
|
|
||||||
|
### III. Change Records
|
||||||
|
Timeline of file modifications with timestamps.
|
||||||
|
|
||||||
|
### IV. User Feedback & Corrections
|
||||||
|
User corrections made today, with persistence status.
|
||||||
|
|
||||||
|
### V. Anomaly Alerts
|
||||||
|
Unresolved issues flagged across sessions.
|
||||||
|
|
||||||
|
### VI. Backlog Tracking
|
||||||
|
Carry-over items from yesterday.
|
||||||
|
|
||||||
|
### VII. Integrity Summary
|
||||||
|
Health checks for all sessions.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Save to `.boo/runs/daily/{YYYYMMDD}_daily.md`.
|
||||||
|
|
||||||
|
### Review variant (`review`)
|
||||||
|
|
||||||
|
After the report, also generate `.boo/runs/daily/{YYYYMMDD}_morning_review.md` with:
|
||||||
|
- Self-correction check (anomalies resolved? feedback persisted? backlog handled?)
|
||||||
|
- Recommended priorities for today
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- If no sessions for the date, generate an empty report labeled "No activity"
|
||||||
|
- Reports are append-only — correct errors with a follow-up report, never edit
|
||||||
|
- Record one [AUDIT] block for report generation itself
|
||||||
72
data/skills/boocode/command-start/SKILL.md
Normal file
72
data/skills/boocode/command-start/SKILL.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: command-start
|
||||||
|
description: Create an audit session with context recovery before starting work. Use when beginning a new task, analysis, or code modification — establishes a session for tracking tool usage, user corrections, and decisions. Examples: "start", "begin session", "start work", "/start 'migrate database schema'".
|
||||||
|
allowed-tools:
|
||||||
|
- Read
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- Bash
|
||||||
|
- Write
|
||||||
|
---
|
||||||
|
|
||||||
|
# /start — Audit Session Start + Context Recovery
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
/start "task description"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Every task should run in an audit session. Without one:
|
||||||
|
- Tool-use data goes to unnamed sessions with no task context
|
||||||
|
- `/end` can't run integrity checks
|
||||||
|
- Daily reports miss task descriptions
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Create session
|
||||||
|
|
||||||
|
Generate `session_id = adhoc_YYYYMMDD_HHMM`, create `.boo/runs/{session_id}/`, write `session.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "adhoc_20260320_1400",
|
||||||
|
"task": "task description",
|
||||||
|
"start_time": "ISO 8601",
|
||||||
|
"status": "in_progress",
|
||||||
|
"expected_record_types": ["data", "change", "conversation"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Write session_id to `.boo/runs/.current_session` (the Stop hook reads this for buffer archiving).
|
||||||
|
|
||||||
|
### 2. Context recovery (L0 + L2)
|
||||||
|
|
||||||
|
**L0 — Index summary**: read `.boo/runs/index.json`, last 5 entries.
|
||||||
|
|
||||||
|
**L2 — User corrections (critical)**: scan all `audit_trail.jsonl` and `audit_pending.jsonl` for `user_correction` records — these must be restored first to avoid repeating mistakes.
|
||||||
|
|
||||||
|
**Check for unfinished sessions**: scan `.boo/runs/adhoc_*/session.json` for `status: "in_progress"`. If found, prompt the user to continue the old session instead.
|
||||||
|
|
||||||
|
### 3. Output summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Session created: adhoc_20260320_1400
|
||||||
|
Task: {description}
|
||||||
|
|
||||||
|
Context recovery:
|
||||||
|
Recent activity: {last 3 completed tasks}
|
||||||
|
User corrections (must follow): {all user_correction records}
|
||||||
|
Unresolved issues: {open anomalies/alerts}
|
||||||
|
Today's priorities: {from morning review}
|
||||||
|
|
||||||
|
All [AUDIT] blocks use batch_id = {session_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Notes
|
||||||
|
|
||||||
|
- If `.boo/runs/` doesn't exist, create it and skip recovery
|
||||||
|
- If `.current_session` already points to an in_progress session, prompt before creating a new one
|
||||||
|
- The session_id stays constant for the whole session — all [AUDIT] blocks share it
|
||||||
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"
|
- "127.0.0.1:8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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
|
TYPE_INJECT_MCP_PATH: /opt/type-inject/packages/mcp/dist/index.js
|
||||||
TREE_SITTER_MCP_CMD: uvx
|
TREE_SITTER_MCP_CMD: uvx
|
||||||
TREE_SITTER_MCP_ARGS: --from tree-sitter-analyzer[mcp] tree-sitter-analyzer-mcp
|
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';
|
||||||
|
}
|
||||||
111
packages/ion/src/schema/index.ts
Normal file
111
packages/ion/src/schema/index.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ion Schema Layer — Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// retry.ts
|
||||||
|
export {
|
||||||
|
stepRetryConfigSchema,
|
||||||
|
type StepRetryConfig,
|
||||||
|
} from './retry.js';
|
||||||
|
|
||||||
|
// loop.ts
|
||||||
|
export {
|
||||||
|
loopNodeConfigSchema,
|
||||||
|
type LoopNodeConfig,
|
||||||
|
} from './loop.js';
|
||||||
|
|
||||||
|
// trigger-rule.ts
|
||||||
|
export {
|
||||||
|
triggerRuleSchema,
|
||||||
|
TRIGGER_RULES,
|
||||||
|
DEFAULT_TRIGGER_RULE,
|
||||||
|
type TriggerRule,
|
||||||
|
} from './trigger-rule.js';
|
||||||
|
|
||||||
|
// dag-node.ts
|
||||||
|
export {
|
||||||
|
effortLevelSchema,
|
||||||
|
type EffortLevel,
|
||||||
|
thinkingConfigSchema,
|
||||||
|
type ThinkingConfig,
|
||||||
|
approvalOnRejectSchema,
|
||||||
|
type ApprovalOnReject,
|
||||||
|
dagNodeBaseSchema,
|
||||||
|
type DagNodeBase,
|
||||||
|
commandNodeSchema,
|
||||||
|
promptNodeSchema,
|
||||||
|
bashNodeSchema,
|
||||||
|
scriptNodeSchema,
|
||||||
|
loopNodeSchema,
|
||||||
|
approvalNodeSchema,
|
||||||
|
cancelNodeSchema,
|
||||||
|
type CommandNode,
|
||||||
|
type PromptNode,
|
||||||
|
type BashNode,
|
||||||
|
type ScriptNode,
|
||||||
|
type LoopNode,
|
||||||
|
type ApprovalNode,
|
||||||
|
type CancelNode,
|
||||||
|
dagNodeSchema,
|
||||||
|
type DagNode,
|
||||||
|
isBashNode,
|
||||||
|
isLoopNode,
|
||||||
|
isApprovalNode,
|
||||||
|
isCancelNode,
|
||||||
|
isScriptNode,
|
||||||
|
isPromptNode,
|
||||||
|
isCommandNode,
|
||||||
|
} from './dag-node.js';
|
||||||
|
|
||||||
|
// workflow.ts
|
||||||
|
export {
|
||||||
|
modelReasoningEffortSchema,
|
||||||
|
type ModelReasoningEffort,
|
||||||
|
webSearchModeSchema,
|
||||||
|
type WebSearchMode,
|
||||||
|
workflowRequirementSchema,
|
||||||
|
type WorkflowRequirement,
|
||||||
|
workflowWorktreePolicySchema,
|
||||||
|
type WorkflowWorktreePolicy,
|
||||||
|
sandboxConfigSchema,
|
||||||
|
type SandboxConfig,
|
||||||
|
providerOverridesSchema,
|
||||||
|
type ProviderOverrides,
|
||||||
|
workflowBaseSchema,
|
||||||
|
type WorkflowBase,
|
||||||
|
workflowDefinitionSchema,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
WorkflowSourceSchema,
|
||||||
|
type WorkflowSource,
|
||||||
|
workflowExecutionResultSchema,
|
||||||
|
type WorkflowExecutionResult,
|
||||||
|
workflowWithSourceSchema,
|
||||||
|
type WorkflowWithSource,
|
||||||
|
workflowLoadErrorSchema,
|
||||||
|
type WorkflowLoadError,
|
||||||
|
workflowLoadResultSchema,
|
||||||
|
type WorkflowLoadResult,
|
||||||
|
loadCommandResultSchema,
|
||||||
|
type LoadCommandResult,
|
||||||
|
} from './workflow.js';
|
||||||
|
|
||||||
|
// workflow-run.ts
|
||||||
|
export {
|
||||||
|
WorkflowRunStatusSchema,
|
||||||
|
type WorkflowRunStatus,
|
||||||
|
TERMINAL_WORKFLOW_STATUSES,
|
||||||
|
RESUMABLE_WORKFLOW_STATUSES,
|
||||||
|
NodeStateSchema,
|
||||||
|
type NodeState,
|
||||||
|
ApprovalContextSchema,
|
||||||
|
type ApprovalContext,
|
||||||
|
WorkflowRunSchema,
|
||||||
|
type WorkflowRun,
|
||||||
|
} from './workflow-run.js';
|
||||||
|
|
||||||
|
// node-output.ts
|
||||||
|
export {
|
||||||
|
nodeOutputSchema,
|
||||||
|
type NodeOutput,
|
||||||
|
type NodeExecutionResult,
|
||||||
|
} from './node-output.js';
|
||||||
46
packages/ion/src/schema/loop.ts
Normal file
46
packages/ion/src/schema/loop.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a loop-type DAG node.
|
||||||
|
*
|
||||||
|
* The loop repeatedly invokes the model with the given prompt until the
|
||||||
|
* `until` condition is satisfied or `max_iterations` is reached.
|
||||||
|
* When `interactive` is true, a human gate must approve each iteration.
|
||||||
|
*/
|
||||||
|
export const loopNodeConfigSchema = z
|
||||||
|
.object({
|
||||||
|
/** The prompt sent to the model on each iteration. */
|
||||||
|
prompt: z.string().min(1, 'loop prompt must not be empty'),
|
||||||
|
|
||||||
|
/** Natural-language condition that must be true for the loop to exit. */
|
||||||
|
until: z.string().min(1, 'loop until condition must not be empty'),
|
||||||
|
|
||||||
|
/** Maximum iterations before the loop is force-stopped. */
|
||||||
|
max_iterations: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive('max_iterations must be a positive integer'),
|
||||||
|
|
||||||
|
/** Whether each iteration starts with a fresh context window. */
|
||||||
|
fresh_context: z.boolean().default(false),
|
||||||
|
|
||||||
|
/** Optional bash command whose exit code is used as the until-check. */
|
||||||
|
until_bash: z.string().optional(),
|
||||||
|
|
||||||
|
/** When true, a human must approve each iteration before it proceeds. */
|
||||||
|
interactive: z.boolean().optional(),
|
||||||
|
|
||||||
|
/** Message shown to the human gate when interactive is true. */
|
||||||
|
gate_message: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((config, ctx) => {
|
||||||
|
if (config.interactive && !config.gate_message) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'gate_message is required when interactive is true',
|
||||||
|
path: ['gate_message'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoopNodeConfig = z.infer<typeof loopNodeConfigSchema>;
|
||||||
46
packages/ion/src/schema/node-output.ts
Normal file
46
packages/ion/src/schema/node-output.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output produced by a single DAG node after execution.
|
||||||
|
*
|
||||||
|
* Captures the result text, structured fields, and execution metadata
|
||||||
|
* so downstream nodes can reference outputs via `$nodeId.output` syntax.
|
||||||
|
*/
|
||||||
|
export const nodeOutputSchema = z.object({
|
||||||
|
/** The node id that produced this output. */
|
||||||
|
nodeId: z.string(),
|
||||||
|
|
||||||
|
/** Current state of the node execution. */
|
||||||
|
state: z.enum(['pending', 'running', 'completed', 'failed', 'skipped']),
|
||||||
|
|
||||||
|
/** The raw text output from the node (alias for text for backward compat). */
|
||||||
|
output: z.string().default(''),
|
||||||
|
|
||||||
|
/** The raw text output from the node. */
|
||||||
|
text: z.string().optional(),
|
||||||
|
|
||||||
|
/** Structured output fields (when output_format is defined). */
|
||||||
|
fields: z.record(z.unknown()).optional(),
|
||||||
|
|
||||||
|
/** Error message if the node failed. */
|
||||||
|
error: z.string().optional(),
|
||||||
|
|
||||||
|
/** Token usage or cost metadata. */
|
||||||
|
costUsd: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NodeOutput = z.infer<typeof nodeOutputSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of executing a single node within the DAG.
|
||||||
|
*
|
||||||
|
* Internal to the engine — not persisted directly but used to build
|
||||||
|
* the nodeOutputs map that flows between layers.
|
||||||
|
*/
|
||||||
|
export interface NodeExecutionResult {
|
||||||
|
state: 'completed' | 'failed' | 'skipped';
|
||||||
|
output?: string;
|
||||||
|
fields?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
costUsd?: number;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user