@worca/app 0.0.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.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
package/ui/server.mjs ADDED
@@ -0,0 +1,3573 @@
1
+ // ui/server.mjs
2
+ // Express static server + REST API + WebSocket bridge that drives the
3
+ // deterministic orchestrator core. Only non-builtin deps: express + ws.
4
+ //
5
+ // Run: node ui/server.mjs (or `npm start`)
6
+ // Env: PORT (default 4317), WORCA_MOCK (forwarded to runs when ?mock or body.mock)
7
+
8
+ import express from 'express';
9
+ import { WebSocketServer } from 'ws';
10
+ import http from 'node:http';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+ import fs from 'node:fs';
14
+ import fsp from 'node:fs/promises';
15
+ import process from 'node:process';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
17
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
18
+
19
+ import { preflightNode } from '../src/core/preflight-node.mjs';
20
+ import { createOrchestrator } from '../src/core/orchestrator.mjs';
21
+ import {
22
+ listPipelines, readPipeline, listAllPipelines, readPipelineByKey,
23
+ enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState,
24
+ readRunLogText, readRunArtifactText, countPipelines, runRootSweepLookups, legacySweepLookups, slugify,
25
+ listArtifacts,
26
+ } from '../src/core/artifacts.mjs';
27
+ import { DIFF_PATCH_FILE } from '../src/core/results.mjs';
28
+ import { listProjects, addProject, removeProject, normalizeProjectPath, countProjects, worcaHome } from '../src/core/projects.mjs';
29
+ import {
30
+ getWorcaRoot, setWorcaRoot, setProjectsRoot, defaultRoot,
31
+ rawProjectsRoot, defaultProjectsRoot, runRootMode,
32
+ pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
33
+ setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
34
+ chatPrefs, setChatPrefs,
35
+ } from '../src/core/settings.mjs';
36
+ import { budgetStatus, readCostCapOverride, setCostCapOverride } from '../src/core/cost-budget.mjs';
37
+ import { getStats } from '../src/core/stats.mjs';
38
+ import { pickFolderNative } from '../src/core/folder-dialog.mjs';
39
+ import { listFolders } from '../src/core/fs-browse.mjs';
40
+ import {
41
+ readConfig, setStep, addCustomModel, removeCustomModel, listModels,
42
+ PREDEFINED_MODELS, agentSteps, EFFORTS,
43
+ readRunConfig, setNodeModel, setFeedbackCycles, setActiveWorkflow, resetWorkflowConfig,
44
+ globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
45
+ } from '../src/core/config.mjs';
46
+ import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
47
+ import { modelEnvRef } from '../src/core/model-env.mjs';
48
+ import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
49
+ import { validateGuardrails } from '../src/core/guardrails.mjs';
50
+ import {
51
+ listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
52
+ writeGuardrailSet, deleteGuardrailSet, isBuiltinGuardrailSetId,
53
+ } from '../src/core/guardrail-store.mjs';
54
+ import {
55
+ DEFAULT_WORKFLOW, listWorkflows, readWorkflow, writeWorkflow, deleteWorkflow,
56
+ setWorkflowNodeDefaults, workflowNodeDefaults,
57
+ } from '../src/core/workflows.mjs';
58
+ import { validateWorkflow } from '../src/core/workflow-validator.mjs';
59
+ import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
60
+ import {
61
+ listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
62
+ } from '../src/core/worktree.mjs';
63
+ import { hasGh, pushBranch, createPr, prMergeable } from '../src/core/git-info.mjs';
64
+ import { archivePipeline, discardRetainedWorktrees } from '../src/core/pipeline-delete.mjs';
65
+ import {
66
+ listWorkspaces, readWorkspace, createWorkspace,
67
+ updateWorkspace, deleteWorkspace, isGitRepo, WORKSPACE_KEY_RE, countWorkspaces,
68
+ } from '../src/core/workspaces.mjs';
69
+ import { listWorkspacePipelines, readWorkspacePipeline } from '../src/core/artifacts.mjs';
70
+ import { generateOverview } from '../src/core/overview-agent.mjs';
71
+ import { projectKey } from '../src/core/store.mjs';
72
+ import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
73
+ import { createAgentGen } from '../src/core/agent-gen.mjs';
74
+ 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
+ import {
77
+ listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin,
78
+ setPluginEnabled, doctorPlugin,
79
+ listOrphanPluginData, purgePluginData,
80
+ } from '../src/core/plugin-store.mjs';
81
+ import { fetchCandidate } from '../src/core/plugin-repo.mjs';
82
+ import {
83
+ addMarketplace, listMarketplaces, syncMarketplace, refreshAllMarketplaces,
84
+ removeMarketplace, readMarketplaces, seedBuiltinMarketplace,
85
+ } from '../src/core/marketplaces.mjs';
86
+ import { redactedConfig, writePluginConfig, readPluginConfig } from '../src/core/plugin-config.mjs';
87
+ import { createChannelHost } from '../src/core/chat/channel-host.mjs';
88
+ import { createCommandRouter } from '../src/core/chat/command-router.mjs';
89
+ import { createChatContext } from '../src/core/chat/chat-context.mjs';
90
+ import { createNotifier } from '../src/core/chat/notifier.mjs';
91
+ import { TokenBucket } from '../src/core/chat/rate-limiter.mjs';
92
+ import { renderTest } from '../src/core/chat/renderers.mjs';
93
+ import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs';
94
+ import { normalizeManifest, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
95
+ import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
96
+ import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
97
+
98
+ // ── node:sqlite runtime guard + warning filter ──────────────────────────────────
99
+ // Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
100
+ // stable enough for our use but still flagged experimental). Everything else (deprec-
101
+ // ations, etc.) is re-printed unchanged. Belt-and-suspenders with the npm scripts'
102
+ // --disable-warning=ExperimentalWarning (the primary suppressor): this filter is the
103
+ // direct-bin fallback. We removeAllListeners('warning') FIRST so Node's default
104
+ // printer no longer fires (a bare listener would NOT suppress the warning and would
105
+ // double-print every OTHER warning), then attach our single filtering listener.
106
+ process.removeAllListeners('warning');
107
+ process.on('warning', (w) => {
108
+ if (w && w.name === 'ExperimentalWarning' && /SQLite/i.test(w.message)) return;
109
+ process.stderr.write(`${w?.stack || w?.message || w}\n`);
110
+ });
111
+ // Fail fast on an unsupported Node / missing node:sqlite BEFORE any DB is opened.
112
+ preflightNode();
113
+
114
+ const __filename = fileURLToPath(import.meta.url);
115
+ const __dirname = path.dirname(__filename);
116
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
117
+ const PUBLIC_DIR = path.join(__dirname, 'public');
118
+ const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
119
+ const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
120
+ const PORT = Number(process.env.PORT) || 4317;
121
+ // Bind to loopback by default (S1). Power users who knowingly want LAN exposure
122
+ // can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
123
+ // applies unless they also front it with auth.
124
+ const HOST = process.env.WORCA_HOST || '127.0.0.1';
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Run registry. Each entry holds the live orchestrator + a ring buffer of the
128
+ // events emitted so far so that a WebSocket which connects late can replay.
129
+ // ---------------------------------------------------------------------------
130
+ /**
131
+ * @type {Map<string, {
132
+ * id: string, // runs-Map key = randomUUID()
133
+ * pipelineId?: string, // short id from src/core/artifacts.mjs#shortId, set after createPipeline
134
+ * orch: import('events').EventEmitter,
135
+ * projectDir: string,
136
+ * title: string,
137
+ * status: string,
138
+ * startedAt: string,
139
+ * events: any[],
140
+ * pendingQuestion: any
141
+ * }>}
142
+ */
143
+ const runs = new Map();
144
+
145
+ // Ids of runs genuinely live in THIS process (non-terminal entries in the runs Map).
146
+ // Passed to reconcileStaleRunning so a same-process run is never relabeled. Both the
147
+ // short pipelineId (matches pipelines.id) and the runs-Map UUID id are pushed; the UUID
148
+ // simply never matches a pipelines.id, so including it is harmless.
149
+ function liveRunIds() {
150
+ const ids = [];
151
+ for (const r of runs.values()) {
152
+ const s = String(r.status || '').toLowerCase();
153
+ if (s === 'running' || s === 'starting' || s === 'created' || s === 'pausing') {
154
+ if (r.pipelineId) ids.push(r.pipelineId);
155
+ if (r.id) ids.push(r.id);
156
+ }
157
+ }
158
+ return ids;
159
+ }
160
+
161
+ // `stepgraphify` (§7.3) was emitted by the orchestrator and handled by the client
162
+ // but missing here, so the graphify badge only appeared after a reload (via the
163
+ // persisted column) and never live. It rides the same pass-through as `stepskills`.
164
+ const EVENT_NAMES = ['phase', 'log', 'question', 'artifact', 'state', 'done', 'error', 'subagent', 'stepskills', 'stepgraphify', 'title'];
165
+ // The scan-* WS family (Workspaces M5, §5.4). A NEW family in the SAME runs Map;
166
+ // the 7-event run plumbing above is untouched. createWorkspaceScan emits many
167
+ // scan-progress then exactly one terminal scan-done OR scan-error.
168
+ const SCAN_EVENT_NAMES = ['scan-progress', 'scan-done', 'scan-error'];
169
+ // The agentgen-* WS family (Agent Platform, Phase 2). Same pattern as scan-*:
170
+ // a NEW family in the SAME runs Map. createAgentGen emits many agentgen-progress
171
+ // then exactly one terminal agentgen-done OR agentgen-error.
172
+ const AGENTGEN_EVENT_NAMES = ['agentgen-progress', 'agentgen-done', 'agentgen-error'];
173
+ const MAX_BUFFER = 5000;
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // WebSocket plumbing
177
+ // ---------------------------------------------------------------------------
178
+ const app = express();
179
+ const server = http.createServer(app);
180
+ const wss = new WebSocketServer({ server, path: '/ws' });
181
+
182
+ /** All currently connected sockets. */
183
+ const sockets = new Set();
184
+
185
+ wss.on('connection', (ws, req) => {
186
+ // S1: WS upgrades bypass the express middleware chain, so re-apply the
187
+ // loopback guard here (same DNS-rebinding protection as the HTTP routes).
188
+ if (!isLocalRequest(req)) {
189
+ try { ws.close(1008, 'forbidden'); } catch { /* already closing */ }
190
+ return;
191
+ }
192
+ sockets.add(ws);
193
+ // Optional ?runId=... (or ?scanId=.../?genId=...) -> replay that entry's buffered
194
+ // events so a reconnecting client immediately sees the full state. Scan + agentgen
195
+ // entries live in the SAME runs Map keyed by scanId/genId, so a single id lookup
196
+ // serves all families.
197
+ let requestedRunId = null;
198
+ let requestedScanId = null;
199
+ let requestedGenId = null;
200
+ try {
201
+ const u = new URL(req.url, 'http://localhost');
202
+ requestedRunId = u.searchParams.get('runId');
203
+ requestedScanId = u.searchParams.get('scanId');
204
+ requestedGenId = u.searchParams.get('genId');
205
+ } catch {
206
+ requestedRunId = null;
207
+ requestedScanId = null;
208
+ requestedGenId = null;
209
+ }
210
+ const id = requestedRunId || requestedScanId || requestedGenId;
211
+
212
+ send(ws, { type: 'hello', runs: summarizeRuns() });
213
+
214
+ if (id && runs.has(id)) {
215
+ replayEntry(ws, runs.get(id));
216
+ }
217
+
218
+ ws.on('close', () => sockets.delete(ws));
219
+ ws.on('error', () => sockets.delete(ws));
220
+ ws.on('message', (data) => {
221
+ // Clients may ask to (re)subscribe / replay an entry's history. A scan's
222
+ // {type:'subscribe', scanId} and an agent generation's {type:'subscribe',
223
+ // genId} are accepted identically to a run's runId.
224
+ let msg = null;
225
+ try {
226
+ msg = JSON.parse(String(data));
227
+ } catch {
228
+ return;
229
+ }
230
+ const subId = msg && msg.type === 'subscribe' ? (msg.runId || msg.scanId || msg.genId) : null;
231
+ if (subId && runs.has(subId)) {
232
+ replayEntry(ws, runs.get(subId));
233
+ }
234
+ });
235
+ });
236
+
237
+ function send(ws, obj) {
238
+ if (ws.readyState === ws.OPEN) {
239
+ try {
240
+ ws.send(JSON.stringify(obj));
241
+ } catch {
242
+ /* ignore individual socket failures */
243
+ }
244
+ }
245
+ }
246
+
247
+ // After replaying an entry's buffered events, push a CURRENT state snapshot so a
248
+ // late-joining socket always has the latest stepper + subAgents even if the run's
249
+ // initial 'state' frame was evicted from the ring buffer (MAX_BUFFER = 5000). For a
250
+ // RUN this re-seeds the stepper, and is idempotent with any replayed 'state' frame
251
+ // (onState merges). SCAN entries DO expose getState() but have no `.state` property,
252
+ // so the `orch.state &&` guard below skips them on purpose: a scan has no stepper to
253
+ // seed, and its scanId/phase/... state is already delivered via scan-* events.
254
+ // getState() returns a clone with an `id` key (not `runId`) and no `type` key, so the
255
+ // explicit { runId, type } below are not clobbered by the spread.
256
+ function sendStateSnapshot(ws, entry) {
257
+ const orch = entry && entry.orch;
258
+ if (orch && orch.state && typeof orch.getState === 'function') {
259
+ send(ws, { runId: entry.id, type: 'state', ...orch.getState() });
260
+ }
261
+ }
262
+
263
+ // Replay a run/scan/gen entry's buffered events to a (re)connecting socket, then
264
+ // push a current state snapshot. A buffered `question` event lingers in the ring
265
+ // buffer forever, but is replayed ONLY while it is still the active pending
266
+ // question (entry.pendingQuestion, the single source of truth that also seeds
267
+ // hello). Once answered — or superseded by a newer question — replaying it would
268
+ // resurrect a clarify/gate card on refresh: a zombie that paints a false "paused"
269
+ // state over an already-running pipeline and routes its answer to a no-longer-
270
+ // pending id ("answer() ignored"). So a question whose id no longer matches the
271
+ // active pending question is skipped on replay; every other event passes through.
272
+ function replayEntry(ws, entry) {
273
+ const pendingId = (entry.pendingQuestion && entry.pendingQuestion.id) || null;
274
+ for (const ev of entry.events) {
275
+ if (ev.type === 'question' && ev.id !== pendingId) continue;
276
+ send(ws, ev);
277
+ }
278
+ sendStateSnapshot(ws, entry);
279
+ }
280
+
281
+ /** Broadcast an already-tagged event object to every open socket. */
282
+ function broadcast(obj) {
283
+ const text = JSON.stringify(obj);
284
+ for (const ws of sockets) {
285
+ if (ws.readyState === ws.OPEN) {
286
+ try {
287
+ ws.send(text);
288
+ } catch {
289
+ /* ignore */
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ // Fire-and-forget "this entity set changed — refetch your counts" signal. Bare +
296
+ // unbuffered + global, exactly like the history-pr broadcast: every connected tab
297
+ // (including the one that triggered the mutation) gets it and re-reads /api/counts.
298
+ // Because the client always SETS counts to an absolute value (never +1/-1), a tab
299
+ // receiving its own echo is idempotent. A tab disconnected at mutation time recovers
300
+ // on its next view switch / reload (the agreed product behavior).
301
+ function emitChanged(type, action) {
302
+ broadcast({ type, action: action || null });
303
+ }
304
+
305
+ // Append a tagged event to an entry's ring buffer (runId LAST so the runs-Map key
306
+ // always wins over any id the orchestrator stamped). Shared by the live wire
307
+ // (record) and out-of-band resolutions (resolvePending) so both honor MAX_BUFFER.
308
+ function bufferEvent(entry, event) {
309
+ const tagged = { ...event, runId: entry.id };
310
+ entry.events.push(tagged);
311
+ if (entry.events.length > MAX_BUFFER) entry.events.splice(0, entry.events.length - MAX_BUFFER);
312
+ return tagged;
313
+ }
314
+
315
+ // Clear an entry's active pending question and tell EVERY connected client to drop
316
+ // its clarify/gate card — not just the tab that answered. A second tab's post-answer
317
+ // `phase` event is gated on its own _answering flag, so without this broadcast it
318
+ // keeps showing a stale card (and a false "paused" stepper) until the run ends.
319
+ // Buffered (so a later reconnect replays the resolution) AND broadcast live. The
320
+ // single chokepoint for clearing entry.pendingQuestion: answer, stop, pause, done,
321
+ // error all route here. Idempotent + id-aware: a no-op when nothing is pending, or
322
+ // when `id` is given and does not match the active question (a stale/dup ack).
323
+ function resolvePending(entry, { id = null, reason = 'resolved' } = {}) {
324
+ const pq = entry && entry.pendingQuestion;
325
+ if (!pq || (id && pq.id !== id)) return false;
326
+ entry.pendingQuestion = null;
327
+ broadcast(bufferEvent(entry, { type: 'question-resolved', id: pq.id, reason }));
328
+ return true;
329
+ }
330
+
331
+ function summarizeRuns() {
332
+ return [...runs.values()].map((r) => ({
333
+ runId: r.id,
334
+ stepper: r.orch?.state?.stepper ?? null,
335
+ pipelineId: r.pipelineId || null,
336
+ projectDir: r.projectDir,
337
+ title: r.title,
338
+ status: r.status,
339
+ // Why the run is paused, or null — ANY orchestrator pause code rides here
340
+ // (e.g. 'usage_limit'), not just the cost pair. Carried in hello so a
341
+ // reload/reconnect restores the cost banner (the client gates that render on
342
+ // 'cost_pipeline'/'cost_total') instead of showing a plain "Paused" card
343
+ // until the next event.
344
+ pauseReason: r.pauseReason || null,
345
+ startedAt: r.startedAt,
346
+ pendingQuestion: r.pendingQuestion || null,
347
+ // kind discriminator so the client routes runs vs scans vs agent generations
348
+ // vs workspace runs without guessing; scanId/genId/workspaceId are the
349
+ // matching attribution fields.
350
+ kind: r.kind || 'run',
351
+ scanId: r.scanId || null,
352
+ genId: r.genId || null,
353
+ workspaceId: r.workspaceId || null,
354
+ projectNames: r.projectNames || null,
355
+ }));
356
+ }
357
+
358
+ // Birth announcement for a freshly-registered run: broadcasts the metadata that
359
+ // otherwise only travels in a hello snapshot (projectDir, kind, workspace
360
+ // attribution, member names), so tabs that did NOT start the run render its
361
+ // child row/card correctly without a reload. Not buffered: late joiners get the
362
+ // same fields from summarizeRuns().
363
+ function announceRun(entry) {
364
+ broadcast({
365
+ type: 'run-created',
366
+ runId: entry.id,
367
+ title: entry.title,
368
+ projectDir: entry.projectDir,
369
+ kind: entry.kind || 'run',
370
+ workspaceId: entry.workspaceId || null,
371
+ projectNames: entry.projectNames || null,
372
+ status: entry.status,
373
+ startedAt: entry.startedAt,
374
+ });
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Wire a core orchestrator's events onto the WebSocket, tagged with runId.
379
+ // ---------------------------------------------------------------------------
380
+ function subscribe(orch, name, handler) {
381
+ // Support a Node EventEmitter (`.on`), an `.addListener` alias, or an
382
+ // EventTarget-style (`.addEventListener`) "EventEmitter-like" object.
383
+ if (typeof orch.on === 'function') {
384
+ orch.on(name, handler);
385
+ } else if (typeof orch.addListener === 'function') {
386
+ orch.addListener(name, handler);
387
+ } else if (typeof orch.addEventListener === 'function') {
388
+ orch.addEventListener(name, (ev) => handler(ev && ev.detail !== undefined ? ev.detail : ev));
389
+ }
390
+ }
391
+
392
+ function wireRun(entry) {
393
+ const { id, orch } = entry;
394
+
395
+ // Chat notifications ride the same per-run subscription (design §4.5): every
396
+ // creation site that wires a run gets chat fan-out for free. Scans/agent-gens
397
+ // use their own wire* helpers and are deliberately not notified.
398
+ if ((entry.kind || 'run') === 'run' || entry.kind === 'workspace-run') {
399
+ try { chatNotifier.attach(orch, { runId: id, entry }); }
400
+ catch (err) { console.error(`[worca-ui] chat notifier attach failed: ${err && err.message ? err.message : err}`); }
401
+ }
402
+
403
+ const record = (event) => {
404
+ // bufferEvent tags runId LAST so the runs-Map key always wins. The
405
+ // orchestrator's `subagent` delta historically carried its own runId
406
+ // (state.id = pipeline SHORT id, NOT this UUID); tagging the UUID last stops
407
+ // the client spawning a phantom run.
408
+ const tagged = bufferEvent(entry, event);
409
+ broadcast(tagged);
410
+ return tagged;
411
+ };
412
+
413
+ for (const name of EVENT_NAMES) {
414
+ subscribe(orch, name, (payload) => {
415
+ const event = { type: name, ...(payload && typeof payload === 'object' ? payload : { value: payload }) };
416
+
417
+ if (name === 'question') {
418
+ entry.pendingQuestion = event;
419
+ }
420
+ if (name === 'done') {
421
+ entry.status = (payload && payload.status) || 'done';
422
+ // Remember the pause reason for summarizeRuns (hello). Reset on every
423
+ // done so a later reasonless finish cannot leave a stale cost banner.
424
+ entry.pauseReason = (payload && payload.reason) || null;
425
+ resolvePending(entry, { reason: entry.status });
426
+ if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
427
+ emitChanged('budget-changed');
428
+ }
429
+ }
430
+ if (name === 'error') {
431
+ entry.status = 'error';
432
+ resolvePending(entry, { reason: 'error' });
433
+ }
434
+ if (name === 'phase') {
435
+ entry.status = 'running';
436
+ }
437
+ if (name === 'state' && payload && typeof payload === 'object') {
438
+ // Mirror status from the snapshot when present. (Pending questions are
439
+ // cleared explicitly on answer/done/error, not from state snapshots.)
440
+ if (payload.status) entry.status = payload.status;
441
+ // Capture the on-disk pipeline short id the orchestrator stamps onto
442
+ // state.id after createPipeline. Guard so null in pre-createPipeline
443
+ // snapshots cannot overwrite a previously-captured value.
444
+ if (typeof payload.id === 'string' && payload.id) entry.pipelineId = payload.id;
445
+ }
446
+ if (name === 'title' && payload && typeof payload.title === 'string') {
447
+ // Keep the in-memory run fresh so a late-joining client's hello
448
+ // (summarizeRuns reads entry.title) sees the settled title.
449
+ entry.title = payload.title;
450
+ }
451
+
452
+ record(event);
453
+ });
454
+ }
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Wire a WorkspaceScan's events onto the WebSocket, tagged with scanId. A NEW
459
+ // family in the SAME runs Map — the 7-event run plumbing (wireRun) is untouched.
460
+ // Maps scan-progress->running, scan-done->done, scan-error->error so the hello
461
+ // snapshot + DELETE-while-live guard see a live scan as "running" and a finished
462
+ // one as terminal. createWorkspaceScan emits many scan-progress then exactly one
463
+ // terminal scan-done OR scan-error (§5.4).
464
+ // ---------------------------------------------------------------------------
465
+ function wireScan(entry) {
466
+ const { scanId, orch } = entry;
467
+
468
+ const record = (event) => {
469
+ // scanId LAST so the runs-Map key always wins (the engine already tags its
470
+ // payload with the same id; this is a defensive override against any drift).
471
+ const tagged = { ...event, scanId };
472
+ entry.events.push(tagged);
473
+ if (entry.events.length > MAX_BUFFER) entry.events.splice(0, entry.events.length - MAX_BUFFER);
474
+ broadcast(tagged);
475
+ return tagged;
476
+ };
477
+
478
+ for (const name of SCAN_EVENT_NAMES) {
479
+ subscribe(orch, name, (payload) => {
480
+ const event = { type: name, ...(payload && typeof payload === 'object' ? payload : { value: payload }) };
481
+ if (name === 'scan-progress') entry.status = 'running';
482
+ else if (name === 'scan-done') entry.status = 'done';
483
+ else if (name === 'scan-error') entry.status = 'error';
484
+ record(event);
485
+ });
486
+ }
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Wire an AgentGen's events onto the WebSocket, tagged with genId. The
491
+ // agentgen-* family: same runs Map, same ring-buffer/replay plumbing as
492
+ // wireScan; the 7-event run plumbing (wireRun) is untouched. createAgentGen
493
+ // emits many agentgen-progress then exactly one terminal agentgen-done OR
494
+ // agentgen-error (run() never throws).
495
+ // ---------------------------------------------------------------------------
496
+ function wireAgentGen(entry) {
497
+ const { genId, orch } = entry;
498
+
499
+ const record = (event) => {
500
+ // genId LAST so the runs-Map key always wins (the engine already tags its
501
+ // payload with the same id; this is a defensive override against drift).
502
+ const tagged = { ...event, genId };
503
+ entry.events.push(tagged);
504
+ if (entry.events.length > MAX_BUFFER) entry.events.splice(0, entry.events.length - MAX_BUFFER);
505
+ broadcast(tagged);
506
+ return tagged;
507
+ };
508
+
509
+ for (const name of AGENTGEN_EVENT_NAMES) {
510
+ subscribe(orch, name, (payload) => {
511
+ const event = { type: name, ...(payload && typeof payload === 'object' ? payload : { value: payload }) };
512
+ if (name === 'agentgen-progress') entry.status = 'running';
513
+ else if (name === 'agentgen-done') entry.status = 'done';
514
+ else if (name === 'agentgen-error') entry.status = 'error';
515
+ record(event);
516
+ });
517
+ }
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Teams ingress (chat-connectivity-design.md §4.7) — the ONE deliberate,
522
+ // auditable exemption from the loopback guard below: Bot Framework can only
523
+ // deliver inbound Teams activities to a public HTTPS endpoint (via a
524
+ // user-supplied tunnel), so this route is mounted BEFORE express.json and
525
+ // BEFORE the guard. Hardening: capability-URL token (per-channel ingressToken
526
+ // secret, timingSafeEqual, uniform 404 on ANY mismatch), 256 KB raw body cap,
527
+ // 60 req/min bucket, worker-down 503, 10 s forward timeout 504. The worker
528
+ // validates the Bot Framework JWT (issuer/audience/exp/serviceUrl) — bodies
529
+ // and Authorization headers are NEVER logged host-side. Everything outside
530
+ // /api/ingress stays loopback-guarded even through the tunnel.
531
+ // ---------------------------------------------------------------------------
532
+ const ingressBucket = new TokenBucket(60);
533
+ const INGRESS_ID_RE = /^[a-z][a-z0-9-]{0,63}$/;
534
+
535
+ app.post('/api/ingress/teams/:plugin/:channelId/:token',
536
+ express.raw({ type: '*/*', limit: '256kb' }),
537
+ async (req, res) => {
538
+ const notFound = () => res.status(404).json({ error: 'not found' });
539
+ if (!ingressBucket.tryConsume()) return res.status(429).json({ error: 'rate limited' });
540
+ const { plugin, channelId, token } = req.params;
541
+ if (!INGRESS_ID_RE.test(plugin) || !INGRESS_ID_RE.test(channelId) || typeof token !== 'string' || !token) {
542
+ return notFound();
543
+ }
544
+ let entry;
545
+ try {
546
+ entry = channelHost.list().find((e) => e.plugin === plugin && e.channelId === channelId && e.ingress === 'webhook');
547
+ } catch { entry = null; }
548
+ if (!entry) return notFound();
549
+ let expected = '';
550
+ try { expected = String(readPluginConfig(plugin, entry.configSchema).ingressToken || ''); }
551
+ catch { return notFound(); }
552
+ const got = Buffer.from(token);
553
+ const want = Buffer.from(expected);
554
+ if (!expected || got.length !== want.length || !timingSafeEqual(got, want)) return notFound();
555
+
556
+ try {
557
+ const out = await channelHost.handleWebhook({
558
+ plugin,
559
+ channelId,
560
+ method: req.method,
561
+ path: req.path,
562
+ headers: req.headers,
563
+ bodyB64: Buffer.isBuffer(req.body) ? req.body.toString('base64') : '',
564
+ timeoutMs: 10000,
565
+ });
566
+ res.status(out.statusCode || 200);
567
+ for (const [k, v] of Object.entries(out.headers || {})) res.set(k, v);
568
+ if (out.bodyB64) return res.send(Buffer.from(out.bodyB64, 'base64'));
569
+ return res.end();
570
+ } catch (err) {
571
+ if (err?.kind === 'timeout') return res.status(504).json({ error: 'worker timeout' });
572
+ return res.status(503).json({ error: 'channel worker unavailable' });
573
+ }
574
+ });
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // Express middleware + static
578
+ // ---------------------------------------------------------------------------
579
+ app.use(express.json({ limit: '8mb' }));
580
+
581
+ // S1: worca-cc's UI/API has no auth and runs agents with permissionMode
582
+ // 'acceptEdits' — it is a single-user *localhost* tool. The server binds to
583
+ // loopback (see HOST below); this guard is the DNS-rebinding belt to that
584
+ // suspenders: reject any request whose Host (or browser Origin) is not a
585
+ // loopback name, so a malicious page resolving a name to 127.0.0.1 still can't
586
+ // drive the API. Override WORCA_HOST only if you understand the exposure.
587
+ app.use((req, res, next) => {
588
+ if (!isLocalRequest(req)) {
589
+ return res.status(403).json({ error: 'forbidden: worca-cc is a localhost-only tool' });
590
+ }
591
+ next();
592
+ });
593
+
594
+ app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
595
+
596
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
597
+ /** Hostname (no port) from a Host header value or full Origin URL, or null. */
598
+ function hostnameOf(value) {
599
+ if (!value) return null;
600
+ try {
601
+ return new URL(value.includes('://') ? value : `http://${value}`).hostname;
602
+ } catch {
603
+ return null;
604
+ }
605
+ }
606
+ /** True when both Host and (if present) Origin are loopback. */
607
+ function isLocalRequest(req) {
608
+ const host = hostnameOf(req.headers.host);
609
+ if (!host || !LOCAL_HOSTNAMES.has(host)) return false;
610
+ const origin = req.headers.origin;
611
+ if (origin) {
612
+ const oh = hostnameOf(origin);
613
+ if (!oh || !LOCAL_HOSTNAMES.has(oh)) return false;
614
+ }
615
+ return true;
616
+ }
617
+
618
+ function badRequest(res, message) {
619
+ res.status(400).json({ error: message });
620
+ }
621
+
622
+ // A workspace id/key is "wks-<nameSlug>-<sha1[:8]>". WORKSPACE_KEY_RE is imported
623
+ // from src/core/workspaces.mjs (one source of truth) and validated against any
624
+ // :id/workspaceId before a disk touch: a value failing it can never contain "/"
625
+ // or ".." so workspaceStorePath(id) cannot escape the namespace, and a stale
626
+ // bookmark reads as "not found" (404), not "bad request".
627
+
628
+ // Map a workspaces.mjs err.code to an HTTP status. BAD_REQUEST->400,
629
+ // DUPLICATE_NAME/DUPLICATE_SET->409, NOT_FOUND->404 (mirrors the thin-delegator
630
+ // pattern of /api/projects + /api/workflows). Anything else is a 500 caller bug.
631
+ function workspaceErrorStatus(code) {
632
+ if (code === 'DUPLICATE_NAME' || code === 'DUPLICATE_SET') return 409;
633
+ if (code === 'RETAINED_WORKTREE') return 409; // retained uncommitted work blocks deletion
634
+ if (code === 'NOT_FOUND') return 404;
635
+ if (code === 'BAD_REQUEST') return 400;
636
+ return 500;
637
+ }
638
+
639
+ // Single source of truth for path normalization lives in the core registry.
640
+ function resolveProjectDir(input) {
641
+ return normalizeProjectPath(input);
642
+ }
643
+
644
+ // ── Per-project source branches (workspace runs) ──────────────────────────────
645
+ // A workspace run may carry a { [projectKey]: sourceBranch } override map. Each
646
+ // member's source is its override (when non-blank) else the shared run default;
647
+ // the feature branch is always shared (the orchestrator suffixes it per project).
648
+ export function buildWorkspaceMembers(projects, branch, sourceByKey = {}) {
649
+ const byKey = sourceByKey && typeof sourceByKey === 'object' ? sourceByKey : {};
650
+ return projects.map((p) => {
651
+ const override = byKey[p.projectKey];
652
+ const source = typeof override === 'string' && override.trim() ? override.trim() : branch.source;
653
+ return { ...p, branch: { source, feature: branch.feature } };
654
+ });
655
+ }
656
+
657
+ // Mirror the shared-source option-injection guard (D2) for every override entry.
658
+ // Returns the first leading-dash value found, or null when all entries are safe.
659
+ export function firstInjectionSource(sourceByKey = {}) {
660
+ if (!sourceByKey || typeof sourceByKey !== 'object') return null;
661
+ for (const v of Object.values(sourceByKey)) {
662
+ if (typeof v === 'string' && v.trim().startsWith('-')) return v.trim();
663
+ }
664
+ return null;
665
+ }
666
+
667
+ // ── /api/run task-source dispatch (plugins §7.3) ────────────────────────────
668
+ // SHAPE-checks body.source only. Resolution (fetching the task, building the
669
+ // prompt text, stamping source_type/source_ref) happens inside the orchestrator
670
+ // via resolveTaskInput (src/core/sources.mjs) so the task is fetched exactly
671
+ // once — the server must never resolve it too.
672
+ // Returns null when absent, else { ok:true, source } | { ok:false, error }.
673
+ function normalizeRunSource(raw) {
674
+ if (raw === undefined || raw === null) return null;
675
+ if (typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'source must be an object' };
676
+ const type = raw.type;
677
+ if (type === 'prompt') {
678
+ if (!(typeof raw.prompt === 'string' && raw.prompt.trim())) {
679
+ return { ok: false, error: 'source.prompt is required for type "prompt"' };
680
+ }
681
+ return { ok: true, source: { type: 'prompt', prompt: raw.prompt } };
682
+ }
683
+ if (type === 'markdown') {
684
+ const promptText = typeof raw.promptText === 'string' && raw.promptText.trim() ? raw.promptText : undefined;
685
+ const promptFile = typeof raw.promptFile === 'string' && raw.promptFile.trim() ? raw.promptFile : undefined;
686
+ if (!promptText && !promptFile) {
687
+ return { ok: false, error: 'source.promptText or source.promptFile is required for type "markdown"' };
688
+ }
689
+ return { ok: true, source: { type: 'markdown', promptText, promptFile } };
690
+ }
691
+ if (type === 'plugin') {
692
+ for (const k of ['plugin', 'sourceId', 'taskId']) {
693
+ if (!(typeof raw[k] === 'string' && raw[k].trim())) {
694
+ return { ok: false, error: `source.${k} is required for type "plugin"` };
695
+ }
696
+ }
697
+ return {
698
+ ok: true,
699
+ source: {
700
+ type: 'plugin',
701
+ plugin: raw.plugin.trim(),
702
+ sourceId: raw.sourceId.trim(),
703
+ taskId: raw.taskId.trim(),
704
+ inputs: raw.inputs && typeof raw.inputs === 'object' && !Array.isArray(raw.inputs) ? raw.inputs : undefined,
705
+ },
706
+ };
707
+ }
708
+ return { ok: false, error: `unknown source.type "${type}"` };
709
+ }
710
+
711
+ // Fallback run title when the client sends none. The legacy path is unchanged
712
+ // (first 80 chars of the prompt — effectivePrompt is guaranteed set there); a
713
+ // plugin source starts as "<plugin>: <taskId>" until the orchestrator resolves
714
+ // the task and settles the real title via the title event.
715
+ function fallbackRunTitle(effectivePrompt, source) {
716
+ if (effectivePrompt) return effectivePrompt.slice(0, 80);
717
+ if (source && source.type === 'plugin') return `${source.plugin}: ${source.taskId}`;
718
+ const text = (source && (source.prompt || source.promptText || source.promptFile)) || 'task';
719
+ return String(text).slice(0, 80);
720
+ }
721
+
722
+ // ---------------------------------------------------------------------------
723
+ // POST /api/run -> start a new orchestration run
724
+ // body (single-project): { projectDir, prompt?, promptMarkdown?, title?, mock? }
725
+ // body (workspace): { workspaceId, prompt?, ... } — mutually exclusive with
726
+ // projectDir (§2.6). Single-project behavior is byte-identical.
727
+ // ---------------------------------------------------------------------------
728
+ app.post('/api/run', async (req, res) => {
729
+ try {
730
+ const body = req.body || {};
731
+
732
+ // Mutual exclusion: exactly one of workspaceId / projectDir (§2.6).
733
+ const hasWorkspace = typeof body.workspaceId === 'string' && body.workspaceId.trim();
734
+ const hasProjectDir = typeof body.projectDir === 'string' && body.projectDir.trim();
735
+ if (hasWorkspace && hasProjectDir) {
736
+ return badRequest(res, 'provide workspaceId OR projectDir, not both');
737
+ }
738
+ if (!hasWorkspace && !hasProjectDir) {
739
+ return badRequest(res, 'workspaceId or projectDir is required');
740
+ }
741
+
742
+ // ── Shared resolution (factored BEFORE the target branch, §2.6) ──────────
743
+ // NEW (plugins §7.3): body.source is the task-source descriptor; shape-check
744
+ // only and pass through — the orchestrator resolves it exactly once. Absent
745
+ // -> the legacy branch below runs byte-identical.
746
+ const sourceCheck = normalizeRunSource(body.source);
747
+ if (sourceCheck && !sourceCheck.ok) return badRequest(res, sourceCheck.error);
748
+ const source = sourceCheck ? sourceCheck.source : null;
749
+
750
+ // prompt OR promptMarkdown. promptMarkdown is treated as the prompt text.
751
+ const prompt = typeof body.prompt === 'string' && body.prompt.trim() ? body.prompt : undefined;
752
+ const promptMarkdown =
753
+ typeof body.promptMarkdown === 'string' && body.promptMarkdown.trim() ? body.promptMarkdown : undefined;
754
+ const effectivePrompt = prompt || promptMarkdown;
755
+ if (source && effectivePrompt) return badRequest(res, 'provide source OR prompt/promptMarkdown, not both');
756
+ if (!source && !effectivePrompt) return badRequest(res, 'prompt or promptMarkdown is required');
757
+
758
+ // UI Markdown runs carry provenance (spec §10): absent an explicit
759
+ // body.source, a promptMarkdown-only body maps to the markdown source type.
760
+ // prompt.md bytes and every legacy guard/message stay identical; only the
761
+ // new source_type column differs ('markdown' instead of the default).
762
+ const effectiveSource = source
763
+ || (promptMarkdown && !prompt ? { type: 'markdown', promptText: promptMarkdown } : null);
764
+
765
+ const mock = !!body.mock || isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK);
766
+
767
+ // Optional workflowId selects a saved (or built-in default) topology. The
768
+ // orchestrator resolves topology + per-project run-config into an executable
769
+ // plan at run start; here we only normalize + reject an unknown id up front
770
+ // so the client gets a clean 400 instead of a mid-run error event.
771
+ const workflowId =
772
+ typeof body.workflowId === 'string' && body.workflowId.trim() ? body.workflowId.trim() : 'wf_default';
773
+ if (!(await readWorkflow(workflowId))) return badRequest(res, `unknown workflowId "${workflowId}"`);
774
+
775
+ // Optional guardrailsId selects the named guardrail set that IS this run's
776
+ // policy (applied uniformly to every member — guardrails are per-run only).
777
+ // Absent/blank/null normalizes to 'permissive' — the empty policy,
778
+ // byte-identical legacy spawn — so pre-picker API/CLI callers keep today's
779
+ // behavior. A NON-STRING value is a caller bug and 400s (normalizing it
780
+ // away would silently drop the requested policy). Unknown ids 400 up
781
+ // front, like workflowId.
782
+ if (body.guardrailsId != null && typeof body.guardrailsId !== 'string') {
783
+ return badRequest(res, 'guardrailsId must be a string');
784
+ }
785
+ const guardrailsId =
786
+ typeof body.guardrailsId === 'string' && body.guardrailsId.trim() ? body.guardrailsId.trim() : 'permissive';
787
+ if (!(await readGuardrailSet(guardrailsId))) {
788
+ return badRequest(res, `unknown guardrailsId "${guardrailsId}"`);
789
+ }
790
+
791
+ // Budget gate: no new pipelines while the total window is spent (F6).
792
+ const budget = budgetStatus();
793
+ if (budget.blocked) {
794
+ return res.status(403).json({ error: 'total cost limit reached', budget });
795
+ }
796
+
797
+ const runId = randomUUID();
798
+ const title = (typeof body.title === 'string' && body.title.trim()) || fallbackRunTitle(effectivePrompt, source);
799
+
800
+ // Materialize any uploaded extra files to a temp dir; the orchestrator's
801
+ // createPipeline copies them into <pipeline>/extras/.
802
+ const extras = await writeExtras(runId, body.extras);
803
+
804
+ const branch = {
805
+ source: typeof body.sourceBranch === 'string' && body.sourceBranch.trim()
806
+ ? body.sourceBranch.trim() : null,
807
+ feature: typeof body.featureBranch === 'string' && body.featureBranch.trim()
808
+ ? body.featureBranch.trim() : null,
809
+ };
810
+
811
+ let orch, entry;
812
+
813
+ if (hasWorkspace) {
814
+ // ── Workspace target (§2.6) ────────────────────────────────────────────
815
+ const workspaceId = body.workspaceId.trim();
816
+ // A stale bookmark / crafted id reads as "not found", not "bad request".
817
+ if (!WORKSPACE_KEY_RE.test(workspaceId)) {
818
+ return res.status(404).json({ error: 'workspace not found' });
819
+ }
820
+ const ws = await readWorkspace(workspaceId);
821
+ if (!ws) return res.status(404).json({ error: 'workspace not found' });
822
+
823
+ // Resolve member detail. Each member must be an existing git repo (D3:
824
+ // per-project worktrees + checkpoints). A vanished member is a hard 400 —
825
+ // skip-missing is NOT allowed; a workspace run is defined over its full set.
826
+ // A member that exists but is no longer a git repo (its .git removed since
827
+ // creation, where createWorkspace enforced isGitRepo) is rejected the same
828
+ // way, so the client gets a clean 400 instead of a mid-run worktree error.
829
+ const projects = [];
830
+ for (const dir of ws.projectPaths) {
831
+ if (!fs.existsSync(dir)) {
832
+ return badRequest(res, 'workspace member path is missing');
833
+ }
834
+ if (!isGitRepo(dir)) {
835
+ return badRequest(res, `workspace member is not a git repository: ${dir}`);
836
+ }
837
+ projects.push({ projectDir: dir, projectKey: projectKey(dir), projectName: path.basename(dir) });
838
+ }
839
+ // Sort by projectKey (the canonical member order used everywhere);
840
+ // projects[0] is the primary (lowest projectKey).
841
+ projects.sort((a, b) => (a.projectKey < b.projectKey ? -1 : a.projectKey > b.projectKey ? 1 : 0));
842
+
843
+ // D2: sourceBranch/featureBranch are per-project DEFAULTS; do NOT
844
+ // pre-validate against any one repo (the orchestrator resolves each
845
+ // project's default via resolveDefaultBranch). This is the single
846
+ // intentional divergence from the single-project isValidSourceRef guard.
847
+ // Still reject option-injection (a leading dash) on sourceBranch.
848
+ if (branch.source && branch.source.startsWith('-')) {
849
+ return badRequest(res, `unknown or invalid sourceBranch: ${branch.source}`);
850
+ }
851
+ // Per-project source overrides { [projectKey]: branch }. Same injection guard.
852
+ const sourceByKey =
853
+ body.sourceBranchByKey && typeof body.sourceBranchByKey === 'object' && !Array.isArray(body.sourceBranchByKey)
854
+ ? body.sourceBranchByKey
855
+ : {};
856
+ const badOverride = firstInjectionSource(sourceByKey);
857
+ if (badOverride) {
858
+ return badRequest(res, `unknown or invalid sourceBranch: ${badOverride}`);
859
+ }
860
+
861
+ orch = createOrchestrator({
862
+ workspace: {
863
+ id: ws.id,
864
+ key: ws.id, // ws.id === workspaceKey(ws); routes artifacts to its store
865
+ name: ws.name,
866
+ description: ws.description,
867
+ projects: buildWorkspaceMembers(projects, branch, sourceByKey),
868
+ },
869
+ prompt: effectivePrompt,
870
+ ...(effectiveSource ? { source: effectiveSource } : {}),
871
+ title,
872
+ extras,
873
+ agentsDir: AGENTS_DIR,
874
+ workflowId,
875
+ guardrailsId,
876
+ branch,
877
+ claude: { permissionMode: 'acceptEdits', mock },
878
+ });
879
+
880
+ entry = {
881
+ id: runId,
882
+ orch,
883
+ projectDir: projects[0].projectDir, // primary, for back-compat readers
884
+ workspaceId: ws.id,
885
+ kind: 'workspace-run',
886
+ projectNames: projects.map((p) => p.projectName),
887
+ title,
888
+ status: 'starting',
889
+ startedAt: new Date().toISOString(),
890
+ events: [],
891
+ pendingQuestion: null,
892
+ };
893
+ } else {
894
+ // ── Single-project target (UNCHANGED) ──────────────────────────────────
895
+ const projectDir = resolveProjectDir(body.projectDir);
896
+ if (!projectDir) return badRequest(res, 'projectDir is required');
897
+
898
+ if (!fs.existsSync(projectDir)) {
899
+ try {
900
+ await fsp.mkdir(projectDir, { recursive: true });
901
+ } catch (err) {
902
+ return badRequest(res, `cannot create projectDir: ${err.message}`);
903
+ }
904
+ }
905
+
906
+ // M1: never hand an unvalidated sourceBranch to `git worktree add`. Reject a
907
+ // leading-dash (option injection) or unknown ref here so the client gets a
908
+ // clean 400 instead of a mid-run error event. featureBranch is sanitized
909
+ // downstream by sanitizeBranchName, so it needs no ref check.
910
+ if (branch.source && !(await isValidSourceRef(projectDir, branch.source))) {
911
+ return badRequest(res, `unknown or invalid sourceBranch: ${branch.source}`);
912
+ }
913
+
914
+ orch = createOrchestrator({
915
+ projectDir,
916
+ prompt: effectivePrompt,
917
+ ...(effectiveSource ? { source: effectiveSource } : {}),
918
+ title,
919
+ extras,
920
+ agentsDir: AGENTS_DIR,
921
+ workflowId,
922
+ guardrailsId,
923
+ branch,
924
+ claude: { permissionMode: 'acceptEdits', mock },
925
+ });
926
+
927
+ entry = {
928
+ id: runId,
929
+ orch,
930
+ projectDir,
931
+ kind: 'run',
932
+ title,
933
+ status: 'starting',
934
+ startedAt: new Date().toISOString(),
935
+ events: [],
936
+ pendingQuestion: null,
937
+ };
938
+ }
939
+
940
+ runs.set(runId, entry);
941
+ wireRun(entry);
942
+ announceRun(entry);
943
+
944
+ // Fire-and-forget; all progress is surfaced through events.
945
+ Promise.resolve()
946
+ .then(() => orch.run())
947
+ .catch((err) => {
948
+ const event = { runId, type: 'error', message: err && err.message ? err.message : String(err) };
949
+ entry.status = 'error';
950
+ entry.events.push(event);
951
+ broadcast(event);
952
+ });
953
+
954
+ res.json({ runId });
955
+ } catch (err) {
956
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
957
+ }
958
+ });
959
+
960
+ // ---------------------------------------------------------------------------
961
+ // Chat connectivity (chat-connectivity-design.md): persistent channel workers
962
+ // + inbound command router. Workers are dumb transports; every command
963
+ // resolves here against the runs Map / DB through chatActions.
964
+ // ---------------------------------------------------------------------------
965
+ // Lazy: worcaHome() must not resolve at import time (tests import the app
966
+ // before their temp WORCA_HOME hook runs); the file loads on first chat use.
967
+ let _chatContext = null;
968
+ const chatCtx = () => (_chatContext ??= createChatContext());
969
+ const chatContext = {
970
+ get: (k) => chatCtx().get(k),
971
+ set: (k, patch) => chatCtx().set(k, patch),
972
+ isMuted: (k) => chatCtx().isMuted(k),
973
+ incrementMuted: (k) => chatCtx().incrementMuted(k),
974
+ };
975
+
976
+ const chatActions = {
977
+ listRuns: () => summarizeRuns(),
978
+ runState: (runId) => { try { return runs.get(runId)?.orch?.getState() ?? null; } catch { return null; } },
979
+ pendingQuestion: (runId) => runs.get(runId)?.pendingQuestion ?? null,
980
+ answer: (runId, id, payload) => answerRun(runId, id, payload),
981
+ stop: (runId) => stopRun(runId),
982
+ pause: (runId) => pauseRun(runId),
983
+ // The long chain of budget/worktree/double-resume guards lives in resumeRun();
984
+ // call it in-process. (It used to be reached by POSTing to 127.0.0.1:PORT — a
985
+ // loopback self-fetch that breaks under WORCA_HOST and can hit another instance.)
986
+ resume: async (pipelineId) => {
987
+ try { return await resumeRun(pipelineId); }
988
+ catch (err) { return { ok: false, error: err?.body?.error || err?.message || String(err) }; }
989
+ },
990
+ // Chat reads only DB fields (id/title/status/cost/activeMs/pauseReason), so bound the
991
+ // rows in SQL and skip git enrichment — /status no longer spawns 2 git procs per pipeline.
992
+ history: async ({ limit = 50 } = {}) => (await listAllPipelines({ limit, lite: true })) || [],
993
+ listProjects: async () => (await listProjects()).map((p) => ({ name: p.name || path.basename(p.path || ''), path: p.path })),
994
+ };
995
+
996
+ const chatRouter = createCommandRouter({
997
+ actions: chatActions,
998
+ chatContext,
999
+ logger: (level, msg) => console.error(`[worca-ui] chat ${level}: ${msg}`),
1000
+ });
1001
+
1002
+ // Same-chat commands must run strictly in order: a batched ['/use beta','/runs']
1003
+ // from one getUpdates poll otherwise interleaves (stale reads, replies out of
1004
+ // order). One promise chain per chatKey; depth-capped (there is NO host-side
1005
+ // inbound bound — a Telegram poll can hand over 100 updates at once, and the
1006
+ // allowlist is only applied inside the router, after enqueue); the catch is
1007
+ // mandatory — nobody awaits this chain, so a rejected tail is an unhandled
1008
+ // rejection that kills the process under Node's default flag.
1009
+ const CHAT_QUEUE_MAX = 50; // per chat; a flood past this is dropped, not buffered
1010
+ const chatQueues = new Map(); // key -> { tail, depth }
1011
+ function enqueueChatWork(key, fn) {
1012
+ const q = chatQueues.get(key) || { tail: Promise.resolve(), depth: 0 };
1013
+ if (q.depth >= CHAT_QUEUE_MAX) {
1014
+ console.error(`[worca-ui] chat queue for ${key} is full (${CHAT_QUEUE_MAX}) — dropping inbound work`);
1015
+ return q.tail;
1016
+ }
1017
+ q.depth += 1;
1018
+ // prev.then(fn, fn): run even after a prior failure (fn takes no arguments,
1019
+ // so the previous error is discarded — do not give fn a parameter).
1020
+ const tail = q.tail.then(fn, fn).catch((err) => {
1021
+ console.error(`[worca-ui] chat work failed: ${err && err.message ? err.message : err}`);
1022
+ }).finally(() => {
1023
+ q.depth -= 1;
1024
+ if (chatQueues.get(key) === q && q.depth === 0) chatQueues.delete(key);
1025
+ });
1026
+ q.tail = tail;
1027
+ chatQueues.set(key, q);
1028
+ return tail;
1029
+ }
1030
+
1031
+ async function handleChatInbound({ plugin, channelId, platform, msg }) {
1032
+ const entry = channelHost.list().find((e) => e.plugin === plugin && e.channelId === channelId);
1033
+ if (!entry) return;
1034
+ let replyMsg;
1035
+ try {
1036
+ replyMsg = await chatRouter.handleIncoming({
1037
+ plugin, channelId, platform,
1038
+ channelConfig: readPluginConfig(plugin, entry.configSchema),
1039
+ msg,
1040
+ });
1041
+ } catch (err) {
1042
+ console.error(`[worca-ui] chat inbound failed: ${err && err.message ? err.message : err}`);
1043
+ return;
1044
+ }
1045
+ if (!replyMsg) return;
1046
+ try {
1047
+ await channelHost.sendMessage({ plugin, channelId, chatId: msg.chatId, message: replyMsg });
1048
+ } catch (err) {
1049
+ console.error(`[worca-ui] chat reply delivery failed: ${err && err.message ? err.message : err}`);
1050
+ }
1051
+ }
1052
+
1053
+ const channelHost = createChannelHost({
1054
+ logger: (level, msg) => console.error(`[worca-ui] ${msg}`),
1055
+ onInbound: (ev) => { enqueueChatWork(`${ev.platform}:${ev.msg.chatId}`, () => handleChatInbound(ev)); },
1056
+ onStatus: (ev) => { try { broadcast({ type: 'channel-status', ...ev }); } catch { /* pre-listen */ } },
1057
+ });
1058
+
1059
+ const chatNotifier = createNotifier({
1060
+ channelHost,
1061
+ getPrefs: chatPrefs,
1062
+ chatContext,
1063
+ logger: (level, msg) => console.error(`[worca-ui] chat ${level}: ${msg}`),
1064
+ });
1065
+
1066
+ /** Best-effort worker restart after any plugin mutation (enable/disable,
1067
+ * config save, install, update, uninstall). Never blocks the route. */
1068
+ function reloadChatWorkers(name) {
1069
+ channelHost.reloadPlugin(name).catch((err) => {
1070
+ console.error(`[worca-ui] chat worker reload failed for ${name}: ${err && err.message ? err.message : err}`);
1071
+ });
1072
+ }
1073
+
1074
+ // ---------------------------------------------------------------------------
1075
+ // Run control actions — ONE implementation shared by the HTTP routes and the
1076
+ // chat command router (chat-connectivity-design.md §4.6), so answering a gate
1077
+ // from Discord clears the question card in every browser tab exactly like the
1078
+ // UI button does (resolvePending is part of the action, not the route).
1079
+ // ---------------------------------------------------------------------------
1080
+ function answerRun(runId, id, payload) {
1081
+ const entry = runs.get(runId);
1082
+ if (!entry) throw new Error('unknown runId');
1083
+ entry.orch.answer(id, payload);
1084
+ resolvePending(entry, { id, reason: 'answered' });
1085
+ }
1086
+ function stopRun(runId) {
1087
+ const entry = runs.get(runId);
1088
+ if (!entry) throw new Error('unknown runId');
1089
+ entry.orch.stop();
1090
+ entry.status = 'stopped';
1091
+ resolvePending(entry, { reason: 'stopped' });
1092
+ }
1093
+ function pauseRun(runId) {
1094
+ const entry = runs.get(runId);
1095
+ if (!entry) throw new Error('unknown runId');
1096
+ const ok = typeof entry.orch?.pause === 'function' && entry.orch.pause();
1097
+ if (!ok) throw Object.assign(new Error('cannot pause in the current state'), { code: 'CANNOT_PAUSE' });
1098
+ entry.status = 'pausing';
1099
+ resolvePending(entry, { reason: 'paused' });
1100
+ }
1101
+
1102
+ // ---------------------------------------------------------------------------
1103
+ // POST /api/answer -> resolve a pending question for a run
1104
+ // body: { runId, id, payload }
1105
+ // ---------------------------------------------------------------------------
1106
+ app.post('/api/answer', (req, res) => {
1107
+ const { runId, id, payload } = req.body || {};
1108
+ if (!runId || !runs.has(runId)) return badRequest(res, 'unknown runId');
1109
+ if (!id) return badRequest(res, 'question id is required');
1110
+ try {
1111
+ answerRun(runId, id, payload);
1112
+ res.json({ ok: true });
1113
+ } catch (err) {
1114
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1115
+ }
1116
+ });
1117
+
1118
+ // ---------------------------------------------------------------------------
1119
+ // POST /api/stop -> abort a run
1120
+ // body: { runId }
1121
+ // ---------------------------------------------------------------------------
1122
+ app.post('/api/stop', (req, res) => {
1123
+ const { runId } = req.body || {};
1124
+ if (!runId || !runs.has(runId)) return badRequest(res, 'unknown runId');
1125
+ try {
1126
+ stopRun(runId);
1127
+ res.json({ ok: true });
1128
+ } catch (err) {
1129
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1130
+ }
1131
+ });
1132
+
1133
+ // ---------------------------------------------------------------------------
1134
+ // POST /api/pause { runId } — gracefully pause a LIVE run. The orchestrator kills
1135
+ // in-flight node children, persists a resume point, and lands on status 'paused'
1136
+ // (announced via the normal state/done events; wireRun mirrors entry.status).
1137
+ // ---------------------------------------------------------------------------
1138
+ app.post('/api/pause', (req, res) => {
1139
+ const { runId } = req.body || {};
1140
+ if (!runId || !runs.has(runId)) return badRequest(res, 'unknown runId');
1141
+ try {
1142
+ pauseRun(runId);
1143
+ res.json({ ok: true });
1144
+ } catch (err) {
1145
+ if (err?.code === 'CANNOT_PAUSE') return badRequest(res, err.message);
1146
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1147
+ }
1148
+ });
1149
+
1150
+ // ---------------------------------------------------------------------------
1151
+ // resumeRun(pipelineId, opts) — the resume guard chain + rehydration, callable
1152
+ // in-process. Chat used to reuse it by POSTing http://127.0.0.1:PORT/api/resume
1153
+ // over loopback, which breaks under WORCA_HOST (the server may not be bound on
1154
+ // 127.0.0.1 at all) and, worse, can land on a DIFFERENT worca instance that
1155
+ // happens to own the port. Both callers now share this one function; the route
1156
+ // is a thin mapper from ResumeError -> (status, body).
1157
+ // ---------------------------------------------------------------------------
1158
+ class ResumeError extends Error {
1159
+ constructor(status, body) {
1160
+ super(body.error || 'resume failed');
1161
+ this.status = status;
1162
+ this.body = body;
1163
+ }
1164
+ }
1165
+
1166
+ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {}) {
1167
+ if (!pipelineId || typeof pipelineId !== 'string') throw new ResumeError(400, { error: 'pipelineId is required' });
1168
+ const saved = readPipelineForResume(pipelineId);
1169
+ if (!saved) throw new ResumeError(404, { error: 'pipeline not found' });
1170
+ if (saved.row.status !== 'paused' && saved.row.status !== 'interrupted') throw new ResumeError(400, { error: `pipeline is "${saved.row.status}", not resumable` });
1171
+ if (!saved.resumePoint) throw new ResumeError(400, { error: 'pipeline has no resume point' });
1172
+
1173
+ if (saved.row.archived_at) {
1174
+ throw new ResumeError(409, { error: 'pipeline is archived' });
1175
+ }
1176
+ const budget = budgetStatus();
1177
+ if (budget.blocked) {
1178
+ throw new ResumeError(403, { error: 'total cost limit reached', budget });
1179
+ }
1180
+ // Override persists only once the (never-bypassable) total gate passes —
1181
+ // a total-refused request must not leave cost_cap_override armed.
1182
+ if (ignoreCostCap === true) {
1183
+ setCostCapOverride(pipelineId); // persistent per-pipeline override (F7)
1184
+ }
1185
+ const pipeCap = budget.pipelineLimitUsd;
1186
+ const spentSoFar = Number(saved.row.total_cost_usd || 0);
1187
+ if (pipeCap != null && spentSoFar >= pipeCap && !readCostCapOverride(pipelineId)) {
1188
+ throw new ResumeError(403, {
1189
+ error: 'pipeline cost limit reached', budget, needsOverride: true,
1190
+ });
1191
+ }
1192
+
1193
+ // Double-resume guard: any live entry already driving this pipeline id.
1194
+ for (const e of runs.values()) {
1195
+ if (e.pipelineId === pipelineId && !['done', 'stopped', 'error', 'paused', 'interrupted'].includes(String(e.status || ''))) {
1196
+ throw new ResumeError(400, { error: 'pipeline is already live' });
1197
+ }
1198
+ }
1199
+
1200
+ // Worktree(s) must still exist (single-project; workspace members are checked
1201
+ // inside orchestrator.resume(), which fails fast with the same message).
1202
+ const branch = saved.row.branch ? JSON.parse(saved.row.branch) : null;
1203
+ if (branch?.worktreeDir && !fs.existsSync(branch.worktreeDir)) {
1204
+ throw new ResumeError(400, { error: `worktree missing: ${branch.worktreeDir}` });
1205
+ }
1206
+
1207
+ // Resolve projectDir: workspace runs carry dirs in workspace_meta; single-project
1208
+ // runs map project_key back through the registry.
1209
+ let projectDir = null;
1210
+ let workspace;
1211
+ if (saved.row.target === 'workspace' && saved.row.workspace_meta) {
1212
+ const meta = JSON.parse(saved.row.workspace_meta);
1213
+ const projects = (meta.projects || []).map((p) => ({ ...p }));
1214
+ if (!projects.length) throw new ResumeError(400, { error: 'workspace metadata incomplete' });
1215
+ projectDir = projects[0].projectDir;
1216
+ workspace = {
1217
+ id: meta.workspaceId, key: saved.row.workspace_key, name: meta.workspaceName,
1218
+ description: meta.workspaceDescription || '', projects,
1219
+ };
1220
+ } else {
1221
+ for (const p of await listProjects()) {
1222
+ if (projectKey(p.path) === saved.row.project_key) { projectDir = p.path; break; }
1223
+ }
1224
+ if (!projectDir) throw new ResumeError(400, { error: 'project for this pipeline is not onboarded on this machine' });
1225
+ }
1226
+
1227
+ const effMock = mock || isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK);
1228
+ const runId = randomUUID();
1229
+ const orch = createOrchestrator({
1230
+ projectDir,
1231
+ ...(workspace ? { workspace } : {}),
1232
+ agentsDir: AGENTS_DIR,
1233
+ claude: { permissionMode: 'acceptEdits', mock: effMock },
1234
+ resume: saved,
1235
+ });
1236
+ const entry = {
1237
+ id: runId,
1238
+ orch,
1239
+ projectDir,
1240
+ ...(workspace
1241
+ ? {
1242
+ workspaceId: workspace.id,
1243
+ kind: 'workspace-run',
1244
+ projectNames: workspace.projects.map((p) => p.projectName || path.basename(p.projectDir || '')),
1245
+ }
1246
+ : { kind: 'run' }),
1247
+ title: saved.row.title,
1248
+ status: 'starting',
1249
+ startedAt: new Date().toISOString(),
1250
+ events: [],
1251
+ pendingQuestion: null,
1252
+ pipelineId,
1253
+ };
1254
+ runs.set(runId, entry);
1255
+ wireRun(entry);
1256
+ announceRun(entry);
1257
+
1258
+ // Evict the superseded paused/interrupted lineage for this pipeline. The old
1259
+ // entry is inert (paused), but summarizeRuns() broadcasts EVERY Map entry on
1260
+ // each hello — leaving it resurfaces the now-resumed (and possibly already
1261
+ // completed) pipeline as a phantom 'Paused' card in Running on reload/reconnect.
1262
+ for (const [id, e] of runs) {
1263
+ if (id !== runId && e.pipelineId === pipelineId &&
1264
+ (e.status === 'paused' || e.status === 'interrupted')) {
1265
+ runs.delete(id);
1266
+ }
1267
+ }
1268
+
1269
+ // Fire-and-forget; all progress is surfaced through events (same idiom as /api/run).
1270
+ Promise.resolve()
1271
+ .then(() => orch.resume())
1272
+ .catch((err) => {
1273
+ const event = { runId, type: 'error', message: err && err.message ? err.message : String(err) };
1274
+ entry.status = 'error';
1275
+ entry.events.push(event);
1276
+ broadcast(event);
1277
+ });
1278
+
1279
+ return { ok: true, runId, pipelineId };
1280
+ }
1281
+
1282
+ // ---------------------------------------------------------------------------
1283
+ // POST /api/resume { pipelineId } — rehydrate a paused pipeline from the DB (works
1284
+ // across server restarts) and continue it as a NEW live run entry with the SAME
1285
+ // pipeline id / history row.
1286
+ // ---------------------------------------------------------------------------
1287
+ app.post('/api/resume', async (req, res) => {
1288
+ try {
1289
+ const out = await resumeRun(req.body?.pipelineId, {
1290
+ ignoreCostCap: req.body?.ignoreCostCap === true,
1291
+ mock: !!(req.body && req.body.mock),
1292
+ });
1293
+ res.json(out);
1294
+ } catch (err) {
1295
+ if (err instanceof ResumeError) return res.status(err.status).json(err.body);
1296
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1297
+ }
1298
+ });
1299
+
1300
+ // ---------------------------------------------------------------------------
1301
+ // GET /api/runs?projectDir -> history of saved pipelines
1302
+ // GET /api/runs?workspaceId -> workspace-store pipelines + live workspace runs
1303
+ // ---------------------------------------------------------------------------
1304
+ app.get('/api/runs', async (req, res) => {
1305
+ // Workspace arm: when workspaceId is present (and projectDir absent), list the
1306
+ // workspace store's pipelines + live workspace runs (§2.7). A bad/unknown id
1307
+ // reads as not-found (404), matching the run-target + detail routes.
1308
+ const workspaceId = typeof req.query.workspaceId === 'string' ? req.query.workspaceId.trim() : '';
1309
+ if (workspaceId && !resolveProjectDir(req.query.projectDir)) {
1310
+ if (!WORKSPACE_KEY_RE.test(workspaceId)) return res.status(404).json({ error: 'workspace not found' });
1311
+ try {
1312
+ const ws = await readWorkspace(workspaceId);
1313
+ if (!ws) return res.status(404).json({ error: 'workspace not found' });
1314
+ const primaryDir = ws.projectPaths[0] || null;
1315
+ const pipelines = (await listWorkspacePipelines(ws.id, primaryDir, { withPr: true })) || [];
1316
+ const live = [...runs.values()]
1317
+ .filter((r) => r.workspaceId === ws.id)
1318
+ .map((r) => ({ id: r.pipelineId || r.id, runId: r.id, title: r.title, status: r.status, live: true }));
1319
+ return res.json({ pipelines, live, ghAvailable: await hasGh() });
1320
+ } catch (err) {
1321
+ return res.status(500).json({ error: err && err.message ? err.message : String(err) });
1322
+ }
1323
+ }
1324
+
1325
+ const projectDir = resolveProjectDir(req.query.projectDir);
1326
+ if (!projectDir) return badRequest(res, 'projectDir is required');
1327
+ try {
1328
+ const pipelines = (await Promise.resolve(listPipelines(projectDir, { withPr: true }))) || [];
1329
+ // Also expose any live (in-memory) runs for this project that may not yet
1330
+ // be on disk, so the UI history reflects an active run too.
1331
+ const live = [...runs.values()]
1332
+ .filter((r) => r.projectDir === projectDir)
1333
+ .map((r) => ({
1334
+ // Surface the on-disk pipeline id as `id` once createPipeline has run, so
1335
+ // renderHistory's dedup-by-id merges this entry with its disk twin. The
1336
+ // UUID stays on `runId` because WS / answer / stop route by runs-Map key.
1337
+ id: r.pipelineId || r.id,
1338
+ runId: r.id,
1339
+ title: r.title,
1340
+ status: r.status,
1341
+ live: true,
1342
+ }));
1343
+ res.json({ pipelines, live, ghAvailable: await hasGh() });
1344
+ } catch (err) {
1345
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1346
+ }
1347
+ });
1348
+
1349
+ // ---------------------------------------------------------------------------
1350
+ // GET /api/runs/:id?projectDir -> saved pipeline markdown + state
1351
+ // ---------------------------------------------------------------------------
1352
+ app.get('/api/runs/:id', async (req, res) => {
1353
+ const projectDir = resolveProjectDir(req.query.projectDir);
1354
+ if (!projectDir) return badRequest(res, 'projectDir is required');
1355
+ const id = req.params.id;
1356
+ try {
1357
+ const data = await Promise.resolve(readPipeline(projectDir, id));
1358
+ if (!data) return res.status(404).json({ error: 'pipeline not found' });
1359
+ res.json(data);
1360
+ } catch (err) {
1361
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1362
+ }
1363
+ });
1364
+
1365
+ // Shared query-scope resolver for the retained-work routes (recovery-patch GET +
1366
+ // discard POST). Returns null after writing the error response itself. The older
1367
+ // DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
1368
+ // imported projectKey(), derives no store key, and maps RETAINED_WORKTREE).
1369
+ function resolveRunScope(req, res) {
1370
+ const workspaceId = typeof req.query.workspaceId === 'string' ? req.query.workspaceId.trim() : '';
1371
+ const projectKey_ = typeof req.query.projectKey === 'string' ? req.query.projectKey.trim() : '';
1372
+ const projectDir = resolveProjectDir(req.query.projectDir);
1373
+ if (workspaceId && !WORKSPACE_KEY_RE.test(workspaceId)) {
1374
+ res.status(404).json({ error: 'pipeline not found' });
1375
+ return null;
1376
+ }
1377
+ if (projectKey_ && !/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(projectKey_)) {
1378
+ res.status(404).json({ error: 'pipeline not found' });
1379
+ return null;
1380
+ }
1381
+ if (!workspaceId && !projectKey_ && !projectDir) {
1382
+ badRequest(res, 'workspaceId, projectKey or projectDir is required');
1383
+ return null;
1384
+ }
1385
+ const key = workspaceId ? `workspaces/${workspaceId}` : (projectKey_ || projectKey(projectDir));
1386
+ return { workspaceId, projectKey: projectKey_, projectDir, key };
1387
+ }
1388
+
1389
+ // Download the durable done-path diff as an alternate recovery route for a
1390
+ // retained worktree. The filename is fixed; callers cannot supply a path.
1391
+ app.get('/api/runs/:id/recovery-patch', async (req, res) => {
1392
+ const id = req.params.id;
1393
+ const scope = resolveRunScope(req, res);
1394
+ if (!scope) return;
1395
+ const key = scope.key; // the body's readRunArtifactText(key, …) calls stay unchanged
1396
+ try {
1397
+ // Prefer a retained-work snapshot (any member's, incl. workspace-suffixed
1398
+ // names) over the done-path diff. Resolved through the artifacts INDEX, which
1399
+ // only gains a row on a SUCCESSFUL snapshot — a truncated or missing file can
1400
+ // never shadow the diff-patch fallback.
1401
+ const arts = await listArtifacts(id).catch(() => []);
1402
+ const retainedRel = arts.find((a) => a && a.kind === 'retained-work-patch')?.relPath || null;
1403
+ let filename = null;
1404
+ let patch = retainedRel == null ? null : await readRunArtifactText(key, id, retainedRel);
1405
+ if (patch != null && patch.length) {
1406
+ filename = `retained-work-${String(id).replace(/[^a-zA-Z0-9._-]/g, '-')}.patch`;
1407
+ } else {
1408
+ patch = await readRunArtifactText(key, id, DIFF_PATCH_FILE);
1409
+ filename = `diff-patch-${String(id).replace(/[^a-zA-Z0-9._-]/g, '-')}.patch`;
1410
+ }
1411
+ if (patch == null) return res.status(404).json({ error: 'recovery patch not found' });
1412
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
1413
+ res.type('text/x-diff').send(patch);
1414
+ } catch (err) {
1415
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1416
+ }
1417
+ });
1418
+
1419
+ // ---------------------------------------------------------------------------
1420
+ // POST /api/runs/:id/overview -> Layer-2 on-demand overview agent.
1421
+ // Accepts ?key=<storeKey> (preferred; history detail uses it) or ?projectDir=...
1422
+ // ?force=1 bypasses the cached overview.json. 200 { overview } | 404 | 500.
1423
+ // ---------------------------------------------------------------------------
1424
+ app.post('/api/runs/:id/overview', async (req, res) => {
1425
+ const id = req.params.id;
1426
+ let key = typeof req.query.key === 'string' ? req.query.key : null;
1427
+ if (!key) {
1428
+ const projectDir = resolveProjectDir(req.query.projectDir);
1429
+ if (!projectDir) return badRequest(res, 'key or projectDir is required');
1430
+ key = projectKey(projectDir);
1431
+ }
1432
+ const force = req.query.force === '1' || req.query.force === 'true';
1433
+ try {
1434
+ const overview = await generateOverview(key, id, { force });
1435
+ res.json({ overview });
1436
+ } catch (err) {
1437
+ const msg = err && err.message ? err.message : String(err);
1438
+ const code = msg === 'pipeline not found' ? 404 : 500;
1439
+ res.status(code).json({ error: msg });
1440
+ }
1441
+ });
1442
+
1443
+ // ---------------------------------------------------------------------------
1444
+ // GET /api/history -> machine-wide history across every onboarded project
1445
+ // ---------------------------------------------------------------------------
1446
+ app.get('/api/history', async (_req, res) => {
1447
+ try {
1448
+ // Self-heal records left 'running' by a dead process before listing, so History
1449
+ // never shows a phantom Running run and its Delete button appears (see
1450
+ // pipeline-delete ACTIVE / app.js isDeletableEntry — both allow 'interrupted').
1451
+ try { reconcileStaleRunning({ liveIds: liveRunIds() }); } catch { /* best-effort */ }
1452
+ // Phase 1: PR-light skeleton (no `gh pr list`). Live PR state is pushed
1453
+ // separately over the WS by POST /api/history/pr -> enrichPipelinesPr.
1454
+ res.json({ pipelines: (await listAllPipelines()) || [], ghAvailable: await hasGh() });
1455
+ } catch (err) {
1456
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1457
+ }
1458
+ });
1459
+
1460
+ // Lightweight sidebar-count snapshot. Three cheap COUNT(*) queries — deliberately NOT
1461
+ // the full list endpoints, so a navigation/refresh never pulls the (potentially large)
1462
+ // machine-wide history just to update a badge. Running is derived client-side from the
1463
+ // in-memory runs map (live via WS), so it is not included here. Synchronous: the three
1464
+ // helpers are sync getDb().prepare(...).get() calls.
1465
+ app.get('/api/counts', (_req, res) => {
1466
+ try {
1467
+ res.json({
1468
+ pipelines: countPipelines(),
1469
+ projects: countProjects(),
1470
+ workspaces: countWorkspaces(),
1471
+ });
1472
+ } catch (err) {
1473
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1474
+ }
1475
+ });
1476
+
1477
+ // ---------------------------------------------------------------------------
1478
+ // GET /api/stats?range=today|week|month|all -> the Statistics view payload (§6.9).
1479
+ // Pure DB reads; an unknown range is the caller's fault, so getStats' RangeError
1480
+ // maps to 400 while anything else keeps bubbling to the error handler.
1481
+ app.get('/api/stats', (req, res) => {
1482
+ try {
1483
+ const range = typeof req.query.range === 'string' && req.query.range ? req.query.range : 'month';
1484
+ res.json(getStats({ range }));
1485
+ } catch (err) {
1486
+ if (err instanceof RangeError) return badRequest(res, err.message);
1487
+ throw err;
1488
+ }
1489
+ });
1490
+
1491
+ // ---------------------------------------------------------------------------
1492
+ // POST /api/history/pr -> enrich the skeleton with live PR state, pushed back
1493
+ // over the WS as batched `history-pr` events (reuses broadcast(), the same
1494
+ // fire-to-every-socket primitive wireRun/wireScan use). The body's `token`
1495
+ // echoes the client's load token so it can drop stale batches after a newer
1496
+ // Refresh. Responds 200 immediately; results arrive asynchronously.
1497
+ // ---------------------------------------------------------------------------
1498
+ app.post('/api/history/pr', async (req, res) => {
1499
+ const token = Number(req.body && req.body.token) || 0;
1500
+ res.json({ ok: true }); // results arrive over WS
1501
+ try {
1502
+ await enrichPipelinesPr((items, done) =>
1503
+ broadcast({ type: 'history-pr', token, done, items }));
1504
+ } catch {
1505
+ broadcast({ type: 'history-pr', token, done: true, items: [] }); // always terminate the spinner
1506
+ }
1507
+ });
1508
+
1509
+ // ---------------------------------------------------------------------------
1510
+ // GET /api/history/:key/:id -> saved pipeline markdown + state, by store key
1511
+ // ---------------------------------------------------------------------------
1512
+ app.get('/api/history/:key/:id', async (req, res) => {
1513
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(req.params.key)) {
1514
+ return res.status(404).json({ error: 'pipeline not found' });
1515
+ }
1516
+ try {
1517
+ const data = await readPipelineByKey(req.params.key, req.params.id);
1518
+ if (!data) return res.status(404).json({ error: 'pipeline not found' });
1519
+ res.json(data);
1520
+ } catch (err) {
1521
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1522
+ }
1523
+ });
1524
+
1525
+ // ---------------------------------------------------------------------------
1526
+ // GET /api/history/:key/:id/log -> the run's persisted live-log NDJSON (text)
1527
+ // ---------------------------------------------------------------------------
1528
+ app.get('/api/history/:key/:id/log', async (req, res) => {
1529
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(req.params.key)) {
1530
+ return res.status(404).json({ error: 'pipeline not found' });
1531
+ }
1532
+ try {
1533
+ const text = await readRunLogText(req.params.key, req.params.id);
1534
+ if (text == null) return res.status(404).json({ error: 'no log' });
1535
+ res.type('application/x-ndjson').send(text);
1536
+ } catch (err) {
1537
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1538
+ }
1539
+ });
1540
+
1541
+ // ---------------------------------------------------------------------------
1542
+ // DELETE /api/runs/:id?projectKey=... (or ?projectDir=...)
1543
+ // ARCHIVE a FINISHED pipeline: reclaims everything on disk — its store folder,
1544
+ // its shared plan/review markdown, its artifacts index rows, and its local
1545
+ // branch + worktree — then soft-deletes the row (`archived_at`) instead of
1546
+ // dropping it, so its cost and outcome stay in Statistics forever. The row
1547
+ // disappears from History and every list/count read. The remote branch is never
1548
+ // touched. Refused (409) while the run is live in this process.
1549
+ // ---------------------------------------------------------------------------
1550
+ app.delete('/api/runs/:id', async (req, res) => {
1551
+ const id = req.params.id;
1552
+ const workspaceId = typeof req.query.workspaceId === 'string' ? req.query.workspaceId.trim() : '';
1553
+ const projectKey = typeof req.query.projectKey === 'string' ? req.query.projectKey.trim() : '';
1554
+ const projectDir = resolveProjectDir(req.query.projectDir);
1555
+ // A workspace pipeline routes to store/workspaces/<key>/; its id reads as
1556
+ // not-found when malformed (no path-traversal surface).
1557
+ if (workspaceId && !WORKSPACE_KEY_RE.test(workspaceId)) {
1558
+ return res.status(404).json({ error: 'pipeline not found' });
1559
+ }
1560
+ if (projectKey && !/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(projectKey)) {
1561
+ return res.status(404).json({ error: 'pipeline not found' });
1562
+ }
1563
+ if (!workspaceId && !projectKey && !projectDir) {
1564
+ return badRequest(res, 'workspaceId, projectKey or projectDir is required');
1565
+ }
1566
+
1567
+ // Never tear down a pipeline that is still live in this server process.
1568
+ const liveActive = [...runs.values()].some((r) =>
1569
+ (r.pipelineId === id || r.id === id) &&
1570
+ ['running', 'starting', 'created', 'pausing'].includes(String(r.status || '').toLowerCase()));
1571
+ if (liveActive) return res.status(409).json({ error: 'cannot delete a running pipeline' });
1572
+
1573
+ try {
1574
+ const report = await archivePipeline({
1575
+ workspaceKey: workspaceId || null,
1576
+ key: workspaceId ? null : (projectKey || null),
1577
+ projectDir: (workspaceId || projectKey) ? null : projectDir,
1578
+ id,
1579
+ });
1580
+ if (!report) return res.status(404).json({ error: 'pipeline not found' });
1581
+ emitChanged('pipelines-changed', 'deleted');
1582
+ res.json({ ok: true, ...report });
1583
+ } catch (e) {
1584
+ if (e && e.code === 'RUNNING') return res.status(409).json({ error: e.message });
1585
+ if (e && e.code === 'RETAINED_WORKTREE') return res.status(409).json({ error: e.message });
1586
+ if (e && e.code === 'BAD_REQUEST') return badRequest(res, e.message);
1587
+ res.status(500).json({ error: e && e.message ? e.message : String(e) });
1588
+ }
1589
+ });
1590
+
1591
+ // Reclaim only worktrees retained after a teardown commit failure. Unlike
1592
+ // Archive, this keeps the pipeline in History and saves recovery patches first.
1593
+ app.post('/api/runs/:id/discard-worktree', async (req, res) => {
1594
+ const id = req.params.id;
1595
+ const scope = resolveRunScope(req, res);
1596
+ if (!scope) return;
1597
+ const liveActive = [...runs.values()].some((r) =>
1598
+ (r.pipelineId === id || r.id === id) &&
1599
+ ['running', 'starting', 'created', 'pausing'].includes(String(r.status || '').toLowerCase()));
1600
+ if (liveActive) return res.status(409).json({ error: 'cannot discard a running pipeline worktree' });
1601
+
1602
+ try {
1603
+ const report = await discardRetainedWorktrees({
1604
+ workspaceKey: scope.workspaceId || null,
1605
+ key: scope.workspaceId ? null : (scope.projectKey || null),
1606
+ projectDir: (scope.workspaceId || scope.projectKey) ? null : scope.projectDir,
1607
+ id,
1608
+ });
1609
+ if (!report) return res.status(404).json({ error: 'pipeline not found' });
1610
+ emitChanged('pipelines-changed', 'updated');
1611
+ res.json({ ok: true, ...report });
1612
+ } catch (e) {
1613
+ if (e && e.code === 'RUNNING') return res.status(409).json({ error: e.message });
1614
+ if (e && e.code === 'SNAPSHOT_FAILED') return res.status(409).json({ error: e.message });
1615
+ if (e && e.code === 'BAD_REQUEST') return badRequest(res, e.message);
1616
+ res.status(500).json({ error: e && e.message ? e.message : String(e) });
1617
+ }
1618
+ });
1619
+
1620
+ // ---------------------------------------------------------------------------
1621
+ // POST /api/pr -> push the pipeline's feature branch (if needed) and open a PR
1622
+ // against its source branch via the GitHub CLI. Mergeability is read back only
1623
+ // here (never during list rendering). body: { id, projectDir? , projectKey? }
1624
+ // ---------------------------------------------------------------------------
1625
+ app.post('/api/pr', async (req, res) => {
1626
+ const body = req.body || {};
1627
+ const id = typeof body.id === 'string' ? body.id.trim() : '';
1628
+ if (!id) return badRequest(res, 'id is required');
1629
+ if (!(await hasGh())) {
1630
+ return res.status(409).json({ error: 'GitHub CLI (gh) is not available' });
1631
+ }
1632
+
1633
+ // Resolve the pipeline state (by store key, else by project dir).
1634
+ let state = null;
1635
+ try {
1636
+ if (typeof body.projectKey === 'string' && body.projectKey.trim()) {
1637
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(body.projectKey)) {
1638
+ return res.status(404).json({ error: 'pipeline not found' });
1639
+ }
1640
+ const data = await readPipelineByKey(body.projectKey, id);
1641
+ state = data && data.state;
1642
+ } else {
1643
+ const projectDir = resolveProjectDir(body.projectDir);
1644
+ if (!projectDir) return badRequest(res, 'projectDir or projectKey is required');
1645
+ const data = await readPipeline(projectDir, id);
1646
+ state = data && data.state;
1647
+ }
1648
+ } catch (err) {
1649
+ return res.status(500).json({ error: err && err.message ? err.message : String(err) });
1650
+ }
1651
+ if (!state) return res.status(404).json({ error: 'pipeline not found' });
1652
+
1653
+ const repoDir = state.projectDir;
1654
+ const feature = state.branch && state.branch.feature;
1655
+ const source = state.branch && state.branch.source;
1656
+ if (!repoDir || !feature || !source) {
1657
+ return badRequest(res, 'pipeline has no branch info to open a PR');
1658
+ }
1659
+
1660
+ // Push (idempotent) -> create PR -> read mergeability. All args are passed as
1661
+ // an argv array (no shell), so branch/source names cannot inject.
1662
+ const pushed = await pushBranch(repoDir, feature);
1663
+ if (!pushed.ok) return res.status(500).json({ error: `git push failed: ${pushed.stderr}` });
1664
+
1665
+ const pr = await createPr({ projectDir: repoDir, base: source, head: feature, title: state.title || feature });
1666
+ if (!pr.ok) return res.status(500).json({ error: `gh pr create failed: ${pr.error}` });
1667
+
1668
+ // Persist the PR facts we just learned, so History/stats survive a gh outage.
1669
+ const parsePrNumber = (u) => Number((/\/pull\/(\d+)/.exec(u) || [])[1]) || null;
1670
+ const pipelineIdForPr = state?.id || id; // prefer the canonical state id
1671
+ if (pipelineIdForPr) {
1672
+ persistPrState(pipelineIdForPr, { url: pr.url, number: parsePrNumber(pr.url), state: 'OPEN' });
1673
+ }
1674
+
1675
+ const mergeable = await prMergeable({ projectDir: repoDir, head: feature });
1676
+ res.json({ ok: true, url: pr.url, mergeable, existed: !!pr.existed });
1677
+ });
1678
+
1679
+ // ---------------------------------------------------------------------------
1680
+ // POST /api/pr/mergeable -> re-read mergeability for a pipeline's PR head so the
1681
+ // History UI can refresh the "merge: checking…" pill after GitHub finishes its
1682
+ // async computation. Read-only + best-effort: no push, no create — just
1683
+ // `gh pr view`. Missing `id` is the ONLY hard error (400, like /api/pr); every
1684
+ // other failure (gh missing, unresolvable pipeline, bad key, thrown error)
1685
+ // resolves to UNKNOWN (200) so the client simply hides the pill.
1686
+ // body: { id, projectKey? , projectDir? }
1687
+ // ---------------------------------------------------------------------------
1688
+ app.post('/api/pr/mergeable', async (req, res) => {
1689
+ const body = req.body || {};
1690
+ const id = typeof body.id === 'string' ? body.id.trim() : '';
1691
+ if (!id) return badRequest(res, 'id is required');
1692
+ if (!(await hasGh())) return res.json({ ok: true, mergeable: 'UNKNOWN' });
1693
+
1694
+ try {
1695
+ // Resolve the pipeline state (by store key, else by project dir) — mirrors /api/pr.
1696
+ let state = null;
1697
+ if (typeof body.projectKey === 'string' && body.projectKey.trim()) {
1698
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(body.projectKey)) {
1699
+ return res.json({ ok: true, mergeable: 'UNKNOWN' });
1700
+ }
1701
+ const data = await readPipelineByKey(body.projectKey, id);
1702
+ state = data && data.state;
1703
+ } else {
1704
+ const projectDir = resolveProjectDir(body.projectDir);
1705
+ if (!projectDir) return badRequest(res, 'projectDir or projectKey is required');
1706
+ const data = await readPipeline(projectDir, id);
1707
+ state = data && data.state;
1708
+ }
1709
+
1710
+ const repoDir = state && state.projectDir;
1711
+ const feature = state && state.branch && state.branch.feature;
1712
+ if (!repoDir || !feature) return res.json({ ok: true, mergeable: 'UNKNOWN' });
1713
+
1714
+ const mergeable = await prMergeable({ projectDir: repoDir, head: feature });
1715
+ res.json({ ok: true, mergeable });
1716
+ } catch {
1717
+ res.json({ ok: true, mergeable: 'UNKNOWN' }); // best-effort: never error the refresh
1718
+ }
1719
+ });
1720
+
1721
+ // ---------------------------------------------------------------------------
1722
+ // POST /api/install -> copy agents + skill into <projectDir>/.claude
1723
+ // body: { projectDir }
1724
+ // ---------------------------------------------------------------------------
1725
+ app.post('/api/install', async (req, res) => {
1726
+ const projectDir = resolveProjectDir((req.body || {}).projectDir);
1727
+ if (!projectDir) return badRequest(res, 'projectDir is required');
1728
+ try {
1729
+ const result = await installAgents(projectDir);
1730
+ res.json(result);
1731
+ } catch (err) {
1732
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1733
+ }
1734
+ });
1735
+
1736
+ // ---------------------------------------------------------------------------
1737
+ // Project registry: GET list / POST add / DELETE remove. Thin delegation to
1738
+ // src/core/projects.mjs (which owns validation + persistence).
1739
+ // ---------------------------------------------------------------------------
1740
+ app.get('/api/branches', async (req, res) => {
1741
+ const projectDir = resolveProjectDir(req.query.projectDir);
1742
+ if (!projectDir) return badRequest(res, 'projectDir is required');
1743
+ try {
1744
+ const [branches, current] = await Promise.all([
1745
+ listLocalBranches(projectDir),
1746
+ currentBranch(projectDir),
1747
+ ]);
1748
+ res.json({ branches, current });
1749
+ } catch (err) {
1750
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1751
+ }
1752
+ });
1753
+
1754
+ app.get('/api/projects', async (_req, res) => {
1755
+ try {
1756
+ res.json({ projects: await listProjects() });
1757
+ } catch (err) {
1758
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1759
+ }
1760
+ });
1761
+
1762
+ app.post('/api/projects', async (req, res) => {
1763
+ const body = req.body || {};
1764
+ try {
1765
+ const projects = await addProject({ name: body.name, path: body.path });
1766
+ emitChanged('projects-changed', 'created');
1767
+ res.json({ projects });
1768
+ } catch (err) {
1769
+ // addProject only throws on validation (empty/duplicate/not-a-directory), so
1770
+ // a thrown error here is a client error -> 400. (A rare write-time I/O error
1771
+ // would also surface as 400; acceptable for this single-user local tool.)
1772
+ return badRequest(res, err && err.message ? err.message : String(err));
1773
+ }
1774
+ });
1775
+
1776
+ app.delete('/api/projects', async (req, res) => {
1777
+ const name = typeof req.query.name === 'string' ? req.query.name : '';
1778
+ if (!name.trim()) return badRequest(res, 'name is required');
1779
+ try {
1780
+ const projects = await removeProject(name);
1781
+ emitChanged('projects-changed', 'deleted');
1782
+ res.json({ projects });
1783
+ } catch (err) {
1784
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1785
+ }
1786
+ });
1787
+
1788
+ // ---------------------------------------------------------------------------
1789
+ // Filesystem browsing for the add-project folder selector. Hybrid picker:
1790
+ // POST /api/fs/pick-folder opens the native OS dialog (the server runs on the
1791
+ // user's machine); when it reports `unsupported` the UI falls back to an
1792
+ // in-app modal fed by GET /api/fs/dirs. Localhost-only like every route here
1793
+ // (global isLocalRequest middleware).
1794
+ app.post('/api/fs/pick-folder', async (_req, res) => {
1795
+ try {
1796
+ res.json(await pickFolderNative());
1797
+ } catch (err) {
1798
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1799
+ }
1800
+ });
1801
+
1802
+ app.get('/api/fs/dirs', async (req, res) => {
1803
+ try {
1804
+ res.json(await listFolders(typeof req.query.path === 'string' ? req.query.path : ''));
1805
+ } catch (err) {
1806
+ if (err && err.code === 'BAD_REQUEST') return badRequest(res, err.message);
1807
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1808
+ }
1809
+ });
1810
+
1811
+ // ---------------------------------------------------------------------------
1812
+ // Workspace registry: a named set of 2+ onboarded git repos with one editable
1813
+ // interconnection description. Thin delegation to src/core/workspaces.mjs (which
1814
+ // owns validation + persistence); the route maps err.code -> HTTP exactly like
1815
+ // /api/projects + /api/workflows. The :id is the workspaceKey, validated against
1816
+ // WORKSPACE_KEY_RE before any disk touch (a stale/crafted id reads as 404).
1817
+ // ---------------------------------------------------------------------------
1818
+ app.get('/api/workspaces', async (_req, res) => {
1819
+ try {
1820
+ res.json({ workspaces: await listWorkspaces() });
1821
+ } catch (err) {
1822
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1823
+ }
1824
+ });
1825
+
1826
+ app.get('/api/workspaces/:id', async (req, res) => {
1827
+ const id = req.params.id;
1828
+ if (!WORKSPACE_KEY_RE.test(id)) return res.status(404).json({ error: 'workspace not found' });
1829
+ try {
1830
+ const workspace = await readWorkspace(id);
1831
+ if (!workspace) return res.status(404).json({ error: 'workspace not found' });
1832
+ res.json({ workspace });
1833
+ } catch (err) {
1834
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1835
+ }
1836
+ });
1837
+
1838
+ app.post('/api/workspaces', async (req, res) => {
1839
+ const body = req.body || {};
1840
+ // Normalize member paths through the same single source of truth as /api/run;
1841
+ // createWorkspace re-normalizes + de-dupes by canonical root, but a fast <2
1842
+ // reject here matches the spec's defense-in-depth (§2.3).
1843
+ const projectPaths = Array.isArray(body.projectPaths)
1844
+ ? body.projectPaths.map((p) => resolveProjectDir(p)).filter(Boolean)
1845
+ : [];
1846
+ if (projectPaths.length < 2) return badRequest(res, 'a workspace needs at least 2 member projects');
1847
+ try {
1848
+ const workspace = await createWorkspace({ name: body.name, projectPaths, description: body.description });
1849
+ emitChanged('workspaces-changed', 'created');
1850
+ res.status(201).json({ workspace });
1851
+ } catch (err) {
1852
+ const status = workspaceErrorStatus(err && err.code);
1853
+ return res.status(status).json({ error: err && err.message ? err.message : String(err) });
1854
+ }
1855
+ });
1856
+
1857
+ app.patch('/api/workspaces/:id', async (req, res) => {
1858
+ const id = req.params.id;
1859
+ if (!WORKSPACE_KEY_RE.test(id)) return res.status(404).json({ error: 'workspace not found' });
1860
+ const body = req.body || {};
1861
+ // Immutability (defense-in-depth, §2.3): the project set never changes via PATCH.
1862
+ if ('projectPaths' in body || 'projectKeys' in body) {
1863
+ return badRequest(res, 'a workspace project set is immutable; PATCH accepts only name/description');
1864
+ }
1865
+ // Pass through only the editable fields.
1866
+ const patch = {};
1867
+ if (typeof body.name === 'string') patch.name = body.name;
1868
+ if (typeof body.description === 'string') patch.description = body.description;
1869
+ try {
1870
+ const workspace = await updateWorkspace(id, patch);
1871
+ res.json({ workspace });
1872
+ } catch (err) {
1873
+ const status = workspaceErrorStatus(err && err.code);
1874
+ return res.status(status).json({ error: err && err.message ? err.message : String(err) });
1875
+ }
1876
+ });
1877
+
1878
+ app.delete('/api/workspaces/:id', async (req, res) => {
1879
+ const id = req.params.id;
1880
+ if (!WORKSPACE_KEY_RE.test(id)) return res.status(404).json({ error: 'workspace not found' });
1881
+ // 409 while a live workspace run OR live scan for this workspace exists. The
1882
+ // module-level deleteWorkspace has no runs map, so this guard lives here (§2.3).
1883
+ const live = [...runs.values()].some((r) =>
1884
+ r.workspaceId === id &&
1885
+ ['running', 'starting', 'created', 'scanning', 'pausing'].includes(String(r.status || '').toLowerCase()));
1886
+ if (live) return res.status(409).json({ error: 'cannot delete a workspace with a live run or scan' });
1887
+ try {
1888
+ const report = await deleteWorkspace(id);
1889
+ emitChanged('workspaces-changed', 'deleted');
1890
+ res.json({ ok: true, warnings: (report && report.warnings) || [] });
1891
+ } catch (err) {
1892
+ const status = workspaceErrorStatus(err && err.code);
1893
+ return res.status(status).json({ error: err && err.message ? err.message : String(err) });
1894
+ }
1895
+ });
1896
+
1897
+ // ---------------------------------------------------------------------------
1898
+ // Scan endpoints (the wizard's backend, §2.4 / §5.4). Both fire-and-forget:
1899
+ // mint scanId, register a kind:'scan' entry in the SAME runs Map, wire its
1900
+ // scan-* events, start createWorkspaceScan(...).run() detached, return {scanId}.
1901
+ // The scan NEVER persists workspaces.json — persistence is the wizard's explicit
1902
+ // follow-up CRUD call (POST create / PATCH re-scan).
1903
+ // ---------------------------------------------------------------------------
1904
+
1905
+ /**
1906
+ * Shared launcher for both scan routes (DRY, §2.4). Mints scanId, registers the
1907
+ * entry, wires events, starts the engine detached with a .catch backstop that
1908
+ * converts an unexpected throw into a broadcast scan-error (status 'error') so
1909
+ * the process never crashes on a fire-and-forget scan.
1910
+ * @param {{projectPaths:string[], name?:string, workspaceId?:string}} args
1911
+ * @returns {string} scanId
1912
+ */
1913
+ function startScan({ projectPaths, name, workspaceId }) {
1914
+ const orch = createWorkspaceScan({
1915
+ projectPaths,
1916
+ name,
1917
+ agentsDir: AGENTS_DIR,
1918
+ claude: { permissionMode: 'acceptEdits', mock: isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK) },
1919
+ });
1920
+ // The engine mints its own scanId (scan_<uuid>) and tags every emitted event
1921
+ // with it; use THAT as the runs-Map key + the returned id so the entry, its
1922
+ // buffered events, and WS reconnect/replay (?scanId=) all agree on one id.
1923
+ const scanId = orch.getState().scanId;
1924
+ const entry = {
1925
+ id: scanId,
1926
+ scanId,
1927
+ orch,
1928
+ kind: 'scan',
1929
+ projectDir: (Array.isArray(projectPaths) && projectPaths[0]) || null,
1930
+ workspaceId: workspaceId || null,
1931
+ title: name || 'workspace scan',
1932
+ status: 'scanning',
1933
+ startedAt: new Date().toISOString(),
1934
+ events: [],
1935
+ pendingQuestion: null,
1936
+ };
1937
+ runs.set(scanId, entry);
1938
+ wireScan(entry);
1939
+
1940
+ Promise.resolve()
1941
+ .then(() => orch.run())
1942
+ .catch((err) => {
1943
+ // run() should never throw (it emits scan-error), but a defensive backstop
1944
+ // mirrors POST /api/run: surface an unexpected throw as a tagged scan-error.
1945
+ const event = { scanId, type: 'scan-error', message: err && err.message ? err.message : String(err) };
1946
+ entry.status = 'error';
1947
+ entry.events.push(event);
1948
+ broadcast(event);
1949
+ });
1950
+
1951
+ return scanId;
1952
+ }
1953
+
1954
+ // POST /api/workspaces/scan (pre-persist, Step 2->3). Takes projectPaths directly:
1955
+ // validate >=2 paths + fs.existsSync each + reject non-git-repos (400); the deep
1956
+ // git work happens inside the engine.
1957
+ app.post('/api/workspaces/scan', async (req, res) => {
1958
+ try {
1959
+ const body = req.body || {};
1960
+ const projectPaths = Array.isArray(body.projectPaths)
1961
+ ? body.projectPaths.map((p) => resolveProjectDir(p)).filter(Boolean)
1962
+ : [];
1963
+ if (projectPaths.length < 2) return badRequest(res, 'a workspace scan needs at least 2 member projects');
1964
+ for (const dir of projectPaths) {
1965
+ if (!fs.existsSync(dir)) return badRequest(res, `member path is missing: ${dir}`);
1966
+ if (!isGitRepo(dir)) return badRequest(res, `member is not a git repository: ${dir}`);
1967
+ }
1968
+ const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined;
1969
+ const scanId = startScan({ projectPaths, name });
1970
+ res.json({ scanId });
1971
+ } catch (err) {
1972
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1973
+ }
1974
+ });
1975
+
1976
+ // POST /api/workspaces/:id/scan (re-scan). Reads the workspace (404 if absent),
1977
+ // scans ws.projectPaths, tags the entry with workspaceId. 409 if a live run for
1978
+ // that workspace already exists (avoid graphify-build contention).
1979
+ app.post('/api/workspaces/:id/scan', async (req, res) => {
1980
+ const id = req.params.id;
1981
+ if (!WORKSPACE_KEY_RE.test(id)) return res.status(404).json({ error: 'workspace not found' });
1982
+ try {
1983
+ const ws = await readWorkspace(id);
1984
+ if (!ws) return res.status(404).json({ error: 'workspace not found' });
1985
+ const liveRun = [...runs.values()].some((r) =>
1986
+ r.workspaceId === id && r.kind === 'workspace-run' &&
1987
+ ['running', 'starting', 'created'].includes(String(r.status || '').toLowerCase()));
1988
+ if (liveRun) return res.status(409).json({ error: 'a live run exists for this workspace' });
1989
+ const scanId = startScan({ projectPaths: ws.projectPaths, name: ws.name, workspaceId: ws.id });
1990
+ res.json({ scanId });
1991
+ } catch (err) {
1992
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1993
+ }
1994
+ });
1995
+
1996
+ // POST /api/scan/stop body:{scanId} -> entry.orch.stop() (aborts in-flight
1997
+ // investigators + best-effort scan-worktree/branch cleanup in the engine's
1998
+ // finally, D4); marks the entry 'stopped'. Idempotent: an unknown/finished scan
1999
+ // still returns ok.
2000
+ app.post('/api/scan/stop', (req, res) => {
2001
+ const scanId = req.body && typeof req.body.scanId === 'string' ? req.body.scanId : '';
2002
+ const entry = scanId ? runs.get(scanId) : null;
2003
+ if (entry && entry.kind === 'scan' && entry.orch && typeof entry.orch.stop === 'function') {
2004
+ try { entry.orch.stop(); } catch { /* best-effort */ }
2005
+ entry.status = 'stopped';
2006
+ }
2007
+ res.json({ ok: true });
2008
+ });
2009
+
2010
+ // ---------------------------------------------------------------------------
2011
+ // GET /api/workspaces/:id/runs/:runId -> persisted state + markdown for a
2012
+ // finished workspace run. The /api/history/:key/:id key regex forbids a slash,
2013
+ // so a workspace run (store key "workspaces/<key>") needs this dedicated route.
2014
+ // readWorkspacePipeline joins ONLY workspaceStorePath(validatedKey) -> no
2015
+ // path-traversal surface; do NOT widen the history :key regex (§2.7).
2016
+ // ---------------------------------------------------------------------------
2017
+ app.get('/api/workspaces/:id/runs/:runId', async (req, res) => {
2018
+ const id = req.params.id;
2019
+ if (!WORKSPACE_KEY_RE.test(id)) return res.status(404).json({ error: 'pipeline not found' });
2020
+ try {
2021
+ const data = await readWorkspacePipeline(id, req.params.runId);
2022
+ if (!data) return res.status(404).json({ error: 'pipeline not found' });
2023
+ res.json(data);
2024
+ } catch (err) {
2025
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2026
+ }
2027
+ });
2028
+
2029
+ app.get('/api/workspaces/:id/runs/:runId/log', async (req, res) => {
2030
+ if (!WORKSPACE_KEY_RE.test(req.params.id)) {
2031
+ return res.status(404).json({ error: 'pipeline not found' });
2032
+ }
2033
+ try {
2034
+ const text = await readRunLogText(`workspaces/${req.params.id}`, req.params.runId);
2035
+ if (text == null) return res.status(404).json({ error: 'no log' });
2036
+ res.type('application/x-ndjson').send(text);
2037
+ } catch (err) {
2038
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2039
+ }
2040
+ });
2041
+
2042
+ // ---------------------------------------------------------------------------
2043
+ // GET /api/settings -> { root, projectsRoot, projectsRootDefault, default }
2044
+ // root : the configured Worca CC data-root base, '' when unset
2045
+ // (the `default` field is what applies then).
2046
+ // projectsRoot : the RAW persisted projectsRoot (§5.1), '' when unset —
2047
+ // the same raw contract as `root`, NOT the effective
2048
+ // value. Two reasons this is not getProjectsRoot():
2049
+ // (a) an effective value is never '', so "unset" would be
2050
+ // indistinguishable from "explicitly set" and the UI's
2051
+ // "leave blank" affordance would be unreachable;
2052
+ // (b) WORCA_PROJECTS_ROOT would be echoed as if stored,
2053
+ // and the next Save would promote that env override into
2054
+ // settings.json. Runs still resolve via getProjectsRoot().
2055
+ // projectsRootDefault : what applies when projectsRoot is blank — the env tier
2056
+ // when exported, else defaultRoot(). The UI placeholder.
2057
+ // Additive; `default` keeps its `root` meaning.
2058
+ // POST /api/settings -> set either key and return the resulting full state.
2059
+ // Only keys PRESENT in the body are written, so a projectsRoot-only POST can
2060
+ // never reset `root` (and vice versa). An explicitly empty value still resets
2061
+ // that one key. A body with neither key keeps today's contract: it resets root.
2062
+ // The other settings keys (runRootMode, the two context caps, skillMount) are
2063
+ // settings-file-only in this change and deliberately not exposed here.
2064
+ // Validation lives in src/core/settings.mjs; this is thin delegation mirroring
2065
+ // /api/projects.
2066
+ // ---------------------------------------------------------------------------
2067
+ const settingsState = () => ({
2068
+ root: getWorcaRoot(), projectsRoot: rawProjectsRoot(),
2069
+ projectsRootDefault: defaultProjectsRoot(), default: defaultRoot(),
2070
+ pipelineCostLimitUsd: pipelineCostLimitUsd(),
2071
+ totalCostLimitUsd: totalCostLimitUsd(),
2072
+ costLimitResetPeriod: costLimitResetPeriod(),
2073
+ });
2074
+
2075
+ app.get('/api/settings', (_req, res) => {
2076
+ res.json({ ...settingsState(), chat: chatPrefs() });
2077
+ });
2078
+
2079
+ app.get('/api/budget', (_req, res) => {
2080
+ res.json(budgetStatus());
2081
+ });
2082
+
2083
+ app.post('/api/settings', async (req, res) => {
2084
+ const body = req.body || {};
2085
+ const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
2086
+ const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
2087
+ // Normalize the budget keys first, then validate them as a SET before ANY write.
2088
+ // Each setter persists on its own, so a two-key POST whose second key is invalid
2089
+ // used to answer 400 with the first key already on disk, no budget-changed
2090
+ // emitted, and a client (which early-returns on !res.ok) still painting its
2091
+ // pre-save values over a half-applied settings file.
2092
+ const budget = {};
2093
+ if (has('pipelineCostLimitUsd')) budget.pipelineCostLimitUsd = body.pipelineCostLimitUsd ?? '';
2094
+ if (has('totalCostLimitUsd')) budget.totalCostLimitUsd = body.totalCostLimitUsd ?? '';
2095
+ if (has('costLimitResetPeriod')) {
2096
+ budget.costLimitResetPeriod = typeof body.costLimitResetPeriod === 'string' ? body.costLimitResetPeriod : '';
2097
+ }
2098
+ try {
2099
+ assertCostLimitInputs(budget);
2100
+ if (has('chat')) await setChatPrefs(body.chat);
2101
+ if (has('projectsRoot')) {
2102
+ await setProjectsRoot(typeof body.projectsRoot === 'string' ? body.projectsRoot : '');
2103
+ }
2104
+ if (has('pipelineCostLimitUsd')) await setPipelineCostLimitUsd(budget.pipelineCostLimitUsd);
2105
+ if (has('totalCostLimitUsd')) await setTotalCostLimitUsd(budget.totalCostLimitUsd);
2106
+ if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
2107
+ // Legacy contract: a POST that names no known key clears root. Budget keys
2108
+ // must not trip it — a budget-only save would otherwise wipe the root.
2109
+ if (has('root') || !(has('projectsRoot') || hasBudgetKey || has('chat'))) {
2110
+ await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
2111
+ }
2112
+ if (hasBudgetKey) emitChanged('budget-changed');
2113
+ res.json({ ...settingsState(), chat: chatPrefs() });
2114
+ } catch (err) {
2115
+ // The setters throw only on an unusable path -> client error (400).
2116
+ return badRequest(res, err && err.message ? err.message : String(err));
2117
+ }
2118
+ });
2119
+
2120
+ // ---------------------------------------------------------------------------
2121
+ // Per-project model/effort config + custom-model registry. Validation lives in
2122
+ // src/core/config.mjs; these routes are thin delegation (mirror /api/projects).
2123
+ // ---------------------------------------------------------------------------
2124
+ app.get('/api/config', async (req, res) => {
2125
+ const raw = req.query.projectDir;
2126
+ // No project selected yet (e.g. a fresh clone): still return the catalog so
2127
+ // the picker is never empty. The project-less catalog is predefined ⊕ GLOBAL
2128
+ // entries (the global catalog is project-independent by design §4.2); only
2129
+ // legacy per-project custom models need a projectDir.
2130
+ if (raw == null || raw === '') {
2131
+ return res.json({
2132
+ config: { steps: {}, customModels: [] },
2133
+ models: await listModels(''), steps: agentSteps(), efforts: EFFORTS,
2134
+ });
2135
+ }
2136
+ const projectDir = resolveProjectDir(raw);
2137
+ if (!projectDir) return badRequest(res, 'projectDir is required');
2138
+ try {
2139
+ // readRunConfig returns the full per-project config: legacy steps/customModels
2140
+ // PLUS the run-config workflows{} (node model/effort, feedback cycles) and
2141
+ // activeWorkflowId. It is a superset of readConfig, so the client keeps using
2142
+ // config.steps unchanged while gaining config.workflows / config.activeWorkflowId.
2143
+ // NOTE: readRunConfig forwards unknown extra keys verbatim — a project
2144
+ // configured under the REMOVED per-project guardrails model may still show
2145
+ // its raw legacy blob under config.guardrails. It is inert: nothing
2146
+ // interprets it (guardrails are selected per run via /api/guardrails).
2147
+ const [config, models] = await Promise.all([
2148
+ readRunConfig(projectDir), listModels(projectDir),
2149
+ ]);
2150
+ res.json({
2151
+ config, models, steps: agentSteps(), efforts: EFFORTS,
2152
+ });
2153
+ } catch (err) {
2154
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2155
+ }
2156
+ });
2157
+
2158
+ app.post('/api/config', async (req, res) => {
2159
+ const body = req.body || {};
2160
+ const projectDir = resolveProjectDir(body.projectDir);
2161
+ if (!projectDir) return badRequest(res, 'projectDir is required');
2162
+ try {
2163
+ await setStep(projectDir, body.step, {
2164
+ model: body.model, effort: body.effort, fanOut: body.fanOut, askQuestions: body.askQuestions,
2165
+ });
2166
+ // Respond with the FULL run-config (mirrors PATCH): setStep's return value is
2167
+ // the legacy {steps, customModels} view only, and clients assign the response
2168
+ // to their whole config state — echoing the narrow view dropped workflows/
2169
+ // activeWorkflowId and made saved node models paint as unconfigured.
2170
+ const config = await readRunConfig(projectDir);
2171
+ res.json({ config });
2172
+ } catch (err) {
2173
+ // setStep throws only on validation (unknown step/model/effort) -> client error.
2174
+ return badRequest(res, err && err.message ? err.message : String(err));
2175
+ }
2176
+ });
2177
+
2178
+ // ---------------------------------------------------------------------------
2179
+ // PATCH /api/config -> write run-config: per-node model/effort, per-feedback
2180
+ // cycle counts, and the active workflow id. Keyed by workflowId + node/feedback
2181
+ // instance ids (see RunConfig in the design). Legacy per-role `steps` are
2182
+ // written via POST /api/config and are left untouched here. setNodeModel now
2183
+ // validates model/effort against the effective catalog exactly like setStep
2184
+ // (configurable-models-design.md §4.5) -> 400; setFeedbackCycles still COERCES
2185
+ // maxCycles to >= 1 (it never throws).
2186
+ // body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, activeWorkflowId? }
2187
+ // ---------------------------------------------------------------------------
2188
+ app.patch('/api/config', async (req, res) => {
2189
+ const body = req.body || {};
2190
+ const projectDir = resolveProjectDir(body.projectDir);
2191
+ if (!projectDir) return badRequest(res, 'projectDir is required');
2192
+ const workflowId = typeof body.workflowId === 'string' ? body.workflowId.trim() : '';
2193
+ try {
2194
+ if (body.nodes && typeof body.nodes === 'object') {
2195
+ if (!workflowId) return badRequest(res, 'workflowId is required to set node config');
2196
+ for (const [nodeId, sel] of Object.entries(body.nodes)) {
2197
+ await setNodeModel(projectDir, workflowId, nodeId, {
2198
+ model: sel && sel.model, effort: sel && sel.effort,
2199
+ fanOut: sel && sel.fanOut, askQuestions: sel && sel.askQuestions,
2200
+ });
2201
+ }
2202
+ }
2203
+ if (body.feedbacks && typeof body.feedbacks === 'object') {
2204
+ if (!workflowId) return badRequest(res, 'workflowId is required to set feedback config');
2205
+ for (const [fbId, sel] of Object.entries(body.feedbacks)) {
2206
+ await setFeedbackCycles(projectDir, workflowId, fbId, sel && sel.maxCycles);
2207
+ }
2208
+ }
2209
+ if (typeof body.activeWorkflowId === 'string' && body.activeWorkflowId.trim()) {
2210
+ await setActiveWorkflow(projectDir, body.activeWorkflowId.trim());
2211
+ }
2212
+ const config = await readRunConfig(projectDir);
2213
+ res.json({ config });
2214
+ } catch (err) {
2215
+ // The config.mjs setters throw only on validation (unknown model/effort,
2216
+ // maxCycles < 1) -> client error, mirroring POST /api/config.
2217
+ return badRequest(res, err && err.message ? err.message : String(err));
2218
+ }
2219
+ });
2220
+
2221
+ // ---------------------------------------------------------------------------
2222
+ // DELETE /api/config/workflow -> "Reset to defaults" for one workflow in one
2223
+ // project (newpipeline-ux-design.md §4.5). Drops every per-node/per-feedback
2224
+ // override (and, for wf_default, the legacy per-role steps) so the accordion
2225
+ // falls back to the workflow's defaults + the agent registry. Idempotent:
2226
+ // resetting an already-clean project is a no-op 200.
2227
+ // query: ?projectDir=&workflowId=
2228
+ // ---------------------------------------------------------------------------
2229
+ app.delete('/api/config/workflow', async (req, res) => {
2230
+ const projectDir = resolveProjectDir(req.query.projectDir);
2231
+ if (!projectDir) return badRequest(res, 'projectDir is required');
2232
+ const workflowId = typeof req.query.workflowId === 'string' ? req.query.workflowId.trim() : '';
2233
+ if (!workflowId) return badRequest(res, 'workflowId is required');
2234
+ try {
2235
+ await resetWorkflowConfig(projectDir, workflowId);
2236
+ res.json({ config: await readRunConfig(projectDir) });
2237
+ } catch (err) {
2238
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2239
+ }
2240
+ });
2241
+
2242
+ // POST /api/config/models (the per-project ADD) is deliberately GONE: new
2243
+ // models are added to the GLOBAL catalog via POST /api/models (design §4.9 —
2244
+ // the add flow moves entirely to the global Models view). DELETE stays so
2245
+ // legacy per-project entries can still be cleaned up.
2246
+ app.delete('/api/config/models', async (req, res) => {
2247
+ const projectDir = resolveProjectDir(req.query.projectDir);
2248
+ if (!projectDir) return badRequest(res, 'projectDir is required');
2249
+ const id = typeof req.query.id === 'string' ? req.query.id : '';
2250
+ if (!id.trim()) return badRequest(res, 'id is required');
2251
+ try {
2252
+ const config = await removeCustomModel(projectDir, id);
2253
+ res.json({ config, models: await listModels(projectDir) });
2254
+ } catch (err) {
2255
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2256
+ }
2257
+ });
2258
+
2259
+ // ---------------------------------------------------------------------------
2260
+ // Global model catalog (configurable-models-design.md §4.10). Project-less by
2261
+ // design — the catalog lives in ~/.worca-cc/settings.json (settings.mjs) and
2262
+ // applies to every project. Env VALUES are secrets-adjacent: responses carry
2263
+ // them MASKED (write-only editing; a whole-value ${VAR} ref is config, not a
2264
+ // secret, and passes through readable), and a PATCH that echoes a masked value
2265
+ // back means "keep" and is dropped from the write.
2266
+ // ---------------------------------------------------------------------------
2267
+
2268
+ const maskEnvValue = (v) =>
2269
+ (modelEnvRef(v) ? v : (v.length > 8 ? `••••••${v.slice(-4)}` : '••••••'));
2270
+ const maskedGlobalModel = (m) => (m.env
2271
+ ? { ...m, env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) }
2272
+ : m);
2273
+ const isMaskedEcho = (v) => typeof v === 'string' && v.startsWith('••');
2274
+ const maskedGlobalModels = () => {
2275
+ const flagged = costUnreliableModelIds(); // §4.6 observed flag, merged for the editor's badge
2276
+ return listGlobalModels().map((m) => ({
2277
+ ...maskedGlobalModel(m),
2278
+ ...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
2279
+ }));
2280
+ };
2281
+
2282
+ /** Read-only plugin model entries (design §9.7): literals masked with the
2283
+ * standard masker, ${VAR} refs readable, {secret} placeholders surfaced as
2284
+ * display markers with their set-ness. */
2285
+ const pluginModelsPayload = () => {
2286
+ const flagged = costUnreliableModelIds();
2287
+ const statusByPlugin = new Map();
2288
+ return listPluginModels().map((m) => {
2289
+ if (!statusByPlugin.has(m.plugin)) statusByPlugin.set(m.plugin, pluginModelSecretStatus(m.plugin));
2290
+ const status = statusByPlugin.get(m.plugin);
2291
+ return {
2292
+ id: m.id, label: m.label, efforts: m.efforts, plugin: m.plugin,
2293
+ env: Object.fromEntries(Object.entries(m.env ?? {}).map(([k, v]) => [
2294
+ k, typeof v === 'string' ? maskEnvValue(v) : `(secret: ${v.secret})`,
2295
+ ])),
2296
+ secrets: status.filter((s) => m.secrets.includes(s.key)),
2297
+ ...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
2298
+ };
2299
+ });
2300
+ };
2301
+
2302
+ app.get('/api/models', (req, res) => {
2303
+ res.json({ models: maskedGlobalModels(), plugin: pluginModelsPayload(), predefined: PREDEFINED_MODELS, efforts: EFFORTS });
2304
+ });
2305
+
2306
+ app.post('/api/models', async (req, res) => {
2307
+ const b = req.body || {};
2308
+ try {
2309
+ const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env });
2310
+ res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
2311
+ } catch (err) {
2312
+ // addGlobalModel throws only on validation (empty/dup id, unknown effort,
2313
+ // reserved env key, non-string env value) -> client error.
2314
+ return badRequest(res, err && err.message ? err.message : String(err));
2315
+ }
2316
+ });
2317
+
2318
+ // Promote a legacy per-project custom model to the global catalog (§4.9).
2319
+ // Refs survive by construction — see promoteCustomModel. Registered before the
2320
+ // :id routes only for readability; POST /api/models/promote shares no method
2321
+ // with them, so there is no capture conflict.
2322
+ app.post('/api/models/promote', async (req, res) => {
2323
+ const b = req.body || {};
2324
+ const projectDir = resolveProjectDir(b.projectDir);
2325
+ if (!projectDir) return badRequest(res, 'projectDir is required');
2326
+ try {
2327
+ const config = await promoteCustomModel(projectDir, b.id);
2328
+ res.json({ config, models: maskedGlobalModels() });
2329
+ } catch (err) {
2330
+ // Throws only on validation (unknown project model) -> client error.
2331
+ return badRequest(res, err && err.message ? err.message : String(err));
2332
+ }
2333
+ });
2334
+
2335
+ // Export selected global models as a plugin scaffold (design §9.5). Body:
2336
+ // { name, description?, version?, dest, models: [{ id, env: {KEY: mode} }] }
2337
+ // with mode 'include' (stored value verbatim — literal or ${VAR} ref text),
2338
+ // 'secret' (strip the value; declare a modelSecrets placeholder the importer
2339
+ // fills at install), or 'omit'. Reads RAW env values server-side — same trust
2340
+ // boundary as GET /api/models/:id/env-value (the user's own settings.json,
2341
+ // deliberate action). Distribution is git-only: the scaffold folder is what
2342
+ // gets pushed; no zip.
2343
+ app.post('/api/models/export-plugin', async (req, res) => {
2344
+ const b = req.body || {};
2345
+ const name = typeof b.name === 'string' ? b.name.trim() : '';
2346
+ if (!MANIFEST_PLUGIN_NAME_RE.test(name) || name.length > 64) {
2347
+ return badRequest(res, 'name must be kebab-case (e.g. "discretestack-models")');
2348
+ }
2349
+ const picks = Array.isArray(b.models) ? b.models : [];
2350
+ if (!picks.length) return badRequest(res, 'models must be a non-empty array');
2351
+ const destRaw = typeof b.dest === 'string' ? b.dest.trim() : '';
2352
+ if (!destRaw) return badRequest(res, 'dest is required');
2353
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
2354
+ const dest = path.resolve(destRaw.startsWith('~') ? path.join(home, destRaw.slice(1)) : destRaw);
2355
+ try {
2356
+ if (fs.existsSync(dest)) {
2357
+ if (!fs.statSync(dest).isDirectory()) return badRequest(res, 'dest exists and is not a directory');
2358
+ if (fs.readdirSync(dest).length) return badRequest(res, 'dest folder is not empty');
2359
+ }
2360
+ } catch (err) {
2361
+ return badRequest(res, `dest is not usable: ${err.message}`);
2362
+ }
2363
+
2364
+ const globals = listGlobalModels();
2365
+ const secretKeyFor = (envKey) => envKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2366
+ const models = [];
2367
+ const modelSecrets = new Map(); // secret key -> { key, label }
2368
+ for (const pick of picks) {
2369
+ const id = pick && typeof pick.id === 'string' ? pick.id.trim() : '';
2370
+ const entry = globals.find((m) => m.id.toLowerCase() === id.toLowerCase());
2371
+ if (!entry) return badRequest(res, `unknown global model id ${JSON.stringify(id)}`);
2372
+ const modes = pick.env && typeof pick.env === 'object' && !Array.isArray(pick.env) ? pick.env : {};
2373
+ const env = {};
2374
+ for (const [k, mode] of Object.entries(modes)) {
2375
+ if (!entry.env || !(k in entry.env)) return badRequest(res, `model ${JSON.stringify(entry.id)} has no env key ${JSON.stringify(k)}`);
2376
+ if (mode === 'omit') continue;
2377
+ if (mode === 'include') { env[k] = entry.env[k]; continue; }
2378
+ if (mode === 'secret') {
2379
+ const skey = secretKeyFor(k);
2380
+ if (!skey) return badRequest(res, `cannot derive a secret key from ${JSON.stringify(k)}`);
2381
+ if (!modelSecrets.has(skey)) modelSecrets.set(skey, { key: skey, label: k });
2382
+ env[k] = { secret: skey };
2383
+ continue;
2384
+ }
2385
+ return badRequest(res, `env mode for ${JSON.stringify(k)} must be include | secret | omit`);
2386
+ }
2387
+ models.push({
2388
+ id: entry.id,
2389
+ ...(entry.label !== entry.id ? { label: entry.label } : {}),
2390
+ ...(entry.efforts.length && entry.efforts.length !== EFFORTS.length ? { efforts: entry.efforts } : {}),
2391
+ ...(Object.keys(env).length ? { env } : {}),
2392
+ });
2393
+ }
2394
+
2395
+ const manifest = {
2396
+ name,
2397
+ ...(typeof b.version === 'string' && b.version.trim() ? { version: b.version.trim() } : { version: '0.1.0' }),
2398
+ ...(typeof b.description === 'string' && b.description.trim() ? { description: b.description.trim() } : {}),
2399
+ models,
2400
+ ...(modelSecrets.size ? { modelSecrets: [...modelSecrets.values()] } : {}),
2401
+ };
2402
+ // Belt: the scaffold must install anywhere this host would — validate before writing.
2403
+ const norm = normalizeManifest(manifest);
2404
+ if (!norm.ok) return badRequest(res, `generated manifest is invalid: ${norm.errors.join('; ')}`);
2405
+
2406
+ const readme = [
2407
+ `# ${name}`,
2408
+ '',
2409
+ manifest.description || 'Worca CC model plugin.',
2410
+ '',
2411
+ '## Models',
2412
+ '',
2413
+ ...models.map((m) => `- \`${m.id}\`${m.label ? ` — ${m.label}` : ''}`),
2414
+ ...(modelSecrets.size ? [
2415
+ '',
2416
+ '## Secrets requested at install',
2417
+ '',
2418
+ ...[...modelSecrets.values()].map((s) => `- \`${s.key}\` (${s.label})`),
2419
+ '',
2420
+ 'Teammates set these under the plugin\'s **Model secrets** after installing;',
2421
+ 'values live in their local `data/secrets.json` (0600) and never in this repo.',
2422
+ ] : []),
2423
+ '',
2424
+ '## Publish',
2425
+ '',
2426
+ '```sh',
2427
+ `cd ${dest}`,
2428
+ 'git init -b main && git add -A && git commit -m "model plugin"',
2429
+ 'git remote add origin <your-team-repo-url> && git push -u origin main',
2430
+ '```',
2431
+ '',
2432
+ '## Install (teammates)',
2433
+ '',
2434
+ 'Worca CC → Plugins → Add repo → paste the repo URL → Install.',
2435
+ 'Model secrets are prompted in the plugin\'s configuration panel.',
2436
+ '',
2437
+ ].join('\n');
2438
+
2439
+ try {
2440
+ fs.mkdirSync(dest, { recursive: true });
2441
+ fs.writeFileSync(path.join(dest, 'worca-cc-plugin.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
2442
+ fs.writeFileSync(path.join(dest, 'README.md'), readme, 'utf8');
2443
+ } catch (err) {
2444
+ return res.status(500).json({ error: `could not write the scaffold: ${err.message}` });
2445
+ }
2446
+ res.json({
2447
+ ok: true, dir: dest, files: ['worca-cc-plugin.json', 'README.md'],
2448
+ modelSecrets: [...modelSecrets.values()],
2449
+ });
2450
+ });
2451
+
2452
+ app.patch('/api/models/:id', async (req, res) => {
2453
+ const b = req.body || {};
2454
+ // Write-only env: strip masked echoes (unchanged values a client sent back)
2455
+ // so they read as "keep", never as a literal '••…' secret. env: null still
2456
+ // means "clear the whole map" and passes through untouched.
2457
+ let env = b.env;
2458
+ if (env && typeof env === 'object' && !Array.isArray(env)) {
2459
+ env = Object.fromEntries(Object.entries(env).filter(([, v]) => !isMaskedEcho(v)));
2460
+ }
2461
+ try {
2462
+ const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env });
2463
+ res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
2464
+ } catch (err) {
2465
+ // updateGlobalModel throws only on validation (unknown id, unknown effort,
2466
+ // reserved env key) -> client error.
2467
+ return badRequest(res, err && err.message ? err.message : String(err));
2468
+ }
2469
+ });
2470
+
2471
+ // Preview what deleting a global entry would clear (feeds the confirmation
2472
+ // dialog; design §4.5). Unknown ids just report empty refs — preview never 400s.
2473
+ app.get('/api/models/:id/refs', (req, res) => {
2474
+ res.json(globalModelRefs(req.params.id));
2475
+ });
2476
+
2477
+ // Reveal raw env value(s) for the editor's copy button and Show-values toggle.
2478
+ // The default GET surface stays masked (accidental exposure in screenshots/
2479
+ // devtools); this is a deliberate read of what the user already owns on disk
2480
+ // in ~/.worca-cc/settings.json — same trust boundary, explicit action.
2481
+ // ?key=K -> { key, value }; no key -> { env } (the whole raw map).
2482
+ app.get('/api/models/:id/env-value', (req, res) => {
2483
+ const entry = listGlobalModels().find((m) => m.id.toLowerCase() === String(req.params.id).toLowerCase());
2484
+ if (!entry) return badRequest(res, `unknown model id ${JSON.stringify(req.params.id)}`);
2485
+ const key = typeof req.query.key === 'string' ? req.query.key : '';
2486
+ if (!key) return res.json({ env: { ...(entry.env || {}) } });
2487
+ if (!entry.env || !(key in entry.env)) return badRequest(res, `model has no env key ${JSON.stringify(key)}`);
2488
+ res.json({ key, value: entry.env[key] });
2489
+ });
2490
+
2491
+ app.delete('/api/models/:id', async (req, res) => {
2492
+ try {
2493
+ const result = await removeGlobalModelAndRefs(req.params.id);
2494
+ res.json({ ...result, models: maskedGlobalModels() });
2495
+ } catch (err) {
2496
+ // Throws only on an unknown id -> client error.
2497
+ return badRequest(res, err && err.message ? err.message : String(err));
2498
+ }
2499
+ });
2500
+
2501
+ // ---------------------------------------------------------------------------
2502
+ // Workflow templates (global store at ~/.worca-cc/workflows). Topology only;
2503
+ // model/effort/cycles live in per-project run-config. CRUD mirrors the
2504
+ // /api/projects + /api/config delegation pattern: thin handlers, validation and
2505
+ // atomic persistence owned by src/core/workflows.mjs + workflow-validator.mjs.
2506
+ // ---------------------------------------------------------------------------
2507
+ // Validate one node-defaults block against the project-less catalog. Returns an
2508
+ // error message, or '' when the block is acceptable. Mirrors setStep's rules so a
2509
+ // workflow default can never name something a per-project override could not.
2510
+ function nodeDefaultsError(raw, models, where) {
2511
+ if (raw == null) return '';
2512
+ if (typeof raw !== 'object' || Array.isArray(raw)) return `defaults for ${where} must be an object`;
2513
+ const model = typeof raw.model === 'string' ? raw.model.trim() : '';
2514
+ const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
2515
+ const entry = model ? models.find((m) => m.id === model) : null;
2516
+ if (model && !entry) return `unknown model "${model}"`;
2517
+ if (!effort) return '';
2518
+ if (!EFFORTS.includes(effort)) return `unknown effort "${effort}"`;
2519
+ if (!entry) return 'select a model before choosing an effort';
2520
+ if (!entry.efforts.includes(effort)) return `model "${model}" does not support effort "${effort}"`;
2521
+ return '';
2522
+ }
2523
+
2524
+ app.get('/api/workflows', async (_req, res) => {
2525
+ try {
2526
+ // The built-in default is never persisted to the user store; callers
2527
+ // prepend it (CONTRACT: GET -> { workflows: [DEFAULT_WORKFLOW, ...listWorkflows()] }).
2528
+ res.json({ workflows: [DEFAULT_WORKFLOW, ...(await listWorkflows())] }); // CONV-1: await
2529
+ } catch (err) {
2530
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2531
+ }
2532
+ });
2533
+
2534
+ app.get('/api/workflows/:id', async (req, res) => {
2535
+ try {
2536
+ const tpl = await readWorkflow(req.params.id); // CONV-1: await; returns DEFAULT_WORKFLOW for "wf_default"
2537
+ if (!tpl) return res.status(404).json({ error: 'workflow not found' });
2538
+ res.json(tpl);
2539
+ } catch (err) {
2540
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2541
+ }
2542
+ });
2543
+
2544
+ app.post('/api/workflows', async (req, res) => {
2545
+ const body = req.body || {};
2546
+ // Build the candidate template from the editor payload (topology only).
2547
+ const tpl = {
2548
+ name: typeof body.name === 'string' ? body.name.trim() : '',
2549
+ domain: typeof body.domain === 'string' ? body.domain : undefined, // writeWorkflow normDomain → 'general' if absent/blank/malformed
2550
+ steps: Array.isArray(body.steps) ? body.steps : [],
2551
+ feedbacks: Array.isArray(body.feedbacks) ? body.feedbacks : [],
2552
+ };
2553
+ if (!tpl.name) return badRequest(res, 'name is required');
2554
+ try {
2555
+ // Per-node defaults ride along in steps (§4.4); hold them to the same catalog
2556
+ // rules as PATCH .../defaults so an imported template cannot smuggle in a
2557
+ // model id that no per-project override would be allowed to name.
2558
+ const models = await listModels('');
2559
+ for (const group of tpl.steps) {
2560
+ for (const node of Array.isArray(group) ? group : []) {
2561
+ if (!node || typeof node !== 'object' || node.defaults === undefined) continue;
2562
+ const err = nodeDefaultsError(node.defaults, models, `node "${node.id}"`);
2563
+ if (err) return badRequest(res, err);
2564
+ }
2565
+ }
2566
+ const registry = loadAgentRegistry(AGENTS_DIR);
2567
+ const { ok, errors, warnings } = validateWorkflow(tpl, registry);
2568
+ if (!ok) return res.status(400).json({ error: 'invalid workflow', errors, warnings });
2569
+ // writeWorkflow stamps id/createdAt/updatedAt and writes atomically (temp+rename).
2570
+ const workflow = await writeWorkflow(tpl); // CONV-1: await
2571
+ res.status(201).json({ workflow, warnings });
2572
+ } catch (err) {
2573
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2574
+ }
2575
+ });
2576
+
2577
+ // ---------------------------------------------------------------------------
2578
+ // PATCH /api/workflows/:id/defaults -> set the template's per-node defaults
2579
+ // (newpipeline-ux-design.md §4.4). body: { defaults: { [nodeId]: {model?, effort?,
2580
+ // fanOut?, askQuestions?} | null } }; null (or an empty block) clears a node, an
2581
+ // absent node keeps what it has. Model/effort validate against the PROJECT-LESS
2582
+ // catalog (predefined ⊕ global ⊕ plugin) — defaults are global, so a legacy
2583
+ // per-project custom model is deliberately not a valid default.
2584
+ // ---------------------------------------------------------------------------
2585
+ app.patch('/api/workflows/:id/defaults', async (req, res) => {
2586
+ const body = req.body || {};
2587
+ const map = body.defaults;
2588
+ if (!map || typeof map !== 'object' || Array.isArray(map)) {
2589
+ return badRequest(res, 'defaults must be an object keyed by node id');
2590
+ }
2591
+ try {
2592
+ const models = await listModels('');
2593
+ for (const [nodeId, raw] of Object.entries(map)) {
2594
+ const err = nodeDefaultsError(raw, models, `node "${nodeId}"`);
2595
+ if (err) return badRequest(res, err);
2596
+ }
2597
+ const workflow = await setWorkflowNodeDefaults(req.params.id, map);
2598
+ res.json({ workflow, defaults: workflowNodeDefaults(workflow) });
2599
+ } catch (err) {
2600
+ const message = err && err.message ? err.message : String(err);
2601
+ // "workflow not found" is a 404; the frozen-default refusal and any shape
2602
+ // complaint are caller errors — nothing here is a server fault.
2603
+ if (/not found/i.test(message)) return res.status(404).json({ error: message });
2604
+ return badRequest(res, message);
2605
+ }
2606
+ });
2607
+
2608
+ app.delete('/api/workflows/:id', async (req, res) => {
2609
+ const id = req.params.id;
2610
+ // The built-in default is not in the user store and must never be deleted.
2611
+ if (id === 'wf_default') return badRequest(res, 'the default workflow cannot be deleted');
2612
+ try {
2613
+ const removed = await deleteWorkflow(id); // CONV-1: await
2614
+ if (!removed) return res.status(404).json({ error: 'workflow not found' });
2615
+ res.json({ ok: true });
2616
+ } catch (err) {
2617
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2618
+ }
2619
+ });
2620
+
2621
+ // ---------------------------------------------------------------------------
2622
+ // Guardrail sets (global store, table guardrail_sets). The built-ins
2623
+ // Permissive / Normal / Strict are VIRTUAL (GUARDRAIL_PRESETS) — the server
2624
+ // prepends them, they are never persisted (CONTRACT mirrors /api/workflows:
2625
+ // GET -> { guardrails: [...listBuiltinGuardrailSets(), ...listGuardrailSets()] }).
2626
+ // Thin handlers: persistence in guardrail-store.mjs, 400s from validateGuardrails.
2627
+ // DELETE maps the store's ReferencedError -> 409 { error, references }
2628
+ // (structural match — the sendPluginError pattern; references are paused runs
2629
+ // whose resume_point pins the set).
2630
+ // ---------------------------------------------------------------------------
2631
+ function sendGuardrailError(res, err) {
2632
+ const message = err && err.message ? err.message : String(err);
2633
+ if (err && (err.name === 'ReferencedError' || err.code === 'REFERENCED')) {
2634
+ return res.status(409).json({ error: message, references: err.references || [] });
2635
+ }
2636
+ res.status(500).json({ error: message });
2637
+ }
2638
+
2639
+ app.get('/api/guardrails', async (_req, res) => {
2640
+ try {
2641
+ const sets = await listGuardrailSets(); // CONV-1: await
2642
+ res.json({ guardrails: [...listBuiltinGuardrailSets(), ...sets] });
2643
+ } catch (err) {
2644
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2645
+ }
2646
+ });
2647
+
2648
+ app.get('/api/guardrails/:id', async (req, res) => {
2649
+ try {
2650
+ const set = await readGuardrailSet(req.params.id); // CONV-1: await; built-ins resolve virtually
2651
+ if (!set) return res.status(404).json({ error: 'guardrail set not found' });
2652
+ res.json(set);
2653
+ } catch (err) {
2654
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2655
+ }
2656
+ });
2657
+
2658
+ app.post('/api/guardrails', async (req, res) => {
2659
+ const body = req.body || {};
2660
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
2661
+ if (!name) return badRequest(res, 'name is required');
2662
+ if (name.length > 200) return badRequest(res, 'name too long (max 200 characters)');
2663
+ const v = validateGuardrails(body.settings ?? {});
2664
+ if (!v.ok) return res.status(400).json({ error: 'invalid guardrails', errors: v.errors });
2665
+ try {
2666
+ // POST is CREATE, not upsert: a minted id colliding with an existing set must
2667
+ // never silently REPLACE it (the existing set may be the policy other runs
2668
+ // select). Renames/edits go through PUT.
2669
+ const mintedId = `gr_${slugify(name)}`;
2670
+ if (await readGuardrailSet(mintedId)) {
2671
+ return res.status(409).json({ error: 'a guardrail set with this name already exists' });
2672
+ }
2673
+ const set = await writeGuardrailSet({ name, settings: body.settings || {} }); // CONV-1: await
2674
+ if (!set) return badRequest(res, 'invalid guardrail set id'); // defensive: minted gr_ ids never hit this
2675
+ res.status(201).json({ guardrails: set });
2676
+ } catch (err) {
2677
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2678
+ }
2679
+ });
2680
+
2681
+ app.put('/api/guardrails/:id', async (req, res) => {
2682
+ const id = req.params.id;
2683
+ if (isBuiltinGuardrailSetId(id)) return badRequest(res, 'built-in guardrail sets cannot be edited');
2684
+ const body = req.body || {};
2685
+ try {
2686
+ const existing = await readGuardrailSet(id);
2687
+ if (!existing) return res.status(404).json({ error: 'guardrail set not found' });
2688
+ const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : existing.name;
2689
+ if (name.length > 200) return badRequest(res, 'name too long (max 200 characters)');
2690
+ // `== null` on purpose: validateGuardrails(null) is early-ok (guardrails.mjs:132-134),
2691
+ // so a strict `=== undefined` check would let {settings: null} silently wipe a
2692
+ // selected set to the empty policy. null/absent both mean "keep stored".
2693
+ const settings = body.settings == null ? existing.settings : body.settings;
2694
+ const v = validateGuardrails(settings);
2695
+ if (!v.ok) return res.status(400).json({ error: 'invalid guardrails', errors: v.errors });
2696
+ const set = await writeGuardrailSet({ id, name, settings, createdAt: existing.createdAt }); // CONV-1: await
2697
+ if (!set) return badRequest(res, 'invalid guardrail set id');
2698
+ res.json({ guardrails: set });
2699
+ } catch (err) {
2700
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2701
+ }
2702
+ });
2703
+
2704
+ app.delete('/api/guardrails/:id', async (req, res) => {
2705
+ const id = req.params.id;
2706
+ // Built-ins are not in the user store and must never be deleted.
2707
+ if (isBuiltinGuardrailSetId(id)) return badRequest(res, 'built-in guardrail sets cannot be deleted');
2708
+ try {
2709
+ const removed = await deleteGuardrailSet(id); // CONV-1: await; throws ReferencedError while pinned
2710
+ if (!removed) return res.status(404).json({ error: 'guardrail set not found' });
2711
+ res.json({ ok: true });
2712
+ } catch (err) {
2713
+ sendGuardrailError(res, err);
2714
+ }
2715
+ });
2716
+
2717
+ // ---------------------------------------------------------------------------
2718
+ // /api/agents* -> agent registry + user-agent CRUD, delegated to
2719
+ // src/core/agent-store.mjs (layered builtin + ~/.worca-cc/agents user pairs).
2720
+ // GET returns palette render order (.order ascending) with origin stamped; the
2721
+ // client builds draggable pills (colored dot + displayName + icon) from this.
2722
+ // ---------------------------------------------------------------------------
2723
+ // Channel vocabulary for the UI editor/wizard: built-in CHANNEL_IDS first, then
2724
+ // every CUSTOM id any registry agent references (produces/consumes/
2725
+ // optionalConsumes/channelDefs[].id), appended sorted + deduped. Channels are an
2726
+ // open vocabulary — a closed list would silently strip custom ids on edit.
2727
+ function collectChannelIds(agents) {
2728
+ const customs = new Set();
2729
+ for (const a of Array.isArray(agents) ? agents : []) {
2730
+ if (!a) continue;
2731
+ const ids = [
2732
+ ...(Array.isArray(a.produces) ? a.produces : []),
2733
+ ...(Array.isArray(a.consumes) ? a.consumes : []),
2734
+ ...(Array.isArray(a.optionalConsumes) ? a.optionalConsumes : []),
2735
+ ...(Array.isArray(a.channelDefs) ? a.channelDefs.map((d) => d && d.id) : []),
2736
+ ];
2737
+ for (const id of ids) {
2738
+ if (typeof id === 'string' && id && !CHANNEL_IDS.includes(id)) customs.add(id);
2739
+ }
2740
+ }
2741
+ return [...CHANNEL_IDS, ...[...customs].sort()];
2742
+ }
2743
+
2744
+ app.get('/api/agents', async (req, res) => {
2745
+ try {
2746
+ const all = await listAgents(); // merged builtin+user, origin stamped, .order ascending
2747
+ // §6.6: workspace-only agents stay out of the Composer palette by default;
2748
+ // the Agents management view passes ?all=1 to see them too.
2749
+ const agents = isTruthy(req.query.all) ? all : all.filter((m) => m.scope !== 'workspace-only');
2750
+ res.json({ agents, channels: collectChannelIds(all) });
2751
+ } catch (err) {
2752
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2753
+ }
2754
+ });
2755
+
2756
+ // Map agent-store err.code -> HTTP (mirrors workspaceErrorStatus).
2757
+ function agentErrorStatus(code) {
2758
+ if (code === 'NOT_FOUND') return 404;
2759
+ if (code === 'BAD_REQUEST') return 400;
2760
+ if (code === 'PLUGIN') return 400;
2761
+ if (code === 'BUILTIN' || code === 'DUPLICATE' || code === 'REFERENCED') return 409;
2762
+ return 500;
2763
+ }
2764
+
2765
+ /**
2766
+ * Fire-and-forget agent generation (mirrors startScan). Mints genId, registers
2767
+ * a kind:'agentgen' entry in the SAME runs Map, wires its agentgen-* events,
2768
+ * starts createAgentGen(...).run() detached with a .catch backstop, returns
2769
+ * genId. The draft is NEVER saved — persistence is the wizard's explicit
2770
+ * follow-up POST /api/agents.
2771
+ * @returns {string} genId
2772
+ */
2773
+ function startAgentGen(input) {
2774
+ const orch = createAgentGen({
2775
+ ...input,
2776
+ // Same open vocabulary as GET /api/agents (callers pass the registry union);
2777
+ // built-ins-only fallback keeps direct/_testing callers working.
2778
+ channels: Array.isArray(input.channels) && input.channels.length ? input.channels : CHANNEL_IDS,
2779
+ claude: { permissionMode: 'acceptEdits', mock: isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK) },
2780
+ });
2781
+ // The engine mints its own genId (agen_<uuid>) and tags every emitted event
2782
+ // with it; use THAT as the runs-Map key + the returned id so the entry, its
2783
+ // buffered events, and WS reconnect/replay (?genId=) all agree on one id.
2784
+ const genId = orch.getState().genId;
2785
+ const entry = {
2786
+ id: genId, genId, orch, kind: 'agentgen', projectDir: null,
2787
+ title: `agent: ${input.name}`, status: 'running',
2788
+ startedAt: new Date().toISOString(), events: [], pendingQuestion: null,
2789
+ };
2790
+ runs.set(genId, entry);
2791
+ wireAgentGen(entry);
2792
+
2793
+ Promise.resolve()
2794
+ .then(() => orch.run())
2795
+ .catch((err) => {
2796
+ // run() should never throw (it emits agentgen-error), but a defensive
2797
+ // backstop mirrors startScan: surface an unexpected throw as a tagged
2798
+ // agentgen-error.
2799
+ const event = { genId, type: 'agentgen-error', message: err && err.message ? err.message : String(err) };
2800
+ entry.status = 'error';
2801
+ entry.events.push(event);
2802
+ broadcast(event);
2803
+ });
2804
+
2805
+ return genId;
2806
+ }
2807
+
2808
+ // POST /api/agents/generate. Registered BEFORE GET /api/agents/:key so the
2809
+ // literal segment is never swallowed by the :key param. Mode A (purpose given):
2810
+ // the LLM drafts both the .md body and the meta JSON. Mode B (userMarkdown
2811
+ // given): the body is the user's verbatim; the LLM infers ONLY the meta.
2812
+ app.post('/api/agents/generate', async (req, res) => {
2813
+ try {
2814
+ const body = req.body || {};
2815
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
2816
+ if (!name) return badRequest(res, 'name is required');
2817
+ const userMarkdown = typeof body.userMarkdown === 'string' && body.userMarkdown.trim() ? body.userMarkdown : '';
2818
+ if (!userMarkdown && !(typeof body.purpose === 'string' && body.purpose.trim())) {
2819
+ return badRequest(res, 'purpose is required (or paste your own markdown)');
2820
+ }
2821
+ // Resolve neighbor keys to full agent metas (produces/consumes feed the
2822
+ // prompt's neighbor block); unknown keys are silently dropped.
2823
+ const allAgents = await listAgents();
2824
+ const byKey = Object.fromEntries(allAgents.map((m) => [m.key, m]));
2825
+ const pick = (keys) => (Array.isArray(keys) ? keys : []).map((k) => byKey[k]).filter(Boolean);
2826
+ const genId = startAgentGen({
2827
+ name, purpose: String(body.purpose || ''), details: String(body.details || ''),
2828
+ expectedBefore: pick(body.expectedBefore), expectedAfter: pick(body.expectedAfter),
2829
+ userMarkdown, channels: collectChannelIds(allAgents),
2830
+ });
2831
+ res.json({ genId });
2832
+ } catch (err) {
2833
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2834
+ }
2835
+ });
2836
+
2837
+ // POST /api/agents/generate/stop body:{genId} -> entry.orch.stop() (aborts the
2838
+ // in-flight runClaude; the engine's finally reaps its scratch dir); marks the
2839
+ // entry 'stopped'. Idempotent: an unknown/finished generation still returns ok
2840
+ // (mirrors POST /api/scan/stop).
2841
+ app.post('/api/agents/generate/stop', (req, res) => {
2842
+ const genId = req.body && typeof req.body.genId === 'string' ? req.body.genId : '';
2843
+ const entry = genId ? runs.get(genId) : null;
2844
+ if (entry && entry.kind === 'agentgen' && entry.orch && typeof entry.orch.stop === 'function') {
2845
+ try { entry.orch.stop(); } catch { /* best-effort */ }
2846
+ entry.status = 'stopped';
2847
+ }
2848
+ res.json({ ok: true });
2849
+ });
2850
+
2851
+ app.get('/api/agents/:key', async (req, res) => {
2852
+ const key = req.params.key;
2853
+ if (!AGENT_KEY_RE.test(key)) return res.status(404).json({ error: 'agent not found' });
2854
+ try {
2855
+ const data = await readAgent(key);
2856
+ if (!data) return res.status(404).json({ error: 'agent not found' });
2857
+ res.json(data); // { meta (incl. origin), markdown }
2858
+ } catch (err) {
2859
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2860
+ }
2861
+ });
2862
+
2863
+ app.post('/api/agents', async (req, res) => {
2864
+ const body = req.body || {};
2865
+ try {
2866
+ const created = await createAgent({ meta: body.meta, markdown: body.markdown });
2867
+ res.status(201).json(created);
2868
+ } catch (err) {
2869
+ res.status(agentErrorStatus(err && err.code)).json({ error: err && err.message ? err.message : String(err) });
2870
+ }
2871
+ });
2872
+
2873
+ app.put('/api/agents/:key', async (req, res) => {
2874
+ const key = req.params.key;
2875
+ if (!AGENT_KEY_RE.test(key)) return res.status(404).json({ error: 'agent not found' });
2876
+ const body = req.body || {};
2877
+ try {
2878
+ res.json(await updateAgent(key, { meta: body.meta, markdown: body.markdown }));
2879
+ } catch (err) {
2880
+ res.status(agentErrorStatus(err && err.code)).json({ error: err && err.message ? err.message : String(err) });
2881
+ }
2882
+ });
2883
+
2884
+ app.delete('/api/agents/:key', async (req, res) => {
2885
+ const key = req.params.key;
2886
+ if (!AGENT_KEY_RE.test(key)) return res.status(404).json({ error: 'agent not found' });
2887
+ try {
2888
+ res.json(await deleteAgent(key));
2889
+ } catch (err) {
2890
+ res.status(agentErrorStatus(err && err.code)).json({ error: err && err.message ? err.message : String(err) });
2891
+ }
2892
+ });
2893
+
2894
+ // ---------------------------------------------------------------------------
2895
+ // /api/plugins* -> plugin lifecycle, delegated to src/core/plugin-store.mjs /
2896
+ // plugin-repo.mjs / plugin-config.mjs (spec §6). Thin wrappers: all policy
2897
+ // (SHA pinning, symlink swap, uninstall guard, secret routing) lives in core.
2898
+ // ---------------------------------------------------------------------------
2899
+ // :name guard for every /api/plugins/:name route. Manifest names are kebab-case
2900
+ // (normalizeManifest, plugin-manifest.mjs), so a value failing this regex can
2901
+ // never contain '/' or '..' — pluginDir(name)/pluginCurrentDir(name) cannot
2902
+ // escape the namespace — and it reads as "not found" (mirrors the AGENT_KEY_RE
2903
+ // guard on /api/agents/:key). Existence = lockfile membership.
2904
+ const PLUGIN_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
2905
+ function requirePlugin(req, res) {
2906
+ const name = req.params.name;
2907
+ if (!PLUGIN_NAME_RE.test(name) || !readPluginsLock()[name]) {
2908
+ res.status(404).json({ error: 'plugin not found' });
2909
+ return null;
2910
+ }
2911
+ return name;
2912
+ }
2913
+
2914
+ // :id guard for every /api/marketplaces/:id route. Real ids come from repoSlug
2915
+ // (`<readable [A-Za-z0-9._-]>-<8 hex>`), so this regex admits every legitimate
2916
+ // id and nothing path-like. The null-prototype map from readMarketplaces already
2917
+ // blocks `__proto__`/`constructor` lookups in add/sync/remove; Object.hasOwn
2918
+ // here is belt-and-suspenders.
2919
+ const MARKETPLACE_ID_RE = /^[A-Za-z0-9._-]{1,100}$/;
2920
+ function requireMarketplace(req, res) {
2921
+ const id = req.params.id;
2922
+ if (!MARKETPLACE_ID_RE.test(id) || !Object.hasOwn(readMarketplaces().marketplaces, id)) {
2923
+ res.status(404).json({ error: 'marketplace not found' });
2924
+ return null;
2925
+ }
2926
+ return id;
2927
+ }
2928
+
2929
+ // Map plugin-core err.code -> HTTP (mirrors agentErrorStatus). The uninstall
2930
+ // guard's ReferencedError (plugin-workflows.mjs) is matched structurally so its
2931
+ // payload (the referencing list) reaches the client; everything uncoded is a
2932
+ // 500 with the verbatim message (spec §11: surface command output unchanged).
2933
+ function pluginErrorStatus(code) {
2934
+ if (code === 'NOT_FOUND') return 404;
2935
+ if (code === 'BAD_REQUEST') return 400;
2936
+ if (code === 'REFERENCED') return 409;
2937
+ if (code === 'EXISTS') return 409;
2938
+ return 500;
2939
+ }
2940
+ function sendPluginError(res, err) {
2941
+ const message = err && err.message ? err.message : String(err);
2942
+ if (err && (err.name === 'ReferencedError' || err.code === 'REFERENCED')) {
2943
+ return res.status(409).json({ error: message, references: err.references || [] });
2944
+ }
2945
+ res.status(pluginErrorStatus(err && err.code)).json({ error: message });
2946
+ }
2947
+
2948
+ // Load the installed manifest through the current/ symlink. null = broken
2949
+ // install (missing/unparseable) — routes answer 409 "run doctor", not a crash.
2950
+ function readInstalledManifest(name) {
2951
+ try {
2952
+ const raw = JSON.parse(fs.readFileSync(path.join(pluginCurrentDir(name), 'worca-cc-plugin.json'), 'utf8'));
2953
+ const norm = normalizeManifest(raw, { dir: pluginCurrentDir(name) });
2954
+ return norm.ok ? norm.manifest : null;
2955
+ } catch {
2956
+ return null;
2957
+ }
2958
+ }
2959
+
2960
+ app.get('/api/plugins', (req, res) => {
2961
+ try {
2962
+ const mkts = readMarketplaces().marketplaces;
2963
+ res.json({
2964
+ plugins: listInstalledPlugins().map((p) => ({
2965
+ ...p,
2966
+ marketplaceName: p.marketplace && mkts[p.marketplace] ? mkts[p.marketplace].name : null,
2967
+ })),
2968
+ orphans: listOrphanPluginData(),
2969
+ });
2970
+ } catch (err) {
2971
+ sendPluginError(res, err);
2972
+ }
2973
+ });
2974
+
2975
+ // Marketplaces (spec §4.7): the persisted repo registry behind the Plugins
2976
+ // view's Available/Marketplaces sections. Snapshots are cached in
2977
+ // marketplaces.json, so GET is zero-network; refresh routes do the git work.
2978
+
2979
+ // Merge lock membership onto each snapshot plugin. MUST wrap every response that
2980
+ // returns marketplace snapshots — refresh routes return raw entries whose plugins
2981
+ // have no `installed` key, and renderAvailableList would re-offer Install on them.
2982
+ function withInstalled(list) {
2983
+ const lock = readPluginsLock();
2984
+ return (list || []).map((m) => ({
2985
+ ...m, plugins: (m.plugins || []).map((p) => ({ ...p, installed: !!lock[p.name] })),
2986
+ }));
2987
+ }
2988
+
2989
+ app.get('/api/marketplaces', (req, res) => {
2990
+ try {
2991
+ res.json({ marketplaces: withInstalled(listMarketplaces()) });
2992
+ } catch (err) { sendPluginError(res, err); }
2993
+ });
2994
+
2995
+ app.post('/api/marketplaces', async (req, res) => {
2996
+ const url = req.body && typeof req.body.url === 'string' ? req.body.url.trim() : '';
2997
+ if (!url) return badRequest(res, 'url is required');
2998
+ try {
2999
+ res.json({ ok: true, marketplace: withInstalled([await addMarketplace(url)])[0] });
3000
+ } catch (err) { sendPluginError(res, err); }
3001
+ });
3002
+
3003
+ // refresh-all (a distinct path from :id/refresh, so registration order is irrelevant).
3004
+ app.post('/api/marketplaces/refresh', async (req, res) => {
3005
+ try {
3006
+ res.json({ ok: true, marketplaces: withInstalled(await refreshAllMarketplaces()) });
3007
+ } catch (err) { sendPluginError(res, err); }
3008
+ });
3009
+
3010
+ app.post('/api/marketplaces/:id/refresh', async (req, res) => {
3011
+ const id = requireMarketplace(req, res);
3012
+ if (!id) return;
3013
+ try {
3014
+ res.json({ ok: true, marketplace: withInstalled([await syncMarketplace(id)])[0] });
3015
+ } catch (err) { sendPluginError(res, err); }
3016
+ });
3017
+
3018
+ app.delete('/api/marketplaces/:id', (req, res) => {
3019
+ const id = requireMarketplace(req, res);
3020
+ if (!id) return;
3021
+ try {
3022
+ res.json(removeMarketplace(id));
3023
+ } catch (err) { sendPluginError(res, err); }
3024
+ });
3025
+
3026
+ // POST /api/plugins/install { repoUrl, subdir, name, sha } — the consent point
3027
+ // (§6.1). installPlugin does export -> setup -> doctor -> atomic swap -> lock,
3028
+ // with cleanup on failure; the returned inventory is echoed as the UI receipt.
3029
+ app.post('/api/plugins/install', async (req, res) => {
3030
+ const body = req.body || {};
3031
+ for (const k of ['repoUrl', 'name', 'sha']) {
3032
+ if (!(typeof body[k] === 'string' && body[k].trim())) return badRequest(res, `${k} is required`);
3033
+ }
3034
+ const subdir = typeof body.subdir === 'string' ? body.subdir : '';
3035
+ // A4: layer-3 option-injection guard on the install body. (?!-) rejects
3036
+ // dash-leading segments too, matching parseMarketplaceManifest exactly — no
3037
+ // defense layer may be laxer than the others.
3038
+ if (subdir && !/^(?!-)[A-Za-z0-9._-]+(\/(?!-)[A-Za-z0-9._-]+)*$/.test(subdir)) {
3039
+ return badRequest(res, 'invalid subdir');
3040
+ }
3041
+ const marketplace = typeof body.marketplace === 'string' && MARKETPLACE_ID_RE.test(body.marketplace)
3042
+ ? body.marketplace : undefined;
3043
+ try {
3044
+ const out = await installPlugin({
3045
+ repoUrl: body.repoUrl.trim(), subdir, name: body.name.trim(), sha: body.sha.trim(), marketplace,
3046
+ });
3047
+ reloadChatWorkers(body.name.trim());
3048
+ res.json(out); // { ok: true, inventory }
3049
+ } catch (err) {
3050
+ sendPluginError(res, err);
3051
+ }
3052
+ });
3053
+
3054
+ // POST /api/plugins/:name/update — two-phase (§6.2): without { confirm: true }
3055
+ // it ONLY previews (commit log + diffstat between pinned and candidate; nothing
3056
+ // changes on disk); with it, updatePlugin performs export/setup/doctor/swap/lock.
3057
+ app.post('/api/plugins/:name/update', async (req, res) => {
3058
+ const name = requirePlugin(req, res);
3059
+ if (!name) return;
3060
+ try {
3061
+ if (!(req.body && req.body.confirm === true)) {
3062
+ return res.json({ preview: await fetchCandidate(name) });
3063
+ }
3064
+ const updated = await updatePlugin(name);
3065
+ reloadChatWorkers(name);
3066
+ res.json(updated);
3067
+ } catch (err) {
3068
+ sendPluginError(res, err);
3069
+ }
3070
+ });
3071
+
3072
+ app.post('/api/plugins/:name/enable', (req, res) => {
3073
+ const name = requirePlugin(req, res);
3074
+ if (!name) return;
3075
+ if (!req.body || typeof req.body.enabled !== 'boolean') {
3076
+ return badRequest(res, 'enabled must be a boolean');
3077
+ }
3078
+ try {
3079
+ setPluginEnabled(name, req.body.enabled);
3080
+ reloadChatWorkers(name);
3081
+ res.json({ ok: true, enabled: req.body.enabled });
3082
+ } catch (err) {
3083
+ sendPluginError(res, err);
3084
+ }
3085
+ });
3086
+
3087
+ // DELETE /api/plugins/:name — uninstall; purge (body { purge: true } or
3088
+ // ?purge=1) also removes data/ (config + secrets + state). The referenced-guard
3089
+ // 409 carries the referencing list so the UI can show what blocks removal.
3090
+ app.delete('/api/plugins/:name', async (req, res) => {
3091
+ const name = requirePlugin(req, res);
3092
+ if (!name) return;
3093
+ const purge = isTruthy(req.query.purge) || !!(req.body && req.body.purge === true);
3094
+ try {
3095
+ await uninstallPlugin(name, { purge });
3096
+ reloadChatWorkers(name);
3097
+ res.json({ ok: true, purged: purge });
3098
+ } catch (err) {
3099
+ sendPluginError(res, err);
3100
+ }
3101
+ });
3102
+
3103
+ // DELETE /api/plugins/:name/data — purge an ORPHAN's leftover data/ (config +
3104
+ // secrets + state). requirePlugin is unusable here: orphans are by definition
3105
+ // NOT in the lock. 409 while installed (purge flows through uninstall), 404
3106
+ // when there is nothing to purge.
3107
+ app.delete('/api/plugins/:name/data', (req, res) => {
3108
+ const name = req.params.name;
3109
+ if (!PLUGIN_NAME_RE.test(name)) return res.status(404).json({ error: 'plugin not found' });
3110
+ try {
3111
+ res.json(purgePluginData(name));
3112
+ } catch (err) {
3113
+ if (err && err.code === 'INSTALLED') return res.status(409).json({ error: err.message });
3114
+ if (err && /nothing to purge/.test(err.message || '')) return res.status(404).json({ error: err.message });
3115
+ sendPluginError(res, err);
3116
+ }
3117
+ });
3118
+
3119
+ app.post('/api/plugins/:name/doctor', async (req, res) => {
3120
+ const name = requirePlugin(req, res);
3121
+ if (!name) return;
3122
+ try {
3123
+ res.json(await doctorPlugin(name)); // { ok, checks: [{ id, ok, detail }] }
3124
+ } catch (err) {
3125
+ sendPluginError(res, err);
3126
+ }
3127
+ });
3128
+
3129
+ // GET /api/plugins/:name/config -> per-source schema + redacted values. Secrets
3130
+ // NEVER travel to the browser: redactedConfig replaces a stored secret with
3131
+ // { set: true } (§7.6).
3132
+ app.get('/api/plugins/:name/config', (req, res) => {
3133
+ const name = requirePlugin(req, res);
3134
+ if (!name) return;
3135
+ const manifest = readInstalledManifest(name);
3136
+ if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
3137
+ try {
3138
+ const sources = (manifest.taskSources || []).map((s) => ({
3139
+ id: s.id,
3140
+ schema: s.configSchema,
3141
+ values: redactedConfig(name, s.configSchema),
3142
+ }));
3143
+ const channels = (manifest.chatChannels || []).map((c) => ({
3144
+ id: c.id,
3145
+ displayName: c.displayName,
3146
+ platform: c.platform,
3147
+ schema: c.configSchema,
3148
+ values: redactedConfig(name, c.configSchema),
3149
+ }));
3150
+ // Model secrets (design §9.7): same redaction contract — { set: true|false }
3151
+ // markers only, never values.
3152
+ const msSchema = modelSecretsSchema(name);
3153
+ res.json({
3154
+ sources,
3155
+ channels,
3156
+ ...(msSchema.length ? { models: { schema: msSchema, values: redactedConfig(name, msSchema) } } : {}),
3157
+ });
3158
+ } catch (err) {
3159
+ sendPluginError(res, err);
3160
+ }
3161
+ });
3162
+
3163
+ // PUT /api/plugins/:name/config { sourceId | channelId, values } ->
3164
+ // writePluginConfig routes secret:true keys to data/secrets.json (0600,
3165
+ // atomic). Request values are NEVER logged and NEVER echoed back (the response
3166
+ // is a bare receipt). A channelId save also hot-restarts the channel worker.
3167
+ app.put('/api/plugins/:name/config', (req, res) => {
3168
+ const name = requirePlugin(req, res);
3169
+ if (!name) return;
3170
+ const body = req.body || {};
3171
+ if (!body.values || typeof body.values !== 'object' || Array.isArray(body.values)) {
3172
+ return badRequest(res, 'values must be an object');
3173
+ }
3174
+ const manifest = readInstalledManifest(name);
3175
+ if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
3176
+ // { target: 'modelSecrets', values } writes the plugin-level model secrets
3177
+ // (design §9.7) — same write-only semantics, routed by the synthesized schema.
3178
+ if (body.target === 'modelSecrets') {
3179
+ const schema = modelSecretsSchema(name);
3180
+ if (!schema.length) return badRequest(res, 'plugin declares no modelSecrets');
3181
+ try {
3182
+ writePluginConfig(name, schema, body.values);
3183
+ return res.json({ ok: true });
3184
+ } catch (err) {
3185
+ return sendPluginError(res, err);
3186
+ }
3187
+ }
3188
+ let schema;
3189
+ if (typeof body.channelId === 'string' && body.channelId) {
3190
+ const channel = (manifest.chatChannels || []).find((c) => c.id === body.channelId);
3191
+ if (!channel) return badRequest(res, 'channelId does not match a chat channel of this plugin');
3192
+ schema = channel.configSchema;
3193
+ } else {
3194
+ const sources = manifest.taskSources || [];
3195
+ const sourceId = typeof body.sourceId === 'string' && body.sourceId
3196
+ ? body.sourceId
3197
+ : (sources.length === 1 ? sources[0].id : '');
3198
+ const source = sources.find((s) => s.id === sourceId);
3199
+ if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
3200
+ schema = source.configSchema;
3201
+ }
3202
+ try {
3203
+ writePluginConfig(name, schema, body.values);
3204
+ reloadChatWorkers(name);
3205
+ res.json({ ok: true });
3206
+ } catch (err) {
3207
+ sendPluginError(res, err);
3208
+ }
3209
+ });
3210
+
3211
+ // GET /api/plugins/:name/model-env?id=<modelId> — the RAW manifest env of one
3212
+ // plugin model, for the Models view "Edit a copy" prefill (design §9.6).
3213
+ // Literals and ${VAR} ref text return verbatim (they came from a shared repo,
3214
+ // not this user's secrets); {secret} placeholders are NEVER resolved — their
3215
+ // env keys are listed in `secretKeys` so the editor renders empty rows.
3216
+ app.get('/api/plugins/:name/model-env', (req, res) => {
3217
+ const name = requirePlugin(req, res);
3218
+ if (!name) return;
3219
+ const id = typeof req.query.id === 'string' ? req.query.id.trim() : '';
3220
+ const model = listPluginModels().find((m) => m.plugin === name && m.id.toLowerCase() === id.toLowerCase());
3221
+ if (!model) return badRequest(res, `plugin "${name}" provides no model ${JSON.stringify(id)}`);
3222
+ const env = {};
3223
+ const secretKeys = [];
3224
+ for (const [k, v] of Object.entries(model.env ?? {})) {
3225
+ if (typeof v === 'string') env[k] = v;
3226
+ else secretKeys.push(k);
3227
+ }
3228
+ res.json({ id: model.id, label: model.label, efforts: model.efforts, env, secretKeys });
3229
+ });
3230
+
3231
+ // ---------------------------------------------------------------------------
3232
+ // /api/sources* -> task-source discovery + browser-driven connector calls.
3233
+ // ---------------------------------------------------------------------------
3234
+ app.get('/api/sources', (req, res) => {
3235
+ try {
3236
+ res.json({ sources: listTaskSources() }); // builtins + enabled plugin sources
3237
+ } catch (err) {
3238
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3239
+ }
3240
+ });
3241
+
3242
+ // POST /api/sources/call { plugin, sourceId, op, args } — the New Pipeline
3243
+ // pane's data channel (task-browser search, remote-select options, Test
3244
+ // connection). op is ALLOWLISTED: the three interface ops + the ops this
3245
+ // source's manifest names in inputs[].optionsFrom. Anything else (reportResult,
3246
+ // arbitrary strings) is a 400 — the browser must not drive unadvertised
3247
+ // connector code paths.
3248
+ // Error convention: HTTP status = caller correctness (400/404, route style);
3249
+ // connector outcomes ride the 200 envelope { ok:false, error:{kind,message} }
3250
+ // because an expired token is a RESULT the pane renders inline (kind-keyed
3251
+ // message + retry) — matching the contract's { ok, result } shape.
3252
+ app.post('/api/sources/call', async (req, res) => {
3253
+ const body = req.body || {};
3254
+ for (const k of ['plugin', 'sourceId', 'op']) {
3255
+ if (!(typeof body[k] === 'string' && body[k].trim())) return badRequest(res, `${k} is required`);
3256
+ }
3257
+ const { plugin, sourceId, op } = body;
3258
+ const args = body.args && typeof body.args === 'object' && !Array.isArray(body.args) ? body.args : {};
3259
+ if (!PLUGIN_NAME_RE.test(plugin) || !readPluginsLock()[plugin]) {
3260
+ return res.status(404).json({ error: 'plugin not found' });
3261
+ }
3262
+ const manifest = readInstalledManifest(plugin);
3263
+ const source = manifest && (manifest.taskSources || []).find((s) => s.id === sourceId);
3264
+ if (!source) return res.status(404).json({ error: 'task source not found' });
3265
+ const allowed = new Set(['listTasks', 'getTask', 'validateConfig']);
3266
+ for (const input of source.inputs || []) {
3267
+ if (input && typeof input.optionsFrom === 'string' && input.optionsFrom) allowed.add(input.optionsFrom);
3268
+ }
3269
+ if (!allowed.has(op)) return badRequest(res, `op "${op}" is not allowed for this source`);
3270
+ try {
3271
+ const result = await callSource({ plugin, sourceId, op, args });
3272
+ res.json({ ok: true, result });
3273
+ } catch (err) {
3274
+ if (err instanceof PluginOpError) {
3275
+ return res.json({ ok: false, error: { kind: err.kind, message: err.message } });
3276
+ }
3277
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3278
+ }
3279
+ });
3280
+
3281
+ // POST /api/pipelines/:id/report-result — manual write-back retry (§7.5). The
3282
+ // automatic write-back never blocks 'done'; this is the results-view retry
3283
+ // button. readPipelineForResume is a pure read-by-id (artifacts.mjs) -> clean
3284
+ // 404 before delegating to retryWriteback (sources.mjs, Task 13), which itself
3285
+ // never throws for connector failures.
3286
+ app.post('/api/pipelines/:id/report-result', async (req, res) => {
3287
+ try {
3288
+ if (!readPipelineForResume(req.params.id)) {
3289
+ return res.status(404).json({ error: 'pipeline not found' });
3290
+ }
3291
+ res.json(await retryWriteback(req.params.id)); // { ok:true, skipped?:true } | { ok:false, error: string }
3292
+ } catch (err) {
3293
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3294
+ }
3295
+ });
3296
+
3297
+ // ---------------------------------------------------------------------------
3298
+ // Install logic (mirrors scripts/install.mjs): copy agents/*.md and
3299
+ // skills/worca/** into <projectDir>/.claude/...
3300
+ // ---------------------------------------------------------------------------
3301
+ async function installAgents(projectDir) {
3302
+ const claudeDir = path.join(projectDir, '.claude');
3303
+ const agentsTarget = path.join(claudeDir, 'agents');
3304
+ const skillTarget = path.join(claudeDir, 'skills', 'worca');
3305
+ await fsp.mkdir(agentsTarget, { recursive: true });
3306
+ await fsp.mkdir(skillTarget, { recursive: true });
3307
+
3308
+ const copied = [];
3309
+
3310
+ // Copy agents/*.md
3311
+ if (fs.existsSync(AGENTS_DIR)) {
3312
+ const entries = await fsp.readdir(AGENTS_DIR);
3313
+ for (const name of entries) {
3314
+ if (!name.endsWith('.md')) continue;
3315
+ const from = path.join(AGENTS_DIR, name);
3316
+ const to = path.join(agentsTarget, name);
3317
+ await fsp.copyFile(from, to);
3318
+ copied.push(path.relative(projectDir, to));
3319
+ }
3320
+ }
3321
+
3322
+ // Copy skills/worca/** recursively
3323
+ const skillSrc = path.join(SKILLS_DIR, 'worca');
3324
+ if (fs.existsSync(skillSrc)) {
3325
+ await copyDir(skillSrc, skillTarget, projectDir, copied);
3326
+ // Personalize the copied SKILL.md so /worca targets this repo's path.
3327
+ await rewriteSkillRepoPath(skillTarget, PROJECT_ROOT);
3328
+ }
3329
+
3330
+ return {
3331
+ ok: true,
3332
+ target: claudeDir,
3333
+ copied,
3334
+ hint: 'Open Claude Code in this folder and run: /worca <prompt>',
3335
+ };
3336
+ }
3337
+
3338
+ /**
3339
+ * Rewrite the `<WORCA_REPO>` placeholder in an installed SKILL.md to this repo's
3340
+ * absolute path. Best-effort; never throws.
3341
+ */
3342
+ async function rewriteSkillRepoPath(skillTarget, repoRoot) {
3343
+ const skillMd = path.join(skillTarget, 'SKILL.md');
3344
+ try {
3345
+ const original = await fsp.readFile(skillMd, 'utf8');
3346
+ const rewritten = original.split('<WORCA_REPO>').join(repoRoot);
3347
+ if (rewritten !== original) await fsp.writeFile(skillMd, rewritten, 'utf8');
3348
+ } catch {
3349
+ /* no SKILL.md or unreadable — skip */
3350
+ }
3351
+ }
3352
+
3353
+ async function copyDir(srcDir, destDir, baseForRel, copiedOut) {
3354
+ await fsp.mkdir(destDir, { recursive: true });
3355
+ const entries = await fsp.readdir(srcDir, { withFileTypes: true });
3356
+ for (const ent of entries) {
3357
+ const from = path.join(srcDir, ent.name);
3358
+ const to = path.join(destDir, ent.name);
3359
+ if (ent.isDirectory()) {
3360
+ await copyDir(from, to, baseForRel, copiedOut);
3361
+ } else if (ent.isFile()) {
3362
+ await fsp.copyFile(from, to);
3363
+ copiedOut.push(path.relative(baseForRel, to));
3364
+ }
3365
+ }
3366
+ }
3367
+
3368
+ // ---------------------------------------------------------------------------
3369
+ // helpers
3370
+ // ---------------------------------------------------------------------------
3371
+ /**
3372
+ * Decode uploaded extra files ([{ name, dataBase64 }]) to a per-run temp dir and
3373
+ * return absolute paths. Filenames are reduced to their basename to prevent
3374
+ * path traversal. Returns [] when nothing usable was provided.
3375
+ * @param {string} runId
3376
+ * @param {Array<{name?:string, dataBase64?:string}>} list
3377
+ * @returns {Promise<string[]>}
3378
+ */
3379
+ async function writeExtras(runId, list) {
3380
+ if (!Array.isArray(list) || list.length === 0) return [];
3381
+ const dir = path.join(os.tmpdir(), `orchestrator-extras-${runId}`);
3382
+ await fsp.mkdir(dir, { recursive: true });
3383
+ const out = [];
3384
+ let i = 0;
3385
+ for (const item of list) {
3386
+ i += 1;
3387
+ if (!item || typeof item !== 'object') continue;
3388
+ const data = typeof item.dataBase64 === 'string' ? item.dataBase64 : '';
3389
+ if (!data) continue;
3390
+ // Sanitize to a bare filename; fall back to a generated name.
3391
+ let name = path.basename(String(item.name || '').trim());
3392
+ if (!name || name === '.' || name === '..') name = `extra-${i}`;
3393
+ const dest = path.join(dir, name);
3394
+ try {
3395
+ await fsp.writeFile(dest, Buffer.from(data, 'base64'));
3396
+ out.push(dest);
3397
+ } catch {
3398
+ /* skip a file we cannot decode/write */
3399
+ }
3400
+ }
3401
+ return out;
3402
+ }
3403
+
3404
+ function isTruthy(v) {
3405
+ if (v === undefined || v === null) return false;
3406
+ const s = String(v).toLowerCase();
3407
+ return s === '1' || s === 'true' || s === 'yes' || s === 'on';
3408
+ }
3409
+
3410
+ // ---------------------------------------------------------------------------
3411
+ // /api/chat* -> channel worker status + test delivery (design §4.8). Prefs ride
3412
+ // GET/POST /api/settings; per-plugin channel CONFIG rides /api/plugins/:name/config.
3413
+ // ---------------------------------------------------------------------------
3414
+ app.get('/api/chat/status', (_req, res) => {
3415
+ try {
3416
+ res.json({ channels: channelHost.status() });
3417
+ } catch (err) {
3418
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3419
+ }
3420
+ });
3421
+
3422
+ app.post('/api/chat/test', async (req, res) => {
3423
+ const { plugin, channelId } = req.body || {};
3424
+ if (!plugin || !channelId) return badRequest(res, 'plugin and channelId are required');
3425
+ try {
3426
+ res.json(await chatNotifier.sendTest(plugin, channelId, renderTest()));
3427
+ } catch (err) {
3428
+ // Connector-outcome convention: caller correctness -> HTTP status; delivery
3429
+ // outcomes ride a 200 envelope elsewhere, but a missing channel/config is
3430
+ // the caller's mistake here.
3431
+ return badRequest(res, err && err.message ? err.message : String(err));
3432
+ }
3433
+ });
3434
+
3435
+ // SPA fallback: any unmatched GET that is not an /api or /ws path serves
3436
+ // index.html. Implemented as middleware (not a route pattern) so it does not
3437
+ // depend on path-to-regexp wildcard syntax, which differs between Express 4
3438
+ // and Express 5.
3439
+ app.use((req, res, next) => {
3440
+ if (req.method !== 'GET') return next();
3441
+ if (req.path.startsWith('/api/') || req.path.startsWith('/ws')) return next();
3442
+ res.sendFile(path.join(PUBLIC_DIR, 'index.html'), (err) => {
3443
+ if (err) next();
3444
+ });
3445
+ });
3446
+
3447
+ /**
3448
+ * Boot maintenance, in the PINNED order (§8.12):
3449
+ * 1. reconcileStaleRunning — stamps every stale `running` row -> `interrupted`,
3450
+ * 2. sweepRunRoots — reclaims <worcaHome>/runs/* of finished/crashed runs,
3451
+ * 3. sweepLegacyWorktrees — over every REGISTERED project, prunes the
3452
+ * <projectDir>/.worca-cc/worktrees/* the flip to detached run roots left behind.
3453
+ *
3454
+ * The order is load-bearing, not cosmetic: `interrupted` is a KEEP status for BOTH
3455
+ * sweeps, so reconciling first is what stops the very boot that made a crashed run
3456
+ * resumable from deleting the checkout it would resume. Reversed, a stale `running`
3457
+ * row is still `running` — also KEEP — and the sweeps are merely no-ops: safe but
3458
+ * useless.
3459
+ *
3460
+ * Both sweeps' DB lookups (`runRootSweepLookups` / `legacySweepLookups`, artifacts.mjs)
3461
+ * THROW on a DB failure rather than reporting "no row", and each sweep turns that into
3462
+ * a SKIPPED candidate in `failed`. An unopenable sqlite file can therefore never be
3463
+ * read as "every run was deleted, reclaim them all".
3464
+ *
3465
+ * Exported (and returning its dispositions) so the order and the legacy sweep's
3466
+ * registry fan-out are testable without spawning a server. Fire-and-forget at boot:
3467
+ * everything up to the first `await` — including the reconcile — still runs before
3468
+ * `server.listen`, exactly as it did when this was an inline block.
3469
+ *
3470
+ * @param {{log?: (scope:'run-root'|'legacy', level:string, msg:string) => void}} [args]
3471
+ * optional sink for the per-candidate lines both sweeps emit; omitted, each
3472
+ * sweep keeps its own console default.
3473
+ */
3474
+ export async function bootMaintenance({ log } = {}) {
3475
+ const summary = { reconciled: 0, runRoots: null, legacy: null };
3476
+ const sink = (scope) => (typeof log === 'function' ? (level, msg) => log(scope, level, msg) : undefined);
3477
+
3478
+ // Runs left 'running' by a previous process that died before writing a terminal
3479
+ // status (crash/kill/restart). At boot this process owns no live runs.
3480
+ try {
3481
+ const { reconciled } = reconcileStaleRunning({ liveIds: [] });
3482
+ summary.reconciled = reconciled;
3483
+ if (reconciled) console.log(`[worca-ui] reconciled ${reconciled} stale running record(s) -> interrupted`);
3484
+ } catch (err) {
3485
+ console.error(`[worca-ui] stale-run reconcile failed: ${err && err.message ? err.message : err}`);
3486
+ }
3487
+
3488
+ try {
3489
+ const r = await sweepRunRoots({
3490
+ worcaHome: worcaHome(), ...runRootSweepLookups(), log: sink('run-root'),
3491
+ });
3492
+ summary.runRoots = r;
3493
+ if (r.removed.length || r.quarantined.length) {
3494
+ console.log(`[worca-ui] run-root sweep: kept ${r.keep.length}, removed ${r.removed.length}, quarantined ${r.quarantined.length}`);
3495
+ }
3496
+ if (r.failed.length) {
3497
+ console.error(`[worca-ui] run-root sweep: ${r.failed.length} run root(s) SKIPPED — run-root lookup failed; nothing was removed`);
3498
+ for (const w of r.warnings) console.error(`[worca-ui] ${w}`);
3499
+ }
3500
+ } catch (err) {
3501
+ console.error(`[worca-ui] run-root sweep failed: ${err && err.message ? err.message : err}`);
3502
+ }
3503
+
3504
+ // The one-time legacy sweep (§6 Phase 7). A TOTAL no-op while the effective mode is
3505
+ // `legacy` — under legacy those paths hold every live and every paused run, so
3506
+ // sweeping them would make the documented §10 rollback self-destroying. The mode is
3507
+ // read ONCE, here, so the legacy default costs not even a DB read.
3508
+ try {
3509
+ const mode = runRootMode();
3510
+ if (mode !== 'detached') {
3511
+ summary.legacy = { skipped: true, projects: 0, keep: [], removed: [], quarantined: [], failed: [], warnings: [] };
3512
+ } else {
3513
+ const projects = await listProjects();
3514
+ const r = await sweepLegacyWorktreesAll(projects.map((p) => p.path), {
3515
+ mode, ...legacySweepLookups(), log: sink('legacy'),
3516
+ });
3517
+ summary.legacy = r;
3518
+ if (r.removed.length || r.quarantined.length) {
3519
+ console.log(`[worca-ui] legacy worktree sweep: kept ${r.keep.length}, removed ${r.removed.length}, quarantined ${r.quarantined.length} across ${r.projects} project(s)`);
3520
+ }
3521
+ if (r.failed.length) {
3522
+ console.error(`[worca-ui] legacy worktree sweep: ${r.failed.length} worktree(s) SKIPPED — pipelines-row lookup failed; nothing was removed`);
3523
+ }
3524
+ for (const w of r.warnings) console.warn(`[worca-ui] ${w}`);
3525
+ }
3526
+ } catch (err) {
3527
+ console.error(`[worca-ui] legacy worktree sweep failed: ${err && err.message ? err.message : err} — nothing was removed`);
3528
+ }
3529
+ return summary;
3530
+ }
3531
+
3532
+ // Only bind a port when run directly (`node ui/server.mjs`). When imported by a
3533
+ // test, skip listening so the test can mount `app` on its own ephemeral port.
3534
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
3535
+ if (isMain) {
3536
+ try {
3537
+ seedBuiltinMarketplace();
3538
+ } catch (err) {
3539
+ console.error(`[worca-ui] builtin marketplace seed skipped: ${err && err.message ? err.message : err}`);
3540
+ }
3541
+
3542
+ bootMaintenance().catch((err) => {
3543
+ console.error(`[worca-ui] boot maintenance failed: ${err && err.message ? err.message : err}`);
3544
+ });
3545
+
3546
+ server.on('error', (err) => {
3547
+ console.error(`[worca-ui] server error: ${err && err.message ? err.message : err}`);
3548
+ });
3549
+
3550
+ server.listen(PORT, HOST, () => {
3551
+ const shown = HOST === '127.0.0.1' || HOST === '::1' ? 'localhost' : HOST;
3552
+ const url = `http://${shown}:${PORT}`;
3553
+ console.log(`[worca-ui] listening on ${url} (bound to ${HOST})`);
3554
+ console.log(`[worca-ui] WebSocket on ws://${shown}:${PORT}/ws`);
3555
+ try { channelHost.start(); } catch (err) {
3556
+ console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
3557
+ }
3558
+ });
3559
+
3560
+ // Channel workers must die with the server (design §9: persistent-process
3561
+ // hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit.
3562
+ let shuttingDown = false;
3563
+ const shutdownChat = (signal) => {
3564
+ if (shuttingDown) return;
3565
+ shuttingDown = true;
3566
+ channelHost.stop().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
3567
+ };
3568
+ process.on('SIGINT', () => shutdownChat('SIGINT'));
3569
+ process.on('SIGTERM', () => shutdownChat('SIGTERM'));
3570
+ }
3571
+
3572
+ export { app, server, runs };
3573
+ export const _testing = { wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen, chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork, chatNotifier, resumeRun };