@worca/app 1.0.0-rc.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
package/ui/server.mjs
CHANGED
|
@@ -13,26 +13,66 @@ import os from 'node:os';
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import fsp from 'node:fs/promises';
|
|
15
15
|
import process from 'node:process';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
16
17
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
18
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
18
19
|
|
|
19
20
|
import { preflightNode } from '../src/core/preflight-node.mjs';
|
|
20
|
-
import {
|
|
21
|
+
import { createOrchestratorFor } from '../src/core/engine-select.mjs';
|
|
21
22
|
import {
|
|
22
23
|
listPipelines, readPipeline, listAllPipelines, readPipelineByKey,
|
|
23
24
|
enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState,
|
|
24
25
|
readRunLogText, readRunArtifactText, countPipelines, runRootSweepLookups, legacySweepLookups, slugify,
|
|
25
|
-
listArtifacts,
|
|
26
|
+
listArtifacts, lookupPipelineRow, findPipelineRowById, resolveIndexedArtifact, resolveIndexedArtifactForRow,
|
|
27
|
+
readPromptFile,
|
|
26
28
|
} from '../src/core/artifacts.mjs';
|
|
27
29
|
import { DIFF_PATCH_FILE } from '../src/core/results.mjs';
|
|
30
|
+
import { protectedSectionKeys } from '../src/core/diff-anchor.mjs';
|
|
31
|
+
import {
|
|
32
|
+
addDiffComment, listDiffComments, getDiffComment, setDiffCommentResolved,
|
|
33
|
+
deleteDiffComment, unresolvedCounts, onDiffCommentsChanged, stampSentRunId,
|
|
34
|
+
peekPendingCardComments, clearPendingCardComments, DiffCommentError, DC_ID_RE,
|
|
35
|
+
} from '../src/core/diff-comments.mjs';
|
|
28
36
|
import { listProjects, addProject, removeProject, normalizeProjectPath, countProjects, worcaHome } from '../src/core/projects.mjs';
|
|
29
37
|
import {
|
|
30
38
|
getWorcaRoot, setWorcaRoot, setProjectsRoot, defaultRoot,
|
|
31
39
|
rawProjectsRoot, defaultProjectsRoot, runRootMode,
|
|
32
40
|
pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
|
|
33
41
|
setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
|
|
42
|
+
askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
|
|
34
43
|
chatPrefs, setChatPrefs,
|
|
35
44
|
} from '../src/core/settings.mjs';
|
|
45
|
+
import {
|
|
46
|
+
ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
|
|
47
|
+
listThreads as askListThreads, updateThread as askUpdateThread,
|
|
48
|
+
deleteThread as askDeleteThread, sweepEmptyThreads, sweepStreamingMessages,
|
|
49
|
+
appendMessage as askAppendMessage, getMessage as askGetMessage,
|
|
50
|
+
listMessages as askListMessages, setMessageBlocks as askSetMessageBlocks,
|
|
51
|
+
findCard as askFindCard, updateCardBlock as askUpdateCardBlock,
|
|
52
|
+
addAttachment as askAddAttachment, listAttachments as askListAttachments,
|
|
53
|
+
readAttachmentText as askReadAttachmentText, threadAttachmentBytes as askThreadAttachmentBytes,
|
|
54
|
+
linkRun as askLinkRun, updateRunLink as askUpdateRunLink, listRunLinks as askListRunLinks,
|
|
55
|
+
findRunLinksByPipeline as askFindRunLinksByPipeline,
|
|
56
|
+
setThreadTitle as askSetThreadTitle, finishMessage as askFinishMessage,
|
|
57
|
+
} from '../src/core/ask/store.mjs';
|
|
58
|
+
import { sanitizeTitle as askSanitizeTitle } from '../src/core/title.mjs';
|
|
59
|
+
import { ASK_LIMITS } from '../src/core/ask/limits.mjs';
|
|
60
|
+
import { askCatalog, validateModelEffort } from '../src/core/ask/models.mjs';
|
|
61
|
+
import { buildCatalog as askBuildCatalog } from '../src/core/ask/catalog.mjs';
|
|
62
|
+
import {
|
|
63
|
+
buildSystemPrompt as askBuildSystemPrompt, buildContextHeader as askBuildContextHeader,
|
|
64
|
+
buildTurnPrompt as askBuildTurnPrompt, buildRestoredPrompt as askBuildRestoredPrompt,
|
|
65
|
+
selectInlineAttachments as askSelectInlineAttachments, validateClientContext,
|
|
66
|
+
} from '../src/core/ask/prompt.mjs';
|
|
67
|
+
import {
|
|
68
|
+
listAskWorktrees as askListWorktrees,
|
|
69
|
+
removeAskWorktree as askRemoveWorktree,
|
|
70
|
+
removeThreadWorktrees as askRemoveThreadWorktrees,
|
|
71
|
+
sweepAskWorktrees,
|
|
72
|
+
} from '../src/core/ask/worktrees.mjs';
|
|
73
|
+
import { createAskTurn } from '../src/core/ask/turn.mjs';
|
|
74
|
+
import { attachRunFollower } from '../src/core/ask/follow.mjs';
|
|
75
|
+
import { mockEnabled, MOCK_WRITER_ROLES } from '../src/core/claude-runner.mjs';
|
|
36
76
|
import { budgetStatus, readCostCapOverride, setCostCapOverride } from '../src/core/cost-budget.mjs';
|
|
37
77
|
import { getStats } from '../src/core/stats.mjs';
|
|
38
78
|
import { pickFolderNative } from '../src/core/folder-dialog.mjs';
|
|
@@ -40,22 +80,25 @@ import { listFolders } from '../src/core/fs-browse.mjs';
|
|
|
40
80
|
import {
|
|
41
81
|
readConfig, setStep, addCustomModel, removeCustomModel, listModels,
|
|
42
82
|
PREDEFINED_MODELS, agentSteps, EFFORTS,
|
|
43
|
-
readRunConfig, setNodeModel, setFeedbackCycles, setActiveWorkflow, resetWorkflowConfig,
|
|
83
|
+
readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, resetWorkflowConfig,
|
|
44
84
|
globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
|
|
45
85
|
} from '../src/core/config.mjs';
|
|
46
86
|
import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
|
|
47
|
-
import { modelEnvRef } from '../src/core/model-env.mjs';
|
|
87
|
+
import { modelEnvRef, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
|
|
48
88
|
import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
|
|
89
|
+
import { testModel } from '../src/core/model-test.mjs';
|
|
49
90
|
import { validateGuardrails } from '../src/core/guardrails.mjs';
|
|
50
91
|
import {
|
|
51
92
|
listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
|
|
52
93
|
writeGuardrailSet, deleteGuardrailSet, isBuiltinGuardrailSetId,
|
|
53
94
|
} from '../src/core/guardrail-store.mjs';
|
|
54
95
|
import {
|
|
55
|
-
|
|
56
|
-
setWorkflowNodeDefaults, workflowNodeDefaults,
|
|
96
|
+
GRAPH_DEFAULT_WORKFLOW, listWorkflows, deleteWorkflow, isSafeWorkflowId,
|
|
97
|
+
setWorkflowNodeDefaults, workflowNodeDefaults, assertRunnableWorkflow, writeGraphWorkflow,
|
|
57
98
|
} from '../src/core/workflows.mjs';
|
|
58
|
-
import {
|
|
99
|
+
import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
|
|
100
|
+
import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
|
|
101
|
+
import { validateGraph, AGENT_TUNABLES } from '../src/shared/graph/validate.mjs';
|
|
59
102
|
import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
|
|
60
103
|
import {
|
|
61
104
|
listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
|
|
@@ -72,7 +115,6 @@ import { projectKey } from '../src/core/store.mjs';
|
|
|
72
115
|
import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
|
|
73
116
|
import { createAgentGen } from '../src/core/agent-gen.mjs';
|
|
74
117
|
import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
|
|
75
|
-
import { CHANNEL_IDS } from '../src/core/channels.mjs';
|
|
76
118
|
import {
|
|
77
119
|
listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin,
|
|
78
120
|
setPluginEnabled, doctorPlugin,
|
|
@@ -83,7 +125,14 @@ import {
|
|
|
83
125
|
addMarketplace, listMarketplaces, syncMarketplace, refreshAllMarketplaces,
|
|
84
126
|
removeMarketplace, readMarketplaces, seedBuiltinMarketplace,
|
|
85
127
|
} from '../src/core/marketplaces.mjs';
|
|
86
|
-
import {
|
|
128
|
+
import {
|
|
129
|
+
redactedConfig, writePluginConfig, readPluginConfig, listProfiles, listProfileIds,
|
|
130
|
+
createProfile, deleteProfile, isValidProfileId, DEFAULT_PROFILE,
|
|
131
|
+
} from '../src/core/plugin-config.mjs';
|
|
132
|
+
import {
|
|
133
|
+
setBinding, clearBinding, listBindingsForScope,
|
|
134
|
+
clearBindingsForProfile, resolveProfile,
|
|
135
|
+
} from '../src/core/source-bindings.mjs';
|
|
87
136
|
import { createChannelHost } from '../src/core/chat/channel-host.mjs';
|
|
88
137
|
import { createCommandRouter } from '../src/core/chat/command-router.mjs';
|
|
89
138
|
import { createChatContext } from '../src/core/chat/chat-context.mjs';
|
|
@@ -94,6 +143,7 @@ import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs'
|
|
|
94
143
|
import { normalizeManifest, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
|
|
95
144
|
import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
|
|
96
145
|
import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
|
|
146
|
+
import { HLJS_GRAMMAR_IDS } from './public/hljs-loader.mjs';
|
|
97
147
|
|
|
98
148
|
// ── node:sqlite runtime guard + warning filter ──────────────────────────────────
|
|
99
149
|
// Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
|
|
@@ -117,6 +167,45 @@ const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
|
117
167
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
118
168
|
const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
|
|
119
169
|
const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
|
|
170
|
+
const require = createRequire(import.meta.url);
|
|
171
|
+
const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
|
|
172
|
+
// Primaries plus the sub-language grammars their instances register
|
|
173
|
+
// (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
|
|
174
|
+
const HLJS_LANGUAGE_FILES = new Set(
|
|
175
|
+
HLJS_GRAMMAR_IDS.map((id) => `${id}.min.js`),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
function resolveHljsAssets(resolve = require.resolve, warn = (msg) => console.warn(msg)) {
|
|
179
|
+
try {
|
|
180
|
+
const core = resolve('@highlightjs/cdn-assets/es/core.min.js');
|
|
181
|
+
return { core, languages: path.join(path.dirname(core), 'languages') };
|
|
182
|
+
} catch (err) {
|
|
183
|
+
warn(`[worca-ui] syntax-highlighter assets unavailable: ${err?.message || err}`);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const HLJS_ASSETS = resolveHljsAssets();
|
|
189
|
+
|
|
190
|
+
// Ask Worca §10.7: the chat's markdown pipeline is served from node_modules the
|
|
191
|
+
// same way the hljs assets are, but resolved with import.meta.resolve — the CJS
|
|
192
|
+
// require.resolve lands on marked's CJS build, and dompurify/package.json is not
|
|
193
|
+
// exported. Each package degrades independently: a missing one just leaves its
|
|
194
|
+
// route unregistered and the existing /vendor no-store 404 answers.
|
|
195
|
+
function resolveEsmAsset(spec, resolve = (s) => import.meta.resolve(s), warn = (msg) => console.warn(msg)) {
|
|
196
|
+
try {
|
|
197
|
+
return fileURLToPath(resolve(spec));
|
|
198
|
+
} catch (err) {
|
|
199
|
+
warn(`[worca-ui] ask markdown asset unavailable (${spec}): ${err?.message || err}`);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const ASK_VENDOR_ASSETS = {
|
|
205
|
+
marked: resolveEsmAsset('marked'),
|
|
206
|
+
dompurify: resolveEsmAsset('dompurify'),
|
|
207
|
+
};
|
|
208
|
+
|
|
120
209
|
const PORT = Number(process.env.PORT) || 4317;
|
|
121
210
|
// Bind to loopback by default (S1). Power users who knowingly want LAN exposure
|
|
122
211
|
// can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
|
|
@@ -161,7 +250,9 @@ function liveRunIds() {
|
|
|
161
250
|
// `stepgraphify` (§7.3) was emitted by the orchestrator and handled by the client
|
|
162
251
|
// but missing here, so the graphify badge only appeared after a reload (via the
|
|
163
252
|
// persisted column) and never live. It rides the same pass-through as `stepskills`.
|
|
164
|
-
|
|
253
|
+
// `exec` and `token` are the graph engine's (§5.7). `phase` stays for the v1
|
|
254
|
+
// engine AND for the v2 shim until the graph cut-over retires it.
|
|
255
|
+
const EVENT_NAMES = ['exec', 'token', 'log', 'question', 'artifact', 'state', 'done', 'error', 'subagent', 'stepskills', 'stepgraphify', 'title'];
|
|
165
256
|
// The scan-* WS family (Workspaces M5, §5.4). A NEW family in the SAME runs Map;
|
|
166
257
|
// the 7-event run plumbing above is untouched. createWorkspaceScan emits many
|
|
167
258
|
// scan-progress then exactly one terminal scan-done OR scan-error.
|
|
@@ -182,6 +273,20 @@ const wss = new WebSocketServer({ server, path: '/ws' });
|
|
|
182
273
|
/** All currently connected sockets. */
|
|
183
274
|
const sockets = new Set();
|
|
184
275
|
|
|
276
|
+
// server.close() only calls back once every connection is gone, and Node's
|
|
277
|
+
// closeAllConnections() skips UPGRADED sockets — a WebSocket whose close
|
|
278
|
+
// handshake has not completed (a client that vanished, or a test tearing down
|
|
279
|
+
// right after ws.close()) keeps the callback from ever firing; under load that
|
|
280
|
+
// is a hang. Terminate the lingering clients first so close() is deterministic
|
|
281
|
+
// on every OS; the per-socket 'close' handlers below drop them from `sockets`.
|
|
282
|
+
{
|
|
283
|
+
const httpClose = server.close.bind(server);
|
|
284
|
+
server.close = (cb) => {
|
|
285
|
+
for (const ws of sockets) { try { ws.terminate(); } catch { /* already gone */ } }
|
|
286
|
+
return httpClose(cb);
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
185
290
|
wss.on('connection', (ws, req) => {
|
|
186
291
|
// S1: WS upgrades bypass the express middleware chain, so re-apply the
|
|
187
292
|
// loopback guard here (same DNS-rebinding protection as the HTTP routes).
|
|
@@ -197,24 +302,31 @@ wss.on('connection', (ws, req) => {
|
|
|
197
302
|
let requestedRunId = null;
|
|
198
303
|
let requestedScanId = null;
|
|
199
304
|
let requestedGenId = null;
|
|
305
|
+
let requestedThreadId = null;
|
|
200
306
|
try {
|
|
201
307
|
const u = new URL(req.url, 'http://localhost');
|
|
202
308
|
requestedRunId = u.searchParams.get('runId');
|
|
203
309
|
requestedScanId = u.searchParams.get('scanId');
|
|
204
310
|
requestedGenId = u.searchParams.get('genId');
|
|
311
|
+
requestedThreadId = u.searchParams.get('threadId');
|
|
205
312
|
} catch {
|
|
206
313
|
requestedRunId = null;
|
|
207
314
|
requestedScanId = null;
|
|
208
315
|
requestedGenId = null;
|
|
316
|
+
requestedThreadId = null;
|
|
209
317
|
}
|
|
210
318
|
const id = requestedRunId || requestedScanId || requestedGenId;
|
|
211
319
|
|
|
212
|
-
send(ws, { type: 'hello', runs: summarizeRuns() });
|
|
320
|
+
send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello() });
|
|
213
321
|
|
|
214
322
|
if (id && runs.has(id)) {
|
|
215
323
|
replayEntry(ws, runs.get(id));
|
|
216
324
|
}
|
|
217
325
|
|
|
326
|
+
if (requestedThreadId && askJobs.has(requestedThreadId)) {
|
|
327
|
+
replayAskJob(ws, askJobs.get(requestedThreadId));
|
|
328
|
+
}
|
|
329
|
+
|
|
218
330
|
ws.on('close', () => sockets.delete(ws));
|
|
219
331
|
ws.on('error', () => sockets.delete(ws));
|
|
220
332
|
ws.on('message', (data) => {
|
|
@@ -231,6 +343,10 @@ wss.on('connection', (ws, req) => {
|
|
|
231
343
|
if (subId && runs.has(subId)) {
|
|
232
344
|
replayEntry(ws, runs.get(subId));
|
|
233
345
|
}
|
|
346
|
+
const askThreadId = msg && msg.type === 'subscribe' && typeof msg.threadId === 'string' ? msg.threadId : null;
|
|
347
|
+
if (askThreadId && askJobs.has(askThreadId)) {
|
|
348
|
+
replayAskJob(ws, askJobs.get(askThreadId));
|
|
349
|
+
}
|
|
234
350
|
});
|
|
235
351
|
});
|
|
236
352
|
|
|
@@ -302,6 +418,32 @@ function emitChanged(type, action) {
|
|
|
302
418
|
broadcast({ type, action: action || null });
|
|
303
419
|
}
|
|
304
420
|
|
|
421
|
+
// Every comment mutation in THIS process (the REST routes below) pokes the open
|
|
422
|
+
// Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
|
|
423
|
+
// ordering concerns; the client refetches and repaints its CARDS, never the diff.
|
|
424
|
+
// MCP-side mutations happen in the stdio CHILD process and cannot reach this
|
|
425
|
+
// listener; they arrive through the turn's comment hook instead.
|
|
426
|
+
onDiffCommentsChanged(({ storeKey, pipelineId }) => {
|
|
427
|
+
broadcast({ type: 'diff-comments-changed', storeKey, pipelineId });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/** Resolve an 8-hex pipeline id to its History store key and poke the open Diff
|
|
431
|
+
* tabs. Used for MCP-side writes, which happen in the stdio CHILD process and
|
|
432
|
+
* cannot reach the listener above. The frame is byte-identical to the REST one,
|
|
433
|
+
* so the client has ONE code path. findPipelineRowById is key-agnostic and
|
|
434
|
+
* includes archived rows. Exported through `_testing` — the wiring at the ask
|
|
435
|
+
* turn is a one-liner precisely so this function is the whole testable surface. */
|
|
436
|
+
function emitDiffCommentsChanged(runId) {
|
|
437
|
+
try {
|
|
438
|
+
const row = findPipelineRowById(runId);
|
|
439
|
+
if (!row) return false;
|
|
440
|
+
const storeKey = (row.target === 'workspace' || row.workspace_key)
|
|
441
|
+
? `workspaces/${row.workspace_key}` : row.project_key;
|
|
442
|
+
broadcast({ type: 'diff-comments-changed', storeKey, pipelineId: row.id });
|
|
443
|
+
return true;
|
|
444
|
+
} catch { return false; } // a poke is best effort
|
|
445
|
+
}
|
|
446
|
+
|
|
305
447
|
// Append a tagged event to an entry's ring buffer (runId LAST so the runs-Map key
|
|
306
448
|
// always wins over any id the orchestrator stamped). Shared by the live wire
|
|
307
449
|
// (record) and out-of-band resolutions (resolvePending) so both honor MAX_BUFFER.
|
|
@@ -431,7 +573,7 @@ function wireRun(entry) {
|
|
|
431
573
|
entry.status = 'error';
|
|
432
574
|
resolvePending(entry, { reason: 'error' });
|
|
433
575
|
}
|
|
434
|
-
if (name === '
|
|
576
|
+
if (name === 'exec') {
|
|
435
577
|
entry.status = 'running';
|
|
436
578
|
}
|
|
437
579
|
if (name === 'state' && payload && typeof payload === 'object') {
|
|
@@ -576,7 +718,6 @@ app.post('/api/ingress/teams/:plugin/:channelId/:token',
|
|
|
576
718
|
// ---------------------------------------------------------------------------
|
|
577
719
|
// Express middleware + static
|
|
578
720
|
// ---------------------------------------------------------------------------
|
|
579
|
-
app.use(express.json({ limit: '8mb' }));
|
|
580
721
|
|
|
581
722
|
// S1: worca-cc's UI/API has no auth and runs agents with permissionMode
|
|
582
723
|
// 'acceptEdits' — it is a single-user *localhost* tool. The server binds to
|
|
@@ -584,13 +725,89 @@ app.use(express.json({ limit: '8mb' }));
|
|
|
584
725
|
// suspenders: reject any request whose Host (or browser Origin) is not a
|
|
585
726
|
// loopback name, so a malicious page resolving a name to 127.0.0.1 still can't
|
|
586
727
|
// drive the API. Override WORCA_HOST only if you understand the exposure.
|
|
728
|
+
//
|
|
729
|
+
// FIRST, ahead of the body parser (MIN-108): a refused request must be refused
|
|
730
|
+
// before a single byte of its body is parsed or buffered, and a malformed body
|
|
731
|
+
// from a non-loopback Host used to answer 400 (with a stack) where a valid one
|
|
732
|
+
// answered 403. The ingress webhook above is deliberately mounted EARLIER and
|
|
733
|
+
// stays exempt — it carries its own token check and 256 KB cap.
|
|
587
734
|
app.use((req, res, next) => {
|
|
588
735
|
if (!isLocalRequest(req)) {
|
|
589
|
-
return res.status(403).json({ error: 'forbidden: worca
|
|
736
|
+
return res.status(403).json({ error: 'forbidden: worca is a localhost-only tool' });
|
|
590
737
|
}
|
|
591
738
|
next();
|
|
592
739
|
});
|
|
593
740
|
|
|
741
|
+
app.use(express.json({ limit: '8mb' }));
|
|
742
|
+
|
|
743
|
+
if (HLJS_ASSETS) {
|
|
744
|
+
const sendHljsModule = (file) => (_req, res, next) => {
|
|
745
|
+
res.type('text/javascript');
|
|
746
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
747
|
+
res.sendFile(file, (err) => {
|
|
748
|
+
if (!err) return;
|
|
749
|
+
if (res.headersSent) return next(err);
|
|
750
|
+
next();
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
app.get('/vendor/hljs/core.min.js', sendHljsModule(HLJS_ASSETS.core));
|
|
754
|
+
app.get('/vendor/hljs/languages/:file', (req, res, next) => {
|
|
755
|
+
const file = String(req.params.file || '');
|
|
756
|
+
if (!HLJS_LANGUAGE_FILE_RE.test(file) || !HLJS_LANGUAGE_FILES.has(file)) return next();
|
|
757
|
+
const candidate = path.join(HLJS_ASSETS.languages, file);
|
|
758
|
+
try {
|
|
759
|
+
if (!fs.statSync(candidate).isFile()) return next();
|
|
760
|
+
} catch {
|
|
761
|
+
return next();
|
|
762
|
+
}
|
|
763
|
+
return sendHljsModule(candidate)(req, res, next);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Ask Worca §10.7 vendor routes. sendHljsModule's shape, reused verbatim: the
|
|
768
|
+
// sendFile error path falls through to the /vendor no-store handlers below.
|
|
769
|
+
const sendEsmModule = (file) => (_req, res, next) => {
|
|
770
|
+
res.type('text/javascript');
|
|
771
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
772
|
+
res.sendFile(file, (err) => {
|
|
773
|
+
if (!err) return;
|
|
774
|
+
if (res.headersSent) return next(err);
|
|
775
|
+
next();
|
|
776
|
+
});
|
|
777
|
+
};
|
|
778
|
+
if (ASK_VENDOR_ASSETS.marked) {
|
|
779
|
+
app.get('/vendor/marked/marked.esm.js', sendEsmModule(ASK_VENDOR_ASSETS.marked));
|
|
780
|
+
}
|
|
781
|
+
if (ASK_VENDOR_ASSETS.dompurify) {
|
|
782
|
+
app.get('/vendor/dompurify/purify.es.mjs', sendEsmModule(ASK_VENDOR_ASSETS.dompurify));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
app.use('/vendor', (err, _req, res, next) => {
|
|
786
|
+
if (res.headersSent) return next(err);
|
|
787
|
+
res.set('Cache-Control', 'no-store');
|
|
788
|
+
const status = err?.status === 400 ? 400 : 404;
|
|
789
|
+
res.status(status).type('text/plain').send(status === 400 ? 'Bad request' : 'Not found');
|
|
790
|
+
});
|
|
791
|
+
app.use('/vendor', (_req, res) => {
|
|
792
|
+
res.set('Cache-Control', 'no-store');
|
|
793
|
+
res.status(404).type('text/plain').send('Not found');
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// src/shared/** is the ONE source of the graph model for server + browser
|
|
797
|
+
// (no build step). ui modules import it by relative path that walks above
|
|
798
|
+
// ui/public; the browser clamps that URL at '/', so it must be served here at
|
|
799
|
+
// exactly the repo-relative path. The 404 tail keeps a typo'd path from
|
|
800
|
+
// falling through to the SPA index.html (which Chrome reports as a MIME error).
|
|
801
|
+
const SHARED_DIR = path.join(PROJECT_ROOT, 'src', 'shared');
|
|
802
|
+
app.use('/src/shared', express.static(SHARED_DIR, {
|
|
803
|
+
index: false,
|
|
804
|
+
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
|
|
805
|
+
}));
|
|
806
|
+
app.use('/src/shared', (_req, res) => {
|
|
807
|
+
res.set('Cache-Control', 'no-store');
|
|
808
|
+
res.status(404).type('text/plain').send('Not found');
|
|
809
|
+
});
|
|
810
|
+
|
|
594
811
|
app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
|
|
595
812
|
|
|
596
813
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
@@ -694,6 +911,9 @@ function normalizeRunSource(raw) {
|
|
|
694
911
|
return { ok: false, error: `source.${k} is required for type "plugin"` };
|
|
695
912
|
}
|
|
696
913
|
}
|
|
914
|
+
if (raw.profile !== undefined && !isValidProfileId(raw.profile)) {
|
|
915
|
+
return { ok: false, error: 'source.profile is not a valid profile id' };
|
|
916
|
+
}
|
|
697
917
|
return {
|
|
698
918
|
ok: true,
|
|
699
919
|
source: {
|
|
@@ -702,12 +922,34 @@ function normalizeRunSource(raw) {
|
|
|
702
922
|
sourceId: raw.sourceId.trim(),
|
|
703
923
|
taskId: raw.taskId.trim(),
|
|
704
924
|
inputs: raw.inputs && typeof raw.inputs === 'object' && !Array.isArray(raw.inputs) ? raw.inputs : undefined,
|
|
925
|
+
// Which configuration of the source the task came from. Absent is legal
|
|
926
|
+
// (single-profile sources); an id that is not path-safe is not.
|
|
927
|
+
profile: typeof raw.profile === 'string' && raw.profile ? raw.profile : undefined,
|
|
705
928
|
},
|
|
706
929
|
};
|
|
707
930
|
}
|
|
708
931
|
return { ok: false, error: `unknown source.type "${type}"` };
|
|
709
932
|
}
|
|
710
933
|
|
|
934
|
+
/**
|
|
935
|
+
* A markdown source that NAMES a promptFile must name one we can read. Resolution
|
|
936
|
+
* happens inside the orchestrator, which this route launches fire-and-forget AFTER
|
|
937
|
+
* it has already answered — so without this submit-time check a bad path surfaces
|
|
938
|
+
* as an anonymous mid-run error event on a pipeline the client was told started.
|
|
939
|
+
* Resolved against the same base the orchestrator uses (the project, or a
|
|
940
|
+
* workspace's primary member).
|
|
941
|
+
* @returns {Promise<string|null>} the error message, or null when there is nothing wrong
|
|
942
|
+
*/
|
|
943
|
+
async function promptFileProblem(source, projectDir) {
|
|
944
|
+
if (!source || source.type !== 'markdown' || !source.promptFile) return null;
|
|
945
|
+
try {
|
|
946
|
+
await readPromptFile(projectDir, source.promptFile);
|
|
947
|
+
return null;
|
|
948
|
+
} catch (err) {
|
|
949
|
+
return err && err.message ? err.message : String(err);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
711
953
|
// Fallback run title when the client sends none. The legacy path is unchanged
|
|
712
954
|
// (first 80 chars of the prompt — effectivePrompt is guaranteed set there); a
|
|
713
955
|
// plugin source starts as "<plugin>: <taskId>" until the orchestrator resolves
|
|
@@ -719,6 +961,67 @@ function fallbackRunTitle(effectivePrompt, source) {
|
|
|
719
961
|
return String(text).slice(0, 80);
|
|
720
962
|
}
|
|
721
963
|
|
|
964
|
+
// Wire an Ask Worca follower for a card-linked run: the orchestrator's
|
|
965
|
+
// state/question/error/done events become thread notices, ask_run_links patches
|
|
966
|
+
// and ask-run-status frames. Used by POST /api/run at launch AND by resumeRun
|
|
967
|
+
// (a resumed pipeline is a NEW orchestrator; the paused lineage's follower
|
|
968
|
+
// detached on done{paused}, so the link must be re-followed — review of PR #376).
|
|
969
|
+
function attachAskFollower(orch, { threadId, runId, cardId }) {
|
|
970
|
+
const follower = attachRunFollower(orch, {
|
|
971
|
+
threadId,
|
|
972
|
+
runId,
|
|
973
|
+
cardId,
|
|
974
|
+
post: ({ text, href }) => {
|
|
975
|
+
try {
|
|
976
|
+
const m = askAppendMessage(threadId, {
|
|
977
|
+
role: 'system', text, blocks: [{ kind: 'notice', text, href }],
|
|
978
|
+
});
|
|
979
|
+
broadcast({ type: 'ask-message', threadId, message: m });
|
|
980
|
+
} catch { /* thread deleted mid-run */ }
|
|
981
|
+
},
|
|
982
|
+
updateStatus: (patch) => {
|
|
983
|
+
try {
|
|
984
|
+
const linkPatch = {};
|
|
985
|
+
if (patch.pipelineId) linkPatch.pipelineId = patch.pipelineId;
|
|
986
|
+
if (patch.status) linkPatch.status = patch.status;
|
|
987
|
+
if (patch.phase !== undefined) linkPatch.phase = patch.phase;
|
|
988
|
+
const row = Object.keys(linkPatch).length
|
|
989
|
+
? askUpdateRunLink(threadId, runId, linkPatch) : null;
|
|
990
|
+
// The 8-hex History id lands on the FIRST state event (follow.mjs guards
|
|
991
|
+
// "first truthy sight only"), which is the first moment a
|
|
992
|
+
// "sent to #<runId>" marker could point anywhere real. Never the
|
|
993
|
+
// runs-Map UUID, never at launch, and never a resolve.
|
|
994
|
+
if (linkPatch.pipelineId && row && row.commentIds.length) {
|
|
995
|
+
try { stampSentRunId(row.commentIds, linkPatch.pipelineId); } catch { /* best effort */ }
|
|
996
|
+
}
|
|
997
|
+
if (patch.cardFailed) {
|
|
998
|
+
flipCard(threadId, cardId, { state: 'failed', error: patch.cardFailed });
|
|
999
|
+
}
|
|
1000
|
+
broadcast({
|
|
1001
|
+
type: 'ask-run-status', threadId, runId,
|
|
1002
|
+
pipelineId: (row && row.pipelineId) || patch.pipelineId || null,
|
|
1003
|
+
cardId,
|
|
1004
|
+
status: patch.status || (row && row.status) || null,
|
|
1005
|
+
phase: patch.phase !== undefined ? patch.phase : ((row && row.phase) || null),
|
|
1006
|
+
});
|
|
1007
|
+
} catch { /* thread deleted mid-run */ }
|
|
1008
|
+
},
|
|
1009
|
+
onDetached: () => {
|
|
1010
|
+
const set = askFollowers.get(threadId);
|
|
1011
|
+
if (set) {
|
|
1012
|
+
set.delete(follower);
|
|
1013
|
+
if (!set.size) askFollowers.delete(threadId);
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
});
|
|
1017
|
+
let set = askFollowers.get(threadId);
|
|
1018
|
+
if (!set) {
|
|
1019
|
+
set = new Set();
|
|
1020
|
+
askFollowers.set(threadId, set);
|
|
1021
|
+
}
|
|
1022
|
+
set.add(follower);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
722
1025
|
// ---------------------------------------------------------------------------
|
|
723
1026
|
// POST /api/run -> start a new orchestration run
|
|
724
1027
|
// body (single-project): { projectDir, prompt?, promptMarkdown?, title?, mock? }
|
|
@@ -739,6 +1042,28 @@ app.post('/api/run', async (req, res) => {
|
|
|
739
1042
|
return badRequest(res, 'workspaceId or projectDir is required');
|
|
740
1043
|
}
|
|
741
1044
|
|
|
1045
|
+
// Ask Worca card link (§8.1): both or neither; the thread must exist and
|
|
1046
|
+
// the card must still be `proposed` BEFORE any run state is created.
|
|
1047
|
+
const hasAskThread = body.askThreadId !== undefined && body.askThreadId !== null;
|
|
1048
|
+
const hasAskCard = body.askCardId !== undefined && body.askCardId !== null;
|
|
1049
|
+
let askLink = null;
|
|
1050
|
+
if (hasAskThread || hasAskCard) {
|
|
1051
|
+
if (!hasAskThread || !hasAskCard) {
|
|
1052
|
+
return badRequest(res, 'askThreadId and askCardId must be provided together');
|
|
1053
|
+
}
|
|
1054
|
+
if (typeof body.askThreadId !== 'string' || !ASK_ID_RE.test(body.askThreadId)
|
|
1055
|
+
|| typeof body.askCardId !== 'string' || !ASK_ID_RE.test(body.askCardId)) {
|
|
1056
|
+
return badRequest(res, 'invalid askThreadId or askCardId');
|
|
1057
|
+
}
|
|
1058
|
+
if (!askGetThread(body.askThreadId)) return badRequest(res, 'unknown askThreadId');
|
|
1059
|
+
const found = askFindCard(body.askThreadId, body.askCardId);
|
|
1060
|
+
if (!found) return badRequest(res, 'unknown askCardId');
|
|
1061
|
+
if (found.block.state !== 'proposed') {
|
|
1062
|
+
return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
1063
|
+
}
|
|
1064
|
+
askLink = { threadId: body.askThreadId, cardId: body.askCardId };
|
|
1065
|
+
}
|
|
1066
|
+
|
|
742
1067
|
// ── Shared resolution (factored BEFORE the target branch, §2.6) ──────────
|
|
743
1068
|
// NEW (plugins §7.3): body.source is the task-source descriptor; shape-check
|
|
744
1069
|
// only and pass through — the orchestrator resolves it exactly once. Absent
|
|
@@ -747,6 +1072,29 @@ app.post('/api/run', async (req, res) => {
|
|
|
747
1072
|
if (sourceCheck && !sourceCheck.ok) return badRequest(res, sourceCheck.error);
|
|
748
1073
|
const source = sourceCheck ? sourceCheck.source : null;
|
|
749
1074
|
|
|
1075
|
+
// A multiProfile source without a profile would run against the (empty)
|
|
1076
|
+
// default bucket and die mid-pipeline with a confusing connector error —
|
|
1077
|
+
// reject it here, at submit, where the client can still fix it. The same
|
|
1078
|
+
// goes for a profile that is no longer IN the roster (deleted in another
|
|
1079
|
+
// tab after the client resolved it) and for a profile supplied to a source
|
|
1080
|
+
// that does not use them (it would read a phantom bucket instead of the
|
|
1081
|
+
// real config). A broken or uninstalled plugin is left for
|
|
1082
|
+
// resolveTaskInput to report.
|
|
1083
|
+
if (source && source.type === 'plugin') {
|
|
1084
|
+
const m = readInstalledManifest(source.plugin);
|
|
1085
|
+
const ts = m && (m.taskSources || []).find((s) => s.id === source.sourceId);
|
|
1086
|
+
if (ts && ts.multiProfile) {
|
|
1087
|
+
if (!source.profile) {
|
|
1088
|
+
return badRequest(res, `source.profile is required — task source "${source.sourceId}" has per-profile configuration`);
|
|
1089
|
+
}
|
|
1090
|
+
if (!listProfileIds(source.plugin).includes(source.profile)) {
|
|
1091
|
+
return badRequest(res, `plugin "${source.plugin}" has no profile "${source.profile}" — it may have been deleted; re-select one`);
|
|
1092
|
+
}
|
|
1093
|
+
} else if (ts && source.profile) {
|
|
1094
|
+
return badRequest(res, `task source "${source.sourceId}" does not use profiles — omit source.profile`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
750
1098
|
// prompt OR promptMarkdown. promptMarkdown is treated as the prompt text.
|
|
751
1099
|
const prompt = typeof body.prompt === 'string' && body.prompt.trim() ? body.prompt : undefined;
|
|
752
1100
|
const promptMarkdown =
|
|
@@ -770,7 +1118,16 @@ app.post('/api/run', async (req, res) => {
|
|
|
770
1118
|
// so the client gets a clean 400 instead of a mid-run error event.
|
|
771
1119
|
const workflowId =
|
|
772
1120
|
typeof body.workflowId === 'string' && body.workflowId.trim() ? body.workflowId.trim() : 'wf_default';
|
|
773
|
-
|
|
1121
|
+
// ONE gate for every run entry point, ONE status: unknown (today's text) and
|
|
1122
|
+
// archived (the upgrade explanation the UI shows verbatim) answer 400 through
|
|
1123
|
+
// badRequest. A graph row runs on the graph engine — createOrchestratorFor
|
|
1124
|
+
// routes it off the row's version.
|
|
1125
|
+
let workflowRow;
|
|
1126
|
+
try {
|
|
1127
|
+
workflowRow = await assertRunnableWorkflow(workflowId);
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
return badRequest(res, err && err.message ? err.message : String(err));
|
|
1130
|
+
}
|
|
774
1131
|
|
|
775
1132
|
// Optional guardrailsId selects the named guardrail set that IS this run's
|
|
776
1133
|
// policy (applied uniformly to every member — guardrails are per-run only).
|
|
@@ -858,7 +1215,10 @@ app.post('/api/run', async (req, res) => {
|
|
|
858
1215
|
return badRequest(res, `unknown or invalid sourceBranch: ${badOverride}`);
|
|
859
1216
|
}
|
|
860
1217
|
|
|
861
|
-
|
|
1218
|
+
const wsFileProblem = await promptFileProblem(effectiveSource, projects[0].projectDir);
|
|
1219
|
+
if (wsFileProblem) return badRequest(res, wsFileProblem);
|
|
1220
|
+
|
|
1221
|
+
orch = await createOrchestratorFor({
|
|
862
1222
|
workspace: {
|
|
863
1223
|
id: ws.id,
|
|
864
1224
|
key: ws.id, // ws.id === workspaceKey(ws); routes artifacts to its store
|
|
@@ -872,6 +1232,7 @@ app.post('/api/run', async (req, res) => {
|
|
|
872
1232
|
extras,
|
|
873
1233
|
agentsDir: AGENTS_DIR,
|
|
874
1234
|
workflowId,
|
|
1235
|
+
template: workflowRow,
|
|
875
1236
|
guardrailsId,
|
|
876
1237
|
branch,
|
|
877
1238
|
claude: { permissionMode: 'acceptEdits', mock },
|
|
@@ -911,7 +1272,10 @@ app.post('/api/run', async (req, res) => {
|
|
|
911
1272
|
return badRequest(res, `unknown or invalid sourceBranch: ${branch.source}`);
|
|
912
1273
|
}
|
|
913
1274
|
|
|
914
|
-
|
|
1275
|
+
const fileProblem = await promptFileProblem(effectiveSource, projectDir);
|
|
1276
|
+
if (fileProblem) return badRequest(res, fileProblem);
|
|
1277
|
+
|
|
1278
|
+
orch = await createOrchestratorFor({
|
|
915
1279
|
projectDir,
|
|
916
1280
|
prompt: effectivePrompt,
|
|
917
1281
|
...(effectiveSource ? { source: effectiveSource } : {}),
|
|
@@ -919,6 +1283,7 @@ app.post('/api/run', async (req, res) => {
|
|
|
919
1283
|
extras,
|
|
920
1284
|
agentsDir: AGENTS_DIR,
|
|
921
1285
|
workflowId,
|
|
1286
|
+
template: workflowRow,
|
|
922
1287
|
guardrailsId,
|
|
923
1288
|
branch,
|
|
924
1289
|
claude: { permissionMode: 'acceptEdits', mock },
|
|
@@ -939,6 +1304,48 @@ app.post('/api/run', async (req, res) => {
|
|
|
939
1304
|
|
|
940
1305
|
runs.set(runId, entry);
|
|
941
1306
|
wireRun(entry);
|
|
1307
|
+
if (askLink) {
|
|
1308
|
+
// Card-state TOCTOU: awaits (source-ref check, budget) sit between Hunk
|
|
1309
|
+
// B's `proposed` check and here — a concurrent Start may have flipped
|
|
1310
|
+
// the card already. That is a LOST RACE, not a detail to log: the loser
|
|
1311
|
+
// must not launch a second pipeline for the same card (review of PR #376).
|
|
1312
|
+
// Withdraw the run entry (nothing has run or been announced yet) and 409.
|
|
1313
|
+
const still = askFindCard(askLink.threadId, askLink.cardId);
|
|
1314
|
+
if (!still || still.block.state !== 'proposed') {
|
|
1315
|
+
runs.delete(runId);
|
|
1316
|
+
return res.status(409).json({ error: `card is no longer proposed (${still ? still.block.state : 'gone'})` });
|
|
1317
|
+
}
|
|
1318
|
+
try {
|
|
1319
|
+
askLinkRun(askLink.threadId, { runId, cardId: askLink.cardId, status: entry.status });
|
|
1320
|
+
flipCard(askLink.threadId, askLink.cardId, { state: 'started', runId });
|
|
1321
|
+
// The card's pending comment ids move onto the link row, keyed by the minted
|
|
1322
|
+
// UUID exactly as pipeline_id is before it exists. Consumed one-shot: a card
|
|
1323
|
+
// launches at most once. Own try/catch — comment bookkeeping must never
|
|
1324
|
+
// abort the card flip or the run.
|
|
1325
|
+
try {
|
|
1326
|
+
// Read, WRITE, then consume — not consume-then-write. A combined take()
|
|
1327
|
+
// deletes the rows it returns, so if askUpdateRunLink throws in between (its
|
|
1328
|
+
// catch here only logs) the ids are gone and the sent_run_id stamp is lost
|
|
1329
|
+
// with no way to recover them. peek/commit keeps the delete on the success
|
|
1330
|
+
// path only; a second launch of the same card cannot happen anyway (the
|
|
1331
|
+
// card must be in state 'proposed' above).
|
|
1332
|
+
const pendingComments = peekPendingCardComments(askLink.cardId);
|
|
1333
|
+
if (pendingComments.length) {
|
|
1334
|
+
askUpdateRunLink(askLink.threadId, runId, { commentIds: pendingComments });
|
|
1335
|
+
clearPendingCardComments(askLink.cardId);
|
|
1336
|
+
}
|
|
1337
|
+
} catch (e) { console.error('[diff-comments] pending-card handoff failed:', e && e.message ? e.message : e); }
|
|
1338
|
+
const startedMsg = askAppendMessage(askLink.threadId, {
|
|
1339
|
+
role: 'system',
|
|
1340
|
+
text: `Run started — "${title}"`,
|
|
1341
|
+
blocks: [{ kind: 'notice', text: `Run started — "${title}"`, href: `#running/${runId}` }],
|
|
1342
|
+
});
|
|
1343
|
+
broadcast({ type: 'ask-message', threadId: askLink.threadId, message: startedMsg });
|
|
1344
|
+
attachAskFollower(orch, { threadId: askLink.threadId, runId, cardId: askLink.cardId });
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
console.error(`[worca-ui] ask run link failed: ${err && err.message ? err.message : err}`);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
942
1349
|
announceRun(entry);
|
|
943
1350
|
|
|
944
1351
|
// Fire-and-forget; all progress is surfaced through events.
|
|
@@ -1169,10 +1576,18 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
|
|
|
1169
1576
|
if (!saved) throw new ResumeError(404, { error: 'pipeline not found' });
|
|
1170
1577
|
if (saved.row.status !== 'paused' && saved.row.status !== 'interrupted') throw new ResumeError(400, { error: `pipeline is "${saved.row.status}", not resumable` });
|
|
1171
1578
|
if (!saved.resumePoint) throw new ResumeError(400, { error: 'pipeline has no resume point' });
|
|
1579
|
+
if (saved.resumePoint.version !== 2) {
|
|
1580
|
+
throw new ResumeError(409, { code: 'ENGINE_RETIRED', error: V1_RUN_RETIRED });
|
|
1581
|
+
}
|
|
1172
1582
|
|
|
1173
1583
|
if (saved.row.archived_at) {
|
|
1174
1584
|
throw new ResumeError(409, { error: 'pipeline is archived' });
|
|
1175
1585
|
}
|
|
1586
|
+
// No graph re-validation on RESUME, on purpose: _restoreFromResumePoint never
|
|
1587
|
+
// reads the workflow row — the frozen manifest supplies topology and port
|
|
1588
|
+
// identity (resolvedFromManifest: snapshot wins), so a template that drifted
|
|
1589
|
+
// while the run sat paused cannot strand it. A vanished agent KEY is the one
|
|
1590
|
+
// resume-time hazard, and _preflightAgentKeys already refuses it (§9.4).
|
|
1176
1591
|
const budget = budgetStatus();
|
|
1177
1592
|
if (budget.blocked) {
|
|
1178
1593
|
throw new ResumeError(403, { error: 'total cost limit reached', budget });
|
|
@@ -1226,7 +1641,7 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
|
|
|
1226
1641
|
|
|
1227
1642
|
const effMock = mock || isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK);
|
|
1228
1643
|
const runId = randomUUID();
|
|
1229
|
-
const orch =
|
|
1644
|
+
const orch = await createOrchestratorFor({
|
|
1230
1645
|
projectDir,
|
|
1231
1646
|
...(workspace ? { workspace } : {}),
|
|
1232
1647
|
agentsDir: AGENTS_DIR,
|
|
@@ -1255,6 +1670,22 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
|
|
|
1255
1670
|
wireRun(entry);
|
|
1256
1671
|
announceRun(entry);
|
|
1257
1672
|
|
|
1673
|
+
// A card-linked run keeps reporting to its chat across the resume: the link
|
|
1674
|
+
// row moves to the new runId and a fresh follower takes over (the old one
|
|
1675
|
+
// detached on done{paused}). Best-effort per row — chat bookkeeping must never
|
|
1676
|
+
// block a resume.
|
|
1677
|
+
for (const link of askFindRunLinksByPipeline(pipelineId)) {
|
|
1678
|
+
try {
|
|
1679
|
+
if (!askUpdateRunLink(link.threadId, link.runId, { runId, status: 'running' })) continue;
|
|
1680
|
+
const text = `Run resumed — "${saved.row.title || 'run'}"`;
|
|
1681
|
+
const m = askAppendMessage(link.threadId, { role: 'system', text, blocks: [{ kind: 'notice', text, href: `#running/${runId}` }] });
|
|
1682
|
+
broadcast({ type: 'ask-message', threadId: link.threadId, message: m });
|
|
1683
|
+
attachAskFollower(orch, { threadId: link.threadId, runId, cardId: link.cardId });
|
|
1684
|
+
} catch (err) {
|
|
1685
|
+
console.error(`[worca-ui] ask follower re-attach failed: ${err && err.message ? err.message : err}`);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1258
1689
|
// Evict the superseded paused/interrupted lineage for this pipeline. The old
|
|
1259
1690
|
// entry is inert (paused), but summarizeRuns() broadcasts EVERY Map entry on
|
|
1260
1691
|
// each hello — leaving it resurfaces the now-resumed (and possibly already
|
|
@@ -1362,6 +1793,22 @@ app.get('/api/runs/:id', async (req, res) => {
|
|
|
1362
1793
|
}
|
|
1363
1794
|
});
|
|
1364
1795
|
|
|
1796
|
+
// GET /api/runs/:id/artifact?rel= -> the same payload, resolved by the pipeline
|
|
1797
|
+
// id ALONE (findPipelineRowById): the Running page knows the run's pipelineId
|
|
1798
|
+
// but no store key until History has been visited. Placed beside /api/runs/:id
|
|
1799
|
+
// (`:id` matches one path segment, so the two never shadow each other).
|
|
1800
|
+
app.get('/api/runs/:id/artifact', async (req, res) => {
|
|
1801
|
+
try {
|
|
1802
|
+
const row = findPipelineRowById(req.params.id);
|
|
1803
|
+
if (!row) return res.status(404).json({ error: 'pipeline not found' });
|
|
1804
|
+
const hit = await resolveIndexedArtifactForRow(row, req.query.rel);
|
|
1805
|
+
if (!hit) return res.status(404).json({ error: 'artifact not found' });
|
|
1806
|
+
res.json(hit);
|
|
1807
|
+
} catch (err) {
|
|
1808
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
1809
|
+
}
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1365
1812
|
// Shared query-scope resolver for the retained-work routes (recovery-patch GET +
|
|
1366
1813
|
// discard POST). Returns null after writing the error response itself. The older
|
|
1367
1814
|
// DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
|
|
@@ -1538,10 +1985,176 @@ app.get('/api/history/:key/:id/log', async (req, res) => {
|
|
|
1538
1985
|
}
|
|
1539
1986
|
});
|
|
1540
1987
|
|
|
1988
|
+
// ---------------------------------------------------------------------------
|
|
1989
|
+
// Internal, line-anchored diff comments. Bound to BOTH route families below: the
|
|
1990
|
+
// /api/history/:key/:id key regex forbids a slash, so a workspace run (store key
|
|
1991
|
+
// "workspaces/<id>") can only be reached through /api/workspaces/:id/runs/:runId —
|
|
1992
|
+
// the same split the /diff and /log routes already carry. One handler set, two
|
|
1993
|
+
// registrations: the two can never diverge.
|
|
1994
|
+
//
|
|
1995
|
+
// Traversal posture matches the /diff route below: the run dir comes from a DB row
|
|
1996
|
+
// via readRunArtifactText, and the relPath is the CONSTANT DIFF_PATCH_FILE. No
|
|
1997
|
+
// route here ever passes user input as a path.
|
|
1998
|
+
// ---------------------------------------------------------------------------
|
|
1999
|
+
|
|
2000
|
+
// The history key regex is an inline literal on every route in this family; this
|
|
2001
|
+
// block keeps that convention rather than introducing a shared constant the rest
|
|
2002
|
+
// of the file does not use.
|
|
2003
|
+
const commentsHistoryKey = (res, key) => {
|
|
2004
|
+
if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(key)) {
|
|
2005
|
+
res.status(404).json({ error: 'pipeline not found' });
|
|
2006
|
+
return null;
|
|
2007
|
+
}
|
|
2008
|
+
return key;
|
|
2009
|
+
};
|
|
2010
|
+
const commentsWorkspaceKey = (res, id) => {
|
|
2011
|
+
if (!WORKSPACE_KEY_RE.test(id)) { res.status(404).json({ error: 'pipeline not found' }); return null; }
|
|
2012
|
+
return `workspaces/${id}`;
|
|
2013
|
+
};
|
|
2014
|
+
const commentIdParam = (res, value) => {
|
|
2015
|
+
if (typeof value !== 'string' || !DC_ID_RE.test(value)) {
|
|
2016
|
+
res.status(400).json({ error: 'invalid comment id' });
|
|
2017
|
+
return null;
|
|
2018
|
+
}
|
|
2019
|
+
return value;
|
|
2020
|
+
};
|
|
2021
|
+
const commentsFail = (res, err) => res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2022
|
+
|
|
2023
|
+
/** The run row for a store key + id, or null after answering 404. */
|
|
2024
|
+
function commentRun(res, storeKey, id) {
|
|
2025
|
+
const row = lookupPipelineRow(storeKey, id);
|
|
2026
|
+
if (!row) { res.status(404).json({ error: 'pipeline not found' }); return null; }
|
|
2027
|
+
return row;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
async function commentsList(res, storeKey, id) {
|
|
2031
|
+
try {
|
|
2032
|
+
const row = commentRun(res, storeKey, id);
|
|
2033
|
+
if (!row) return;
|
|
2034
|
+
// The UI needs to know whether the '+' affordance may appear at all; a run
|
|
2035
|
+
// whose patch is gone (archived, or never captured) can only read and delete.
|
|
2036
|
+
const patchText = await readRunArtifactText(storeKey, row.id, DIFF_PATCH_FILE);
|
|
2037
|
+
res.json({
|
|
2038
|
+
comments: listDiffComments(storeKey, row.id),
|
|
2039
|
+
patchAvailable: !!patchText,
|
|
2040
|
+
// Section keys the protected-path floor will refuse whatever the line, so the
|
|
2041
|
+
// browser can drop the '+' up front instead of surfacing a 400 on submit. The
|
|
2042
|
+
// preset itself never leaves the server.
|
|
2043
|
+
protectedPaths: protectedSectionKeys(patchText),
|
|
2044
|
+
});
|
|
2045
|
+
} catch (err) { commentsFail(res, err); }
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
async function commentsCreate(req, res, storeKey, id) {
|
|
2049
|
+
try {
|
|
2050
|
+
const row = commentRun(res, storeKey, id);
|
|
2051
|
+
if (!row) return;
|
|
2052
|
+
const body = req.body || {};
|
|
2053
|
+
const patchText = await readRunArtifactText(storeKey, row.id, DIFF_PATCH_FILE);
|
|
2054
|
+
// `!patchText` covers BOTH null (absent/unreadable) and '' (present but empty):
|
|
2055
|
+
// addDiffComment refuses the empty string too, and it must surface as 409, not
|
|
2056
|
+
// as the 400 an anchor failure would get.
|
|
2057
|
+
if (!patchText) {
|
|
2058
|
+
// 409, not 400: the request is well-formed, the RUN is no longer commentable.
|
|
2059
|
+
return res.status(409).json({ error: 'this run has no stored diff — comments cannot be created on it' });
|
|
2060
|
+
}
|
|
2061
|
+
const comment = addDiffComment({
|
|
2062
|
+
storeKey, pipelineId: row.id, patchText,
|
|
2063
|
+
project: body.project ?? null, path: body.path, side: body.side, line: body.line,
|
|
2064
|
+
body: body.body, author: 'user',
|
|
2065
|
+
});
|
|
2066
|
+
res.status(201).json({ comment });
|
|
2067
|
+
} catch (err) {
|
|
2068
|
+
if (err instanceof DiffCommentError) return badRequest(res, err.message);
|
|
2069
|
+
commentsFail(res, err);
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/** A comment reached through a run URL must BELONG to that run — never id alone. */
|
|
2074
|
+
function commentOfRun(res, storeKey, id, cid) {
|
|
2075
|
+
const row = commentRun(res, storeKey, id);
|
|
2076
|
+
if (!row) return null;
|
|
2077
|
+
const comment = getDiffComment(cid);
|
|
2078
|
+
if (!comment || comment.storeKey !== storeKey || comment.pipelineId !== row.id) {
|
|
2079
|
+
res.status(404).json({ error: 'comment not found' });
|
|
2080
|
+
return null;
|
|
2081
|
+
}
|
|
2082
|
+
return comment;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function commentsPatch(req, res, storeKey, id, cid) {
|
|
2086
|
+
try {
|
|
2087
|
+
// Existence BEFORE shape: an unknown run must 404 on every verb, including a
|
|
2088
|
+
// PATCH whose body happens to be malformed.
|
|
2089
|
+
if (!commentOfRun(res, storeKey, id, cid)) return;
|
|
2090
|
+
const raw = (req.body || {}).resolved;
|
|
2091
|
+
if (typeof raw !== 'boolean') return badRequest(res, 'resolved must be a boolean');
|
|
2092
|
+
res.json({ comment: setDiffCommentResolved(cid, raw) });
|
|
2093
|
+
} catch (err) { commentsFail(res, err); }
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function commentsDelete(res, storeKey, id, cid) {
|
|
2097
|
+
try {
|
|
2098
|
+
if (!commentOfRun(res, storeKey, id, cid)) return;
|
|
2099
|
+
deleteDiffComment(cid);
|
|
2100
|
+
res.json({ ok: true });
|
|
2101
|
+
} catch (err) { commentsFail(res, err); }
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
app.get('/api/history/:key/:id/comments', async (req, res) => {
|
|
2105
|
+
const key = commentsHistoryKey(res, req.params.key); if (!key) return;
|
|
2106
|
+
await commentsList(res, key, req.params.id);
|
|
2107
|
+
});
|
|
2108
|
+
app.post('/api/history/:key/:id/comments', async (req, res) => {
|
|
2109
|
+
const key = commentsHistoryKey(res, req.params.key); if (!key) return;
|
|
2110
|
+
await commentsCreate(req, res, key, req.params.id);
|
|
2111
|
+
});
|
|
2112
|
+
app.patch('/api/history/:key/:id/comments/:cid', (req, res) => {
|
|
2113
|
+
const key = commentsHistoryKey(res, req.params.key); if (!key) return;
|
|
2114
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2115
|
+
commentsPatch(req, res, key, req.params.id, cid);
|
|
2116
|
+
});
|
|
2117
|
+
app.delete('/api/history/:key/:id/comments/:cid', (req, res) => {
|
|
2118
|
+
const key = commentsHistoryKey(res, req.params.key); if (!key) return;
|
|
2119
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2120
|
+
commentsDelete(res, key, req.params.id, cid);
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
app.get('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
|
|
2124
|
+
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
2125
|
+
await commentsList(res, key, req.params.runId);
|
|
2126
|
+
});
|
|
2127
|
+
app.post('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
|
|
2128
|
+
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
2129
|
+
await commentsCreate(req, res, key, req.params.runId);
|
|
2130
|
+
});
|
|
2131
|
+
app.patch('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
|
|
2132
|
+
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
2133
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2134
|
+
commentsPatch(req, res, key, req.params.runId, cid);
|
|
2135
|
+
});
|
|
2136
|
+
app.delete('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
|
|
2137
|
+
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
2138
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2139
|
+
commentsDelete(res, key, req.params.runId, cid);
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
// Unresolved counts for every run, for the History list pill. Its own endpoint
|
|
2143
|
+
// rather than a field on /api/history: that response has a localStorage skeleton
|
|
2144
|
+
// cache, so a cached paint would show a stale pill; and diff-comments-changed can
|
|
2145
|
+
// repaint pills from here without forcing a whole History reload.
|
|
2146
|
+
app.get('/api/diff-comments/counts', (_req, res) => {
|
|
2147
|
+
try { res.json({ counts: unresolvedCounts() }); } catch (err) { commentsFail(res, err); }
|
|
2148
|
+
});
|
|
2149
|
+
|
|
1541
2150
|
// ---------------------------------------------------------------------------
|
|
1542
2151
|
// GET /api/history/:key/:id/diff -> the run's persisted diff-patch.patch, inline
|
|
1543
|
-
// (text/x-diff).
|
|
1544
|
-
//
|
|
2152
|
+
// (text/x-diff). The route is status-agnostic and always has been: the artifact
|
|
2153
|
+
// exists for every run that reached a checkpoint AND changed something under it —
|
|
2154
|
+
// the done path AND the stopped/error paths, which build results too (orchestrator
|
|
2155
|
+
// run() and resume()). A run stopped before its checkpoint has none, nor does one
|
|
2156
|
+
// that changed nothing (_buildResults writes neither artifact for an empty patch),
|
|
2157
|
+
// and neither does an archived one; all of those 404 and the UI shows its empty state.
|
|
1545
2158
|
// Key validation mirrors the /log route (:1529); the artifact read follows the
|
|
1546
2159
|
// recovery-patch route's readRunArtifactText pattern (:1408) — the log routes
|
|
1547
2160
|
// themselves use the specialized readRunLogText. The relPath is the CONSTANT
|
|
@@ -1561,6 +2174,23 @@ app.get('/api/history/:key/:id/diff', async (req, res) => {
|
|
|
1561
2174
|
}
|
|
1562
2175
|
});
|
|
1563
2176
|
|
|
2177
|
+
// GET /api/history/:key/:id/artifact?rel= -> { rel, text } for ONE artifact the
|
|
2178
|
+
// run indexed (the End card's result chip). `rel` never reaches the FS: it only
|
|
2179
|
+
// selects among the pipeline's own artifacts rows (exact rel_path, else a path
|
|
2180
|
+
// suffix). Same key regex as /diff.
|
|
2181
|
+
app.get('/api/history/:key/:id/artifact', async (req, res) => {
|
|
2182
|
+
if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(req.params.key)) {
|
|
2183
|
+
return res.status(404).json({ error: 'pipeline not found' });
|
|
2184
|
+
}
|
|
2185
|
+
try {
|
|
2186
|
+
const hit = await resolveIndexedArtifact(req.params.key, req.params.id, req.query.rel);
|
|
2187
|
+
if (!hit) return res.status(404).json({ error: 'artifact not found' });
|
|
2188
|
+
res.json(hit);
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
2193
|
+
|
|
1564
2194
|
// ---------------------------------------------------------------------------
|
|
1565
2195
|
// DELETE /api/runs/:id?projectKey=... (or ?projectDir=...)
|
|
1566
2196
|
// ARCHIVE a FINISHED pipeline: reclaims everything on disk — its store folder,
|
|
@@ -2062,6 +2692,8 @@ app.get('/api/workspaces/:id/runs/:runId/log', async (req, res) => {
|
|
|
2062
2692
|
}
|
|
2063
2693
|
});
|
|
2064
2694
|
|
|
2695
|
+
// NOTE: the /comments twins for workspace runs are registered with their project
|
|
2696
|
+
// siblings up at the diff-comments block — the pair must be read together.
|
|
2065
2697
|
app.get('/api/workspaces/:id/runs/:runId/diff', async (req, res) => {
|
|
2066
2698
|
if (!WORKSPACE_KEY_RE.test(req.params.id)) {
|
|
2067
2699
|
return res.status(404).json({ error: 'pipeline not found' });
|
|
@@ -2075,6 +2707,18 @@ app.get('/api/workspaces/:id/runs/:runId/diff', async (req, res) => {
|
|
|
2075
2707
|
}
|
|
2076
2708
|
});
|
|
2077
2709
|
|
|
2710
|
+
// The End-card result chip's workspace twin (see the project route above).
|
|
2711
|
+
app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
|
|
2712
|
+
if (!WORKSPACE_KEY_RE.test(req.params.id)) return res.status(404).json({ error: 'pipeline not found' });
|
|
2713
|
+
try {
|
|
2714
|
+
const hit = await resolveIndexedArtifact(`workspaces/${req.params.id}`, req.params.runId, req.query.rel);
|
|
2715
|
+
if (!hit) return res.status(404).json({ error: 'artifact not found' });
|
|
2716
|
+
res.json(hit);
|
|
2717
|
+
} catch (err) {
|
|
2718
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2719
|
+
}
|
|
2720
|
+
});
|
|
2721
|
+
|
|
2078
2722
|
// ---------------------------------------------------------------------------
|
|
2079
2723
|
// GET /api/settings -> { root, projectsRoot, projectsRootDefault, default }
|
|
2080
2724
|
// root : the configured Worca CC data-root base, '' when unset
|
|
@@ -2106,6 +2750,8 @@ const settingsState = () => ({
|
|
|
2106
2750
|
pipelineCostLimitUsd: pipelineCostLimitUsd(),
|
|
2107
2751
|
totalCostLimitUsd: totalCostLimitUsd(),
|
|
2108
2752
|
costLimitResetPeriod: costLimitResetPeriod(),
|
|
2753
|
+
askMaxTurns: askMaxTurns(),
|
|
2754
|
+
askMaxBudgetUsd: askMaxBudgetUsd(),
|
|
2109
2755
|
});
|
|
2110
2756
|
|
|
2111
2757
|
app.get('/api/settings', (_req, res) => {
|
|
@@ -2120,6 +2766,7 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2120
2766
|
const body = req.body || {};
|
|
2121
2767
|
const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
|
|
2122
2768
|
const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
|
|
2769
|
+
const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
|
|
2123
2770
|
// Normalize the budget keys first, then validate them as a SET before ANY write.
|
|
2124
2771
|
// Each setter persists on its own, so a two-key POST whose second key is invalid
|
|
2125
2772
|
// used to answer 400 with the first key already on disk, no budget-changed
|
|
@@ -2131,8 +2778,15 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2131
2778
|
if (has('costLimitResetPeriod')) {
|
|
2132
2779
|
budget.costLimitResetPeriod = typeof body.costLimitResetPeriod === 'string' ? body.costLimitResetPeriod : '';
|
|
2133
2780
|
}
|
|
2781
|
+
// Ask Worca per-turn guards (ask-worca-design.md §6.9): same set-validation
|
|
2782
|
+
// discipline. `null` is a VALUE for askMaxBudgetUsd (no cap) and must survive
|
|
2783
|
+
// normalisation; only undefined becomes a clear.
|
|
2784
|
+
const ask = {};
|
|
2785
|
+
if (has('askMaxTurns')) ask.askMaxTurns = body.askMaxTurns ?? '';
|
|
2786
|
+
if (has('askMaxBudgetUsd')) ask.askMaxBudgetUsd = body.askMaxBudgetUsd === undefined ? '' : body.askMaxBudgetUsd;
|
|
2134
2787
|
try {
|
|
2135
2788
|
assertCostLimitInputs(budget);
|
|
2789
|
+
assertAskLimitInputs(ask);
|
|
2136
2790
|
if (has('chat')) await setChatPrefs(body.chat);
|
|
2137
2791
|
if (has('projectsRoot')) {
|
|
2138
2792
|
await setProjectsRoot(typeof body.projectsRoot === 'string' ? body.projectsRoot : '');
|
|
@@ -2140,9 +2794,11 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2140
2794
|
if (has('pipelineCostLimitUsd')) await setPipelineCostLimitUsd(budget.pipelineCostLimitUsd);
|
|
2141
2795
|
if (has('totalCostLimitUsd')) await setTotalCostLimitUsd(budget.totalCostLimitUsd);
|
|
2142
2796
|
if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2797
|
+
if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
|
|
2798
|
+
if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
|
|
2799
|
+
// Legacy contract: a POST that names no known key clears root. Budget and ask
|
|
2800
|
+
// keys must not trip it — a budget-only or ask-only save would otherwise wipe the root.
|
|
2801
|
+
if (has('root') || !(has('projectsRoot') || hasBudgetKey || hasAskKey || has('chat'))) {
|
|
2146
2802
|
await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
|
|
2147
2803
|
}
|
|
2148
2804
|
if (hasBudgetKey) emitChanged('budget-changed');
|
|
@@ -2167,6 +2823,7 @@ app.get('/api/config', async (req, res) => {
|
|
|
2167
2823
|
return res.json({
|
|
2168
2824
|
config: { steps: {}, customModels: [] },
|
|
2169
2825
|
models: await listModels(''), steps: agentSteps(), efforts: EFFORTS,
|
|
2826
|
+
subagentModels: SUBAGENT_MODEL_VALUES,
|
|
2170
2827
|
});
|
|
2171
2828
|
}
|
|
2172
2829
|
const projectDir = resolveProjectDir(raw);
|
|
@@ -2185,6 +2842,10 @@ app.get('/api/config', async (req, res) => {
|
|
|
2185
2842
|
]);
|
|
2186
2843
|
res.json({
|
|
2187
2844
|
config, models, steps: agentSteps(), efforts: EFFORTS,
|
|
2845
|
+
// The sub-agent model policy vocabulary is a FIXED alias enum (the CLI's Task
|
|
2846
|
+
// tool refuses catalog ids), so it ships beside `efforts` rather than being
|
|
2847
|
+
// derived from `models`.
|
|
2848
|
+
subagentModels: SUBAGENT_MODEL_VALUES,
|
|
2188
2849
|
});
|
|
2189
2850
|
} catch (err) {
|
|
2190
2851
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
@@ -2198,6 +2859,7 @@ app.post('/api/config', async (req, res) => {
|
|
|
2198
2859
|
try {
|
|
2199
2860
|
await setStep(projectDir, body.step, {
|
|
2200
2861
|
model: body.model, effort: body.effort, fanOut: body.fanOut, askQuestions: body.askQuestions,
|
|
2862
|
+
subagentModel: body.subagentModel,
|
|
2201
2863
|
});
|
|
2202
2864
|
// Respond with the FULL run-config (mirrors PATCH): setStep's return value is
|
|
2203
2865
|
// the legacy {steps, customModels} view only, and clients assign the response
|
|
@@ -2219,31 +2881,55 @@ app.post('/api/config', async (req, res) => {
|
|
|
2219
2881
|
// validates model/effort against the effective catalog exactly like setStep
|
|
2220
2882
|
// (configurable-models-design.md §4.5) -> 400; setFeedbackCycles still COERCES
|
|
2221
2883
|
// maxCycles to >= 1 (it never throws).
|
|
2222
|
-
// body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, activeWorkflowId? }
|
|
2884
|
+
// body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, wires?:{[wireId]:{maxCycles}}, activeWorkflowId? }
|
|
2223
2885
|
// ---------------------------------------------------------------------------
|
|
2224
2886
|
app.patch('/api/config', async (req, res) => {
|
|
2225
2887
|
const body = req.body || {};
|
|
2226
2888
|
const projectDir = resolveProjectDir(body.projectDir);
|
|
2227
2889
|
if (!projectDir) return badRequest(res, 'projectDir is required');
|
|
2228
2890
|
const workflowId = typeof body.workflowId === 'string' ? body.workflowId.trim() : '';
|
|
2891
|
+
// MAJ-1: every arm below keys a normalized table by workflowId, and
|
|
2892
|
+
// readWorkflowsMap rebuilds a map from those keys — an id like '__proto__'
|
|
2893
|
+
// used to be persisted unchecked and then broke readRunConfig for the whole
|
|
2894
|
+
// project. isSafeWorkflowId is the store's own id rule, so the API can never
|
|
2895
|
+
// write an id the store would refuse. (The recovery route DELETE
|
|
2896
|
+
// /api/config/workflow stays deliberately ungated: an already-poisoned row
|
|
2897
|
+
// must still be clearable.)
|
|
2898
|
+
const workflowIdError = (what) => {
|
|
2899
|
+
if (!workflowId) return `workflowId is required to set ${what} config`;
|
|
2900
|
+
if (!isSafeWorkflowId(workflowId)) return 'invalid workflowId';
|
|
2901
|
+
return null;
|
|
2902
|
+
};
|
|
2229
2903
|
try {
|
|
2230
2904
|
if (body.nodes && typeof body.nodes === 'object') {
|
|
2231
|
-
|
|
2905
|
+
const bad = workflowIdError('node');
|
|
2906
|
+
if (bad) return badRequest(res, bad);
|
|
2232
2907
|
for (const [nodeId, sel] of Object.entries(body.nodes)) {
|
|
2233
2908
|
await setNodeModel(projectDir, workflowId, nodeId, {
|
|
2234
2909
|
model: sel && sel.model, effort: sel && sel.effort,
|
|
2235
2910
|
fanOut: sel && sel.fanOut, askQuestions: sel && sel.askQuestions,
|
|
2911
|
+
subagentModel: sel && sel.subagentModel,
|
|
2236
2912
|
});
|
|
2237
2913
|
}
|
|
2238
2914
|
}
|
|
2239
2915
|
if (body.feedbacks && typeof body.feedbacks === 'object') {
|
|
2240
|
-
|
|
2916
|
+
const bad = workflowIdError('feedback');
|
|
2917
|
+
if (bad) return badRequest(res, bad);
|
|
2241
2918
|
for (const [fbId, sel] of Object.entries(body.feedbacks)) {
|
|
2242
2919
|
await setFeedbackCycles(projectDir, workflowId, fbId, sel && sel.maxCycles);
|
|
2243
2920
|
}
|
|
2244
2921
|
}
|
|
2922
|
+
if (body.wires && typeof body.wires === 'object') {
|
|
2923
|
+
const bad = workflowIdError('wire');
|
|
2924
|
+
if (bad) return badRequest(res, bad);
|
|
2925
|
+
for (const [wireId, sel] of Object.entries(body.wires)) {
|
|
2926
|
+
await setWireCycles(projectDir, workflowId, wireId, sel && sel.maxCycles);
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2245
2929
|
if (typeof body.activeWorkflowId === 'string' && body.activeWorkflowId.trim()) {
|
|
2246
|
-
|
|
2930
|
+
const active = body.activeWorkflowId.trim();
|
|
2931
|
+
if (!isSafeWorkflowId(active)) return badRequest(res, 'invalid workflowId');
|
|
2932
|
+
await setActiveWorkflow(projectDir, active);
|
|
2247
2933
|
}
|
|
2248
2934
|
const config = await readRunConfig(projectDir);
|
|
2249
2935
|
res.json({ config });
|
|
@@ -2330,6 +3016,7 @@ const pluginModelsPayload = () => {
|
|
|
2330
3016
|
k, typeof v === 'string' ? maskEnvValue(v) : `(secret: ${v.secret})`,
|
|
2331
3017
|
])),
|
|
2332
3018
|
secrets: status.filter((s) => m.secrets.includes(s.key)),
|
|
3019
|
+
...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing — config, never a credential
|
|
2333
3020
|
...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
|
|
2334
3021
|
};
|
|
2335
3022
|
});
|
|
@@ -2342,7 +3029,7 @@ app.get('/api/models', (req, res) => {
|
|
|
2342
3029
|
app.post('/api/models', async (req, res) => {
|
|
2343
3030
|
const b = req.body || {};
|
|
2344
3031
|
try {
|
|
2345
|
-
const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env });
|
|
3032
|
+
const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env, cost: b.cost });
|
|
2346
3033
|
res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
|
|
2347
3034
|
} catch (err) {
|
|
2348
3035
|
// addGlobalModel throws only on validation (empty/dup id, unknown effort,
|
|
@@ -2425,6 +3112,10 @@ app.post('/api/models/export-plugin', async (req, res) => {
|
|
|
2425
3112
|
...(entry.label !== entry.id ? { label: entry.label } : {}),
|
|
2426
3113
|
...(entry.efforts.length && entry.efforts.length !== EFFORTS.length ? { efforts: entry.efforts } : {}),
|
|
2427
3114
|
...(Object.keys(env).length ? { env } : {}),
|
|
3115
|
+
// Pricing travels with the model. It is configuration, not a credential —
|
|
3116
|
+
// and a shared on-prem model is precisely one the CLI would otherwise
|
|
3117
|
+
// price by NAME on every machine that installs the plugin.
|
|
3118
|
+
...(entry.cost ? { cost: entry.cost } : {}),
|
|
2428
3119
|
});
|
|
2429
3120
|
}
|
|
2430
3121
|
|
|
@@ -2495,7 +3186,7 @@ app.patch('/api/models/:id', async (req, res) => {
|
|
|
2495
3186
|
env = Object.fromEntries(Object.entries(env).filter(([, v]) => !isMaskedEcho(v)));
|
|
2496
3187
|
}
|
|
2497
3188
|
try {
|
|
2498
|
-
const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env });
|
|
3189
|
+
const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env, cost: b.cost });
|
|
2499
3190
|
res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
|
|
2500
3191
|
} catch (err) {
|
|
2501
3192
|
// updateGlobalModel throws only on validation (unknown id, unknown effort,
|
|
@@ -2534,6 +3225,35 @@ app.delete('/api/models/:id', async (req, res) => {
|
|
|
2534
3225
|
}
|
|
2535
3226
|
});
|
|
2536
3227
|
|
|
3228
|
+
// Live connectivity check for a catalog model — the Models-view Test button.
|
|
3229
|
+
// Explicit user action only (one real, tiny API call against wherever the
|
|
3230
|
+
// model routes). Caller mistakes get an HTTP status; the test OUTCOME rides a
|
|
3231
|
+
// 200 envelope, same convention as POST /api/chat/test. Ids resolve global
|
|
3232
|
+
// first, then plugin — resolveModelEnv's precedence.
|
|
3233
|
+
const modelTestsInFlight = new Set();
|
|
3234
|
+
app.post('/api/models/:id/test', async (req, res) => {
|
|
3235
|
+
const id = String(req.params.id);
|
|
3236
|
+
const lc = id.toLowerCase();
|
|
3237
|
+
const global = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
|
|
3238
|
+
const plugin = global ? null : listPluginModels().find((m) => m.id.toLowerCase() === lc);
|
|
3239
|
+
if (!global && !plugin) return res.status(404).json({ error: `unknown model id ${JSON.stringify(id)}` });
|
|
3240
|
+
if (plugin && plugin.secrets.length) {
|
|
3241
|
+
// Don't burn a spawn guaranteed to fail — resolveModelEnv drops unset secrets.
|
|
3242
|
+
const unset = pluginModelSecretStatus(plugin.plugin)
|
|
3243
|
+
.filter((s) => plugin.secrets.includes(s.key) && !s.set).map((s) => s.key);
|
|
3244
|
+
if (unset.length) {
|
|
3245
|
+
return badRequest(res, `secret ${unset.join(', ')} is not set — configure it in the plugin's settings`);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
if (modelTestsInFlight.has(lc)) return badRequest(res, 'test already running for this model');
|
|
3249
|
+
modelTestsInFlight.add(lc);
|
|
3250
|
+
try {
|
|
3251
|
+
res.json(await testModel(id));
|
|
3252
|
+
} finally {
|
|
3253
|
+
modelTestsInFlight.delete(lc);
|
|
3254
|
+
}
|
|
3255
|
+
});
|
|
3256
|
+
|
|
2537
3257
|
// ---------------------------------------------------------------------------
|
|
2538
3258
|
// Workflow templates (global store at ~/.worca-cc/workflows). Topology only;
|
|
2539
3259
|
// model/effort/cycles live in per-project run-config. CRUD mirrors the
|
|
@@ -2550,6 +3270,10 @@ function nodeDefaultsError(raw, models, where) {
|
|
|
2550
3270
|
const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
|
|
2551
3271
|
const entry = model ? models.find((m) => m.id === model) : null;
|
|
2552
3272
|
if (model && !entry) return `unknown model "${model}"`;
|
|
3273
|
+
// subagentModel is a fixed alias enum, NOT a catalog id: validated via the
|
|
3274
|
+
// shared helper so a typo is a 400 with the same message every writer uses.
|
|
3275
|
+
const subIssue = subagentModelIssue(raw.subagentModel);
|
|
3276
|
+
if (subIssue) return subIssue;
|
|
2553
3277
|
if (!effort) return '';
|
|
2554
3278
|
if (!EFFORTS.includes(effort)) return `unknown effort "${effort}"`;
|
|
2555
3279
|
if (!entry) return 'select a model before choosing an effort';
|
|
@@ -2557,11 +3281,15 @@ function nodeDefaultsError(raw, models, where) {
|
|
|
2557
3281
|
return '';
|
|
2558
3282
|
}
|
|
2559
3283
|
|
|
2560
|
-
app.get('/api/workflows', async (
|
|
3284
|
+
app.get('/api/workflows', async (req, res) => {
|
|
2561
3285
|
try {
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
3286
|
+
if (isTruthy(req.query.archived)) {
|
|
3287
|
+
const all = await listWorkflows({ includeArchived: true });
|
|
3288
|
+
return res.json({ workflows: all.filter((w) => w.archivedAt) });
|
|
3289
|
+
}
|
|
3290
|
+
// CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, ...listWorkflows() ]. The built-in is
|
|
3291
|
+
// never a persisted row (listWorkflows filters its id), so it cannot appear twice.
|
|
3292
|
+
res.json({ workflows: [GRAPH_DEFAULT_WORKFLOW, ...(await listWorkflows())] });
|
|
2565
3293
|
} catch (err) {
|
|
2566
3294
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2567
3295
|
}
|
|
@@ -2569,51 +3297,80 @@ app.get('/api/workflows', async (_req, res) => {
|
|
|
2569
3297
|
|
|
2570
3298
|
app.get('/api/workflows/:id', async (req, res) => {
|
|
2571
3299
|
try {
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
3300
|
+
// ONE gate, ONE message: an archived id explains itself instead of reading
|
|
3301
|
+
// as a plain 404 (assertRunnableWorkflow owns both texts). checkGraph:false —
|
|
3302
|
+
// this is a READ (the Composer's Open): a template stranded by an agent-port
|
|
3303
|
+
// edit must still load, or the user could never repair it. The RUN path keeps
|
|
3304
|
+
// the graph check.
|
|
3305
|
+
res.json(await assertRunnableWorkflow(req.params.id, { checkGraph: false }));
|
|
2575
3306
|
} catch (err) {
|
|
3307
|
+
if (err && (err.code === 'NOT_FOUND' || err.code === 'ARCHIVED')) {
|
|
3308
|
+
return res.status(404).json({ error: err.message });
|
|
3309
|
+
}
|
|
2576
3310
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2577
3311
|
}
|
|
2578
3312
|
});
|
|
2579
3313
|
|
|
2580
3314
|
app.post('/api/workflows', async (req, res) => {
|
|
2581
3315
|
const body = req.body || {};
|
|
2582
|
-
//
|
|
2583
|
-
|
|
3316
|
+
// The v1 pipeline format is RETIRED: only graphs are accepted (spec §10.2).
|
|
3317
|
+
// The whole v1 arm (its steps-borne node defaults, validateWorkflow and
|
|
3318
|
+
// writeWorkflow) died with it — nothing reaches the v1 store through the API.
|
|
3319
|
+
if (body.version !== 2) {
|
|
3320
|
+
return badRequest(res, 'v1 pipeline templates are no longer accepted — save a graph (version 2)');
|
|
3321
|
+
}
|
|
3322
|
+
// ── v2 graph save ──────────────────────────────────────────────────────────
|
|
3323
|
+
// The 422 body is the SHARED validator's issue list, by construction: the
|
|
3324
|
+
// composer renders exactly what it would have computed locally, so the server
|
|
3325
|
+
// and the client can never disagree about why a graph is illegal.
|
|
3326
|
+
const graph = {
|
|
3327
|
+
id: typeof body.id === 'string' ? body.id : undefined,
|
|
2584
3328
|
name: typeof body.name === 'string' ? body.name.trim() : '',
|
|
2585
|
-
domain: typeof body.domain === 'string' ? body.domain : undefined,
|
|
2586
|
-
|
|
2587
|
-
|
|
3329
|
+
domain: typeof body.domain === 'string' ? body.domain : undefined,
|
|
3330
|
+
nodes: Array.isArray(body.nodes) ? body.nodes : [],
|
|
3331
|
+
wires: Array.isArray(body.wires) ? body.wires : [],
|
|
3332
|
+
...(body.canvas && typeof body.canvas === 'object' ? { canvas: body.canvas } : {}),
|
|
2588
3333
|
};
|
|
2589
|
-
if (!
|
|
3334
|
+
if (!graph.name) return badRequest(res, 'name is required');
|
|
2590
3335
|
try {
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
//
|
|
3336
|
+
// Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
|
|
3337
|
+
// so a value the per-project override could not name must not ride in
|
|
3338
|
+
// through a template save. nodeDefaultsError checks the tunables (model +
|
|
3339
|
+
// effort against the catalog, subagentModel against the alias enum), so
|
|
3340
|
+
// only AGENT_TUNABLES are handed to it — topology keys (awaitAll, arity,
|
|
3341
|
+
// planStoreSeed) never are.
|
|
2594
3342
|
const models = await listModels('');
|
|
2595
|
-
for (const
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
3343
|
+
for (const n of graph.nodes) {
|
|
3344
|
+
if (!n || n.kind !== 'agent' || !n.config || typeof n.config !== 'object') continue;
|
|
3345
|
+
const picked = Object.fromEntries(
|
|
3346
|
+
AGENT_TUNABLES.filter((k) => k in n.config).map((k) => [k, n.config[k]]));
|
|
3347
|
+
const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
|
|
3348
|
+
if (bad) return badRequest(res, bad);
|
|
2601
3349
|
}
|
|
2602
|
-
const
|
|
2603
|
-
const {
|
|
2604
|
-
if (
|
|
2605
|
-
//
|
|
2606
|
-
|
|
2607
|
-
|
|
3350
|
+
const portsFn = registryPortsFn(loadAgentRegistry(AGENTS_DIR));
|
|
3351
|
+
const { errors, warnings } = validateGraph({ ...graph, version: 2 }, portsFn);
|
|
3352
|
+
if (errors.length) return res.status(422).json({ error: 'invalid graph', errors, warnings });
|
|
3353
|
+
// rejectCollision (MAJ-5): the body carried no id, so wf_<slug(name)> is a
|
|
3354
|
+
// GUESS — it must never silently replace a pipeline the user can see.
|
|
3355
|
+
const workflow = await writeGraphWorkflow(graph, { rejectCollision: true });
|
|
3356
|
+
return res.status(201).json({ workflow, warnings });
|
|
2608
3357
|
} catch (err) {
|
|
2609
|
-
|
|
3358
|
+
// C-3: a name that slugs onto the reserved wf_default is a caller error, not
|
|
3359
|
+
// a server fault — 422, the same code the validator's refusal uses. MAJ-5: a
|
|
3360
|
+
// minted id already in use is a 409 carrying that id, so the dialog can offer
|
|
3361
|
+
// rename/overwrite. Both bodies carry NO issues/errors array on purpose:
|
|
3362
|
+
// app.js's saveWorkflow maps `error` straight into the save dialog's message
|
|
3363
|
+
// line, verbatim.
|
|
3364
|
+
if (err && err.code === 'RESERVED_NAME') return res.status(422).json({ error: err.message });
|
|
3365
|
+
if (err && err.code === 'ID_TAKEN') return res.status(409).json({ error: err.message, id: err.id });
|
|
3366
|
+
return res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2610
3367
|
}
|
|
2611
3368
|
});
|
|
2612
3369
|
|
|
2613
3370
|
// ---------------------------------------------------------------------------
|
|
2614
3371
|
// PATCH /api/workflows/:id/defaults -> set the template's per-node defaults
|
|
2615
3372
|
// (newpipeline-ux-design.md §4.4). body: { defaults: { [nodeId]: {model?, effort?,
|
|
2616
|
-
// fanOut?, askQuestions?} | null } }; null (or an empty block) clears a node, an
|
|
3373
|
+
// fanOut?, askQuestions?, subagentModel?} | null } }; null (or an empty block) clears a node, an
|
|
2617
3374
|
// absent node keeps what it has. Model/effort validate against the PROJECT-LESS
|
|
2618
3375
|
// catalog (predefined ⊕ global ⊕ plugin) — defaults are global, so a legacy
|
|
2619
3376
|
// per-project custom model is deliberately not a valid default.
|
|
@@ -2750,32 +3507,583 @@ app.delete('/api/guardrails/:id', async (req, res) => {
|
|
|
2750
3507
|
}
|
|
2751
3508
|
});
|
|
2752
3509
|
|
|
3510
|
+
// ---------------------------------------------------------------------------
|
|
3511
|
+
// Ask Worca (ask-worca-design.md §8). askJobs is SEPARATE from the runs Map —
|
|
3512
|
+
// the client's Running badge counts runs entries, and a thread id is the
|
|
3513
|
+
// subscription key (§8.3). No store/home access at import time (the chatCtx
|
|
3514
|
+
// rule): the Maps are bare and every store call lives inside a handler or
|
|
3515
|
+
// bootMaintenance.
|
|
3516
|
+
// ---------------------------------------------------------------------------
|
|
3517
|
+
const askJobs = new Map(); // threadId -> {turn, messageId, userMessageId, events, seq, status, startedAt, graceTimer}
|
|
3518
|
+
// Threads whose DELETE is past its first await (worktree removal spawns git):
|
|
3519
|
+
// POST /messages refuses them so no turn can start against rows that are
|
|
3520
|
+
// about to cascade (review of PR #376 — a turn started in that window outlived
|
|
3521
|
+
// the delete as a live job holding a global slot).
|
|
3522
|
+
const askDeleting = new Set();
|
|
3523
|
+
const askFollowers = new Map(); // threadId -> Set<{detach}>
|
|
3524
|
+
const ASK_JOB_MAX_BUFFER = 5000; // same arithmetic as MAX_BUFFER: deltas dominate; eviction ⇒ client seq-gap re-sync
|
|
3525
|
+
|
|
3526
|
+
function askInFlight(threadId) {
|
|
3527
|
+
const job = askJobs.get(threadId);
|
|
3528
|
+
return job && job.status === 'running' ? job : null;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
function askRunningCount() {
|
|
3532
|
+
let n = 0;
|
|
3533
|
+
for (const job of askJobs.values()) if (job.status === 'running') n += 1;
|
|
3534
|
+
return n;
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
/** hello payload: running turns only (§8.2). A job whose slot was just
|
|
3538
|
+
* reserved (messageId still null — the message route's atomic reservation,
|
|
3539
|
+
* Task 6) is skipped: it becomes visible once its assistant row exists. */
|
|
3540
|
+
function askHello() {
|
|
3541
|
+
const out = [];
|
|
3542
|
+
for (const [threadId, job] of askJobs.entries()) {
|
|
3543
|
+
if (job.status === 'running' && job.messageId) out.push({ threadId, messageId: job.messageId });
|
|
3544
|
+
}
|
|
3545
|
+
return out;
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
/** Replay a job's stamped ring buffer to one socket. No state snapshot — the
|
|
3549
|
+
* REST thread GET is the snapshot; the client dedupes by seq (§6.6). */
|
|
3550
|
+
function replayAskJob(ws, job) {
|
|
3551
|
+
for (const ev of job.events) send(ws, ev);
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
/** The stamping closure (§17: reducer frames are BARE; the server stamps).
|
|
3555
|
+
* Shared by the turn's own ask-start/ask-done/ask-error and every reducer
|
|
3556
|
+
* frame, so ALL job frames are buffered, replayed and seq-ordered alike. */
|
|
3557
|
+
function stampAskFrames(threadId, job) {
|
|
3558
|
+
return (bare) => {
|
|
3559
|
+
const frame = { ...bare, threadId, messageId: job.messageId, seq: ++job.seq };
|
|
3560
|
+
job.events.push(frame);
|
|
3561
|
+
if (job.events.length > ASK_JOB_MAX_BUFFER) job.events.splice(0, job.events.length - ASK_JOB_MAX_BUFFER);
|
|
3562
|
+
broadcast(frame);
|
|
3563
|
+
};
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
/** 400 on shape (spec §8.1 — a DELIBERATE divergence from the house 404-on-
|
|
3567
|
+
* malformed-param style), null-return contract like badRequest. */
|
|
3568
|
+
function askIdParam(res, value, kind) {
|
|
3569
|
+
if (typeof value !== 'string' || !ASK_ID_RE.test(value)) {
|
|
3570
|
+
res.status(400).json({ error: `invalid ${kind} id` });
|
|
3571
|
+
return null;
|
|
3572
|
+
}
|
|
3573
|
+
return value;
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
app.get('/api/ask/threads', (req, res) => {
|
|
3577
|
+
try {
|
|
3578
|
+
const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
|
|
3579
|
+
const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
|
|
3580
|
+
const threads = askListThreads({ limit }).map((t) => ({ ...t, inFlight: !!askInFlight(t.id) }));
|
|
3581
|
+
res.json({ threads });
|
|
3582
|
+
} catch (err) {
|
|
3583
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3584
|
+
}
|
|
3585
|
+
});
|
|
3586
|
+
|
|
3587
|
+
app.post('/api/ask/threads', (req, res) => {
|
|
3588
|
+
try {
|
|
3589
|
+
const body = req.body || {};
|
|
3590
|
+
let title = null;
|
|
3591
|
+
if (body.title !== undefined && body.title !== null && body.title !== '') {
|
|
3592
|
+
if (typeof body.title !== 'string' || body.title.length > 120) {
|
|
3593
|
+
return badRequest(res, 'title must be a string of at most 120 characters');
|
|
3594
|
+
}
|
|
3595
|
+
title = body.title.trim() || null;
|
|
3596
|
+
}
|
|
3597
|
+
const thread = askCreateThread();
|
|
3598
|
+
if (title) askUpdateThread(thread.id, { title });
|
|
3599
|
+
res.status(201).json({ thread: askGetThread(thread.id) });
|
|
3600
|
+
} catch (err) {
|
|
3601
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3602
|
+
}
|
|
3603
|
+
});
|
|
3604
|
+
|
|
3605
|
+
app.get('/api/ask/threads/:id', (req, res) => {
|
|
3606
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3607
|
+
if (!id) return;
|
|
3608
|
+
try {
|
|
3609
|
+
const thread = askGetThread(id);
|
|
3610
|
+
if (!thread) return res.status(404).json({ error: 'thread not found' });
|
|
3611
|
+
const job = askInFlight(id);
|
|
3612
|
+
res.json({
|
|
3613
|
+
thread,
|
|
3614
|
+
messages: askListMessages(id),
|
|
3615
|
+
attachments: askListAttachments(id),
|
|
3616
|
+
runLinks: askListRunLinks(id),
|
|
3617
|
+
// P4 §10: the SAME narrow envelope the list_worktrees MCP tool returns —
|
|
3618
|
+
// never the full row (threadId/projectDir/updatedAt stay server-side).
|
|
3619
|
+
worktrees: askListWorktrees(id).map((w) => ({
|
|
3620
|
+
worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref,
|
|
3621
|
+
commit: w.commit, path: w.path, createdAt: w.createdAt,
|
|
3622
|
+
})),
|
|
3623
|
+
inFlight: job && job.messageId ? { messageId: job.messageId } : null, // null while the slot is only reserved
|
|
3624
|
+
});
|
|
3625
|
+
} catch (err) {
|
|
3626
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3627
|
+
}
|
|
3628
|
+
});
|
|
3629
|
+
|
|
3630
|
+
app.patch('/api/ask/threads/:id', (req, res) => {
|
|
3631
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3632
|
+
if (!id) return;
|
|
3633
|
+
try {
|
|
3634
|
+
const raw = (req.body || {}).title;
|
|
3635
|
+
if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
|
|
3636
|
+
return badRequest(res, 'title must be a non-empty string of at most 120 characters');
|
|
3637
|
+
}
|
|
3638
|
+
const thread = askUpdateThread(id, { title: raw.trim() });
|
|
3639
|
+
if (!thread) return res.status(404).json({ error: 'thread not found' });
|
|
3640
|
+
res.json({ thread });
|
|
3641
|
+
} catch (err) {
|
|
3642
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3643
|
+
}
|
|
3644
|
+
});
|
|
3645
|
+
|
|
3646
|
+
// §7.5 order: abort the in-flight turn -> detach followers -> remove the chat's
|
|
3647
|
+
// worktrees git-properly -> delete the row (tx + cascades) + rm -rf inside
|
|
3648
|
+
// deleteThread -> drop the job entry.
|
|
3649
|
+
app.delete('/api/ask/threads/:id', async (req, res) => {
|
|
3650
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3651
|
+
if (!id) return;
|
|
3652
|
+
try {
|
|
3653
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3654
|
+
askDeleting.add(id);
|
|
3655
|
+
const stopJob = () => {
|
|
3656
|
+
const job = askJobs.get(id);
|
|
3657
|
+
if (job && job.turn && typeof job.turn.stop === 'function') {
|
|
3658
|
+
try { job.turn.stop(); } catch { /* best-effort */ }
|
|
3659
|
+
}
|
|
3660
|
+
return job;
|
|
3661
|
+
};
|
|
3662
|
+
stopJob();
|
|
3663
|
+
const followers = askFollowers.get(id);
|
|
3664
|
+
if (followers) {
|
|
3665
|
+
for (const f of [...followers]) {
|
|
3666
|
+
try { f.detach(); } catch { /* best-effort */ }
|
|
3667
|
+
}
|
|
3668
|
+
askFollowers.delete(id);
|
|
3669
|
+
}
|
|
3670
|
+
// P4 §5: git-proper removal of every worktree BEFORE the row cascade — the
|
|
3671
|
+
// rmSync inside askDeleteThread alone would leave stale `git worktree`
|
|
3672
|
+
// registrations in the source repos. Never throws (best-effort per row).
|
|
3673
|
+
await askRemoveThreadWorktrees(id);
|
|
3674
|
+
// Re-read the job AFTER the await: askDeleting blocks new turns, but a turn
|
|
3675
|
+
// that was already mid-start is stopped here rather than left running.
|
|
3676
|
+
const job = stopJob();
|
|
3677
|
+
askDeleteThread(id);
|
|
3678
|
+
if (job) {
|
|
3679
|
+
if (job.graceTimer) clearTimeout(job.graceTimer);
|
|
3680
|
+
askJobs.delete(id);
|
|
3681
|
+
}
|
|
3682
|
+
res.json({ ok: true });
|
|
3683
|
+
} catch (err) {
|
|
3684
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3685
|
+
} finally {
|
|
3686
|
+
askDeleting.delete(id);
|
|
3687
|
+
}
|
|
3688
|
+
});
|
|
3689
|
+
|
|
3690
|
+
// P4 §10: manual worktree delete from the panel. Allowed while a turn is in
|
|
3691
|
+
// flight — the model's next operation on it gets a clean tool error.
|
|
3692
|
+
app.delete('/api/ask/threads/:id/worktrees/:wtId', async (req, res) => {
|
|
3693
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3694
|
+
if (!id) return;
|
|
3695
|
+
const wtId = askIdParam(res, req.params.wtId, 'worktree');
|
|
3696
|
+
if (!wtId) return;
|
|
3697
|
+
try {
|
|
3698
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3699
|
+
const out = await askRemoveWorktree({ threadId: id, wtId });
|
|
3700
|
+
res.json(out);
|
|
3701
|
+
} catch (err) {
|
|
3702
|
+
if (err && err.name === 'AskWorktreeError') return res.status(404).json({ error: err.message });
|
|
3703
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3704
|
+
}
|
|
3705
|
+
});
|
|
3706
|
+
|
|
3707
|
+
app.get('/api/ask/threads/:id/attachments/:attId', (req, res) => {
|
|
3708
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3709
|
+
if (!id) return;
|
|
3710
|
+
const attId = askIdParam(res, req.params.attId, 'attachment');
|
|
3711
|
+
if (!attId) return;
|
|
3712
|
+
try {
|
|
3713
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3714
|
+
const att = askReadAttachmentText(id, attId);
|
|
3715
|
+
if (!att) return res.status(404).json({ error: 'attachment not found' });
|
|
3716
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
3717
|
+
res.set('Content-Disposition', 'inline');
|
|
3718
|
+
res.type('text/plain; charset=utf-8').send(att.text);
|
|
3719
|
+
} catch (err) {
|
|
3720
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3721
|
+
}
|
|
3722
|
+
});
|
|
3723
|
+
|
|
3724
|
+
// D8/§8.1: the chat model catalog. Fresh per request (the /api/config
|
|
3725
|
+
// precedent) — a cache would go stale against global-model edits.
|
|
3726
|
+
app.get('/api/ask/models', async (_req, res) => {
|
|
3727
|
+
try {
|
|
3728
|
+
res.json(await askCatalog());
|
|
3729
|
+
} catch (err) {
|
|
3730
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3731
|
+
}
|
|
3732
|
+
});
|
|
3733
|
+
|
|
3734
|
+
/** lookupPipelineRow/findPipelineRowById return the RAW `SELECT * FROM pipelines`
|
|
3735
|
+
* row: snake_case columns, and `branch` is a JSON DOCUMENT
|
|
3736
|
+
* ({source, feature, worktreeDir, …}), not a branch name. Reading
|
|
3737
|
+
* row.startedAt/row.branch directly loses the date and pastes a JSON blob into
|
|
3738
|
+
* the [worca context] line (dry-run-verified). */
|
|
3739
|
+
function askRunFromPipelineRow(row) {
|
|
3740
|
+
let branchObj = null;
|
|
3741
|
+
if (typeof row.branch === 'string') {
|
|
3742
|
+
try { branchObj = JSON.parse(row.branch); } catch { branchObj = null; }
|
|
3743
|
+
} else if (row.branch && typeof row.branch === 'object') {
|
|
3744
|
+
branchObj = row.branch;
|
|
3745
|
+
}
|
|
3746
|
+
const branch = branchObj && typeof branchObj.feature === 'string'
|
|
3747
|
+
? branchObj.feature
|
|
3748
|
+
: (typeof branchObj === 'string' ? branchObj : null);
|
|
3749
|
+
return {
|
|
3750
|
+
id: row.id,
|
|
3751
|
+
title: row.title || '',
|
|
3752
|
+
status: row.status || '',
|
|
3753
|
+
startedAt: row.started_at || row.updated_at || '',
|
|
3754
|
+
branch,
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
/** Resolve the VALIDATED client context into the server-side shape
|
|
3759
|
+
* buildContextHeader consumes (§6.5: server-resolved rows only — never
|
|
3760
|
+
* client-supplied titles or paths). Every lookup is individually guarded:
|
|
3761
|
+
* a vanished row degrades to an absent header line, never a 500. */
|
|
3762
|
+
async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
|
|
3763
|
+
const out = { now: new Date().toISOString() };
|
|
3764
|
+
if (ctx.view) out.view = ctx.view;
|
|
3765
|
+
if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
|
|
3766
|
+
try {
|
|
3767
|
+
if (ctx.projectKey || ctx.projectDir) {
|
|
3768
|
+
const projects = await listProjects();
|
|
3769
|
+
const p = projects.find((x) =>
|
|
3770
|
+
(ctx.projectKey && x.key === ctx.projectKey) || (ctx.projectDir && x.path === ctx.projectDir));
|
|
3771
|
+
if (p) out.project = { name: p.name, key: p.key };
|
|
3772
|
+
}
|
|
3773
|
+
} catch { /* absent line */ }
|
|
3774
|
+
try {
|
|
3775
|
+
if (ctx.workspaceId) {
|
|
3776
|
+
const ws = await readWorkspace(ctx.workspaceId);
|
|
3777
|
+
if (ws) {
|
|
3778
|
+
// readWorkspace returns {id, name, projectPaths, projectKeys, …} — there
|
|
3779
|
+
// is NO per-member name object (the {projectName} shape is a local
|
|
3780
|
+
// /api/run construction, ui/server.mjs:894). Member display names are
|
|
3781
|
+
// the path basenames, same as that precedent.
|
|
3782
|
+
out.workspace = {
|
|
3783
|
+
name: ws.name, id: ws.id,
|
|
3784
|
+
members: (ws.projectPaths || []).map((p) => path.basename(p)).filter(Boolean),
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
} catch { /* absent line */ }
|
|
3789
|
+
try {
|
|
3790
|
+
if (ctx.pipelineId) {
|
|
3791
|
+
const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
|
|
3792
|
+
const row = (key ? lookupPipelineRow(key, ctx.pipelineId) : null) || findPipelineRowById(ctx.pipelineId);
|
|
3793
|
+
if (row) out.run = askRunFromPipelineRow(row);
|
|
3794
|
+
} else if (ctx.runId && runs.has(ctx.runId)) {
|
|
3795
|
+
const entry = runs.get(ctx.runId);
|
|
3796
|
+
out.run = {
|
|
3797
|
+
id: entry.pipelineId || ctx.runId.slice(0, 8), title: entry.title || '',
|
|
3798
|
+
status: entry.status || '', startedAt: entry.startedAt || '', branch: null,
|
|
3799
|
+
};
|
|
3800
|
+
}
|
|
3801
|
+
} catch { /* absent line */ }
|
|
3802
|
+
// (the ctx.runId branch reads the LIVE runs-Map entry, which really is
|
|
3803
|
+
// camelCase — only the DB pipeline row needs askRunFromPipelineRow)
|
|
3804
|
+
try {
|
|
3805
|
+
const links = askListRunLinks(threadId).slice(0, ASK_LIMITS.headerRuns).map((l) => {
|
|
3806
|
+
const live = runs.get(l.runId);
|
|
3807
|
+
return {
|
|
3808
|
+
id: l.pipelineId || l.runId.slice(0, 8),
|
|
3809
|
+
title: (live && live.title) || '', status: l.status || (live && live.status) || '',
|
|
3810
|
+
phase: l.phase || '',
|
|
3811
|
+
};
|
|
3812
|
+
});
|
|
3813
|
+
if (links.length) out.linkedRuns = links;
|
|
3814
|
+
const cards = [];
|
|
3815
|
+
for (const m of askListMessages(threadId)) {
|
|
3816
|
+
if (!Array.isArray(m.blocks)) continue;
|
|
3817
|
+
for (const b of m.blocks) {
|
|
3818
|
+
if (b && b.kind === 'card') {
|
|
3819
|
+
cards.push({
|
|
3820
|
+
id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
|
|
3821
|
+
targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
|
|
3822
|
+
});
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
if (cards.length) out.cards = cards.slice(-ASK_LIMITS.headerCards);
|
|
3827
|
+
// §6.5: the CURRENT message's non-inlined files, then EARLIER attachments
|
|
3828
|
+
// newest first — inlined current files must not be double-listed, so the
|
|
3829
|
+
// earlier set excludes the whole current message, not just `listed` ids.
|
|
3830
|
+
const earlier = askListAttachments(threadId)
|
|
3831
|
+
.filter((a) => !currentMessageId || a.messageId !== currentMessageId)
|
|
3832
|
+
.slice(-ASK_LIMITS.headerAttachments)
|
|
3833
|
+
.reverse()
|
|
3834
|
+
.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes }));
|
|
3835
|
+
const atts = [...listedAttachments.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes })), ...earlier];
|
|
3836
|
+
if (atts.length) out.attachments = atts.slice(0, ASK_LIMITS.headerAttachments);
|
|
3837
|
+
} catch { /* absent lines */ }
|
|
3838
|
+
return out;
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
/** R-F: whenever mock mode is on, EVERY ask spawn carries markers. The card is
|
|
3842
|
+
* the mock propose_run INPUT, derived from page context so a seeded project/
|
|
3843
|
+
* workspace validates and an empty context exercises the rejection notice. */
|
|
3844
|
+
function mockAskCard(ctx = {}, text = '') {
|
|
3845
|
+
const target = ctx.workspaceId
|
|
3846
|
+
? { workspaceId: ctx.workspaceId }
|
|
3847
|
+
: { projectKey: ctx.projectKey || 'mock-project-00000000' };
|
|
3848
|
+
return { ...target, workflowId: 'wf_default', guardrailsId: 'normal', brief: text.slice(0, 200) || 'Mock run' };
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
3852
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3853
|
+
if (!id) return;
|
|
3854
|
+
try {
|
|
3855
|
+
const thread = askGetThread(id);
|
|
3856
|
+
if (!thread) return res.status(404).json({ error: 'thread not found' });
|
|
3857
|
+
if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
|
|
3858
|
+
if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
|
|
3859
|
+
// Budget gate (F6), same figure /api/run enforces: Ask spend is folded into the
|
|
3860
|
+
// total window (cost-budget.mjs totalWindowSpendUsd), so chat must stop at
|
|
3861
|
+
// the cap it helps fill instead of spending past it while pipelines are 403'd
|
|
3862
|
+
// (review of PR #376).
|
|
3863
|
+
const budget = budgetStatus();
|
|
3864
|
+
if (budget.blocked) return res.status(403).json({ error: 'total cost limit reached', budget });
|
|
3865
|
+
if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
|
|
3866
|
+
return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
|
|
3867
|
+
}
|
|
3868
|
+
const body = req.body || {};
|
|
3869
|
+
const text = typeof body.text === 'string' ? body.text : '';
|
|
3870
|
+
if (!text.trim()) return badRequest(res, 'text is required');
|
|
3871
|
+
const mv = await validateModelEffort(body.model, body.effort);
|
|
3872
|
+
if (!mv.ok) return badRequest(res, mv.error);
|
|
3873
|
+
const cv = validateClientContext(body.context);
|
|
3874
|
+
if (!cv.ok) return badRequest(res, cv.error);
|
|
3875
|
+
|
|
3876
|
+
// §7.3 — validate EVERY attachment before ANY write (all-or-nothing).
|
|
3877
|
+
const files = [];
|
|
3878
|
+
if (body.attachments !== undefined) {
|
|
3879
|
+
if (!Array.isArray(body.attachments)) return badRequest(res, 'attachments must be an array');
|
|
3880
|
+
if (body.attachments.length > ASK_LIMITS.attachment.maxFiles) {
|
|
3881
|
+
return badRequest(res, `at most ${ASK_LIMITS.attachment.maxFiles} attachments per message`);
|
|
3882
|
+
}
|
|
3883
|
+
const dec = new TextDecoder('utf-8', { fatal: true });
|
|
3884
|
+
for (const a of body.attachments) {
|
|
3885
|
+
const name = a && typeof a.name === 'string' ? a.name : '';
|
|
3886
|
+
const dot = name.lastIndexOf('.');
|
|
3887
|
+
const ext = dot === -1 ? '' : name.slice(dot).toLowerCase();
|
|
3888
|
+
if (!ASK_LIMITS.attachment.extensions.includes(ext)) {
|
|
3889
|
+
return badRequest(res, `attachment type not allowed: ${name || '(unnamed)'}`);
|
|
3890
|
+
}
|
|
3891
|
+
const raw = typeof a.dataBase64 === 'string' ? a.dataBase64 : '';
|
|
3892
|
+
const buf = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);
|
|
3893
|
+
if (!buf.length) return badRequest(res, `attachment is empty or not valid base64: ${name}`);
|
|
3894
|
+
if (buf.length > ASK_LIMITS.attachment.maxBytesPerFile) {
|
|
3895
|
+
return res.status(413).json({ error: `attachment over ${ASK_LIMITS.attachment.maxBytesPerFile} bytes: ${name}` });
|
|
3896
|
+
}
|
|
3897
|
+
let bodyText;
|
|
3898
|
+
try { bodyText = dec.decode(buf); } catch { return badRequest(res, `attachment is not valid UTF-8: ${name}`); }
|
|
3899
|
+
if (bodyText.includes('\u0000')) return badRequest(res, `attachment contains NUL bytes: ${name}`);
|
|
3900
|
+
files.push({ name, text: bodyText, bytes: buf.length });
|
|
3901
|
+
}
|
|
3902
|
+
const total = askThreadAttachmentBytes(id) + files.reduce((s, f) => s + f.bytes, 0);
|
|
3903
|
+
if (total > ASK_LIMITS.attachment.maxBytesPerThread) {
|
|
3904
|
+
return res.status(413).json({ error: 'attachment budget for this thread exceeded' });
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
// §6.2.2 ATOMIC re-check + slot reservation. Today every await between the
|
|
3909
|
+
// top 409/429 pair and here resolves in microtasks (validateModelEffort ->
|
|
3910
|
+
// composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
|
|
3911
|
+
// reads), so the route is macrotask-atomic and two POSTs cannot interleave
|
|
3912
|
+
// (empirically instrumented). The reservation is what keeps that true if
|
|
3913
|
+
// any of those readers ever becomes genuinely async: it is synchronous —
|
|
3914
|
+
// check-and-set cannot interleave — and runs BEFORE the first write, so a
|
|
3915
|
+
// loser leaves no rows.
|
|
3916
|
+
if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
|
|
3917
|
+
if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
|
|
3918
|
+
if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
|
|
3919
|
+
return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
|
|
3920
|
+
}
|
|
3921
|
+
const prev = askJobs.get(id);
|
|
3922
|
+
if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
|
|
3923
|
+
const job = {
|
|
3924
|
+
turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
|
|
3925
|
+
events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
|
|
3926
|
+
startedAt: new Date().toISOString(), graceTimer: null,
|
|
3927
|
+
};
|
|
3928
|
+
askJobs.set(id, job);
|
|
3929
|
+
|
|
3930
|
+
let asstMsg = null;
|
|
3931
|
+
let turn;
|
|
3932
|
+
try {
|
|
3933
|
+
// Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
|
|
3934
|
+
askUpdateThread(id, { context: cv.context, model: mv.model, effort: mv.effort });
|
|
3935
|
+
let deterministicTitle = thread.title;
|
|
3936
|
+
let titleWasAuto = false;
|
|
3937
|
+
if (thread.title == null) {
|
|
3938
|
+
// §7.4 — no frame for the deterministic title. titleWasAuto gates the
|
|
3939
|
+
// D13 background replacement: a title given at THREAD CREATION is the
|
|
3940
|
+
// user's, and the haiku call must never fire for it (§17 Q&A 1).
|
|
3941
|
+
deterministicTitle = askSanitizeTitle(text.slice(0, 80)) || 'New chat';
|
|
3942
|
+
askSetThreadTitle(id, deterministicTitle);
|
|
3943
|
+
titleWasAuto = true;
|
|
3944
|
+
}
|
|
3945
|
+
const userMsg = askAppendMessage(id, { role: 'user', text });
|
|
3946
|
+
job.userMessageId = userMsg.id;
|
|
3947
|
+
const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, text: f.text }));
|
|
3948
|
+
if (attRows.length) {
|
|
3949
|
+
askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes })));
|
|
3950
|
+
}
|
|
3951
|
+
broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
|
|
3952
|
+
asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
|
|
3953
|
+
job.messageId = asstMsg.id;
|
|
3954
|
+
|
|
3955
|
+
// Prompt assembly (§6.5) — the route owns it; the turn only spawns.
|
|
3956
|
+
const catalog = await askBuildCatalog();
|
|
3957
|
+
const systemPrompt = askBuildSystemPrompt(catalog);
|
|
3958
|
+
const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, text: files[i].text }));
|
|
3959
|
+
const { inline, listed } = askSelectInlineAttachments(withText);
|
|
3960
|
+
const headerCtx = await resolveAskContext(id, cv.context, listed, userMsg.id);
|
|
3961
|
+
const header = askBuildContextHeader(headerCtx);
|
|
3962
|
+
const prompt = askBuildTurnPrompt(header, text, inline);
|
|
3963
|
+
const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
|
|
3964
|
+
const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
|
|
3965
|
+
const attachmentNames = {};
|
|
3966
|
+
for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
|
|
3967
|
+
|
|
3968
|
+
turn = createAskTurn({
|
|
3969
|
+
threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
|
|
3970
|
+
prompt, systemPrompt, restoredPrompt,
|
|
3971
|
+
model: mv.model, effort: mv.effort,
|
|
3972
|
+
resumeSessionId: thread.sessionId || null,
|
|
3973
|
+
firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
|
|
3974
|
+
firstText: text,
|
|
3975
|
+
deterministicTitle,
|
|
3976
|
+
mock: mockEnabled({}) ? { card: mockAskCard(cv.context, text) } : null, // R-F
|
|
3977
|
+
attachmentNames,
|
|
3978
|
+
deps: {
|
|
3979
|
+
onFrame: stampAskFrames(id, job),
|
|
3980
|
+
onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
|
|
3981
|
+
onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
|
|
3982
|
+
},
|
|
3983
|
+
});
|
|
3984
|
+
job.turn = turn;
|
|
3985
|
+
} catch (err) {
|
|
3986
|
+
// A write/assembly failure must release the reserved slot and never leave
|
|
3987
|
+
// a `streaming` row for the boot sweep to find.
|
|
3988
|
+
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
3989
|
+
if (asstMsg) {
|
|
3990
|
+
try {
|
|
3991
|
+
askFinishMessage(asstMsg.id, {
|
|
3992
|
+
text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
|
|
3993
|
+
status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
|
|
3994
|
+
});
|
|
3995
|
+
} catch { /* thread gone */ }
|
|
3996
|
+
}
|
|
3997
|
+
return res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3998
|
+
}
|
|
3999
|
+
const settleJob = (status) => {
|
|
4000
|
+
if (askJobs.get(id) !== job) return;
|
|
4001
|
+
job.status = status;
|
|
4002
|
+
job.graceTimer = setTimeout(() => {
|
|
4003
|
+
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
4004
|
+
}, ASK_LIMITS.jobGraceMs);
|
|
4005
|
+
job.graceTimer.unref?.();
|
|
4006
|
+
};
|
|
4007
|
+
turn.on('done', () => settleJob('done'));
|
|
4008
|
+
turn.on('error', () => settleJob('error'));
|
|
4009
|
+
// Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
|
|
4010
|
+
Promise.resolve()
|
|
4011
|
+
.then(() => turn.run())
|
|
4012
|
+
.catch((err) => {
|
|
4013
|
+
console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
|
|
4014
|
+
settleJob('error');
|
|
4015
|
+
});
|
|
4016
|
+
res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId });
|
|
4017
|
+
} catch (err) {
|
|
4018
|
+
// Only pre-reservation throws land here (`job` is block-scoped to the outer
|
|
4019
|
+
// try and every post-reservation failure returned from the inner catch), so
|
|
4020
|
+
// there is no slot to release.
|
|
4021
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4022
|
+
}
|
|
4023
|
+
});
|
|
4024
|
+
|
|
4025
|
+
// Idempotent stop (the /api/agents/generate/stop family): always {ok:true}
|
|
4026
|
+
// after the shape check; the costUsd:null rule lives in the turn (R-C).
|
|
4027
|
+
app.post('/api/ask/threads/:id/stop', (req, res) => {
|
|
4028
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
4029
|
+
if (!id) return;
|
|
4030
|
+
const job = askInFlight(id);
|
|
4031
|
+
if (job && job.turn && typeof job.turn.stop === 'function') {
|
|
4032
|
+
try { job.turn.stop(); } catch { /* best-effort */ }
|
|
4033
|
+
}
|
|
4034
|
+
res.json({ ok: true });
|
|
4035
|
+
});
|
|
4036
|
+
|
|
4037
|
+
/** R-B dual update. Flip in the STORE and, when the owning thread's turn is
|
|
4038
|
+
* still streaming, in the LIVE reducer (updateBlock re-emits the stamped
|
|
4039
|
+
* ask-card job frame) — otherwise finishMessage at turn end reverts the flip
|
|
4040
|
+
* with the reducer's stale copy. When no live reducer held the card (turn
|
|
4041
|
+
* over, or the card sits on an earlier message), re-broadcast the whole
|
|
4042
|
+
* message so tabs upsert the flipped block by message.id (§6.6 out-of-turn). */
|
|
4043
|
+
function flipCard(threadId, cardId, patch) {
|
|
4044
|
+
const block = askUpdateCardBlock(threadId, cardId, patch);
|
|
4045
|
+
if (!block) return null;
|
|
4046
|
+
const job = askInFlight(threadId);
|
|
4047
|
+
const live = job && job.turn && job.turn.reducer ? job.turn.reducer.updateBlock(cardId, patch) : null;
|
|
4048
|
+
if (!live) {
|
|
4049
|
+
const found = askFindCard(threadId, cardId);
|
|
4050
|
+
if (found) broadcast({ type: 'ask-message', threadId, message: found.message });
|
|
4051
|
+
}
|
|
4052
|
+
return block;
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
// D14 dismiss ("Not now" keeps a stub — the client renders state:'dismissed').
|
|
4056
|
+
app.post('/api/ask/threads/:id/cards/:cardId', (req, res) => {
|
|
4057
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
4058
|
+
if (!id) return;
|
|
4059
|
+
const cardId = askIdParam(res, req.params.cardId, 'card');
|
|
4060
|
+
if (!cardId) return;
|
|
4061
|
+
try {
|
|
4062
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
4063
|
+
if ((req.body || {}).state !== 'dismissed') return badRequest(res, 'state must be "dismissed"');
|
|
4064
|
+
const found = askFindCard(id, cardId);
|
|
4065
|
+
if (!found) return res.status(404).json({ error: 'card not found' });
|
|
4066
|
+
if (found.block.state !== 'proposed') {
|
|
4067
|
+
return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
4068
|
+
}
|
|
4069
|
+
const block = flipCard(id, cardId, { state: 'dismissed' });
|
|
4070
|
+
// Dismiss is terminal: the card's parked comment ids can never reach a run,
|
|
4071
|
+
// so drop them here exactly as the launch path does at its own success point
|
|
4072
|
+
// (:1155). Own try/catch — comment bookkeeping must never fail the dismiss.
|
|
4073
|
+
try { clearPendingCardComments(cardId); }
|
|
4074
|
+
catch (e) { console.error('[diff-comments] dismiss cleanup failed:', e && e.message ? e.message : e); }
|
|
4075
|
+
res.json({ block });
|
|
4076
|
+
} catch (err) {
|
|
4077
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4078
|
+
}
|
|
4079
|
+
});
|
|
4080
|
+
|
|
2753
4081
|
// ---------------------------------------------------------------------------
|
|
2754
4082
|
// /api/agents* -> agent registry + user-agent CRUD, delegated to
|
|
2755
4083
|
// src/core/agent-store.mjs (layered builtin + ~/.worca-cc/agents user pairs).
|
|
2756
4084
|
// GET returns palette render order (.order ascending) with origin stamped; the
|
|
2757
4085
|
// client builds draggable pills (colored dot + displayName + icon) from this.
|
|
2758
4086
|
// ---------------------------------------------------------------------------
|
|
2759
|
-
// Channel vocabulary for the UI editor/wizard: built-in CHANNEL_IDS first, then
|
|
2760
|
-
// every CUSTOM id any registry agent references (produces/consumes/
|
|
2761
|
-
// optionalConsumes/channelDefs[].id), appended sorted + deduped. Channels are an
|
|
2762
|
-
// open vocabulary — a closed list would silently strip custom ids on edit.
|
|
2763
|
-
function collectChannelIds(agents) {
|
|
2764
|
-
const customs = new Set();
|
|
2765
|
-
for (const a of Array.isArray(agents) ? agents : []) {
|
|
2766
|
-
if (!a) continue;
|
|
2767
|
-
const ids = [
|
|
2768
|
-
...(Array.isArray(a.produces) ? a.produces : []),
|
|
2769
|
-
...(Array.isArray(a.consumes) ? a.consumes : []),
|
|
2770
|
-
...(Array.isArray(a.optionalConsumes) ? a.optionalConsumes : []),
|
|
2771
|
-
...(Array.isArray(a.channelDefs) ? a.channelDefs.map((d) => d && d.id) : []),
|
|
2772
|
-
];
|
|
2773
|
-
for (const id of ids) {
|
|
2774
|
-
if (typeof id === 'string' && id && !CHANNEL_IDS.includes(id)) customs.add(id);
|
|
2775
|
-
}
|
|
2776
|
-
}
|
|
2777
|
-
return [...CHANNEL_IDS, ...[...customs].sort()];
|
|
2778
|
-
}
|
|
2779
4087
|
|
|
2780
4088
|
app.get('/api/agents', async (req, res) => {
|
|
2781
4089
|
try {
|
|
@@ -2783,7 +4091,11 @@ app.get('/api/agents', async (req, res) => {
|
|
|
2783
4091
|
// §6.6: workspace-only agents stay out of the Composer palette by default;
|
|
2784
4092
|
// the Agents management view passes ?all=1 to see them too.
|
|
2785
4093
|
const agents = isTruthy(req.query.all) ? all : all.filter((m) => m.scope !== 'workspace-only');
|
|
2786
|
-
|
|
4094
|
+
// mockWriterRoles drives ONE select in the agent form. It is a CLOSED list
|
|
4095
|
+
// (the mock switch in claude-runner.mjs), unlike the open channel vocabulary
|
|
4096
|
+
// it replaces in Task 12: an unknown mockRole is dropped by the registry
|
|
4097
|
+
// with a warning, never rejected.
|
|
4098
|
+
res.json({ agents, mockWriterRoles: [...MOCK_WRITER_ROLES] });
|
|
2787
4099
|
} catch (err) {
|
|
2788
4100
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2789
4101
|
}
|
|
@@ -2809,9 +4121,6 @@ function agentErrorStatus(code) {
|
|
|
2809
4121
|
function startAgentGen(input) {
|
|
2810
4122
|
const orch = createAgentGen({
|
|
2811
4123
|
...input,
|
|
2812
|
-
// Same open vocabulary as GET /api/agents (callers pass the registry union);
|
|
2813
|
-
// built-ins-only fallback keeps direct/_testing callers working.
|
|
2814
|
-
channels: Array.isArray(input.channels) && input.channels.length ? input.channels : CHANNEL_IDS,
|
|
2815
4124
|
claude: { permissionMode: 'acceptEdits', mock: isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK) },
|
|
2816
4125
|
});
|
|
2817
4126
|
// The engine mints its own genId (agen_<uuid>) and tags every emitted event
|
|
@@ -2862,7 +4171,7 @@ app.post('/api/agents/generate', async (req, res) => {
|
|
|
2862
4171
|
const genId = startAgentGen({
|
|
2863
4172
|
name, purpose: String(body.purpose || ''), details: String(body.details || ''),
|
|
2864
4173
|
expectedBefore: pick(body.expectedBefore), expectedAfter: pick(body.expectedAfter),
|
|
2865
|
-
userMarkdown,
|
|
4174
|
+
userMarkdown,
|
|
2866
4175
|
});
|
|
2867
4176
|
res.json({ genId });
|
|
2868
4177
|
} catch (err) {
|
|
@@ -3081,7 +4390,7 @@ app.post('/api/plugins/install', async (req, res) => {
|
|
|
3081
4390
|
repoUrl: body.repoUrl.trim(), subdir, name: body.name.trim(), sha: body.sha.trim(), marketplace,
|
|
3082
4391
|
});
|
|
3083
4392
|
reloadChatWorkers(body.name.trim());
|
|
3084
|
-
res.json(out); // { ok: true, inventory }
|
|
4393
|
+
res.json(out); // { ok: true, inventory, warnings, ignored }
|
|
3085
4394
|
} catch (err) {
|
|
3086
4395
|
sendPluginError(res, err);
|
|
3087
4396
|
}
|
|
@@ -3128,6 +4437,8 @@ app.delete('/api/plugins/:name', async (req, res) => {
|
|
|
3128
4437
|
if (!name) return;
|
|
3129
4438
|
const purge = isTruthy(req.query.purge) || !!(req.body && req.body.purge === true);
|
|
3130
4439
|
try {
|
|
4440
|
+
// uninstallPlugin also drops the plugin's source bindings (core-side, so
|
|
4441
|
+
// the CLI's `worca plugin remove` clears them identically).
|
|
3131
4442
|
await uninstallPlugin(name, { purge });
|
|
3132
4443
|
reloadChatWorkers(name);
|
|
3133
4444
|
res.json({ ok: true, purged: purge });
|
|
@@ -3165,17 +4476,38 @@ app.post('/api/plugins/:name/doctor', async (req, res) => {
|
|
|
3165
4476
|
// GET /api/plugins/:name/config -> per-source schema + redacted values. Secrets
|
|
3166
4477
|
// NEVER travel to the browser: redactedConfig replaces a stored secret with
|
|
3167
4478
|
// { set: true } (§7.6).
|
|
4479
|
+
// ?profile=<id> selects which configuration to echo (multi-profile sources);
|
|
4480
|
+
// absent = the default bucket, which is all a single-profile source ever uses.
|
|
3168
4481
|
app.get('/api/plugins/:name/config', (req, res) => {
|
|
3169
4482
|
const name = requirePlugin(req, res);
|
|
3170
4483
|
if (!name) return;
|
|
3171
4484
|
const manifest = readInstalledManifest(name);
|
|
3172
4485
|
if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
|
|
4486
|
+
const wanted = typeof req.query.profile === 'string' && req.query.profile ? req.query.profile : null;
|
|
4487
|
+
if (wanted && !isValidProfileId(wanted)) return badRequest(res, 'invalid profile id');
|
|
3173
4488
|
try {
|
|
3174
|
-
const
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
4489
|
+
const profiles = listProfiles(name);
|
|
4490
|
+
const sources = (manifest.taskSources || []).map((s) => {
|
|
4491
|
+
// For a multi-profile source, "which profile" is a real choice: echo the
|
|
4492
|
+
// requested one, else the first in the roster. A source with no profiles
|
|
4493
|
+
// yet has nothing to show — the UI's move is "create one", not a form.
|
|
4494
|
+
// A requested profile that is not in the roster is a caller error (a
|
|
4495
|
+
// typo'd URL) — echoing an empty form for it would let a Save quietly
|
|
4496
|
+
// create the typo as a real profile. Checked inside the map so the guard
|
|
4497
|
+
// only fires for sources that use profiles at all.
|
|
4498
|
+
if (s.multiProfile && wanted && !profiles.some((p) => p.id === wanted)) {
|
|
4499
|
+
throw Object.assign(new Error(`plugin "${name}" has no profile "${wanted}"`), { code: 'BAD_REQUEST' });
|
|
4500
|
+
}
|
|
4501
|
+
const profile = s.multiProfile ? (wanted || profiles[0]?.id || null) : null;
|
|
4502
|
+
return {
|
|
4503
|
+
id: s.id,
|
|
4504
|
+
schema: s.configSchema,
|
|
4505
|
+
multiProfile: s.multiProfile === true,
|
|
4506
|
+
profile,
|
|
4507
|
+
profiles: s.multiProfile ? profiles : [],
|
|
4508
|
+
values: s.multiProfile && !profile ? {} : redactedConfig(name, s.configSchema, profile),
|
|
4509
|
+
};
|
|
4510
|
+
});
|
|
3179
4511
|
const channels = (manifest.chatChannels || []).map((c) => ({
|
|
3180
4512
|
id: c.id,
|
|
3181
4513
|
displayName: c.displayName,
|
|
@@ -3196,7 +4528,78 @@ app.get('/api/plugins/:name/config', (req, res) => {
|
|
|
3196
4528
|
}
|
|
3197
4529
|
});
|
|
3198
4530
|
|
|
3199
|
-
//
|
|
4531
|
+
// POST /api/plugins/:name/profiles { sourceId, id, label } — create (or relabel)
|
|
4532
|
+
// a profile of a multi-profile source. Creating a profile is deliberately
|
|
4533
|
+
// separate from saving into it: the roster entry must exist BEFORE the config
|
|
4534
|
+
// form has anything to write to.
|
|
4535
|
+
app.post('/api/plugins/:name/profiles', (req, res) => {
|
|
4536
|
+
const name = requirePlugin(req, res);
|
|
4537
|
+
if (!name) return;
|
|
4538
|
+
const body = req.body || {};
|
|
4539
|
+
const manifest = readInstalledManifest(name);
|
|
4540
|
+
if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
|
|
4541
|
+
const source = (manifest.taskSources || []).find((s) => s.id === body.sourceId);
|
|
4542
|
+
if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
|
|
4543
|
+
if (!source.multiProfile) return badRequest(res, `task source "${source.id}" does not support profiles`);
|
|
4544
|
+
if (!isValidProfileId(body.id)) {
|
|
4545
|
+
return badRequest(res, 'profile id must be lowercase letters, digits and dashes');
|
|
4546
|
+
}
|
|
4547
|
+
// "default" is the implicit bucket every profile-less read/write shares
|
|
4548
|
+
// (chat channels, model secrets, migrated legacy config). Enrolled in the
|
|
4549
|
+
// roster it would become deletable like any member — and deleting it wipes
|
|
4550
|
+
// that shared bucket. createProfile throws too; 400 with the reason here.
|
|
4551
|
+
if (body.id === DEFAULT_PROFILE) {
|
|
4552
|
+
return badRequest(res, `profile id "${DEFAULT_PROFILE}" is reserved — pick another name`);
|
|
4553
|
+
}
|
|
4554
|
+
try {
|
|
4555
|
+
res.json({ ok: true, profile: createProfile(name, body.id, body.label) });
|
|
4556
|
+
} catch (err) {
|
|
4557
|
+
sendPluginError(res, err);
|
|
4558
|
+
}
|
|
4559
|
+
});
|
|
4560
|
+
|
|
4561
|
+
// DELETE /api/plugins/:name/profiles/:id?sourceId=… — drop a profile, its stored
|
|
4562
|
+
// config/secrets/state, and every project binding that named it (a binding
|
|
4563
|
+
// pointing at a deleted profile would otherwise resolve to nothing at run time).
|
|
4564
|
+
// Binding cleanup is PLUGIN-wide, not per-source: deleteProfile removes the
|
|
4565
|
+
// profile's buckets for the whole plugin, so a sibling source's binding naming
|
|
4566
|
+
// it would dangle just the same. sourceId is still required — it authorizes the
|
|
4567
|
+
// call against a source that actually uses profiles.
|
|
4568
|
+
app.delete('/api/plugins/:name/profiles/:id', (req, res) => {
|
|
4569
|
+
const name = requirePlugin(req, res);
|
|
4570
|
+
if (!name) return;
|
|
4571
|
+
const manifest = readInstalledManifest(name);
|
|
4572
|
+
if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
|
|
4573
|
+
const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : '';
|
|
4574
|
+
const sources = manifest.taskSources || [];
|
|
4575
|
+
const source = sources.find((s) => s.id === sourceId) || (sources.length === 1 ? sources[0] : null);
|
|
4576
|
+
if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
|
|
4577
|
+
// Mirror the POST guard: a single-profile source only has the implicit
|
|
4578
|
+
// 'default' bucket, and deleting THAT would wipe its entire config/secrets/
|
|
4579
|
+
// state. Same for ids not in the roster — deleteProfile would still drop
|
|
4580
|
+
// whatever buckets happen to share the id (e.g. migrated legacy data under
|
|
4581
|
+
// 'default'), so only roster members are deletable.
|
|
4582
|
+
if (!source.multiProfile) return badRequest(res, `task source "${source.id}" does not support profiles`);
|
|
4583
|
+
if (!isValidProfileId(req.params.id)) return badRequest(res, 'invalid profile id');
|
|
4584
|
+
// Reserved even if a pre-reservation roster enrolled it: deleting "default"
|
|
4585
|
+
// would strip the shared bucket (chat-channel config, model secrets,
|
|
4586
|
+
// migrated legacy data) out of all three files.
|
|
4587
|
+
if (req.params.id === DEFAULT_PROFILE) {
|
|
4588
|
+
return badRequest(res, `profile id "${DEFAULT_PROFILE}" is reserved — it cannot be deleted`);
|
|
4589
|
+
}
|
|
4590
|
+
if (!listProfiles(name).some((p) => p.id === req.params.id)) {
|
|
4591
|
+
return badRequest(res, `plugin "${name}" has no profile "${req.params.id}"`);
|
|
4592
|
+
}
|
|
4593
|
+
try {
|
|
4594
|
+
deleteProfile(name, req.params.id);
|
|
4595
|
+
const unbound = clearBindingsForProfile(name, req.params.id);
|
|
4596
|
+
res.json({ ok: true, unbound });
|
|
4597
|
+
} catch (err) {
|
|
4598
|
+
sendPluginError(res, err);
|
|
4599
|
+
}
|
|
4600
|
+
});
|
|
4601
|
+
|
|
4602
|
+
// PUT /api/plugins/:name/config { sourceId | channelId, values, profile? } ->
|
|
3200
4603
|
// writePluginConfig routes secret:true keys to data/secrets.json (0600,
|
|
3201
4604
|
// atomic). Request values are NEVER logged and NEVER echoed back (the response
|
|
3202
4605
|
// is a bare receipt). A channelId save also hot-restarts the channel worker.
|
|
@@ -3222,6 +4625,8 @@ app.put('/api/plugins/:name/config', (req, res) => {
|
|
|
3222
4625
|
}
|
|
3223
4626
|
}
|
|
3224
4627
|
let schema;
|
|
4628
|
+
let source = null;
|
|
4629
|
+
let profile = null;
|
|
3225
4630
|
if (typeof body.channelId === 'string' && body.channelId) {
|
|
3226
4631
|
const channel = (manifest.chatChannels || []).find((c) => c.id === body.channelId);
|
|
3227
4632
|
if (!channel) return badRequest(res, 'channelId does not match a chat channel of this plugin');
|
|
@@ -3231,12 +4636,22 @@ app.put('/api/plugins/:name/config', (req, res) => {
|
|
|
3231
4636
|
const sourceId = typeof body.sourceId === 'string' && body.sourceId
|
|
3232
4637
|
? body.sourceId
|
|
3233
4638
|
: (sources.length === 1 ? sources[0].id : '');
|
|
3234
|
-
|
|
4639
|
+
source = sources.find((s) => s.id === sourceId);
|
|
3235
4640
|
if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
|
|
4641
|
+
profile = typeof body.profile === 'string' && body.profile ? body.profile : null;
|
|
4642
|
+
if (profile && !isValidProfileId(profile)) return badRequest(res, 'invalid profile id');
|
|
4643
|
+
if (source.multiProfile && !profile) return badRequest(res, 'profile is required for this task source');
|
|
4644
|
+
// Saves go only into EXISTING roster members — mirroring the GET guard,
|
|
4645
|
+
// whose whole point is that a Save must not quietly mint a typo'd (or
|
|
4646
|
+
// just-deleted) id as a real profile with secrets stored under it.
|
|
4647
|
+
// Creation stays solely on POST /profiles.
|
|
4648
|
+
if (source.multiProfile && !listProfiles(name).some((p) => p.id === profile)) {
|
|
4649
|
+
return badRequest(res, `plugin "${name}" has no profile "${profile}" — create it first`);
|
|
4650
|
+
}
|
|
3236
4651
|
schema = source.configSchema;
|
|
3237
4652
|
}
|
|
3238
4653
|
try {
|
|
3239
|
-
writePluginConfig(name, schema, body.values);
|
|
4654
|
+
writePluginConfig(name, schema, body.values, profile);
|
|
3240
4655
|
reloadChatWorkers(name);
|
|
3241
4656
|
res.json({ ok: true });
|
|
3242
4657
|
} catch (err) {
|
|
@@ -3261,7 +4676,84 @@ app.get('/api/plugins/:name/model-env', (req, res) => {
|
|
|
3261
4676
|
if (typeof v === 'string') env[k] = v;
|
|
3262
4677
|
else secretKeys.push(k);
|
|
3263
4678
|
}
|
|
3264
|
-
|
|
4679
|
+
// `cost` rides along so "Edit a copy" starts from the plugin's pricing — a
|
|
4680
|
+
// copy that silently dropped it would repay the CLI's by-name figure.
|
|
4681
|
+
res.json({
|
|
4682
|
+
id: model.id, label: model.label, efforts: model.efforts, env, secretKeys,
|
|
4683
|
+
...(model.cost ? { cost: model.cost } : {}),
|
|
4684
|
+
});
|
|
4685
|
+
});
|
|
4686
|
+
|
|
4687
|
+
// ---------------------------------------------------------------------------
|
|
4688
|
+
// /api/source-bindings -> which PROFILE of a task source a project/workspace
|
|
4689
|
+
// pulls from. Set once per project; every run then resolves it silently, which
|
|
4690
|
+
// is the point — a per-run dropdown is how you start a pipeline against the
|
|
4691
|
+
// wrong tracker without noticing (see src/core/source-bindings.mjs).
|
|
4692
|
+
// ---------------------------------------------------------------------------
|
|
4693
|
+
|
|
4694
|
+
/** Shared scope parsing for the two binding routes. Accepts a project by key or
|
|
4695
|
+
* by path (the New Pipeline form knows the path, the Projects view the key). */
|
|
4696
|
+
function bindingScope(q = {}) {
|
|
4697
|
+
const workspaceId = typeof q.workspaceId === 'string' && q.workspaceId.trim() ? q.workspaceId.trim() : '';
|
|
4698
|
+
if (workspaceId) return { scopeType: 'workspace', scopeKey: workspaceId };
|
|
4699
|
+
const key = typeof q.projectKey === 'string' && q.projectKey.trim() ? q.projectKey.trim() : '';
|
|
4700
|
+
if (key) return { scopeType: 'project', scopeKey: key };
|
|
4701
|
+
const dir = typeof q.projectDir === 'string' && q.projectDir.trim() ? q.projectDir.trim() : '';
|
|
4702
|
+
if (dir) return { scopeType: 'project', scopeKey: projectKey(path.resolve(dir)) };
|
|
4703
|
+
return null;
|
|
4704
|
+
}
|
|
4705
|
+
|
|
4706
|
+
// GET /api/source-bindings?projectDir=…|projectKey=…|workspaceId=…
|
|
4707
|
+
// [&plugin=&sourceId=] -> { bindings: [...] } or, when a source is named,
|
|
4708
|
+
// the RESOLVED profile for it: { profile, via, candidates? }.
|
|
4709
|
+
app.get('/api/source-bindings', async (req, res) => {
|
|
4710
|
+
const scope = bindingScope(req.query);
|
|
4711
|
+
if (!scope) return badRequest(res, 'projectDir, projectKey or workspaceId is required');
|
|
4712
|
+
const plugin = typeof req.query.plugin === 'string' ? req.query.plugin.trim() : '';
|
|
4713
|
+
const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId.trim() : '';
|
|
4714
|
+
try {
|
|
4715
|
+
if (!plugin || !sourceId) return res.json({ ...scope, bindings: listBindingsForScope(scope.scopeType, scope.scopeKey) });
|
|
4716
|
+
// A workspace with no binding of its own inherits from its members when they
|
|
4717
|
+
// agree, so the member keys have to be resolved before asking.
|
|
4718
|
+
let memberKeys;
|
|
4719
|
+
if (scope.scopeType === 'workspace') {
|
|
4720
|
+
const ws = await readWorkspace(scope.scopeKey);
|
|
4721
|
+
memberKeys = ws ? ws.projectKeys : [];
|
|
4722
|
+
}
|
|
4723
|
+
res.json({
|
|
4724
|
+
...scope,
|
|
4725
|
+
...resolveProfile({ ...scope, plugin, sourceId, memberKeys, available: listProfileIds(plugin) }),
|
|
4726
|
+
});
|
|
4727
|
+
} catch (err) {
|
|
4728
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4729
|
+
}
|
|
4730
|
+
});
|
|
4731
|
+
|
|
4732
|
+
// PUT /api/source-bindings { projectDir|projectKey|workspaceId, plugin,
|
|
4733
|
+
// sourceId, profile } — profile:null clears the binding.
|
|
4734
|
+
app.put('/api/source-bindings', (req, res) => {
|
|
4735
|
+
const body = req.body || {};
|
|
4736
|
+
const scope = bindingScope(body);
|
|
4737
|
+
if (!scope) return badRequest(res, 'projectDir, projectKey or workspaceId is required');
|
|
4738
|
+
const plugin = typeof body.plugin === 'string' ? body.plugin.trim() : '';
|
|
4739
|
+
const sourceId = typeof body.sourceId === 'string' ? body.sourceId.trim() : '';
|
|
4740
|
+
if (!plugin || !sourceId) return badRequest(res, 'plugin and sourceId are required');
|
|
4741
|
+
const ref = { ...scope, plugin, sourceId };
|
|
4742
|
+
try {
|
|
4743
|
+
if (body.profile === null || body.profile === '') {
|
|
4744
|
+
clearBinding(ref);
|
|
4745
|
+
return res.json({ ok: true, ...scope, profile: null });
|
|
4746
|
+
}
|
|
4747
|
+
if (!isValidProfileId(body.profile)) return badRequest(res, 'invalid profile id');
|
|
4748
|
+
// Binding to a profile that does not exist would resolve to nothing at run
|
|
4749
|
+
// time — reject it here, where the user can still see why.
|
|
4750
|
+
if (!listProfileIds(plugin).includes(body.profile)) {
|
|
4751
|
+
return badRequest(res, `plugin "${plugin}" has no profile "${body.profile}"`);
|
|
4752
|
+
}
|
|
4753
|
+
res.json({ ok: true, ...scope, profile: setBinding(ref, body.profile) });
|
|
4754
|
+
} catch (err) {
|
|
4755
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4756
|
+
}
|
|
3265
4757
|
});
|
|
3266
4758
|
|
|
3267
4759
|
// ---------------------------------------------------------------------------
|
|
@@ -3303,8 +4795,21 @@ app.post('/api/sources/call', async (req, res) => {
|
|
|
3303
4795
|
if (input && typeof input.optionsFrom === 'string' && input.optionsFrom) allowed.add(input.optionsFrom);
|
|
3304
4796
|
}
|
|
3305
4797
|
if (!allowed.has(op)) return badRequest(res, `op "${op}" is not allowed for this source`);
|
|
4798
|
+
const profile = typeof body.profile === 'string' && body.profile ? body.profile : null;
|
|
4799
|
+
if (profile && !isValidProfileId(profile)) return badRequest(res, 'invalid profile id');
|
|
4800
|
+
if (source.multiProfile && !profile) return badRequest(res, 'profile is required for this task source');
|
|
4801
|
+
// Same submit-time guards as /api/run: a deleted profile must 400 here, not
|
|
4802
|
+
// fail deep in the connector against an empty bucket, and a profile on a
|
|
4803
|
+
// single-profile source would read (and persist state into) a phantom
|
|
4804
|
+
// bucket instead of the real config.
|
|
4805
|
+
if (source.multiProfile && !listProfileIds(plugin).includes(profile)) {
|
|
4806
|
+
return badRequest(res, `plugin "${plugin}" has no profile "${profile}"`);
|
|
4807
|
+
}
|
|
4808
|
+
if (!source.multiProfile && profile) {
|
|
4809
|
+
return badRequest(res, `task source "${sourceId}" does not use profiles — omit profile`);
|
|
4810
|
+
}
|
|
3306
4811
|
try {
|
|
3307
|
-
const result = await callSource({ plugin, sourceId, op, args });
|
|
4812
|
+
const result = await callSource({ plugin, sourceId, op, args, profile });
|
|
3308
4813
|
res.json({ ok: true, result });
|
|
3309
4814
|
} catch (err) {
|
|
3310
4815
|
if (err instanceof PluginOpError) {
|
|
@@ -3475,11 +4980,32 @@ app.post('/api/chat/test', async (req, res) => {
|
|
|
3475
4980
|
app.use((req, res, next) => {
|
|
3476
4981
|
if (req.method !== 'GET') return next();
|
|
3477
4982
|
if (req.path.startsWith('/api/') || req.path.startsWith('/ws')) return next();
|
|
4983
|
+
if (req.path === '/vendor' || req.path.startsWith('/vendor/')) return next();
|
|
3478
4984
|
res.sendFile(path.join(PUBLIC_DIR, 'index.html'), (err) => {
|
|
3479
4985
|
if (err) next();
|
|
3480
4986
|
});
|
|
3481
4987
|
});
|
|
3482
4988
|
|
|
4989
|
+
// ---------------------------------------------------------------------------
|
|
4990
|
+
// The LAST middleware, and the only GLOBAL error handler (`/vendor` keeps its
|
|
4991
|
+
// own path-scoped one): every failure answers { error } as JSON (MIN-108).
|
|
4992
|
+
// Without it express's default handler renders an HTML page carrying the thrown
|
|
4993
|
+
// stack — absolute node_modules paths included — for the two failures that happen
|
|
4994
|
+
// BEFORE any route runs: a malformed JSON body and a body past the cap. The
|
|
4995
|
+
// four-argument signature is what makes express treat this as an error handler,
|
|
4996
|
+
// so `next` stays even though only the headers-sent path uses it.
|
|
4997
|
+
// ---------------------------------------------------------------------------
|
|
4998
|
+
app.use((err, _req, res, next) => {
|
|
4999
|
+
if (res.headersSent) return next(err); // let express abort the stream
|
|
5000
|
+
if (err && err.type === 'entity.parse.failed') return res.status(400).json({ error: 'malformed JSON body' });
|
|
5001
|
+
if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'request body too large' });
|
|
5002
|
+
// Anything else: honour a body-parser 4xx (charset.unsupported / encoding.unsupported
|
|
5003
|
+
// are 415, request.aborted is 400) — a client error logged as a 500 misleads. Ours or
|
|
5004
|
+
// not, it is one line, no stack, never HTML.
|
|
5005
|
+
const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 500 ? err.status : 500;
|
|
5006
|
+
return res.status(status).json({ error: err && err.message ? err.message : 'internal error' });
|
|
5007
|
+
});
|
|
5008
|
+
|
|
3483
5009
|
/**
|
|
3484
5010
|
* Boot maintenance, in the PINNED order (§8.12):
|
|
3485
5011
|
* 1. reconcileStaleRunning — stamps every stale `running` row -> `interrupted`,
|
|
@@ -3503,12 +5029,12 @@ app.use((req, res, next) => {
|
|
|
3503
5029
|
* everything up to the first `await` — including the reconcile — still runs before
|
|
3504
5030
|
* `server.listen`, exactly as it did when this was an inline block.
|
|
3505
5031
|
*
|
|
3506
|
-
* @param {{log?: (scope:'run-root'|'legacy', level:string, msg:string) => void}} [args]
|
|
5032
|
+
* @param {{log?: (scope:'run-root'|'legacy'|'ask-worktrees', level:string, msg:string) => void}} [args]
|
|
3507
5033
|
* optional sink for the per-candidate lines both sweeps emit; omitted, each
|
|
3508
5034
|
* sweep keeps its own console default.
|
|
3509
5035
|
*/
|
|
3510
5036
|
export async function bootMaintenance({ log } = {}) {
|
|
3511
|
-
const summary = { reconciled: 0, runRoots: null, legacy: null };
|
|
5037
|
+
const summary = { reconciled: 0, sweptV1: 0, runRoots: null, legacy: null, ask: null, askWorktrees: null };
|
|
3512
5038
|
const sink = (scope) => (typeof log === 'function' ? (level, msg) => log(scope, level, msg) : undefined);
|
|
3513
5039
|
|
|
3514
5040
|
// Runs left 'running' by a previous process that died before writing a terminal
|
|
@@ -3521,6 +5047,16 @@ export async function bootMaintenance({ log } = {}) {
|
|
|
3521
5047
|
console.error(`[worca-ui] stale-run reconcile failed: ${err && err.message ? err.message : err}`);
|
|
3522
5048
|
}
|
|
3523
5049
|
|
|
5050
|
+
// A DB stamped past 24 by a divergent ladder can still hold v1 resume points
|
|
5051
|
+
// (crash-reconciled runs keep theirs). One idempotent sweep per boot.
|
|
5052
|
+
try {
|
|
5053
|
+
const swept = sweepV1Runs();
|
|
5054
|
+
summary.sweptV1 = swept.length;
|
|
5055
|
+
if (swept.length) console.log(`[worca-ui] retired ${swept.length} run(s) paused on the v1 engine`);
|
|
5056
|
+
} catch (err) {
|
|
5057
|
+
console.error(`[worca-ui] v1-run sweep failed: ${err && err.message ? err.message : err}`);
|
|
5058
|
+
}
|
|
5059
|
+
|
|
3524
5060
|
try {
|
|
3525
5061
|
const r = await sweepRunRoots({
|
|
3526
5062
|
worcaHome: worcaHome(), ...runRootSweepLookups(), log: sink('run-root'),
|
|
@@ -3562,6 +5098,34 @@ export async function bootMaintenance({ log } = {}) {
|
|
|
3562
5098
|
} catch (err) {
|
|
3563
5099
|
console.error(`[worca-ui] legacy worktree sweep failed: ${err && err.message ? err.message : err} — nothing was removed`);
|
|
3564
5100
|
}
|
|
5101
|
+
|
|
5102
|
+
// Ask Worca (§6.2): mark turns orphaned by a restart, sweep stale empty threads.
|
|
5103
|
+
try {
|
|
5104
|
+
const interrupted = sweepStreamingMessages();
|
|
5105
|
+
const emptyThreads = sweepEmptyThreads();
|
|
5106
|
+
summary.ask = { interrupted, emptyThreads };
|
|
5107
|
+
if (interrupted || emptyThreads) {
|
|
5108
|
+
console.log(`[worca-ui] ask sweep: ${interrupted} interrupted turn(s), ${emptyThreads} empty thread(s)`);
|
|
5109
|
+
}
|
|
5110
|
+
} catch (err) {
|
|
5111
|
+
summary.ask = { interrupted: 0, emptyThreads: 0 };
|
|
5112
|
+
console.error(`[worca-ui] ask sweep failed: ${err && err.message ? err.message : err}`);
|
|
5113
|
+
}
|
|
5114
|
+
|
|
5115
|
+
// Ask worktrees (P4 §5): reconcile ask_worktrees rows vs on-disk checkouts
|
|
5116
|
+
// both ways. Three-state inside the sweep: a DB failure aborts with nothing
|
|
5117
|
+
// removed. `sink('ask-worktrees')` is undefined on a log-less boot, which is
|
|
5118
|
+
// exactly the sweep's own default — never call sink(...) directly.
|
|
5119
|
+
try {
|
|
5120
|
+
const r = await sweepAskWorktrees({ log: sink('ask-worktrees') });
|
|
5121
|
+
summary.askWorktrees = r;
|
|
5122
|
+
if (r.removedDirs || r.prunedRows) {
|
|
5123
|
+
console.log(`[worca-ui] ask-worktree sweep: removed ${r.removedDirs} orphan dir(s), dropped ${r.prunedRows} stale row(s)`);
|
|
5124
|
+
}
|
|
5125
|
+
if (r.failed) console.error(`[worca-ui] ask-worktree sweep: ${r.failed} candidate(s) skipped`);
|
|
5126
|
+
} catch (err) {
|
|
5127
|
+
console.error(`[worca-ui] ask-worktree sweep failed: ${err && err.message ? err.message : err}`);
|
|
5128
|
+
}
|
|
3565
5129
|
return summary;
|
|
3566
5130
|
}
|
|
3567
5131
|
|
|
@@ -3606,4 +5170,9 @@ if (isMain) {
|
|
|
3606
5170
|
}
|
|
3607
5171
|
|
|
3608
5172
|
export { app, server, runs };
|
|
3609
|
-
export const _testing = {
|
|
5173
|
+
export const _testing = {
|
|
5174
|
+
wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
|
|
5175
|
+
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
|
|
5176
|
+
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, resolveAskContext, flipCard,
|
|
5177
|
+
emitDiffCommentsChanged,
|
|
5178
|
+
};
|