@worca/app 1.0.0 → 1.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
package/ui/server.mjs
CHANGED
|
@@ -13,26 +13,72 @@ import os from 'node:os';
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import fsp from 'node:fs/promises';
|
|
15
15
|
import process from 'node:process';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
16
17
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
18
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
18
19
|
|
|
19
20
|
import { preflightNode } from '../src/core/preflight-node.mjs';
|
|
20
|
-
import {
|
|
21
|
+
import { createOrchestratorFor } from '../src/core/engine-select.mjs';
|
|
21
22
|
import {
|
|
22
23
|
listPipelines, readPipeline, listAllPipelines, readPipelineByKey,
|
|
23
24
|
enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState,
|
|
24
25
|
readRunLogText, readRunArtifactText, countPipelines, runRootSweepLookups, legacySweepLookups, slugify,
|
|
25
|
-
listArtifacts,
|
|
26
|
+
listArtifacts, lookupPipelineRow, findPipelineRowById, resolveIndexedArtifact, resolveIndexedArtifactForRow,
|
|
27
|
+
readPromptFile,
|
|
26
28
|
} from '../src/core/artifacts.mjs';
|
|
27
29
|
import { DIFF_PATCH_FILE } from '../src/core/results.mjs';
|
|
30
|
+
import { protectedSectionKeys } from '../src/core/diff-anchor.mjs';
|
|
31
|
+
import {
|
|
32
|
+
addDiffComment, listDiffComments, getDiffComment, setDiffCommentResolved,
|
|
33
|
+
deleteDiffComment, unresolvedCounts, onDiffCommentsChanged, stampSentRunId,
|
|
34
|
+
peekPendingCardComments, clearPendingCardComments, DiffCommentError, DC_ID_RE,
|
|
35
|
+
} from '../src/core/diff-comments.mjs';
|
|
28
36
|
import { listProjects, addProject, removeProject, normalizeProjectPath, countProjects, worcaHome } from '../src/core/projects.mjs';
|
|
29
37
|
import {
|
|
30
38
|
getWorcaRoot, setWorcaRoot, setProjectsRoot, defaultRoot,
|
|
31
39
|
rawProjectsRoot, defaultProjectsRoot, runRootMode,
|
|
32
40
|
pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
|
|
33
41
|
setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
|
|
42
|
+
askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
|
|
34
43
|
chatPrefs, setChatPrefs,
|
|
44
|
+
debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
|
|
35
45
|
} from '../src/core/settings.mjs';
|
|
46
|
+
import {
|
|
47
|
+
ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
|
|
48
|
+
listThreads as askListThreads, updateThread as askUpdateThread,
|
|
49
|
+
deleteThread as askDeleteThread, sweepEmptyThreads, sweepStreamingMessages,
|
|
50
|
+
countThreads as askCountThreads, listThreadIds as askListThreadIds,
|
|
51
|
+
countWorktrees as askCountWorktrees, countAttachments as askCountAttachments,
|
|
52
|
+
appendMessage as askAppendMessage, getMessage as askGetMessage,
|
|
53
|
+
listMessages as askListMessages, setMessageBlocks as askSetMessageBlocks,
|
|
54
|
+
findCard as askFindCard, updateCardBlock as askUpdateCardBlock,
|
|
55
|
+
addAttachment as askAddAttachment, listAttachments as askListAttachments,
|
|
56
|
+
getAttachment as askGetAttachment, attachmentPath as askAttachmentPath, threadAttachmentBytes as askThreadAttachmentBytes,
|
|
57
|
+
linkRun as askLinkRun, updateRunLink as askUpdateRunLink, listRunLinks as askListRunLinks,
|
|
58
|
+
findRunLinksByPipeline as askFindRunLinksByPipeline,
|
|
59
|
+
finishMessage as askFinishMessage,
|
|
60
|
+
} from '../src/core/ask/store.mjs';
|
|
61
|
+
import { sanitizeTitle as askSanitizeTitle } from '../src/core/title.mjs';
|
|
62
|
+
import { ASK_LIMITS } from '../src/core/ask/limits.mjs';
|
|
63
|
+
import { askCatalog, validateModelEffort } from '../src/core/ask/models.mjs';
|
|
64
|
+
import { buildCatalog as askBuildCatalog } from '../src/core/ask/catalog.mjs';
|
|
65
|
+
import {
|
|
66
|
+
buildSystemPrompt as askBuildSystemPrompt, buildContextHeader as askBuildContextHeader,
|
|
67
|
+
buildTurnPrompt as askBuildTurnPrompt, buildRestoredPrompt as askBuildRestoredPrompt,
|
|
68
|
+
selectInlineAttachments as askSelectInlineAttachments, validateClientContext,
|
|
69
|
+
} from '../src/core/ask/prompt.mjs';
|
|
70
|
+
import {
|
|
71
|
+
classifyExtension as askClassifyExtension, sniffMime as askSniffMime,
|
|
72
|
+
} from '../src/core/ask/attachment-kind.mjs';
|
|
73
|
+
import {
|
|
74
|
+
listAskWorktrees as askListWorktrees,
|
|
75
|
+
removeAskWorktree as askRemoveWorktree,
|
|
76
|
+
removeThreadWorktrees as askRemoveThreadWorktrees,
|
|
77
|
+
sweepAskWorktrees,
|
|
78
|
+
} from '../src/core/ask/worktrees.mjs';
|
|
79
|
+
import { createAskTurn } from '../src/core/ask/turn.mjs';
|
|
80
|
+
import { attachRunFollower } from '../src/core/ask/follow.mjs';
|
|
81
|
+
import { mockEnabled, MOCK_WRITER_ROLES } from '../src/core/claude-runner.mjs';
|
|
36
82
|
import { budgetStatus, readCostCapOverride, setCostCapOverride } from '../src/core/cost-budget.mjs';
|
|
37
83
|
import { getStats } from '../src/core/stats.mjs';
|
|
38
84
|
import { pickFolderNative } from '../src/core/folder-dialog.mjs';
|
|
@@ -40,22 +86,25 @@ import { listFolders } from '../src/core/fs-browse.mjs';
|
|
|
40
86
|
import {
|
|
41
87
|
readConfig, setStep, addCustomModel, removeCustomModel, listModels,
|
|
42
88
|
PREDEFINED_MODELS, agentSteps, EFFORTS,
|
|
43
|
-
readRunConfig, setNodeModel, setFeedbackCycles, setActiveWorkflow, resetWorkflowConfig,
|
|
89
|
+
readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, resetWorkflowConfig,
|
|
44
90
|
globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
|
|
45
91
|
} from '../src/core/config.mjs';
|
|
46
92
|
import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
|
|
47
|
-
import { modelEnvRef } from '../src/core/model-env.mjs';
|
|
93
|
+
import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
|
|
48
94
|
import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
|
|
95
|
+
import { testModel } from '../src/core/model-test.mjs';
|
|
49
96
|
import { validateGuardrails } from '../src/core/guardrails.mjs';
|
|
50
97
|
import {
|
|
51
98
|
listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
|
|
52
99
|
writeGuardrailSet, deleteGuardrailSet, isBuiltinGuardrailSetId,
|
|
53
100
|
} from '../src/core/guardrail-store.mjs';
|
|
54
101
|
import {
|
|
55
|
-
|
|
56
|
-
setWorkflowNodeDefaults, workflowNodeDefaults,
|
|
102
|
+
GRAPH_DEFAULT_WORKFLOW, listWorkflows, deleteWorkflow, isSafeWorkflowId,
|
|
103
|
+
setWorkflowNodeDefaults, workflowNodeDefaults, assertRunnableWorkflow, writeGraphWorkflow,
|
|
57
104
|
} from '../src/core/workflows.mjs';
|
|
58
|
-
import {
|
|
105
|
+
import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
|
|
106
|
+
import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
|
|
107
|
+
import { validateGraph, AGENT_TUNABLES } from '../src/shared/graph/validate.mjs';
|
|
59
108
|
import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
|
|
60
109
|
import {
|
|
61
110
|
listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
|
|
@@ -72,7 +121,6 @@ import { projectKey } from '../src/core/store.mjs';
|
|
|
72
121
|
import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
|
|
73
122
|
import { createAgentGen } from '../src/core/agent-gen.mjs';
|
|
74
123
|
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
124
|
import {
|
|
77
125
|
listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin,
|
|
78
126
|
setPluginEnabled, doctorPlugin,
|
|
@@ -83,7 +131,14 @@ import {
|
|
|
83
131
|
addMarketplace, listMarketplaces, syncMarketplace, refreshAllMarketplaces,
|
|
84
132
|
removeMarketplace, readMarketplaces, seedBuiltinMarketplace,
|
|
85
133
|
} from '../src/core/marketplaces.mjs';
|
|
86
|
-
import {
|
|
134
|
+
import {
|
|
135
|
+
redactedConfig, writePluginConfig, readPluginConfig, listProfiles, listProfileIds,
|
|
136
|
+
createProfile, deleteProfile, isValidProfileId, DEFAULT_PROFILE,
|
|
137
|
+
} from '../src/core/plugin-config.mjs';
|
|
138
|
+
import {
|
|
139
|
+
setBinding, clearBinding, listBindingsForScope,
|
|
140
|
+
clearBindingsForProfile, resolveProfile,
|
|
141
|
+
} from '../src/core/source-bindings.mjs';
|
|
87
142
|
import { createChannelHost } from '../src/core/chat/channel-host.mjs';
|
|
88
143
|
import { createCommandRouter } from '../src/core/chat/command-router.mjs';
|
|
89
144
|
import { createChatContext } from '../src/core/chat/chat-context.mjs';
|
|
@@ -94,6 +149,7 @@ import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs'
|
|
|
94
149
|
import { normalizeManifest, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
|
|
95
150
|
import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
|
|
96
151
|
import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
|
|
152
|
+
import { HLJS_GRAMMAR_IDS } from './public/hljs-loader.mjs';
|
|
97
153
|
|
|
98
154
|
// ── node:sqlite runtime guard + warning filter ──────────────────────────────────
|
|
99
155
|
// Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
|
|
@@ -117,6 +173,45 @@ const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
|
117
173
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
118
174
|
const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
|
|
119
175
|
const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
|
|
176
|
+
const require = createRequire(import.meta.url);
|
|
177
|
+
const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
|
|
178
|
+
// Primaries plus the sub-language grammars their instances register
|
|
179
|
+
// (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
|
|
180
|
+
const HLJS_LANGUAGE_FILES = new Set(
|
|
181
|
+
HLJS_GRAMMAR_IDS.map((id) => `${id}.min.js`),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
function resolveHljsAssets(resolve = require.resolve, warn = (msg) => console.warn(msg)) {
|
|
185
|
+
try {
|
|
186
|
+
const core = resolve('@highlightjs/cdn-assets/es/core.min.js');
|
|
187
|
+
return { core, languages: path.join(path.dirname(core), 'languages') };
|
|
188
|
+
} catch (err) {
|
|
189
|
+
warn(`[worca-ui] syntax-highlighter assets unavailable: ${err?.message || err}`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const HLJS_ASSETS = resolveHljsAssets();
|
|
195
|
+
|
|
196
|
+
// Ask Worca §10.7: the chat's markdown pipeline is served from node_modules the
|
|
197
|
+
// same way the hljs assets are, but resolved with import.meta.resolve — the CJS
|
|
198
|
+
// require.resolve lands on marked's CJS build, and dompurify/package.json is not
|
|
199
|
+
// exported. Each package degrades independently: a missing one just leaves its
|
|
200
|
+
// route unregistered and the existing /vendor no-store 404 answers.
|
|
201
|
+
function resolveEsmAsset(spec, resolve = (s) => import.meta.resolve(s), warn = (msg) => console.warn(msg)) {
|
|
202
|
+
try {
|
|
203
|
+
return fileURLToPath(resolve(spec));
|
|
204
|
+
} catch (err) {
|
|
205
|
+
warn(`[worca-ui] ask markdown asset unavailable (${spec}): ${err?.message || err}`);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ASK_VENDOR_ASSETS = {
|
|
211
|
+
marked: resolveEsmAsset('marked'),
|
|
212
|
+
dompurify: resolveEsmAsset('dompurify'),
|
|
213
|
+
};
|
|
214
|
+
|
|
120
215
|
const PORT = Number(process.env.PORT) || 4317;
|
|
121
216
|
// Bind to loopback by default (S1). Power users who knowingly want LAN exposure
|
|
122
217
|
// can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
|
|
@@ -161,7 +256,9 @@ function liveRunIds() {
|
|
|
161
256
|
// `stepgraphify` (§7.3) was emitted by the orchestrator and handled by the client
|
|
162
257
|
// but missing here, so the graphify badge only appeared after a reload (via the
|
|
163
258
|
// persisted column) and never live. It rides the same pass-through as `stepskills`.
|
|
164
|
-
|
|
259
|
+
// `exec` and `token` are the graph engine's (§5.7). `phase` stays for the v1
|
|
260
|
+
// engine AND for the v2 shim until the graph cut-over retires it.
|
|
261
|
+
const EVENT_NAMES = ['exec', 'token', 'log', 'question', 'artifact', 'state', 'done', 'error', 'subagent', 'stepskills', 'stepgraphify', 'title'];
|
|
165
262
|
// The scan-* WS family (Workspaces M5, §5.4). A NEW family in the SAME runs Map;
|
|
166
263
|
// the 7-event run plumbing above is untouched. createWorkspaceScan emits many
|
|
167
264
|
// scan-progress then exactly one terminal scan-done OR scan-error.
|
|
@@ -182,6 +279,20 @@ const wss = new WebSocketServer({ server, path: '/ws' });
|
|
|
182
279
|
/** All currently connected sockets. */
|
|
183
280
|
const sockets = new Set();
|
|
184
281
|
|
|
282
|
+
// server.close() only calls back once every connection is gone, and Node's
|
|
283
|
+
// closeAllConnections() skips UPGRADED sockets — a WebSocket whose close
|
|
284
|
+
// handshake has not completed (a client that vanished, or a test tearing down
|
|
285
|
+
// right after ws.close()) keeps the callback from ever firing; under load that
|
|
286
|
+
// is a hang. Terminate the lingering clients first so close() is deterministic
|
|
287
|
+
// on every OS; the per-socket 'close' handlers below drop them from `sockets`.
|
|
288
|
+
{
|
|
289
|
+
const httpClose = server.close.bind(server);
|
|
290
|
+
server.close = (cb) => {
|
|
291
|
+
for (const ws of sockets) { try { ws.terminate(); } catch { /* already gone */ } }
|
|
292
|
+
return httpClose(cb);
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
185
296
|
wss.on('connection', (ws, req) => {
|
|
186
297
|
// S1: WS upgrades bypass the express middleware chain, so re-apply the
|
|
187
298
|
// loopback guard here (same DNS-rebinding protection as the HTTP routes).
|
|
@@ -197,24 +308,31 @@ wss.on('connection', (ws, req) => {
|
|
|
197
308
|
let requestedRunId = null;
|
|
198
309
|
let requestedScanId = null;
|
|
199
310
|
let requestedGenId = null;
|
|
311
|
+
let requestedThreadId = null;
|
|
200
312
|
try {
|
|
201
313
|
const u = new URL(req.url, 'http://localhost');
|
|
202
314
|
requestedRunId = u.searchParams.get('runId');
|
|
203
315
|
requestedScanId = u.searchParams.get('scanId');
|
|
204
316
|
requestedGenId = u.searchParams.get('genId');
|
|
317
|
+
requestedThreadId = u.searchParams.get('threadId');
|
|
205
318
|
} catch {
|
|
206
319
|
requestedRunId = null;
|
|
207
320
|
requestedScanId = null;
|
|
208
321
|
requestedGenId = null;
|
|
322
|
+
requestedThreadId = null;
|
|
209
323
|
}
|
|
210
324
|
const id = requestedRunId || requestedScanId || requestedGenId;
|
|
211
325
|
|
|
212
|
-
send(ws, { type: 'hello', runs: summarizeRuns() });
|
|
326
|
+
send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello() });
|
|
213
327
|
|
|
214
328
|
if (id && runs.has(id)) {
|
|
215
329
|
replayEntry(ws, runs.get(id));
|
|
216
330
|
}
|
|
217
331
|
|
|
332
|
+
if (requestedThreadId && askJobs.has(requestedThreadId)) {
|
|
333
|
+
replayAskJob(ws, askJobs.get(requestedThreadId));
|
|
334
|
+
}
|
|
335
|
+
|
|
218
336
|
ws.on('close', () => sockets.delete(ws));
|
|
219
337
|
ws.on('error', () => sockets.delete(ws));
|
|
220
338
|
ws.on('message', (data) => {
|
|
@@ -231,6 +349,10 @@ wss.on('connection', (ws, req) => {
|
|
|
231
349
|
if (subId && runs.has(subId)) {
|
|
232
350
|
replayEntry(ws, runs.get(subId));
|
|
233
351
|
}
|
|
352
|
+
const askThreadId = msg && msg.type === 'subscribe' && typeof msg.threadId === 'string' ? msg.threadId : null;
|
|
353
|
+
if (askThreadId && askJobs.has(askThreadId)) {
|
|
354
|
+
replayAskJob(ws, askJobs.get(askThreadId));
|
|
355
|
+
}
|
|
234
356
|
});
|
|
235
357
|
});
|
|
236
358
|
|
|
@@ -302,6 +424,32 @@ function emitChanged(type, action) {
|
|
|
302
424
|
broadcast({ type, action: action || null });
|
|
303
425
|
}
|
|
304
426
|
|
|
427
|
+
// Every comment mutation in THIS process (the REST routes below) pokes the open
|
|
428
|
+
// Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
|
|
429
|
+
// ordering concerns; the client refetches and repaints its CARDS, never the diff.
|
|
430
|
+
// MCP-side mutations happen in the stdio CHILD process and cannot reach this
|
|
431
|
+
// listener; they arrive through the turn's comment hook instead.
|
|
432
|
+
onDiffCommentsChanged(({ storeKey, pipelineId }) => {
|
|
433
|
+
broadcast({ type: 'diff-comments-changed', storeKey, pipelineId });
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
/** Resolve an 8-hex pipeline id to its History store key and poke the open Diff
|
|
437
|
+
* tabs. Used for MCP-side writes, which happen in the stdio CHILD process and
|
|
438
|
+
* cannot reach the listener above. The frame is byte-identical to the REST one,
|
|
439
|
+
* so the client has ONE code path. findPipelineRowById is key-agnostic and
|
|
440
|
+
* includes archived rows. Exported through `_testing` — the wiring at the ask
|
|
441
|
+
* turn is a one-liner precisely so this function is the whole testable surface. */
|
|
442
|
+
function emitDiffCommentsChanged(runId) {
|
|
443
|
+
try {
|
|
444
|
+
const row = findPipelineRowById(runId);
|
|
445
|
+
if (!row) return false;
|
|
446
|
+
const storeKey = (row.target === 'workspace' || row.workspace_key)
|
|
447
|
+
? `workspaces/${row.workspace_key}` : row.project_key;
|
|
448
|
+
broadcast({ type: 'diff-comments-changed', storeKey, pipelineId: row.id });
|
|
449
|
+
return true;
|
|
450
|
+
} catch { return false; } // a poke is best effort
|
|
451
|
+
}
|
|
452
|
+
|
|
305
453
|
// Append a tagged event to an entry's ring buffer (runId LAST so the runs-Map key
|
|
306
454
|
// always wins over any id the orchestrator stamped). Shared by the live wire
|
|
307
455
|
// (record) and out-of-band resolutions (resolvePending) so both honor MAX_BUFFER.
|
|
@@ -342,6 +490,9 @@ function summarizeRuns() {
|
|
|
342
490
|
// 'cost_pipeline'/'cost_total') instead of showing a plain "Paused" card
|
|
343
491
|
// until the next event.
|
|
344
492
|
pauseReason: r.pauseReason || null,
|
|
493
|
+
// The clipped failure message behind reason 'error', or null — so a
|
|
494
|
+
// reload/reconnect restores the "Paused · error" detail, not a bare card.
|
|
495
|
+
pauseDetail: r.pauseDetail || null,
|
|
345
496
|
startedAt: r.startedAt,
|
|
346
497
|
pendingQuestion: r.pendingQuestion || null,
|
|
347
498
|
// kind discriminator so the client routes runs vs scans vs agent generations
|
|
@@ -422,16 +573,23 @@ function wireRun(entry) {
|
|
|
422
573
|
// Remember the pause reason for summarizeRuns (hello). Reset on every
|
|
423
574
|
// done so a later reasonless finish cannot leave a stale cost banner.
|
|
424
575
|
entry.pauseReason = (payload && payload.reason) || null;
|
|
576
|
+
// ...and WHAT went wrong for an error-pause, reset alongside it.
|
|
577
|
+
entry.pauseDetail = (payload && payload.detail) || null;
|
|
425
578
|
resolvePending(entry, { reason: entry.status });
|
|
426
579
|
if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
|
|
427
580
|
emitChanged('budget-changed');
|
|
428
581
|
}
|
|
429
582
|
}
|
|
430
583
|
if (name === 'error') {
|
|
431
|
-
|
|
432
|
-
|
|
584
|
+
// The launch-error channel (a failure BEFORE the pipeline row exists). A
|
|
585
|
+
// converted in-run failure pauses and emits no 'error'; never let a stray
|
|
586
|
+
// one demote a parked run.
|
|
587
|
+
if (entry.status !== 'paused' && entry.status !== 'pausing') {
|
|
588
|
+
entry.status = 'error';
|
|
589
|
+
resolvePending(entry, { reason: 'error' });
|
|
590
|
+
}
|
|
433
591
|
}
|
|
434
|
-
if (name === '
|
|
592
|
+
if (name === 'exec') {
|
|
435
593
|
entry.status = 'running';
|
|
436
594
|
}
|
|
437
595
|
if (name === 'state' && payload && typeof payload === 'object') {
|
|
@@ -576,7 +734,6 @@ app.post('/api/ingress/teams/:plugin/:channelId/:token',
|
|
|
576
734
|
// ---------------------------------------------------------------------------
|
|
577
735
|
// Express middleware + static
|
|
578
736
|
// ---------------------------------------------------------------------------
|
|
579
|
-
app.use(express.json({ limit: '8mb' }));
|
|
580
737
|
|
|
581
738
|
// S1: worca-cc's UI/API has no auth and runs agents with permissionMode
|
|
582
739
|
// 'acceptEdits' — it is a single-user *localhost* tool. The server binds to
|
|
@@ -584,13 +741,98 @@ app.use(express.json({ limit: '8mb' }));
|
|
|
584
741
|
// suspenders: reject any request whose Host (or browser Origin) is not a
|
|
585
742
|
// loopback name, so a malicious page resolving a name to 127.0.0.1 still can't
|
|
586
743
|
// drive the API. Override WORCA_HOST only if you understand the exposure.
|
|
744
|
+
//
|
|
745
|
+
// FIRST, ahead of the body parser (MIN-108): a refused request must be refused
|
|
746
|
+
// before a single byte of its body is parsed or buffered, and a malformed body
|
|
747
|
+
// from a non-loopback Host used to answer 400 (with a stack) where a valid one
|
|
748
|
+
// answered 403. The ingress webhook above is deliberately mounted EARLIER and
|
|
749
|
+
// stays exempt — it carries its own token check and 256 KB cap.
|
|
587
750
|
app.use((req, res, next) => {
|
|
588
751
|
if (!isLocalRequest(req)) {
|
|
589
|
-
return res.status(403).json({ error: 'forbidden: worca
|
|
752
|
+
return res.status(403).json({ error: 'forbidden: worca is a localhost-only tool' });
|
|
590
753
|
}
|
|
591
754
|
next();
|
|
592
755
|
});
|
|
593
756
|
|
|
757
|
+
// Ask attachments ride base64 inside the message JSON (§7.3), and a binary
|
|
758
|
+
// attachment (#398) may legitimately be 5 MB — several of them blow the app-wide
|
|
759
|
+
// 8mb cap below. Registered BEFORE the global parser on the ONE route that
|
|
760
|
+
// carries uploads (a body parsed here is skipped there): every other ask route
|
|
761
|
+
// reads a string field or nothing and keeps the 8mb window. 64mb covers
|
|
762
|
+
// maxFiles × maxBytesPerBinaryFile at base64's 4/3 inflation, so every
|
|
763
|
+
// over-budget upload still reaches the route's OWN clear 400/413, not a raw
|
|
764
|
+
// parser error.
|
|
765
|
+
app.post('/api/ask/threads/:id/messages', express.json({ limit: '64mb' }));
|
|
766
|
+
app.use(express.json({ limit: '8mb' }));
|
|
767
|
+
|
|
768
|
+
if (HLJS_ASSETS) {
|
|
769
|
+
const sendHljsModule = (file) => (_req, res, next) => {
|
|
770
|
+
res.type('text/javascript');
|
|
771
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
772
|
+
res.sendFile(file, (err) => {
|
|
773
|
+
if (!err) return;
|
|
774
|
+
if (res.headersSent) return next(err);
|
|
775
|
+
next();
|
|
776
|
+
});
|
|
777
|
+
};
|
|
778
|
+
app.get('/vendor/hljs/core.min.js', sendHljsModule(HLJS_ASSETS.core));
|
|
779
|
+
app.get('/vendor/hljs/languages/:file', (req, res, next) => {
|
|
780
|
+
const file = String(req.params.file || '');
|
|
781
|
+
if (!HLJS_LANGUAGE_FILE_RE.test(file) || !HLJS_LANGUAGE_FILES.has(file)) return next();
|
|
782
|
+
const candidate = path.join(HLJS_ASSETS.languages, file);
|
|
783
|
+
try {
|
|
784
|
+
if (!fs.statSync(candidate).isFile()) return next();
|
|
785
|
+
} catch {
|
|
786
|
+
return next();
|
|
787
|
+
}
|
|
788
|
+
return sendHljsModule(candidate)(req, res, next);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Ask Worca §10.7 vendor routes. sendHljsModule's shape, reused verbatim: the
|
|
793
|
+
// sendFile error path falls through to the /vendor no-store handlers below.
|
|
794
|
+
const sendEsmModule = (file) => (_req, res, next) => {
|
|
795
|
+
res.type('text/javascript');
|
|
796
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
797
|
+
res.sendFile(file, (err) => {
|
|
798
|
+
if (!err) return;
|
|
799
|
+
if (res.headersSent) return next(err);
|
|
800
|
+
next();
|
|
801
|
+
});
|
|
802
|
+
};
|
|
803
|
+
if (ASK_VENDOR_ASSETS.marked) {
|
|
804
|
+
app.get('/vendor/marked/marked.esm.js', sendEsmModule(ASK_VENDOR_ASSETS.marked));
|
|
805
|
+
}
|
|
806
|
+
if (ASK_VENDOR_ASSETS.dompurify) {
|
|
807
|
+
app.get('/vendor/dompurify/purify.es.mjs', sendEsmModule(ASK_VENDOR_ASSETS.dompurify));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
app.use('/vendor', (err, _req, res, next) => {
|
|
811
|
+
if (res.headersSent) return next(err);
|
|
812
|
+
res.set('Cache-Control', 'no-store');
|
|
813
|
+
const status = err?.status === 400 ? 400 : 404;
|
|
814
|
+
res.status(status).type('text/plain').send(status === 400 ? 'Bad request' : 'Not found');
|
|
815
|
+
});
|
|
816
|
+
app.use('/vendor', (_req, res) => {
|
|
817
|
+
res.set('Cache-Control', 'no-store');
|
|
818
|
+
res.status(404).type('text/plain').send('Not found');
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// src/shared/** is the ONE source of the graph model for server + browser
|
|
822
|
+
// (no build step). ui modules import it by relative path that walks above
|
|
823
|
+
// ui/public; the browser clamps that URL at '/', so it must be served here at
|
|
824
|
+
// exactly the repo-relative path. The 404 tail keeps a typo'd path from
|
|
825
|
+
// falling through to the SPA index.html (which Chrome reports as a MIME error).
|
|
826
|
+
const SHARED_DIR = path.join(PROJECT_ROOT, 'src', 'shared');
|
|
827
|
+
app.use('/src/shared', express.static(SHARED_DIR, {
|
|
828
|
+
index: false,
|
|
829
|
+
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
|
|
830
|
+
}));
|
|
831
|
+
app.use('/src/shared', (_req, res) => {
|
|
832
|
+
res.set('Cache-Control', 'no-store');
|
|
833
|
+
res.status(404).type('text/plain').send('Not found');
|
|
834
|
+
});
|
|
835
|
+
|
|
594
836
|
app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
|
|
595
837
|
|
|
596
838
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
@@ -694,6 +936,9 @@ function normalizeRunSource(raw) {
|
|
|
694
936
|
return { ok: false, error: `source.${k} is required for type "plugin"` };
|
|
695
937
|
}
|
|
696
938
|
}
|
|
939
|
+
if (raw.profile !== undefined && !isValidProfileId(raw.profile)) {
|
|
940
|
+
return { ok: false, error: 'source.profile is not a valid profile id' };
|
|
941
|
+
}
|
|
697
942
|
return {
|
|
698
943
|
ok: true,
|
|
699
944
|
source: {
|
|
@@ -702,12 +947,34 @@ function normalizeRunSource(raw) {
|
|
|
702
947
|
sourceId: raw.sourceId.trim(),
|
|
703
948
|
taskId: raw.taskId.trim(),
|
|
704
949
|
inputs: raw.inputs && typeof raw.inputs === 'object' && !Array.isArray(raw.inputs) ? raw.inputs : undefined,
|
|
950
|
+
// Which configuration of the source the task came from. Absent is legal
|
|
951
|
+
// (single-profile sources); an id that is not path-safe is not.
|
|
952
|
+
profile: typeof raw.profile === 'string' && raw.profile ? raw.profile : undefined,
|
|
705
953
|
},
|
|
706
954
|
};
|
|
707
955
|
}
|
|
708
956
|
return { ok: false, error: `unknown source.type "${type}"` };
|
|
709
957
|
}
|
|
710
958
|
|
|
959
|
+
/**
|
|
960
|
+
* A markdown source that NAMES a promptFile must name one we can read. Resolution
|
|
961
|
+
* happens inside the orchestrator, which this route launches fire-and-forget AFTER
|
|
962
|
+
* it has already answered — so without this submit-time check a bad path surfaces
|
|
963
|
+
* as an anonymous mid-run error event on a pipeline the client was told started.
|
|
964
|
+
* Resolved against the same base the orchestrator uses (the project, or a
|
|
965
|
+
* workspace's primary member).
|
|
966
|
+
* @returns {Promise<string|null>} the error message, or null when there is nothing wrong
|
|
967
|
+
*/
|
|
968
|
+
async function promptFileProblem(source, projectDir) {
|
|
969
|
+
if (!source || source.type !== 'markdown' || !source.promptFile) return null;
|
|
970
|
+
try {
|
|
971
|
+
await readPromptFile(projectDir, source.promptFile);
|
|
972
|
+
return null;
|
|
973
|
+
} catch (err) {
|
|
974
|
+
return err && err.message ? err.message : String(err);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
711
978
|
// Fallback run title when the client sends none. The legacy path is unchanged
|
|
712
979
|
// (first 80 chars of the prompt — effectivePrompt is guaranteed set there); a
|
|
713
980
|
// plugin source starts as "<plugin>: <taskId>" until the orchestrator resolves
|
|
@@ -719,6 +986,67 @@ function fallbackRunTitle(effectivePrompt, source) {
|
|
|
719
986
|
return String(text).slice(0, 80);
|
|
720
987
|
}
|
|
721
988
|
|
|
989
|
+
// Wire an Ask Worca follower for a card-linked run: the orchestrator's
|
|
990
|
+
// state/question/error/done events become thread notices, ask_run_links patches
|
|
991
|
+
// and ask-run-status frames. Used by POST /api/run at launch AND by resumeRun
|
|
992
|
+
// (a resumed pipeline is a NEW orchestrator; the paused lineage's follower
|
|
993
|
+
// detached on done{paused}, so the link must be re-followed — review of PR #376).
|
|
994
|
+
function attachAskFollower(orch, { threadId, runId, cardId }) {
|
|
995
|
+
const follower = attachRunFollower(orch, {
|
|
996
|
+
threadId,
|
|
997
|
+
runId,
|
|
998
|
+
cardId,
|
|
999
|
+
post: ({ text, href }) => {
|
|
1000
|
+
try {
|
|
1001
|
+
const m = askAppendMessage(threadId, {
|
|
1002
|
+
role: 'system', text, blocks: [{ kind: 'notice', text, href }],
|
|
1003
|
+
});
|
|
1004
|
+
broadcast({ type: 'ask-message', threadId, message: m });
|
|
1005
|
+
} catch { /* thread deleted mid-run */ }
|
|
1006
|
+
},
|
|
1007
|
+
updateStatus: (patch) => {
|
|
1008
|
+
try {
|
|
1009
|
+
const linkPatch = {};
|
|
1010
|
+
if (patch.pipelineId) linkPatch.pipelineId = patch.pipelineId;
|
|
1011
|
+
if (patch.status) linkPatch.status = patch.status;
|
|
1012
|
+
if (patch.phase !== undefined) linkPatch.phase = patch.phase;
|
|
1013
|
+
const row = Object.keys(linkPatch).length
|
|
1014
|
+
? askUpdateRunLink(threadId, runId, linkPatch) : null;
|
|
1015
|
+
// The 8-hex History id lands on the FIRST state event (follow.mjs guards
|
|
1016
|
+
// "first truthy sight only"), which is the first moment a
|
|
1017
|
+
// "sent to #<runId>" marker could point anywhere real. Never the
|
|
1018
|
+
// runs-Map UUID, never at launch, and never a resolve.
|
|
1019
|
+
if (linkPatch.pipelineId && row && row.commentIds.length) {
|
|
1020
|
+
try { stampSentRunId(row.commentIds, linkPatch.pipelineId); } catch { /* best effort */ }
|
|
1021
|
+
}
|
|
1022
|
+
if (patch.cardFailed) {
|
|
1023
|
+
flipCard(threadId, cardId, { state: 'failed', error: patch.cardFailed });
|
|
1024
|
+
}
|
|
1025
|
+
broadcast({
|
|
1026
|
+
type: 'ask-run-status', threadId, runId,
|
|
1027
|
+
pipelineId: (row && row.pipelineId) || patch.pipelineId || null,
|
|
1028
|
+
cardId,
|
|
1029
|
+
status: patch.status || (row && row.status) || null,
|
|
1030
|
+
phase: patch.phase !== undefined ? patch.phase : ((row && row.phase) || null),
|
|
1031
|
+
});
|
|
1032
|
+
} catch { /* thread deleted mid-run */ }
|
|
1033
|
+
},
|
|
1034
|
+
onDetached: () => {
|
|
1035
|
+
const set = askFollowers.get(threadId);
|
|
1036
|
+
if (set) {
|
|
1037
|
+
set.delete(follower);
|
|
1038
|
+
if (!set.size) askFollowers.delete(threadId);
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
let set = askFollowers.get(threadId);
|
|
1043
|
+
if (!set) {
|
|
1044
|
+
set = new Set();
|
|
1045
|
+
askFollowers.set(threadId, set);
|
|
1046
|
+
}
|
|
1047
|
+
set.add(follower);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
722
1050
|
// ---------------------------------------------------------------------------
|
|
723
1051
|
// POST /api/run -> start a new orchestration run
|
|
724
1052
|
// body (single-project): { projectDir, prompt?, promptMarkdown?, title?, mock? }
|
|
@@ -739,6 +1067,28 @@ app.post('/api/run', async (req, res) => {
|
|
|
739
1067
|
return badRequest(res, 'workspaceId or projectDir is required');
|
|
740
1068
|
}
|
|
741
1069
|
|
|
1070
|
+
// Ask Worca card link (§8.1): both or neither; the thread must exist and
|
|
1071
|
+
// the card must still be `proposed` BEFORE any run state is created.
|
|
1072
|
+
const hasAskThread = body.askThreadId !== undefined && body.askThreadId !== null;
|
|
1073
|
+
const hasAskCard = body.askCardId !== undefined && body.askCardId !== null;
|
|
1074
|
+
let askLink = null;
|
|
1075
|
+
if (hasAskThread || hasAskCard) {
|
|
1076
|
+
if (!hasAskThread || !hasAskCard) {
|
|
1077
|
+
return badRequest(res, 'askThreadId and askCardId must be provided together');
|
|
1078
|
+
}
|
|
1079
|
+
if (typeof body.askThreadId !== 'string' || !ASK_ID_RE.test(body.askThreadId)
|
|
1080
|
+
|| typeof body.askCardId !== 'string' || !ASK_ID_RE.test(body.askCardId)) {
|
|
1081
|
+
return badRequest(res, 'invalid askThreadId or askCardId');
|
|
1082
|
+
}
|
|
1083
|
+
if (!askGetThread(body.askThreadId)) return badRequest(res, 'unknown askThreadId');
|
|
1084
|
+
const found = askFindCard(body.askThreadId, body.askCardId);
|
|
1085
|
+
if (!found) return badRequest(res, 'unknown askCardId');
|
|
1086
|
+
if (found.block.state !== 'proposed') {
|
|
1087
|
+
return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
1088
|
+
}
|
|
1089
|
+
askLink = { threadId: body.askThreadId, cardId: body.askCardId };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
742
1092
|
// ── Shared resolution (factored BEFORE the target branch, §2.6) ──────────
|
|
743
1093
|
// NEW (plugins §7.3): body.source is the task-source descriptor; shape-check
|
|
744
1094
|
// only and pass through — the orchestrator resolves it exactly once. Absent
|
|
@@ -747,6 +1097,29 @@ app.post('/api/run', async (req, res) => {
|
|
|
747
1097
|
if (sourceCheck && !sourceCheck.ok) return badRequest(res, sourceCheck.error);
|
|
748
1098
|
const source = sourceCheck ? sourceCheck.source : null;
|
|
749
1099
|
|
|
1100
|
+
// A multiProfile source without a profile would run against the (empty)
|
|
1101
|
+
// default bucket and die mid-pipeline with a confusing connector error —
|
|
1102
|
+
// reject it here, at submit, where the client can still fix it. The same
|
|
1103
|
+
// goes for a profile that is no longer IN the roster (deleted in another
|
|
1104
|
+
// tab after the client resolved it) and for a profile supplied to a source
|
|
1105
|
+
// that does not use them (it would read a phantom bucket instead of the
|
|
1106
|
+
// real config). A broken or uninstalled plugin is left for
|
|
1107
|
+
// resolveTaskInput to report.
|
|
1108
|
+
if (source && source.type === 'plugin') {
|
|
1109
|
+
const m = readInstalledManifest(source.plugin);
|
|
1110
|
+
const ts = m && (m.taskSources || []).find((s) => s.id === source.sourceId);
|
|
1111
|
+
if (ts && ts.multiProfile) {
|
|
1112
|
+
if (!source.profile) {
|
|
1113
|
+
return badRequest(res, `source.profile is required — task source "${source.sourceId}" has per-profile configuration`);
|
|
1114
|
+
}
|
|
1115
|
+
if (!listProfileIds(source.plugin).includes(source.profile)) {
|
|
1116
|
+
return badRequest(res, `plugin "${source.plugin}" has no profile "${source.profile}" — it may have been deleted; re-select one`);
|
|
1117
|
+
}
|
|
1118
|
+
} else if (ts && source.profile) {
|
|
1119
|
+
return badRequest(res, `task source "${source.sourceId}" does not use profiles — omit source.profile`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
750
1123
|
// prompt OR promptMarkdown. promptMarkdown is treated as the prompt text.
|
|
751
1124
|
const prompt = typeof body.prompt === 'string' && body.prompt.trim() ? body.prompt : undefined;
|
|
752
1125
|
const promptMarkdown =
|
|
@@ -770,7 +1143,16 @@ app.post('/api/run', async (req, res) => {
|
|
|
770
1143
|
// so the client gets a clean 400 instead of a mid-run error event.
|
|
771
1144
|
const workflowId =
|
|
772
1145
|
typeof body.workflowId === 'string' && body.workflowId.trim() ? body.workflowId.trim() : 'wf_default';
|
|
773
|
-
|
|
1146
|
+
// ONE gate for every run entry point, ONE status: unknown (today's text) and
|
|
1147
|
+
// archived (the upgrade explanation the UI shows verbatim) answer 400 through
|
|
1148
|
+
// badRequest. A graph row runs on the graph engine — createOrchestratorFor
|
|
1149
|
+
// routes it off the row's version.
|
|
1150
|
+
let workflowRow;
|
|
1151
|
+
try {
|
|
1152
|
+
workflowRow = await assertRunnableWorkflow(workflowId);
|
|
1153
|
+
} catch (err) {
|
|
1154
|
+
return badRequest(res, err && err.message ? err.message : String(err));
|
|
1155
|
+
}
|
|
774
1156
|
|
|
775
1157
|
// Optional guardrailsId selects the named guardrail set that IS this run's
|
|
776
1158
|
// policy (applied uniformly to every member — guardrails are per-run only).
|
|
@@ -858,7 +1240,10 @@ app.post('/api/run', async (req, res) => {
|
|
|
858
1240
|
return badRequest(res, `unknown or invalid sourceBranch: ${badOverride}`);
|
|
859
1241
|
}
|
|
860
1242
|
|
|
861
|
-
|
|
1243
|
+
const wsFileProblem = await promptFileProblem(effectiveSource, projects[0].projectDir);
|
|
1244
|
+
if (wsFileProblem) return badRequest(res, wsFileProblem);
|
|
1245
|
+
|
|
1246
|
+
orch = await createOrchestratorFor({
|
|
862
1247
|
workspace: {
|
|
863
1248
|
id: ws.id,
|
|
864
1249
|
key: ws.id, // ws.id === workspaceKey(ws); routes artifacts to its store
|
|
@@ -872,6 +1257,7 @@ app.post('/api/run', async (req, res) => {
|
|
|
872
1257
|
extras,
|
|
873
1258
|
agentsDir: AGENTS_DIR,
|
|
874
1259
|
workflowId,
|
|
1260
|
+
template: workflowRow,
|
|
875
1261
|
guardrailsId,
|
|
876
1262
|
branch,
|
|
877
1263
|
claude: { permissionMode: 'acceptEdits', mock },
|
|
@@ -911,7 +1297,10 @@ app.post('/api/run', async (req, res) => {
|
|
|
911
1297
|
return badRequest(res, `unknown or invalid sourceBranch: ${branch.source}`);
|
|
912
1298
|
}
|
|
913
1299
|
|
|
914
|
-
|
|
1300
|
+
const fileProblem = await promptFileProblem(effectiveSource, projectDir);
|
|
1301
|
+
if (fileProblem) return badRequest(res, fileProblem);
|
|
1302
|
+
|
|
1303
|
+
orch = await createOrchestratorFor({
|
|
915
1304
|
projectDir,
|
|
916
1305
|
prompt: effectivePrompt,
|
|
917
1306
|
...(effectiveSource ? { source: effectiveSource } : {}),
|
|
@@ -919,6 +1308,7 @@ app.post('/api/run', async (req, res) => {
|
|
|
919
1308
|
extras,
|
|
920
1309
|
agentsDir: AGENTS_DIR,
|
|
921
1310
|
workflowId,
|
|
1311
|
+
template: workflowRow,
|
|
922
1312
|
guardrailsId,
|
|
923
1313
|
branch,
|
|
924
1314
|
claude: { permissionMode: 'acceptEdits', mock },
|
|
@@ -939,6 +1329,48 @@ app.post('/api/run', async (req, res) => {
|
|
|
939
1329
|
|
|
940
1330
|
runs.set(runId, entry);
|
|
941
1331
|
wireRun(entry);
|
|
1332
|
+
if (askLink) {
|
|
1333
|
+
// Card-state TOCTOU: awaits (source-ref check, budget) sit between Hunk
|
|
1334
|
+
// B's `proposed` check and here — a concurrent Start may have flipped
|
|
1335
|
+
// the card already. That is a LOST RACE, not a detail to log: the loser
|
|
1336
|
+
// must not launch a second pipeline for the same card (review of PR #376).
|
|
1337
|
+
// Withdraw the run entry (nothing has run or been announced yet) and 409.
|
|
1338
|
+
const still = askFindCard(askLink.threadId, askLink.cardId);
|
|
1339
|
+
if (!still || still.block.state !== 'proposed') {
|
|
1340
|
+
runs.delete(runId);
|
|
1341
|
+
return res.status(409).json({ error: `card is no longer proposed (${still ? still.block.state : 'gone'})` });
|
|
1342
|
+
}
|
|
1343
|
+
try {
|
|
1344
|
+
askLinkRun(askLink.threadId, { runId, cardId: askLink.cardId, status: entry.status });
|
|
1345
|
+
flipCard(askLink.threadId, askLink.cardId, { state: 'started', runId });
|
|
1346
|
+
// The card's pending comment ids move onto the link row, keyed by the minted
|
|
1347
|
+
// UUID exactly as pipeline_id is before it exists. Consumed one-shot: a card
|
|
1348
|
+
// launches at most once. Own try/catch — comment bookkeeping must never
|
|
1349
|
+
// abort the card flip or the run.
|
|
1350
|
+
try {
|
|
1351
|
+
// Read, WRITE, then consume — not consume-then-write. A combined take()
|
|
1352
|
+
// deletes the rows it returns, so if askUpdateRunLink throws in between (its
|
|
1353
|
+
// catch here only logs) the ids are gone and the sent_run_id stamp is lost
|
|
1354
|
+
// with no way to recover them. peek/commit keeps the delete on the success
|
|
1355
|
+
// path only; a second launch of the same card cannot happen anyway (the
|
|
1356
|
+
// card must be in state 'proposed' above).
|
|
1357
|
+
const pendingComments = peekPendingCardComments(askLink.cardId);
|
|
1358
|
+
if (pendingComments.length) {
|
|
1359
|
+
askUpdateRunLink(askLink.threadId, runId, { commentIds: pendingComments });
|
|
1360
|
+
clearPendingCardComments(askLink.cardId);
|
|
1361
|
+
}
|
|
1362
|
+
} catch (e) { console.error('[diff-comments] pending-card handoff failed:', e && e.message ? e.message : e); }
|
|
1363
|
+
const startedMsg = askAppendMessage(askLink.threadId, {
|
|
1364
|
+
role: 'system',
|
|
1365
|
+
text: `Run started — "${title}"`,
|
|
1366
|
+
blocks: [{ kind: 'notice', text: `Run started — "${title}"`, href: `#running/${runId}` }],
|
|
1367
|
+
});
|
|
1368
|
+
broadcast({ type: 'ask-message', threadId: askLink.threadId, message: startedMsg });
|
|
1369
|
+
attachAskFollower(orch, { threadId: askLink.threadId, runId, cardId: askLink.cardId });
|
|
1370
|
+
} catch (err) {
|
|
1371
|
+
console.error(`[worca-ui] ask run link failed: ${err && err.message ? err.message : err}`);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
942
1374
|
announceRun(entry);
|
|
943
1375
|
|
|
944
1376
|
// Fire-and-forget; all progress is surfaced through events.
|
|
@@ -1169,10 +1601,18 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
|
|
|
1169
1601
|
if (!saved) throw new ResumeError(404, { error: 'pipeline not found' });
|
|
1170
1602
|
if (saved.row.status !== 'paused' && saved.row.status !== 'interrupted') throw new ResumeError(400, { error: `pipeline is "${saved.row.status}", not resumable` });
|
|
1171
1603
|
if (!saved.resumePoint) throw new ResumeError(400, { error: 'pipeline has no resume point' });
|
|
1604
|
+
if (saved.resumePoint.version !== 2) {
|
|
1605
|
+
throw new ResumeError(409, { code: 'ENGINE_RETIRED', error: V1_RUN_RETIRED });
|
|
1606
|
+
}
|
|
1172
1607
|
|
|
1173
1608
|
if (saved.row.archived_at) {
|
|
1174
1609
|
throw new ResumeError(409, { error: 'pipeline is archived' });
|
|
1175
1610
|
}
|
|
1611
|
+
// No graph re-validation on RESUME, on purpose: _restoreFromResumePoint never
|
|
1612
|
+
// reads the workflow row — the frozen manifest supplies topology and port
|
|
1613
|
+
// identity (resolvedFromManifest: snapshot wins), so a template that drifted
|
|
1614
|
+
// while the run sat paused cannot strand it. A vanished agent KEY is the one
|
|
1615
|
+
// resume-time hazard, and _preflightAgentKeys already refuses it (§9.4).
|
|
1176
1616
|
const budget = budgetStatus();
|
|
1177
1617
|
if (budget.blocked) {
|
|
1178
1618
|
throw new ResumeError(403, { error: 'total cost limit reached', budget });
|
|
@@ -1226,7 +1666,7 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
|
|
|
1226
1666
|
|
|
1227
1667
|
const effMock = mock || isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK);
|
|
1228
1668
|
const runId = randomUUID();
|
|
1229
|
-
const orch =
|
|
1669
|
+
const orch = await createOrchestratorFor({
|
|
1230
1670
|
projectDir,
|
|
1231
1671
|
...(workspace ? { workspace } : {}),
|
|
1232
1672
|
agentsDir: AGENTS_DIR,
|
|
@@ -1255,6 +1695,22 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
|
|
|
1255
1695
|
wireRun(entry);
|
|
1256
1696
|
announceRun(entry);
|
|
1257
1697
|
|
|
1698
|
+
// A card-linked run keeps reporting to its chat across the resume: the link
|
|
1699
|
+
// row moves to the new runId and a fresh follower takes over (the old one
|
|
1700
|
+
// detached on done{paused}). Best-effort per row — chat bookkeeping must never
|
|
1701
|
+
// block a resume.
|
|
1702
|
+
for (const link of askFindRunLinksByPipeline(pipelineId)) {
|
|
1703
|
+
try {
|
|
1704
|
+
if (!askUpdateRunLink(link.threadId, link.runId, { runId, status: 'running' })) continue;
|
|
1705
|
+
const text = `Run resumed — "${saved.row.title || 'run'}"`;
|
|
1706
|
+
const m = askAppendMessage(link.threadId, { role: 'system', text, blocks: [{ kind: 'notice', text, href: `#running/${runId}` }] });
|
|
1707
|
+
broadcast({ type: 'ask-message', threadId: link.threadId, message: m });
|
|
1708
|
+
attachAskFollower(orch, { threadId: link.threadId, runId, cardId: link.cardId });
|
|
1709
|
+
} catch (err) {
|
|
1710
|
+
console.error(`[worca-ui] ask follower re-attach failed: ${err && err.message ? err.message : err}`);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1258
1714
|
// Evict the superseded paused/interrupted lineage for this pipeline. The old
|
|
1259
1715
|
// entry is inert (paused), but summarizeRuns() broadcasts EVERY Map entry on
|
|
1260
1716
|
// each hello — leaving it resurfaces the now-resumed (and possibly already
|
|
@@ -1362,6 +1818,22 @@ app.get('/api/runs/:id', async (req, res) => {
|
|
|
1362
1818
|
}
|
|
1363
1819
|
});
|
|
1364
1820
|
|
|
1821
|
+
// GET /api/runs/:id/artifact?rel= -> the same payload, resolved by the pipeline
|
|
1822
|
+
// id ALONE (findPipelineRowById): the Running page knows the run's pipelineId
|
|
1823
|
+
// but no store key until History has been visited. Placed beside /api/runs/:id
|
|
1824
|
+
// (`:id` matches one path segment, so the two never shadow each other).
|
|
1825
|
+
app.get('/api/runs/:id/artifact', async (req, res) => {
|
|
1826
|
+
try {
|
|
1827
|
+
const row = findPipelineRowById(req.params.id);
|
|
1828
|
+
if (!row) return res.status(404).json({ error: 'pipeline not found' });
|
|
1829
|
+
const hit = await resolveIndexedArtifactForRow(row, req.query.rel);
|
|
1830
|
+
if (!hit) return res.status(404).json({ error: 'artifact not found' });
|
|
1831
|
+
res.json(hit);
|
|
1832
|
+
} catch (err) {
|
|
1833
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
1834
|
+
}
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1365
1837
|
// Shared query-scope resolver for the retained-work routes (recovery-patch GET +
|
|
1366
1838
|
// discard POST). Returns null after writing the error response itself. The older
|
|
1367
1839
|
// DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
|
|
@@ -1538,10 +2010,176 @@ app.get('/api/history/:key/:id/log', async (req, res) => {
|
|
|
1538
2010
|
}
|
|
1539
2011
|
});
|
|
1540
2012
|
|
|
2013
|
+
// ---------------------------------------------------------------------------
|
|
2014
|
+
// Internal, line-anchored diff comments. Bound to BOTH route families below: the
|
|
2015
|
+
// /api/history/:key/:id key regex forbids a slash, so a workspace run (store key
|
|
2016
|
+
// "workspaces/<id>") can only be reached through /api/workspaces/:id/runs/:runId —
|
|
2017
|
+
// the same split the /diff and /log routes already carry. One handler set, two
|
|
2018
|
+
// registrations: the two can never diverge.
|
|
2019
|
+
//
|
|
2020
|
+
// Traversal posture matches the /diff route below: the run dir comes from a DB row
|
|
2021
|
+
// via readRunArtifactText, and the relPath is the CONSTANT DIFF_PATCH_FILE. No
|
|
2022
|
+
// route here ever passes user input as a path.
|
|
2023
|
+
// ---------------------------------------------------------------------------
|
|
2024
|
+
|
|
2025
|
+
// The history key regex is an inline literal on every route in this family; this
|
|
2026
|
+
// block keeps that convention rather than introducing a shared constant the rest
|
|
2027
|
+
// of the file does not use.
|
|
2028
|
+
const commentsHistoryKey = (res, key) => {
|
|
2029
|
+
if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(key)) {
|
|
2030
|
+
res.status(404).json({ error: 'pipeline not found' });
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
return key;
|
|
2034
|
+
};
|
|
2035
|
+
const commentsWorkspaceKey = (res, id) => {
|
|
2036
|
+
if (!WORKSPACE_KEY_RE.test(id)) { res.status(404).json({ error: 'pipeline not found' }); return null; }
|
|
2037
|
+
return `workspaces/${id}`;
|
|
2038
|
+
};
|
|
2039
|
+
const commentIdParam = (res, value) => {
|
|
2040
|
+
if (typeof value !== 'string' || !DC_ID_RE.test(value)) {
|
|
2041
|
+
res.status(400).json({ error: 'invalid comment id' });
|
|
2042
|
+
return null;
|
|
2043
|
+
}
|
|
2044
|
+
return value;
|
|
2045
|
+
};
|
|
2046
|
+
const commentsFail = (res, err) => res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2047
|
+
|
|
2048
|
+
/** The run row for a store key + id, or null after answering 404. */
|
|
2049
|
+
function commentRun(res, storeKey, id) {
|
|
2050
|
+
const row = lookupPipelineRow(storeKey, id);
|
|
2051
|
+
if (!row) { res.status(404).json({ error: 'pipeline not found' }); return null; }
|
|
2052
|
+
return row;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
async function commentsList(res, storeKey, id) {
|
|
2056
|
+
try {
|
|
2057
|
+
const row = commentRun(res, storeKey, id);
|
|
2058
|
+
if (!row) return;
|
|
2059
|
+
// The UI needs to know whether the '+' affordance may appear at all; a run
|
|
2060
|
+
// whose patch is gone (archived, or never captured) can only read and delete.
|
|
2061
|
+
const patchText = await readRunArtifactText(storeKey, row.id, DIFF_PATCH_FILE);
|
|
2062
|
+
res.json({
|
|
2063
|
+
comments: listDiffComments(storeKey, row.id),
|
|
2064
|
+
patchAvailable: !!patchText,
|
|
2065
|
+
// Section keys the protected-path floor will refuse whatever the line, so the
|
|
2066
|
+
// browser can drop the '+' up front instead of surfacing a 400 on submit. The
|
|
2067
|
+
// preset itself never leaves the server.
|
|
2068
|
+
protectedPaths: protectedSectionKeys(patchText),
|
|
2069
|
+
});
|
|
2070
|
+
} catch (err) { commentsFail(res, err); }
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
async function commentsCreate(req, res, storeKey, id) {
|
|
2074
|
+
try {
|
|
2075
|
+
const row = commentRun(res, storeKey, id);
|
|
2076
|
+
if (!row) return;
|
|
2077
|
+
const body = req.body || {};
|
|
2078
|
+
const patchText = await readRunArtifactText(storeKey, row.id, DIFF_PATCH_FILE);
|
|
2079
|
+
// `!patchText` covers BOTH null (absent/unreadable) and '' (present but empty):
|
|
2080
|
+
// addDiffComment refuses the empty string too, and it must surface as 409, not
|
|
2081
|
+
// as the 400 an anchor failure would get.
|
|
2082
|
+
if (!patchText) {
|
|
2083
|
+
// 409, not 400: the request is well-formed, the RUN is no longer commentable.
|
|
2084
|
+
return res.status(409).json({ error: 'this run has no stored diff — comments cannot be created on it' });
|
|
2085
|
+
}
|
|
2086
|
+
const comment = addDiffComment({
|
|
2087
|
+
storeKey, pipelineId: row.id, patchText,
|
|
2088
|
+
project: body.project ?? null, path: body.path, side: body.side, line: body.line,
|
|
2089
|
+
body: body.body, author: 'user',
|
|
2090
|
+
});
|
|
2091
|
+
res.status(201).json({ comment });
|
|
2092
|
+
} catch (err) {
|
|
2093
|
+
if (err instanceof DiffCommentError) return badRequest(res, err.message);
|
|
2094
|
+
commentsFail(res, err);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/** A comment reached through a run URL must BELONG to that run — never id alone. */
|
|
2099
|
+
function commentOfRun(res, storeKey, id, cid) {
|
|
2100
|
+
const row = commentRun(res, storeKey, id);
|
|
2101
|
+
if (!row) return null;
|
|
2102
|
+
const comment = getDiffComment(cid);
|
|
2103
|
+
if (!comment || comment.storeKey !== storeKey || comment.pipelineId !== row.id) {
|
|
2104
|
+
res.status(404).json({ error: 'comment not found' });
|
|
2105
|
+
return null;
|
|
2106
|
+
}
|
|
2107
|
+
return comment;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
function commentsPatch(req, res, storeKey, id, cid) {
|
|
2111
|
+
try {
|
|
2112
|
+
// Existence BEFORE shape: an unknown run must 404 on every verb, including a
|
|
2113
|
+
// PATCH whose body happens to be malformed.
|
|
2114
|
+
if (!commentOfRun(res, storeKey, id, cid)) return;
|
|
2115
|
+
const raw = (req.body || {}).resolved;
|
|
2116
|
+
if (typeof raw !== 'boolean') return badRequest(res, 'resolved must be a boolean');
|
|
2117
|
+
res.json({ comment: setDiffCommentResolved(cid, raw) });
|
|
2118
|
+
} catch (err) { commentsFail(res, err); }
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function commentsDelete(res, storeKey, id, cid) {
|
|
2122
|
+
try {
|
|
2123
|
+
if (!commentOfRun(res, storeKey, id, cid)) return;
|
|
2124
|
+
deleteDiffComment(cid);
|
|
2125
|
+
res.json({ ok: true });
|
|
2126
|
+
} catch (err) { commentsFail(res, err); }
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
app.get('/api/history/:key/:id/comments', async (req, res) => {
|
|
2130
|
+
const key = commentsHistoryKey(res, req.params.key); if (!key) return;
|
|
2131
|
+
await commentsList(res, key, req.params.id);
|
|
2132
|
+
});
|
|
2133
|
+
app.post('/api/history/:key/:id/comments', async (req, res) => {
|
|
2134
|
+
const key = commentsHistoryKey(res, req.params.key); if (!key) return;
|
|
2135
|
+
await commentsCreate(req, res, key, req.params.id);
|
|
2136
|
+
});
|
|
2137
|
+
app.patch('/api/history/:key/:id/comments/:cid', (req, res) => {
|
|
2138
|
+
const key = commentsHistoryKey(res, req.params.key); if (!key) return;
|
|
2139
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2140
|
+
commentsPatch(req, res, key, req.params.id, cid);
|
|
2141
|
+
});
|
|
2142
|
+
app.delete('/api/history/:key/:id/comments/:cid', (req, res) => {
|
|
2143
|
+
const key = commentsHistoryKey(res, req.params.key); if (!key) return;
|
|
2144
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2145
|
+
commentsDelete(res, key, req.params.id, cid);
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
app.get('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
|
|
2149
|
+
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
2150
|
+
await commentsList(res, key, req.params.runId);
|
|
2151
|
+
});
|
|
2152
|
+
app.post('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
|
|
2153
|
+
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
2154
|
+
await commentsCreate(req, res, key, req.params.runId);
|
|
2155
|
+
});
|
|
2156
|
+
app.patch('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
|
|
2157
|
+
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
2158
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2159
|
+
commentsPatch(req, res, key, req.params.runId, cid);
|
|
2160
|
+
});
|
|
2161
|
+
app.delete('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
|
|
2162
|
+
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
2163
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2164
|
+
commentsDelete(res, key, req.params.runId, cid);
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
// Unresolved counts for every run, for the History list pill. Its own endpoint
|
|
2168
|
+
// rather than a field on /api/history: that response has a localStorage skeleton
|
|
2169
|
+
// cache, so a cached paint would show a stale pill; and diff-comments-changed can
|
|
2170
|
+
// repaint pills from here without forcing a whole History reload.
|
|
2171
|
+
app.get('/api/diff-comments/counts', (_req, res) => {
|
|
2172
|
+
try { res.json({ counts: unresolvedCounts() }); } catch (err) { commentsFail(res, err); }
|
|
2173
|
+
});
|
|
2174
|
+
|
|
1541
2175
|
// ---------------------------------------------------------------------------
|
|
1542
2176
|
// GET /api/history/:key/:id/diff -> the run's persisted diff-patch.patch, inline
|
|
1543
|
-
// (text/x-diff).
|
|
1544
|
-
//
|
|
2177
|
+
// (text/x-diff). The route is status-agnostic and always has been: the artifact
|
|
2178
|
+
// exists for every run that reached a checkpoint AND changed something under it —
|
|
2179
|
+
// the done path AND the stopped/error paths, which build results too (orchestrator
|
|
2180
|
+
// run() and resume()). A run stopped before its checkpoint has none, nor does one
|
|
2181
|
+
// that changed nothing (_buildResults writes neither artifact for an empty patch),
|
|
2182
|
+
// and neither does an archived one; all of those 404 and the UI shows its empty state.
|
|
1545
2183
|
// Key validation mirrors the /log route (:1529); the artifact read follows the
|
|
1546
2184
|
// recovery-patch route's readRunArtifactText pattern (:1408) — the log routes
|
|
1547
2185
|
// themselves use the specialized readRunLogText. The relPath is the CONSTANT
|
|
@@ -1561,6 +2199,23 @@ app.get('/api/history/:key/:id/diff', async (req, res) => {
|
|
|
1561
2199
|
}
|
|
1562
2200
|
});
|
|
1563
2201
|
|
|
2202
|
+
// GET /api/history/:key/:id/artifact?rel= -> { rel, text } for ONE artifact the
|
|
2203
|
+
// run indexed (the End card's result chip). `rel` never reaches the FS: it only
|
|
2204
|
+
// selects among the pipeline's own artifacts rows (exact rel_path, else a path
|
|
2205
|
+
// suffix). Same key regex as /diff.
|
|
2206
|
+
app.get('/api/history/:key/:id/artifact', async (req, res) => {
|
|
2207
|
+
if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(req.params.key)) {
|
|
2208
|
+
return res.status(404).json({ error: 'pipeline not found' });
|
|
2209
|
+
}
|
|
2210
|
+
try {
|
|
2211
|
+
const hit = await resolveIndexedArtifact(req.params.key, req.params.id, req.query.rel);
|
|
2212
|
+
if (!hit) return res.status(404).json({ error: 'artifact not found' });
|
|
2213
|
+
res.json(hit);
|
|
2214
|
+
} catch (err) {
|
|
2215
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2216
|
+
}
|
|
2217
|
+
});
|
|
2218
|
+
|
|
1564
2219
|
// ---------------------------------------------------------------------------
|
|
1565
2220
|
// DELETE /api/runs/:id?projectKey=... (or ?projectDir=...)
|
|
1566
2221
|
// ARCHIVE a FINISHED pipeline: reclaims everything on disk — its store folder,
|
|
@@ -2062,6 +2717,8 @@ app.get('/api/workspaces/:id/runs/:runId/log', async (req, res) => {
|
|
|
2062
2717
|
}
|
|
2063
2718
|
});
|
|
2064
2719
|
|
|
2720
|
+
// NOTE: the /comments twins for workspace runs are registered with their project
|
|
2721
|
+
// siblings up at the diff-comments block — the pair must be read together.
|
|
2065
2722
|
app.get('/api/workspaces/:id/runs/:runId/diff', async (req, res) => {
|
|
2066
2723
|
if (!WORKSPACE_KEY_RE.test(req.params.id)) {
|
|
2067
2724
|
return res.status(404).json({ error: 'pipeline not found' });
|
|
@@ -2075,6 +2732,18 @@ app.get('/api/workspaces/:id/runs/:runId/diff', async (req, res) => {
|
|
|
2075
2732
|
}
|
|
2076
2733
|
});
|
|
2077
2734
|
|
|
2735
|
+
// The End-card result chip's workspace twin (see the project route above).
|
|
2736
|
+
app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
|
|
2737
|
+
if (!WORKSPACE_KEY_RE.test(req.params.id)) return res.status(404).json({ error: 'pipeline not found' });
|
|
2738
|
+
try {
|
|
2739
|
+
const hit = await resolveIndexedArtifact(`workspaces/${req.params.id}`, req.params.runId, req.query.rel);
|
|
2740
|
+
if (!hit) return res.status(404).json({ error: 'artifact not found' });
|
|
2741
|
+
res.json(hit);
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2744
|
+
}
|
|
2745
|
+
});
|
|
2746
|
+
|
|
2078
2747
|
// ---------------------------------------------------------------------------
|
|
2079
2748
|
// GET /api/settings -> { root, projectsRoot, projectsRootDefault, default }
|
|
2080
2749
|
// root : the configured Worca CC data-root base, '' when unset
|
|
@@ -2106,6 +2775,10 @@ const settingsState = () => ({
|
|
|
2106
2775
|
pipelineCostLimitUsd: pipelineCostLimitUsd(),
|
|
2107
2776
|
totalCostLimitUsd: totalCostLimitUsd(),
|
|
2108
2777
|
costLimitResetPeriod: costLimitResetPeriod(),
|
|
2778
|
+
askMaxTurns: askMaxTurns(),
|
|
2779
|
+
askMaxBudgetUsd: askMaxBudgetUsd(),
|
|
2780
|
+
debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
|
|
2781
|
+
debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
|
|
2109
2782
|
});
|
|
2110
2783
|
|
|
2111
2784
|
app.get('/api/settings', (_req, res) => {
|
|
@@ -2120,6 +2793,8 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2120
2793
|
const body = req.body || {};
|
|
2121
2794
|
const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
|
|
2122
2795
|
const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
|
|
2796
|
+
const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
|
|
2797
|
+
const hasDebugSpawnKey = has('debugSpawnEnabled');
|
|
2123
2798
|
// Normalize the budget keys first, then validate them as a SET before ANY write.
|
|
2124
2799
|
// Each setter persists on its own, so a two-key POST whose second key is invalid
|
|
2125
2800
|
// used to answer 400 with the first key already on disk, no budget-changed
|
|
@@ -2131,8 +2806,24 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2131
2806
|
if (has('costLimitResetPeriod')) {
|
|
2132
2807
|
budget.costLimitResetPeriod = typeof body.costLimitResetPeriod === 'string' ? body.costLimitResetPeriod : '';
|
|
2133
2808
|
}
|
|
2809
|
+
// Ask Worca per-turn guards (ask-worca-design.md §6.9): same set-validation
|
|
2810
|
+
// discipline. `null` is a VALUE for askMaxBudgetUsd (no cap) and must survive
|
|
2811
|
+
// normalisation; only undefined becomes a clear.
|
|
2812
|
+
const ask = {};
|
|
2813
|
+
if (has('askMaxTurns')) ask.askMaxTurns = body.askMaxTurns ?? '';
|
|
2814
|
+
if (has('askMaxBudgetUsd')) ask.askMaxBudgetUsd = body.askMaxBudgetUsd === undefined ? '' : body.askMaxBudgetUsd;
|
|
2134
2815
|
try {
|
|
2135
2816
|
assertCostLimitInputs(budget);
|
|
2817
|
+
assertAskLimitInputs(ask);
|
|
2818
|
+
if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
|
|
2819
|
+
// Root first: it is the one key whose setter can still fail AFTER the asserts
|
|
2820
|
+
// above (an unusable path), so every other key's write must come after it or
|
|
2821
|
+
// a mixed POST would answer 400 with those keys already applied on disk.
|
|
2822
|
+
// Legacy contract: a POST that names NO known key clears root; the known
|
|
2823
|
+
// keys live beside their setters (SETTINGS_POST_KEYS), not in a list here.
|
|
2824
|
+
if (has('root') || !SETTINGS_POST_KEYS.some(has)) {
|
|
2825
|
+
await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
|
|
2826
|
+
}
|
|
2136
2827
|
if (has('chat')) await setChatPrefs(body.chat);
|
|
2137
2828
|
if (has('projectsRoot')) {
|
|
2138
2829
|
await setProjectsRoot(typeof body.projectsRoot === 'string' ? body.projectsRoot : '');
|
|
@@ -2140,12 +2831,13 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2140
2831
|
if (has('pipelineCostLimitUsd')) await setPipelineCostLimitUsd(budget.pipelineCostLimitUsd);
|
|
2141
2832
|
if (has('totalCostLimitUsd')) await setTotalCostLimitUsd(budget.totalCostLimitUsd);
|
|
2142
2833
|
if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
if (
|
|
2146
|
-
await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
|
|
2147
|
-
}
|
|
2834
|
+
if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
|
|
2835
|
+
if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
|
|
2836
|
+
if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
|
|
2148
2837
|
if (hasBudgetKey) emitChanged('budget-changed');
|
|
2838
|
+
// Other open tabs repaint their Settings cards (a stale tab could otherwise
|
|
2839
|
+
// "save" its old checkbox state over this one with no feedback to either).
|
|
2840
|
+
if (hasAskKey || hasDebugSpawnKey) emitChanged('settings-changed');
|
|
2149
2841
|
res.json({ ...settingsState(), chat: chatPrefs() });
|
|
2150
2842
|
} catch (err) {
|
|
2151
2843
|
// The setters throw only on an unusable path -> client error (400).
|
|
@@ -2167,6 +2859,7 @@ app.get('/api/config', async (req, res) => {
|
|
|
2167
2859
|
return res.json({
|
|
2168
2860
|
config: { steps: {}, customModels: [] },
|
|
2169
2861
|
models: await listModels(''), steps: agentSteps(), efforts: EFFORTS,
|
|
2862
|
+
subagentModels: SUBAGENT_MODEL_VALUES,
|
|
2170
2863
|
});
|
|
2171
2864
|
}
|
|
2172
2865
|
const projectDir = resolveProjectDir(raw);
|
|
@@ -2185,6 +2878,10 @@ app.get('/api/config', async (req, res) => {
|
|
|
2185
2878
|
]);
|
|
2186
2879
|
res.json({
|
|
2187
2880
|
config, models, steps: agentSteps(), efforts: EFFORTS,
|
|
2881
|
+
// The sub-agent model policy vocabulary is a FIXED alias enum (the CLI's Task
|
|
2882
|
+
// tool refuses catalog ids), so it ships beside `efforts` rather than being
|
|
2883
|
+
// derived from `models`.
|
|
2884
|
+
subagentModels: SUBAGENT_MODEL_VALUES,
|
|
2188
2885
|
});
|
|
2189
2886
|
} catch (err) {
|
|
2190
2887
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
@@ -2198,6 +2895,7 @@ app.post('/api/config', async (req, res) => {
|
|
|
2198
2895
|
try {
|
|
2199
2896
|
await setStep(projectDir, body.step, {
|
|
2200
2897
|
model: body.model, effort: body.effort, fanOut: body.fanOut, askQuestions: body.askQuestions,
|
|
2898
|
+
subagentModel: body.subagentModel,
|
|
2201
2899
|
});
|
|
2202
2900
|
// Respond with the FULL run-config (mirrors PATCH): setStep's return value is
|
|
2203
2901
|
// the legacy {steps, customModels} view only, and clients assign the response
|
|
@@ -2219,31 +2917,55 @@ app.post('/api/config', async (req, res) => {
|
|
|
2219
2917
|
// validates model/effort against the effective catalog exactly like setStep
|
|
2220
2918
|
// (configurable-models-design.md §4.5) -> 400; setFeedbackCycles still COERCES
|
|
2221
2919
|
// maxCycles to >= 1 (it never throws).
|
|
2222
|
-
// body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, activeWorkflowId? }
|
|
2920
|
+
// body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, wires?:{[wireId]:{maxCycles}}, activeWorkflowId? }
|
|
2223
2921
|
// ---------------------------------------------------------------------------
|
|
2224
2922
|
app.patch('/api/config', async (req, res) => {
|
|
2225
2923
|
const body = req.body || {};
|
|
2226
2924
|
const projectDir = resolveProjectDir(body.projectDir);
|
|
2227
2925
|
if (!projectDir) return badRequest(res, 'projectDir is required');
|
|
2228
2926
|
const workflowId = typeof body.workflowId === 'string' ? body.workflowId.trim() : '';
|
|
2927
|
+
// MAJ-1: every arm below keys a normalized table by workflowId, and
|
|
2928
|
+
// readWorkflowsMap rebuilds a map from those keys — an id like '__proto__'
|
|
2929
|
+
// used to be persisted unchecked and then broke readRunConfig for the whole
|
|
2930
|
+
// project. isSafeWorkflowId is the store's own id rule, so the API can never
|
|
2931
|
+
// write an id the store would refuse. (The recovery route DELETE
|
|
2932
|
+
// /api/config/workflow stays deliberately ungated: an already-poisoned row
|
|
2933
|
+
// must still be clearable.)
|
|
2934
|
+
const workflowIdError = (what) => {
|
|
2935
|
+
if (!workflowId) return `workflowId is required to set ${what} config`;
|
|
2936
|
+
if (!isSafeWorkflowId(workflowId)) return 'invalid workflowId';
|
|
2937
|
+
return null;
|
|
2938
|
+
};
|
|
2229
2939
|
try {
|
|
2230
2940
|
if (body.nodes && typeof body.nodes === 'object') {
|
|
2231
|
-
|
|
2941
|
+
const bad = workflowIdError('node');
|
|
2942
|
+
if (bad) return badRequest(res, bad);
|
|
2232
2943
|
for (const [nodeId, sel] of Object.entries(body.nodes)) {
|
|
2233
2944
|
await setNodeModel(projectDir, workflowId, nodeId, {
|
|
2234
2945
|
model: sel && sel.model, effort: sel && sel.effort,
|
|
2235
2946
|
fanOut: sel && sel.fanOut, askQuestions: sel && sel.askQuestions,
|
|
2947
|
+
subagentModel: sel && sel.subagentModel,
|
|
2236
2948
|
});
|
|
2237
2949
|
}
|
|
2238
2950
|
}
|
|
2239
2951
|
if (body.feedbacks && typeof body.feedbacks === 'object') {
|
|
2240
|
-
|
|
2952
|
+
const bad = workflowIdError('feedback');
|
|
2953
|
+
if (bad) return badRequest(res, bad);
|
|
2241
2954
|
for (const [fbId, sel] of Object.entries(body.feedbacks)) {
|
|
2242
2955
|
await setFeedbackCycles(projectDir, workflowId, fbId, sel && sel.maxCycles);
|
|
2243
2956
|
}
|
|
2244
2957
|
}
|
|
2958
|
+
if (body.wires && typeof body.wires === 'object') {
|
|
2959
|
+
const bad = workflowIdError('wire');
|
|
2960
|
+
if (bad) return badRequest(res, bad);
|
|
2961
|
+
for (const [wireId, sel] of Object.entries(body.wires)) {
|
|
2962
|
+
await setWireCycles(projectDir, workflowId, wireId, sel && sel.maxCycles);
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2245
2965
|
if (typeof body.activeWorkflowId === 'string' && body.activeWorkflowId.trim()) {
|
|
2246
|
-
|
|
2966
|
+
const active = body.activeWorkflowId.trim();
|
|
2967
|
+
if (!isSafeWorkflowId(active)) return badRequest(res, 'invalid workflowId');
|
|
2968
|
+
await setActiveWorkflow(projectDir, active);
|
|
2247
2969
|
}
|
|
2248
2970
|
const config = await readRunConfig(projectDir);
|
|
2249
2971
|
res.json({ config });
|
|
@@ -2301,8 +3023,7 @@ app.delete('/api/config/models', async (req, res) => {
|
|
|
2301
3023
|
// back means "keep" and is dropped from the write.
|
|
2302
3024
|
// ---------------------------------------------------------------------------
|
|
2303
3025
|
|
|
2304
|
-
const maskEnvValue = (v) =>
|
|
2305
|
-
(modelEnvRef(v) ? v : (v.length > 8 ? `••••••${v.slice(-4)}` : '••••••'));
|
|
3026
|
+
const maskEnvValue = (v) => (modelEnvRef(v) ? v : maskModelEnvValue(v));
|
|
2306
3027
|
const maskedGlobalModel = (m) => (m.env
|
|
2307
3028
|
? { ...m, env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) }
|
|
2308
3029
|
: m);
|
|
@@ -2330,6 +3051,7 @@ const pluginModelsPayload = () => {
|
|
|
2330
3051
|
k, typeof v === 'string' ? maskEnvValue(v) : `(secret: ${v.secret})`,
|
|
2331
3052
|
])),
|
|
2332
3053
|
secrets: status.filter((s) => m.secrets.includes(s.key)),
|
|
3054
|
+
...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing — config, never a credential
|
|
2333
3055
|
...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
|
|
2334
3056
|
};
|
|
2335
3057
|
});
|
|
@@ -2342,7 +3064,7 @@ app.get('/api/models', (req, res) => {
|
|
|
2342
3064
|
app.post('/api/models', async (req, res) => {
|
|
2343
3065
|
const b = req.body || {};
|
|
2344
3066
|
try {
|
|
2345
|
-
const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env });
|
|
3067
|
+
const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env, cost: b.cost });
|
|
2346
3068
|
res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
|
|
2347
3069
|
} catch (err) {
|
|
2348
3070
|
// addGlobalModel throws only on validation (empty/dup id, unknown effort,
|
|
@@ -2425,6 +3147,10 @@ app.post('/api/models/export-plugin', async (req, res) => {
|
|
|
2425
3147
|
...(entry.label !== entry.id ? { label: entry.label } : {}),
|
|
2426
3148
|
...(entry.efforts.length && entry.efforts.length !== EFFORTS.length ? { efforts: entry.efforts } : {}),
|
|
2427
3149
|
...(Object.keys(env).length ? { env } : {}),
|
|
3150
|
+
// Pricing travels with the model. It is configuration, not a credential —
|
|
3151
|
+
// and a shared on-prem model is precisely one the CLI would otherwise
|
|
3152
|
+
// price by NAME on every machine that installs the plugin.
|
|
3153
|
+
...(entry.cost ? { cost: entry.cost } : {}),
|
|
2428
3154
|
});
|
|
2429
3155
|
}
|
|
2430
3156
|
|
|
@@ -2495,7 +3221,7 @@ app.patch('/api/models/:id', async (req, res) => {
|
|
|
2495
3221
|
env = Object.fromEntries(Object.entries(env).filter(([, v]) => !isMaskedEcho(v)));
|
|
2496
3222
|
}
|
|
2497
3223
|
try {
|
|
2498
|
-
const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env });
|
|
3224
|
+
const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env, cost: b.cost });
|
|
2499
3225
|
res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
|
|
2500
3226
|
} catch (err) {
|
|
2501
3227
|
// updateGlobalModel throws only on validation (unknown id, unknown effort,
|
|
@@ -2534,6 +3260,35 @@ app.delete('/api/models/:id', async (req, res) => {
|
|
|
2534
3260
|
}
|
|
2535
3261
|
});
|
|
2536
3262
|
|
|
3263
|
+
// Live connectivity check for a catalog model — the Models-view Test button.
|
|
3264
|
+
// Explicit user action only (one real, tiny API call against wherever the
|
|
3265
|
+
// model routes). Caller mistakes get an HTTP status; the test OUTCOME rides a
|
|
3266
|
+
// 200 envelope, same convention as POST /api/chat/test. Ids resolve global
|
|
3267
|
+
// first, then plugin — resolveModelEnv's precedence.
|
|
3268
|
+
const modelTestsInFlight = new Set();
|
|
3269
|
+
app.post('/api/models/:id/test', async (req, res) => {
|
|
3270
|
+
const id = String(req.params.id);
|
|
3271
|
+
const lc = id.toLowerCase();
|
|
3272
|
+
const global = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
|
|
3273
|
+
const plugin = global ? null : listPluginModels().find((m) => m.id.toLowerCase() === lc);
|
|
3274
|
+
if (!global && !plugin) return res.status(404).json({ error: `unknown model id ${JSON.stringify(id)}` });
|
|
3275
|
+
if (plugin && plugin.secrets.length) {
|
|
3276
|
+
// Don't burn a spawn guaranteed to fail — resolveModelEnv drops unset secrets.
|
|
3277
|
+
const unset = pluginModelSecretStatus(plugin.plugin)
|
|
3278
|
+
.filter((s) => plugin.secrets.includes(s.key) && !s.set).map((s) => s.key);
|
|
3279
|
+
if (unset.length) {
|
|
3280
|
+
return badRequest(res, `secret ${unset.join(', ')} is not set — configure it in the plugin's settings`);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
if (modelTestsInFlight.has(lc)) return badRequest(res, 'test already running for this model');
|
|
3284
|
+
modelTestsInFlight.add(lc);
|
|
3285
|
+
try {
|
|
3286
|
+
res.json(await testModel(id));
|
|
3287
|
+
} finally {
|
|
3288
|
+
modelTestsInFlight.delete(lc);
|
|
3289
|
+
}
|
|
3290
|
+
});
|
|
3291
|
+
|
|
2537
3292
|
// ---------------------------------------------------------------------------
|
|
2538
3293
|
// Workflow templates (global store at ~/.worca-cc/workflows). Topology only;
|
|
2539
3294
|
// model/effort/cycles live in per-project run-config. CRUD mirrors the
|
|
@@ -2550,6 +3305,10 @@ function nodeDefaultsError(raw, models, where) {
|
|
|
2550
3305
|
const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
|
|
2551
3306
|
const entry = model ? models.find((m) => m.id === model) : null;
|
|
2552
3307
|
if (model && !entry) return `unknown model "${model}"`;
|
|
3308
|
+
// subagentModel is a fixed alias enum, NOT a catalog id: validated via the
|
|
3309
|
+
// shared helper so a typo is a 400 with the same message every writer uses.
|
|
3310
|
+
const subIssue = subagentModelIssue(raw.subagentModel);
|
|
3311
|
+
if (subIssue) return subIssue;
|
|
2553
3312
|
if (!effort) return '';
|
|
2554
3313
|
if (!EFFORTS.includes(effort)) return `unknown effort "${effort}"`;
|
|
2555
3314
|
if (!entry) return 'select a model before choosing an effort';
|
|
@@ -2557,11 +3316,15 @@ function nodeDefaultsError(raw, models, where) {
|
|
|
2557
3316
|
return '';
|
|
2558
3317
|
}
|
|
2559
3318
|
|
|
2560
|
-
app.get('/api/workflows', async (
|
|
3319
|
+
app.get('/api/workflows', async (req, res) => {
|
|
2561
3320
|
try {
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
3321
|
+
if (isTruthy(req.query.archived)) {
|
|
3322
|
+
const all = await listWorkflows({ includeArchived: true });
|
|
3323
|
+
return res.json({ workflows: all.filter((w) => w.archivedAt) });
|
|
3324
|
+
}
|
|
3325
|
+
// CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, ...listWorkflows() ]. The built-in is
|
|
3326
|
+
// never a persisted row (listWorkflows filters its id), so it cannot appear twice.
|
|
3327
|
+
res.json({ workflows: [GRAPH_DEFAULT_WORKFLOW, ...(await listWorkflows())] });
|
|
2565
3328
|
} catch (err) {
|
|
2566
3329
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2567
3330
|
}
|
|
@@ -2569,51 +3332,80 @@ app.get('/api/workflows', async (_req, res) => {
|
|
|
2569
3332
|
|
|
2570
3333
|
app.get('/api/workflows/:id', async (req, res) => {
|
|
2571
3334
|
try {
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
3335
|
+
// ONE gate, ONE message: an archived id explains itself instead of reading
|
|
3336
|
+
// as a plain 404 (assertRunnableWorkflow owns both texts). checkGraph:false —
|
|
3337
|
+
// this is a READ (the Composer's Open): a template stranded by an agent-port
|
|
3338
|
+
// edit must still load, or the user could never repair it. The RUN path keeps
|
|
3339
|
+
// the graph check.
|
|
3340
|
+
res.json(await assertRunnableWorkflow(req.params.id, { checkGraph: false }));
|
|
2575
3341
|
} catch (err) {
|
|
3342
|
+
if (err && (err.code === 'NOT_FOUND' || err.code === 'ARCHIVED')) {
|
|
3343
|
+
return res.status(404).json({ error: err.message });
|
|
3344
|
+
}
|
|
2576
3345
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2577
3346
|
}
|
|
2578
3347
|
});
|
|
2579
3348
|
|
|
2580
3349
|
app.post('/api/workflows', async (req, res) => {
|
|
2581
3350
|
const body = req.body || {};
|
|
2582
|
-
//
|
|
2583
|
-
|
|
3351
|
+
// The v1 pipeline format is RETIRED: only graphs are accepted (spec §10.2).
|
|
3352
|
+
// The whole v1 arm (its steps-borne node defaults, validateWorkflow and
|
|
3353
|
+
// writeWorkflow) died with it — nothing reaches the v1 store through the API.
|
|
3354
|
+
if (body.version !== 2) {
|
|
3355
|
+
return badRequest(res, 'v1 pipeline templates are no longer accepted — save a graph (version 2)');
|
|
3356
|
+
}
|
|
3357
|
+
// ── v2 graph save ──────────────────────────────────────────────────────────
|
|
3358
|
+
// The 422 body is the SHARED validator's issue list, by construction: the
|
|
3359
|
+
// composer renders exactly what it would have computed locally, so the server
|
|
3360
|
+
// and the client can never disagree about why a graph is illegal.
|
|
3361
|
+
const graph = {
|
|
3362
|
+
id: typeof body.id === 'string' ? body.id : undefined,
|
|
2584
3363
|
name: typeof body.name === 'string' ? body.name.trim() : '',
|
|
2585
|
-
domain: typeof body.domain === 'string' ? body.domain : undefined,
|
|
2586
|
-
|
|
2587
|
-
|
|
3364
|
+
domain: typeof body.domain === 'string' ? body.domain : undefined,
|
|
3365
|
+
nodes: Array.isArray(body.nodes) ? body.nodes : [],
|
|
3366
|
+
wires: Array.isArray(body.wires) ? body.wires : [],
|
|
3367
|
+
...(body.canvas && typeof body.canvas === 'object' ? { canvas: body.canvas } : {}),
|
|
2588
3368
|
};
|
|
2589
|
-
if (!
|
|
3369
|
+
if (!graph.name) return badRequest(res, 'name is required');
|
|
2590
3370
|
try {
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
//
|
|
3371
|
+
// Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
|
|
3372
|
+
// so a value the per-project override could not name must not ride in
|
|
3373
|
+
// through a template save. nodeDefaultsError checks the tunables (model +
|
|
3374
|
+
// effort against the catalog, subagentModel against the alias enum), so
|
|
3375
|
+
// only AGENT_TUNABLES are handed to it — topology keys (awaitAll, arity,
|
|
3376
|
+
// planStoreSeed) never are.
|
|
2594
3377
|
const models = await listModels('');
|
|
2595
|
-
for (const
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
3378
|
+
for (const n of graph.nodes) {
|
|
3379
|
+
if (!n || n.kind !== 'agent' || !n.config || typeof n.config !== 'object') continue;
|
|
3380
|
+
const picked = Object.fromEntries(
|
|
3381
|
+
AGENT_TUNABLES.filter((k) => k in n.config).map((k) => [k, n.config[k]]));
|
|
3382
|
+
const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
|
|
3383
|
+
if (bad) return badRequest(res, bad);
|
|
2601
3384
|
}
|
|
2602
|
-
const
|
|
2603
|
-
const {
|
|
2604
|
-
if (
|
|
2605
|
-
//
|
|
2606
|
-
|
|
2607
|
-
|
|
3385
|
+
const portsFn = registryPortsFn(loadAgentRegistry(AGENTS_DIR));
|
|
3386
|
+
const { errors, warnings } = validateGraph({ ...graph, version: 2 }, portsFn);
|
|
3387
|
+
if (errors.length) return res.status(422).json({ error: 'invalid graph', errors, warnings });
|
|
3388
|
+
// rejectCollision (MAJ-5): the body carried no id, so wf_<slug(name)> is a
|
|
3389
|
+
// GUESS — it must never silently replace a pipeline the user can see.
|
|
3390
|
+
const workflow = await writeGraphWorkflow(graph, { rejectCollision: true });
|
|
3391
|
+
return res.status(201).json({ workflow, warnings });
|
|
2608
3392
|
} catch (err) {
|
|
2609
|
-
|
|
3393
|
+
// C-3: a name that slugs onto the reserved wf_default is a caller error, not
|
|
3394
|
+
// a server fault — 422, the same code the validator's refusal uses. MAJ-5: a
|
|
3395
|
+
// minted id already in use is a 409 carrying that id, so the dialog can offer
|
|
3396
|
+
// rename/overwrite. Both bodies carry NO issues/errors array on purpose:
|
|
3397
|
+
// app.js's saveWorkflow maps `error` straight into the save dialog's message
|
|
3398
|
+
// line, verbatim.
|
|
3399
|
+
if (err && err.code === 'RESERVED_NAME') return res.status(422).json({ error: err.message });
|
|
3400
|
+
if (err && err.code === 'ID_TAKEN') return res.status(409).json({ error: err.message, id: err.id });
|
|
3401
|
+
return res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2610
3402
|
}
|
|
2611
3403
|
});
|
|
2612
3404
|
|
|
2613
3405
|
// ---------------------------------------------------------------------------
|
|
2614
3406
|
// PATCH /api/workflows/:id/defaults -> set the template's per-node defaults
|
|
2615
3407
|
// (newpipeline-ux-design.md §4.4). body: { defaults: { [nodeId]: {model?, effort?,
|
|
2616
|
-
// fanOut?, askQuestions?} | null } }; null (or an empty block) clears a node, an
|
|
3408
|
+
// fanOut?, askQuestions?, subagentModel?} | null } }; null (or an empty block) clears a node, an
|
|
2617
3409
|
// absent node keeps what it has. Model/effort validate against the PROJECT-LESS
|
|
2618
3410
|
// catalog (predefined ⊕ global ⊕ plugin) — defaults are global, so a legacy
|
|
2619
3411
|
// per-project custom model is deliberately not a valid default.
|
|
@@ -2750,32 +3542,769 @@ app.delete('/api/guardrails/:id', async (req, res) => {
|
|
|
2750
3542
|
}
|
|
2751
3543
|
});
|
|
2752
3544
|
|
|
3545
|
+
// ---------------------------------------------------------------------------
|
|
3546
|
+
// Ask Worca (ask-worca-design.md §8). askJobs is SEPARATE from the runs Map —
|
|
3547
|
+
// the client's Running badge counts runs entries, and a thread id is the
|
|
3548
|
+
// subscription key (§8.3). No store/home access at import time (the chatCtx
|
|
3549
|
+
// rule): the Maps are bare and every store call lives inside a handler or
|
|
3550
|
+
// bootMaintenance.
|
|
3551
|
+
// ---------------------------------------------------------------------------
|
|
3552
|
+
const askJobs = new Map(); // threadId -> {turn, messageId, userMessageId, events, seq, status, startedAt, graceTimer}
|
|
3553
|
+
// Threads whose DELETE is past its first await (worktree removal spawns git):
|
|
3554
|
+
// POST /messages refuses them so no turn can start against rows that are
|
|
3555
|
+
// about to cascade (review of PR #376 — a turn started in that window outlived
|
|
3556
|
+
// the delete as a live job holding a global slot).
|
|
3557
|
+
const askDeleting = new Set();
|
|
3558
|
+
const askFollowers = new Map(); // threadId -> Set<{detach}>
|
|
3559
|
+
const ASK_JOB_MAX_BUFFER = 5000; // same arithmetic as MAX_BUFFER: deltas dominate; eviction ⇒ client seq-gap re-sync
|
|
3560
|
+
|
|
3561
|
+
function askInFlight(threadId) {
|
|
3562
|
+
const job = askJobs.get(threadId);
|
|
3563
|
+
return job && job.status === 'running' ? job : null;
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
function askRunningCount() {
|
|
3567
|
+
let n = 0;
|
|
3568
|
+
for (const job of askJobs.values()) if (job.status === 'running') n += 1;
|
|
3569
|
+
return n;
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
/** hello payload: running turns only (§8.2). A job whose slot was just
|
|
3573
|
+
* reserved (messageId still null — the message route's atomic reservation,
|
|
3574
|
+
* Task 6) is skipped: it becomes visible once its assistant row exists. */
|
|
3575
|
+
function askHello() {
|
|
3576
|
+
const out = [];
|
|
3577
|
+
for (const [threadId, job] of askJobs.entries()) {
|
|
3578
|
+
if (job.status === 'running' && job.messageId) out.push({ threadId, messageId: job.messageId });
|
|
3579
|
+
}
|
|
3580
|
+
return out;
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
/** Replay a job's stamped ring buffer to one socket. No state snapshot — the
|
|
3584
|
+
* REST thread GET is the snapshot; the client dedupes by seq (§6.6). */
|
|
3585
|
+
function replayAskJob(ws, job) {
|
|
3586
|
+
for (const ev of job.events) send(ws, ev);
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
/** The stamping closure (§17: reducer frames are BARE; the server stamps).
|
|
3590
|
+
* Shared by the turn's own ask-start/ask-done/ask-error and every reducer
|
|
3591
|
+
* frame, so ALL job frames are buffered, replayed and seq-ordered alike. */
|
|
3592
|
+
function stampAskFrames(threadId, job) {
|
|
3593
|
+
return (bare) => {
|
|
3594
|
+
const frame = { ...bare, threadId, messageId: job.messageId, seq: ++job.seq };
|
|
3595
|
+
job.events.push(frame);
|
|
3596
|
+
if (job.events.length > ASK_JOB_MAX_BUFFER) job.events.splice(0, job.events.length - ASK_JOB_MAX_BUFFER);
|
|
3597
|
+
broadcast(frame);
|
|
3598
|
+
};
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
/** The narrow worktree envelope the snapshot GET and the `ask-worktrees` frame
|
|
3602
|
+
* share (P4 §10): never the full row — threadId/projectDir/updatedAt stay
|
|
3603
|
+
* server-side. Mirrors the list_worktrees MCP tool (src/core/ask/tools.mjs). */
|
|
3604
|
+
function askWorktreesEnvelope(threadId) {
|
|
3605
|
+
return askListWorktrees(threadId).map((w) => ({
|
|
3606
|
+
worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref,
|
|
3607
|
+
commit: w.commit, path: w.path, createdAt: w.createdAt,
|
|
3608
|
+
}));
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
/** Broadcast the thread's CURRENT worktrees as an out-of-turn frame (seq-less,
|
|
3612
|
+
* threadId-tagged, like ask-title). Fed by the turn's onWorktreeMutation hook —
|
|
3613
|
+
* the MCP child opened/removed/navigated a checkout this process never saw —
|
|
3614
|
+
* and by the manual DELETE route, so every tab's count and popover follow
|
|
3615
|
+
* without a snapshot GET. Best effort; false when the thread is gone. */
|
|
3616
|
+
function emitAskWorktrees(threadId) {
|
|
3617
|
+
try {
|
|
3618
|
+
if (!askGetThread(threadId)) return false;
|
|
3619
|
+
broadcast({ type: 'ask-worktrees', threadId, worktrees: askWorktreesEnvelope(threadId) });
|
|
3620
|
+
return true;
|
|
3621
|
+
} catch { return false; } // a poke is best effort
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
/** 400 on shape (spec §8.1 — a DELIBERATE divergence from the house 404-on-
|
|
3625
|
+
* malformed-param style), null-return contract like badRequest. */
|
|
3626
|
+
function askIdParam(res, value, kind) {
|
|
3627
|
+
if (typeof value !== 'string' || !ASK_ID_RE.test(value)) {
|
|
3628
|
+
res.status(400).json({ error: `invalid ${kind} id` });
|
|
3629
|
+
return null;
|
|
3630
|
+
}
|
|
3631
|
+
return value;
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
app.get('/api/ask/threads', (req, res) => {
|
|
3635
|
+
try {
|
|
3636
|
+
const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
|
|
3637
|
+
const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
|
|
3638
|
+
const threads = askListThreads({ limit }).map((t) => ({ ...t, inFlight: !!askInFlight(t.id) }));
|
|
3639
|
+
// total = EVERY saved chat (the History popover's meter), not the capped page above.
|
|
3640
|
+
res.json({ threads, total: askCountThreads() });
|
|
3641
|
+
} catch (err) {
|
|
3642
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3643
|
+
}
|
|
3644
|
+
});
|
|
3645
|
+
|
|
3646
|
+
// Settings → "Delete all chat history": the counts the confirm dialog quotes,
|
|
3647
|
+
// read fresh right before it opens.
|
|
3648
|
+
app.get('/api/ask/history', (req, res) => {
|
|
3649
|
+
try {
|
|
3650
|
+
res.json({
|
|
3651
|
+
threads: askCountThreads(),
|
|
3652
|
+
worktrees: askCountWorktrees(),
|
|
3653
|
+
attachments: askCountAttachments(),
|
|
3654
|
+
inFlight: askRunningCount(),
|
|
3655
|
+
});
|
|
3656
|
+
} catch (err) {
|
|
3657
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3658
|
+
}
|
|
3659
|
+
});
|
|
3660
|
+
|
|
3661
|
+
// Bulk delete: every thread through deleteAskThreadFully, SEQUENTIALLY (each
|
|
3662
|
+
// worktree removal spawns git — never in parallel), best-effort per thread. The
|
|
3663
|
+
// ids come from listThreadIds (no cap), never from the LIMIT-ed listThreads.
|
|
3664
|
+
// One JSON at the end; then a seq-less out-of-turn frame so every open tab
|
|
3665
|
+
// drops its now-dead st.threadId (the panel would otherwise keep it until the
|
|
3666
|
+
// next 404).
|
|
3667
|
+
app.delete('/api/ask/threads', async (req, res) => {
|
|
3668
|
+
const removed = { threads: 0, worktrees: 0 };
|
|
3669
|
+
const failed = [];
|
|
3670
|
+
try {
|
|
3671
|
+
for (const id of askListThreadIds()) {
|
|
3672
|
+
try {
|
|
3673
|
+
const r = await deleteAskThreadFully(id);
|
|
3674
|
+
if (r.deleted) {
|
|
3675
|
+
removed.threads += 1;
|
|
3676
|
+
removed.worktrees += r.worktrees;
|
|
3677
|
+
} else failed.push(id);
|
|
3678
|
+
} catch {
|
|
3679
|
+
failed.push(id);
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
res.json({ ok: true, removed, failed });
|
|
3683
|
+
broadcast({ type: 'ask-history-cleared' });
|
|
3684
|
+
} catch (err) {
|
|
3685
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3686
|
+
}
|
|
3687
|
+
});
|
|
3688
|
+
|
|
3689
|
+
app.post('/api/ask/threads', (req, res) => {
|
|
3690
|
+
try {
|
|
3691
|
+
const body = req.body || {};
|
|
3692
|
+
let title = null;
|
|
3693
|
+
if (body.title !== undefined && body.title !== null && body.title !== '') {
|
|
3694
|
+
if (typeof body.title !== 'string' || body.title.length > 120) {
|
|
3695
|
+
return badRequest(res, 'title must be a string of at most 120 characters');
|
|
3696
|
+
}
|
|
3697
|
+
title = body.title.trim() || null;
|
|
3698
|
+
}
|
|
3699
|
+
const thread = askCreateThread();
|
|
3700
|
+
if (title) askUpdateThread(thread.id, { title });
|
|
3701
|
+
res.status(201).json({ thread: askGetThread(thread.id) });
|
|
3702
|
+
} catch (err) {
|
|
3703
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3704
|
+
}
|
|
3705
|
+
});
|
|
3706
|
+
|
|
3707
|
+
app.get('/api/ask/threads/:id', (req, res) => {
|
|
3708
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3709
|
+
if (!id) return;
|
|
3710
|
+
try {
|
|
3711
|
+
const thread = askGetThread(id);
|
|
3712
|
+
if (!thread) return res.status(404).json({ error: 'thread not found' });
|
|
3713
|
+
const job = askInFlight(id);
|
|
3714
|
+
res.json({
|
|
3715
|
+
thread,
|
|
3716
|
+
messages: askListMessages(id),
|
|
3717
|
+
attachments: askListAttachments(id),
|
|
3718
|
+
runLinks: askListRunLinks(id),
|
|
3719
|
+
// P4 §10: the SAME narrow envelope the list_worktrees MCP tool and the
|
|
3720
|
+
// ask-worktrees frame carry — never the full row.
|
|
3721
|
+
worktrees: askWorktreesEnvelope(id),
|
|
3722
|
+
inFlight: job && job.messageId ? { messageId: job.messageId } : null, // null while the slot is only reserved
|
|
3723
|
+
});
|
|
3724
|
+
} catch (err) {
|
|
3725
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3726
|
+
}
|
|
3727
|
+
});
|
|
3728
|
+
|
|
3729
|
+
app.patch('/api/ask/threads/:id', (req, res) => {
|
|
3730
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3731
|
+
if (!id) return;
|
|
3732
|
+
try {
|
|
3733
|
+
const body = req.body || {};
|
|
3734
|
+
const patch = {};
|
|
3735
|
+
// Title keeps its original contract exactly: a PATCH that names neither field
|
|
3736
|
+
// still earns the title error, so pre-#397 callers see identical behaviour.
|
|
3737
|
+
if (body.title !== undefined || body.scope === undefined) {
|
|
3738
|
+
const raw = body.title;
|
|
3739
|
+
if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
|
|
3740
|
+
return badRequest(res, 'title must be a non-empty string of at most 120 characters');
|
|
3741
|
+
}
|
|
3742
|
+
patch.title = raw.trim();
|
|
3743
|
+
}
|
|
3744
|
+
if (body.scope !== undefined) {
|
|
3745
|
+
// #397: the Ask panel's scope selector. Merged per field into the stored
|
|
3746
|
+
// context — the pin replaces only the target keys, so the last page
|
|
3747
|
+
// context (view, run, diff file) survives a selector change.
|
|
3748
|
+
const sv = askValidateScope(body.scope);
|
|
3749
|
+
if (!sv.ok) return badRequest(res, sv.error);
|
|
3750
|
+
const cur = askGetThread(id);
|
|
3751
|
+
if (!cur) return res.status(404).json({ error: 'thread not found' });
|
|
3752
|
+
const base = cur.context && typeof cur.context === 'object' && !Array.isArray(cur.context) ? { ...cur.context } : {};
|
|
3753
|
+
delete base.projectDir;
|
|
3754
|
+
delete base.projectKey;
|
|
3755
|
+
delete base.workspaceId;
|
|
3756
|
+
patch.context = { ...base, ...sv.scope };
|
|
3757
|
+
}
|
|
3758
|
+
const thread = askUpdateThread(id, patch);
|
|
3759
|
+
if (!thread) return res.status(404).json({ error: 'thread not found' });
|
|
3760
|
+
res.json({ thread });
|
|
3761
|
+
} catch (err) {
|
|
3762
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3763
|
+
}
|
|
3764
|
+
});
|
|
3765
|
+
|
|
3766
|
+
// §7.5 order: abort the in-flight turn -> detach followers -> remove the chat's
|
|
3767
|
+
// worktrees git-properly -> delete the row (tx + cascades) + rm -rf inside
|
|
3768
|
+
// deleteThread -> drop the job entry. Shared by the per-thread DELETE and the
|
|
3769
|
+
// bulk DELETE; askDeleting brackets the whole thing per id (POST /messages
|
|
3770
|
+
// refuses the thread while its delete is past the first await).
|
|
3771
|
+
// Returns { deleted, worktrees } — worktrees = rows removeThreadWorktrees removed.
|
|
3772
|
+
async function deleteAskThreadFully(id) {
|
|
3773
|
+
askDeleting.add(id);
|
|
3774
|
+
try {
|
|
3775
|
+
const stopJob = () => {
|
|
3776
|
+
const job = askJobs.get(id);
|
|
3777
|
+
if (job && job.turn && typeof job.turn.stop === 'function') {
|
|
3778
|
+
try { job.turn.stop(); } catch { /* best-effort */ }
|
|
3779
|
+
}
|
|
3780
|
+
return job;
|
|
3781
|
+
};
|
|
3782
|
+
stopJob();
|
|
3783
|
+
const followers = askFollowers.get(id);
|
|
3784
|
+
if (followers) {
|
|
3785
|
+
for (const f of [...followers]) {
|
|
3786
|
+
try { f.detach(); } catch { /* best-effort */ }
|
|
3787
|
+
}
|
|
3788
|
+
askFollowers.delete(id);
|
|
3789
|
+
}
|
|
3790
|
+
// P4 §5: git-proper removal of every worktree BEFORE the row cascade — the
|
|
3791
|
+
// rmSync inside askDeleteThread alone would leave stale `git worktree`
|
|
3792
|
+
// registrations in the source repos. Never throws (best-effort per row).
|
|
3793
|
+
const { removed } = await askRemoveThreadWorktrees(id);
|
|
3794
|
+
// Re-read the job AFTER the await: askDeleting blocks new turns, but a turn
|
|
3795
|
+
// that was already mid-start is stopped here rather than left running.
|
|
3796
|
+
const job = stopJob();
|
|
3797
|
+
const deleted = askDeleteThread(id);
|
|
3798
|
+
if (job) {
|
|
3799
|
+
if (job.graceTimer) clearTimeout(job.graceTimer);
|
|
3800
|
+
askJobs.delete(id);
|
|
3801
|
+
}
|
|
3802
|
+
return { deleted, worktrees: removed };
|
|
3803
|
+
} finally {
|
|
3804
|
+
askDeleting.delete(id);
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
app.delete('/api/ask/threads/:id', async (req, res) => {
|
|
3809
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3810
|
+
if (!id) return;
|
|
3811
|
+
try {
|
|
3812
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3813
|
+
await deleteAskThreadFully(id);
|
|
3814
|
+
res.json({ ok: true });
|
|
3815
|
+
} catch (err) {
|
|
3816
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3817
|
+
}
|
|
3818
|
+
});
|
|
3819
|
+
|
|
3820
|
+
// P4 §10: manual worktree delete from the panel. Allowed while a turn is in
|
|
3821
|
+
// flight — the model's next operation on it gets a clean tool error.
|
|
3822
|
+
app.delete('/api/ask/threads/:id/worktrees/:wtId', async (req, res) => {
|
|
3823
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3824
|
+
if (!id) return;
|
|
3825
|
+
const wtId = askIdParam(res, req.params.wtId, 'worktree');
|
|
3826
|
+
if (!wtId) return;
|
|
3827
|
+
try {
|
|
3828
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3829
|
+
const out = await askRemoveWorktree({ threadId: id, wtId });
|
|
3830
|
+
emitAskWorktrees(id); // every open tab's count/popover follows the delete
|
|
3831
|
+
res.json(out);
|
|
3832
|
+
} catch (err) {
|
|
3833
|
+
if (err && err.name === 'AskWorktreeError') return res.status(404).json({ error: err.message });
|
|
3834
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3835
|
+
}
|
|
3836
|
+
});
|
|
3837
|
+
|
|
3838
|
+
app.get('/api/ask/threads/:id/attachments/:attId', (req, res) => {
|
|
3839
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3840
|
+
if (!id) return;
|
|
3841
|
+
const attId = askIdParam(res, req.params.attId, 'attachment');
|
|
3842
|
+
if (!attId) return;
|
|
3843
|
+
try {
|
|
3844
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3845
|
+
const att = askGetAttachment(id, attId);
|
|
3846
|
+
const file = att ? askAttachmentPath(id, attId) : null;
|
|
3847
|
+
if (!file) return res.status(404).json({ error: 'attachment not found' });
|
|
3848
|
+
// Text bodies serve as utf-8 text/plain (pre-#398, byte-for-byte: the body
|
|
3849
|
+
// was UTF-8-validated at upload and stored verbatim). Only sniff-verified
|
|
3850
|
+
// allowlisted mimes are ever stored (never scriptable markup like SVG/HTML),
|
|
3851
|
+
// so serving the real mime inline is safe — and it is what lets the
|
|
3852
|
+
// transcript render <img> thumbnails (#398).
|
|
3853
|
+
const type = att.kind === 'text' ? 'text/plain; charset=utf-8' : (att.mime || 'application/octet-stream');
|
|
3854
|
+
// Streamed, not readFileSync + send: a body is immutable under its
|
|
3855
|
+
// store-minted id, so a stat-based ETag/Last-Modified plus a year-long
|
|
3856
|
+
// private immutable cache replaces a 5 MB sync read and sha1 per request —
|
|
3857
|
+
// the transcript re-creates every <img> on each structural render.
|
|
3858
|
+
res.sendFile(path.basename(file), {
|
|
3859
|
+
root: path.dirname(file),
|
|
3860
|
+
dotfiles: 'deny',
|
|
3861
|
+
cacheControl: false,
|
|
3862
|
+
headers: {
|
|
3863
|
+
'Content-Type': type,
|
|
3864
|
+
'X-Content-Type-Options': 'nosniff',
|
|
3865
|
+
'Content-Disposition': 'inline',
|
|
3866
|
+
'Cache-Control': 'private, max-age=31536000, immutable',
|
|
3867
|
+
},
|
|
3868
|
+
}, (err) => {
|
|
3869
|
+
if (!err || res.headersSent) return;
|
|
3870
|
+
if (err.code === 'ENOENT' || err.status === 404) return res.status(404).json({ error: 'attachment not found' });
|
|
3871
|
+
res.status(500).json({ error: err.message || String(err) });
|
|
3872
|
+
});
|
|
3873
|
+
} catch (err) {
|
|
3874
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3875
|
+
}
|
|
3876
|
+
});
|
|
3877
|
+
|
|
3878
|
+
// D8/§8.1: the chat model catalog. Fresh per request (the /api/config
|
|
3879
|
+
// precedent) — a cache would go stale against global-model edits.
|
|
3880
|
+
app.get('/api/ask/models', async (_req, res) => {
|
|
3881
|
+
try {
|
|
3882
|
+
res.json(await askCatalog());
|
|
3883
|
+
} catch (err) {
|
|
3884
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3885
|
+
}
|
|
3886
|
+
});
|
|
3887
|
+
|
|
3888
|
+
/** lookupPipelineRow/findPipelineRowById return the RAW `SELECT * FROM pipelines`
|
|
3889
|
+
* row: snake_case columns, and `branch` is a JSON DOCUMENT
|
|
3890
|
+
* ({source, feature, worktreeDir, …}), not a branch name. Reading
|
|
3891
|
+
* row.startedAt/row.branch directly loses the date and pastes a JSON blob into
|
|
3892
|
+
* the [worca context] line (dry-run-verified). */
|
|
3893
|
+
function askRunFromPipelineRow(row) {
|
|
3894
|
+
let branchObj = null;
|
|
3895
|
+
if (typeof row.branch === 'string') {
|
|
3896
|
+
try { branchObj = JSON.parse(row.branch); } catch { branchObj = null; }
|
|
3897
|
+
} else if (row.branch && typeof row.branch === 'object') {
|
|
3898
|
+
branchObj = row.branch;
|
|
3899
|
+
}
|
|
3900
|
+
const branch = branchObj && typeof branchObj.feature === 'string'
|
|
3901
|
+
? branchObj.feature
|
|
3902
|
+
: (typeof branchObj === 'string' ? branchObj : null);
|
|
3903
|
+
return {
|
|
3904
|
+
id: row.id,
|
|
3905
|
+
title: row.title || '',
|
|
3906
|
+
status: row.status || '',
|
|
3907
|
+
startedAt: row.started_at || row.updated_at || '',
|
|
3908
|
+
branch,
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
/** #397: the user-pinned scope of an ask context — {projectKey} | {workspaceId} | null. */
|
|
3913
|
+
function askPinnedScope(context) {
|
|
3914
|
+
if (!context || typeof context !== 'object' || context.pinned !== true) return null;
|
|
3915
|
+
if (typeof context.projectKey === 'string' && context.projectKey) return { projectKey: context.projectKey };
|
|
3916
|
+
if (typeof context.workspaceId === 'string' && context.workspaceId) return { workspaceId: context.workspaceId };
|
|
3917
|
+
return null;
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
/** #397 per-field merge: the pinned scope replaces the page context's TARGET keys
|
|
3921
|
+
* (projectDir/projectKey/workspaceId); view, run, pipeline and diff-file context
|
|
3922
|
+
* still follow the page. */
|
|
3923
|
+
function askApplyPin(ctx, pin) {
|
|
3924
|
+
const out = { ...ctx, pinned: true };
|
|
3925
|
+
delete out.projectDir;
|
|
3926
|
+
delete out.projectKey;
|
|
3927
|
+
delete out.workspaceId;
|
|
3928
|
+
return { ...out, ...pin };
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
/** #397 selector PATCH body: {pinned:false} | {pinned:true, projectKey|workspaceId}. */
|
|
3932
|
+
function askValidateScope(raw) {
|
|
3933
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'scope must be an object' };
|
|
3934
|
+
if (typeof raw.pinned !== 'boolean') return { ok: false, error: 'scope.pinned must be true or false' };
|
|
3935
|
+
if (!raw.pinned) return { ok: true, scope: { pinned: false } };
|
|
3936
|
+
const cv = validateClientContext({ projectKey: raw.projectKey, workspaceId: raw.workspaceId });
|
|
3937
|
+
if (!cv.ok) return { ok: false, error: cv.error.replace('context.', 'scope.') };
|
|
3938
|
+
const keys = ['projectKey', 'workspaceId'].filter((k) => cv.context[k]);
|
|
3939
|
+
if (keys.length !== 1) return { ok: false, error: 'scope needs exactly one of projectKey / workspaceId' };
|
|
3940
|
+
return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
/** Resolve the VALIDATED client context into the server-side shape
|
|
3944
|
+
* buildContextHeader consumes (§6.5: server-resolved rows only — never
|
|
3945
|
+
* client-supplied titles or paths). Every lookup is individually guarded:
|
|
3946
|
+
* a vanished row degrades to an absent header line, never a 500. */
|
|
3947
|
+
async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
|
|
3948
|
+
const out = { now: new Date().toISOString() };
|
|
3949
|
+
if (ctx.pinned === true) out.pinned = true; // #397: rendered as the [pinned by the user] marker
|
|
3950
|
+
if (ctx.view) out.view = ctx.view;
|
|
3951
|
+
if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
|
|
3952
|
+
try {
|
|
3953
|
+
if (ctx.projectKey || ctx.projectDir) {
|
|
3954
|
+
const projects = await listProjects();
|
|
3955
|
+
const p = projects.find((x) =>
|
|
3956
|
+
(ctx.projectKey && x.key === ctx.projectKey) || (ctx.projectDir && x.path === ctx.projectDir));
|
|
3957
|
+
if (p) out.project = { name: p.name, key: p.key };
|
|
3958
|
+
}
|
|
3959
|
+
} catch { /* absent line */ }
|
|
3960
|
+
try {
|
|
3961
|
+
if (ctx.workspaceId) {
|
|
3962
|
+
const ws = await readWorkspace(ctx.workspaceId);
|
|
3963
|
+
if (ws) {
|
|
3964
|
+
// readWorkspace returns {id, name, projectPaths, projectKeys, …} — there
|
|
3965
|
+
// is NO per-member name object (the {projectName} shape is a local
|
|
3966
|
+
// /api/run construction, ui/server.mjs:894). Member display names are
|
|
3967
|
+
// the path basenames, same as that precedent.
|
|
3968
|
+
out.workspace = {
|
|
3969
|
+
name: ws.name, id: ws.id,
|
|
3970
|
+
members: (ws.projectPaths || []).map((p) => path.basename(p)).filter(Boolean),
|
|
3971
|
+
};
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
} catch { /* absent line */ }
|
|
3975
|
+
try {
|
|
3976
|
+
if (ctx.pipelineId) {
|
|
3977
|
+
const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
|
|
3978
|
+
const row = (key ? lookupPipelineRow(key, ctx.pipelineId) : null) || findPipelineRowById(ctx.pipelineId);
|
|
3979
|
+
if (row) out.run = askRunFromPipelineRow(row);
|
|
3980
|
+
} else if (ctx.runId && runs.has(ctx.runId)) {
|
|
3981
|
+
const entry = runs.get(ctx.runId);
|
|
3982
|
+
out.run = {
|
|
3983
|
+
id: entry.pipelineId || ctx.runId.slice(0, 8), title: entry.title || '',
|
|
3984
|
+
status: entry.status || '', startedAt: entry.startedAt || '', branch: null,
|
|
3985
|
+
};
|
|
3986
|
+
}
|
|
3987
|
+
} catch { /* absent line */ }
|
|
3988
|
+
// (the ctx.runId branch reads the LIVE runs-Map entry, which really is
|
|
3989
|
+
// camelCase — only the DB pipeline row needs askRunFromPipelineRow)
|
|
3990
|
+
try {
|
|
3991
|
+
const links = askListRunLinks(threadId).slice(0, ASK_LIMITS.headerRuns).map((l) => {
|
|
3992
|
+
const live = runs.get(l.runId);
|
|
3993
|
+
return {
|
|
3994
|
+
id: l.pipelineId || l.runId.slice(0, 8),
|
|
3995
|
+
title: (live && live.title) || '', status: l.status || (live && live.status) || '',
|
|
3996
|
+
phase: l.phase || '',
|
|
3997
|
+
};
|
|
3998
|
+
});
|
|
3999
|
+
if (links.length) out.linkedRuns = links;
|
|
4000
|
+
const cards = [];
|
|
4001
|
+
for (const m of askListMessages(threadId)) {
|
|
4002
|
+
if (!Array.isArray(m.blocks)) continue;
|
|
4003
|
+
for (const b of m.blocks) {
|
|
4004
|
+
if (b && b.kind === 'card') {
|
|
4005
|
+
cards.push({
|
|
4006
|
+
id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
|
|
4007
|
+
targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
|
|
4008
|
+
});
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
if (cards.length) out.cards = cards.slice(-ASK_LIMITS.headerCards);
|
|
4013
|
+
// §6.5: the CURRENT message's non-inlined files, then EARLIER attachments
|
|
4014
|
+
// newest first — inlined current files must not be double-listed, so the
|
|
4015
|
+
// earlier set excludes the whole current message, not just `listed` ids.
|
|
4016
|
+
const earlier = askListAttachments(threadId)
|
|
4017
|
+
.filter((a) => !currentMessageId || a.messageId !== currentMessageId)
|
|
4018
|
+
.slice(-ASK_LIMITS.headerAttachments)
|
|
4019
|
+
.reverse()
|
|
4020
|
+
.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
|
|
4021
|
+
const atts = [...listedAttachments.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime })), ...earlier];
|
|
4022
|
+
if (atts.length) out.attachments = atts.slice(0, ASK_LIMITS.headerAttachments);
|
|
4023
|
+
} catch { /* absent lines */ }
|
|
4024
|
+
return out;
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
/** R-F: whenever mock mode is on, EVERY ask spawn carries markers. The card is
|
|
4028
|
+
* the mock propose_run INPUT, derived from page context so a seeded project/
|
|
4029
|
+
* workspace validates and an empty context exercises the rejection notice. */
|
|
4030
|
+
function mockAskCard(ctx = {}, text = '') {
|
|
4031
|
+
const target = ctx.workspaceId
|
|
4032
|
+
? { workspaceId: ctx.workspaceId }
|
|
4033
|
+
: { projectKey: ctx.projectKey || 'mock-project-00000000' };
|
|
4034
|
+
return { ...target, workflowId: 'wf_default', guardrailsId: 'normal', brief: text.slice(0, 200) || 'Mock run' };
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
4038
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
4039
|
+
if (!id) return;
|
|
4040
|
+
try {
|
|
4041
|
+
const thread = askGetThread(id);
|
|
4042
|
+
if (!thread) return res.status(404).json({ error: 'thread not found' });
|
|
4043
|
+
if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
|
|
4044
|
+
if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
|
|
4045
|
+
// Budget gate (F6), same figure /api/run enforces: Ask spend is folded into the
|
|
4046
|
+
// total window (cost-budget.mjs totalWindowSpendUsd), so chat must stop at
|
|
4047
|
+
// the cap it helps fill instead of spending past it while pipelines are 403'd
|
|
4048
|
+
// (review of PR #376).
|
|
4049
|
+
const budget = budgetStatus();
|
|
4050
|
+
if (budget.blocked) return res.status(403).json({ error: 'total cost limit reached', budget });
|
|
4051
|
+
if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
|
|
4052
|
+
return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
|
|
4053
|
+
}
|
|
4054
|
+
const body = req.body || {};
|
|
4055
|
+
const text = typeof body.text === 'string' ? body.text : '';
|
|
4056
|
+
if (!text.trim()) return badRequest(res, 'text is required');
|
|
4057
|
+
const mv = await validateModelEffort(body.model, body.effort);
|
|
4058
|
+
if (!mv.ok) return badRequest(res, mv.error);
|
|
4059
|
+
const cv = validateClientContext(body.context);
|
|
4060
|
+
if (!cv.ok) return badRequest(res, cv.error);
|
|
4061
|
+
// #397: explicit pin beats page context, per field. A context carrying its own
|
|
4062
|
+
// `pinned` verdict is authoritative — the selector-aware client already merged
|
|
4063
|
+
// (true) or explicitly chose Auto (false). A context WITHOUT one comes from a
|
|
4064
|
+
// pre-selector tab, and inherits the thread's stored pin so a stale tab can
|
|
4065
|
+
// never silently unpin (or re-scope) the conversation.
|
|
4066
|
+
let ctx = cv.context;
|
|
4067
|
+
if (ctx.pinned === undefined) {
|
|
4068
|
+
const inherited = askPinnedScope(thread.context);
|
|
4069
|
+
if (inherited) ctx = askApplyPin(ctx, inherited);
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
// §7.3 — validate EVERY attachment before ANY write (all-or-nothing).
|
|
4073
|
+
const files = [];
|
|
4074
|
+
if (body.attachments !== undefined) {
|
|
4075
|
+
if (!Array.isArray(body.attachments)) return badRequest(res, 'attachments must be an array');
|
|
4076
|
+
if (body.attachments.length > ASK_LIMITS.attachment.maxFiles) {
|
|
4077
|
+
return badRequest(res, `at most ${ASK_LIMITS.attachment.maxFiles} attachments per message`);
|
|
4078
|
+
}
|
|
4079
|
+
const dec = new TextDecoder('utf-8', { fatal: true });
|
|
4080
|
+
for (const a of body.attachments) {
|
|
4081
|
+
const name = a && typeof a.name === 'string' ? a.name : '';
|
|
4082
|
+
const dot = name.lastIndexOf('.');
|
|
4083
|
+
const ext = dot === -1 ? '' : name.slice(dot).toLowerCase();
|
|
4084
|
+
// #398: the extension CLAIMS a type; text kinds are then proven by UTF-8
|
|
4085
|
+
// decoding (as before), binary kinds by their magic number — a body that
|
|
4086
|
+
// does not match its claim is refused here, before any write.
|
|
4087
|
+
const cls = askClassifyExtension(ext);
|
|
4088
|
+
if (!cls) return badRequest(res, `attachment type not allowed: ${name || '(unnamed)'}`);
|
|
4089
|
+
const raw = typeof a.dataBase64 === 'string' ? a.dataBase64 : '';
|
|
4090
|
+
const buf = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);
|
|
4091
|
+
if (!buf.length) return badRequest(res, `attachment is empty or not valid base64: ${name}`);
|
|
4092
|
+
const cap = cls.kind === 'text' ? ASK_LIMITS.attachment.maxBytesPerFile : ASK_LIMITS.attachment.maxBytesPerBinaryFile;
|
|
4093
|
+
if (buf.length > cap) {
|
|
4094
|
+
return res.status(413).json({ error: `attachment over ${cap} bytes: ${name}` });
|
|
4095
|
+
}
|
|
4096
|
+
if (cls.kind !== 'text') {
|
|
4097
|
+
const sniffed = askSniffMime(buf);
|
|
4098
|
+
if (sniffed !== cls.mime) {
|
|
4099
|
+
return badRequest(res, `attachment content does not match its extension: ${name}`);
|
|
4100
|
+
}
|
|
4101
|
+
files.push({ name, kind: cls.kind, mime: cls.mime, data: buf, bytes: buf.length });
|
|
4102
|
+
continue;
|
|
4103
|
+
}
|
|
4104
|
+
let bodyText;
|
|
4105
|
+
try { bodyText = dec.decode(buf); } catch { return badRequest(res, `attachment is not valid UTF-8: ${name}`); }
|
|
4106
|
+
if (bodyText.includes('\u0000')) return badRequest(res, `attachment contains NUL bytes: ${name}`);
|
|
4107
|
+
files.push({ name, kind: 'text', mime: cls.mime, text: bodyText, bytes: buf.length });
|
|
4108
|
+
}
|
|
4109
|
+
const total = askThreadAttachmentBytes(id) + files.reduce((s, f) => s + f.bytes, 0);
|
|
4110
|
+
if (total > ASK_LIMITS.attachment.maxBytesPerThread) {
|
|
4111
|
+
return res.status(413).json({ error: 'attachment budget for this thread exceeded' });
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
// §6.2.2 ATOMIC re-check + slot reservation. Today every await between the
|
|
4116
|
+
// top 409/429 pair and here resolves in microtasks (validateModelEffort ->
|
|
4117
|
+
// composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
|
|
4118
|
+
// reads), so the route is macrotask-atomic and two POSTs cannot interleave
|
|
4119
|
+
// (empirically instrumented). The reservation is what keeps that true if
|
|
4120
|
+
// any of those readers ever becomes genuinely async: it is synchronous —
|
|
4121
|
+
// check-and-set cannot interleave — and runs BEFORE the first write, so a
|
|
4122
|
+
// loser leaves no rows.
|
|
4123
|
+
if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
|
|
4124
|
+
if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
|
|
4125
|
+
if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
|
|
4126
|
+
return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
|
|
4127
|
+
}
|
|
4128
|
+
const prev = askJobs.get(id);
|
|
4129
|
+
if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
|
|
4130
|
+
const job = {
|
|
4131
|
+
turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
|
|
4132
|
+
events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
|
|
4133
|
+
startedAt: new Date().toISOString(), graceTimer: null,
|
|
4134
|
+
};
|
|
4135
|
+
askJobs.set(id, job);
|
|
4136
|
+
|
|
4137
|
+
let asstMsg = null;
|
|
4138
|
+
let turn;
|
|
4139
|
+
let echoAttachments = [];
|
|
4140
|
+
try {
|
|
4141
|
+
// Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
|
|
4142
|
+
// `ctx` (pin-merged) rather than cv.context: the stored row is what restores
|
|
4143
|
+
// the selector on reopen and what the MCP child reads for tool defaulting.
|
|
4144
|
+
askUpdateThread(id, { context: ctx, model: mv.model, effort: mv.effort });
|
|
4145
|
+
// §7.4 — NOTHING is stamped on the row before the 202: the thread stays
|
|
4146
|
+
// untitled (the header reads "Ask Worca") until the D13 background title
|
|
4147
|
+
// announces itself. titleWasAuto gates that call: a title given at THREAD
|
|
4148
|
+
// CREATION is the user's, and the haiku call must never fire for it
|
|
4149
|
+
// (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
|
|
4150
|
+
// empty haiku result (turn.mjs _kickoffTitle), never written here.
|
|
4151
|
+
const titleWasAuto = thread.title == null;
|
|
4152
|
+
const deterministicTitle = titleWasAuto ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
|
|
4153
|
+
const userMsg = askAppendMessage(id, { role: 'user', text });
|
|
4154
|
+
job.userMessageId = userMsg.id;
|
|
4155
|
+
const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
|
|
4156
|
+
// The decoded binary bodies are on disk now. `files` is captured by this
|
|
4157
|
+
// scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
|
|
4158
|
+
// turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
|
|
4159
|
+
// reachable per running thread.
|
|
4160
|
+
for (const f of files) f.data = null;
|
|
4161
|
+
echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
|
|
4162
|
+
if (attRows.length) {
|
|
4163
|
+
// `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
|
|
4164
|
+
// (the UI keys image thumbnails off it, #398).
|
|
4165
|
+
askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })));
|
|
4166
|
+
}
|
|
4167
|
+
broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
|
|
4168
|
+
asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
|
|
4169
|
+
job.messageId = asstMsg.id;
|
|
4170
|
+
|
|
4171
|
+
// Prompt assembly (§6.5) — the route owns it; the turn only spawns.
|
|
4172
|
+
const catalog = await askBuildCatalog();
|
|
4173
|
+
const systemPrompt = askBuildSystemPrompt(catalog);
|
|
4174
|
+
const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime, text: files[i].text }));
|
|
4175
|
+
const { inline, listed } = askSelectInlineAttachments(withText);
|
|
4176
|
+
const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
|
|
4177
|
+
const header = askBuildContextHeader(headerCtx);
|
|
4178
|
+
const prompt = askBuildTurnPrompt(header, text, inline);
|
|
4179
|
+
const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
|
|
4180
|
+
const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
|
|
4181
|
+
const attachmentNames = {};
|
|
4182
|
+
for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
|
|
4183
|
+
|
|
4184
|
+
turn = createAskTurn({
|
|
4185
|
+
threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
|
|
4186
|
+
prompt, systemPrompt, restoredPrompt,
|
|
4187
|
+
model: mv.model, effort: mv.effort,
|
|
4188
|
+
resumeSessionId: thread.sessionId || null,
|
|
4189
|
+
firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
|
|
4190
|
+
firstText: text,
|
|
4191
|
+
deterministicTitle,
|
|
4192
|
+
pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
|
|
4193
|
+
mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
|
|
4194
|
+
attachmentNames,
|
|
4195
|
+
deps: {
|
|
4196
|
+
onFrame: stampAskFrames(id, job),
|
|
4197
|
+
onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
|
|
4198
|
+
onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
|
|
4199
|
+
onWorktreeMutation: () => { emitAskWorktrees(id); },
|
|
4200
|
+
},
|
|
4201
|
+
});
|
|
4202
|
+
job.turn = turn;
|
|
4203
|
+
} catch (err) {
|
|
4204
|
+
// A write/assembly failure must release the reserved slot and never leave
|
|
4205
|
+
// a `streaming` row for the boot sweep to find.
|
|
4206
|
+
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
4207
|
+
if (asstMsg) {
|
|
4208
|
+
try {
|
|
4209
|
+
askFinishMessage(asstMsg.id, {
|
|
4210
|
+
text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
|
|
4211
|
+
status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
|
|
4212
|
+
});
|
|
4213
|
+
} catch { /* thread gone */ }
|
|
4214
|
+
}
|
|
4215
|
+
return res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4216
|
+
}
|
|
4217
|
+
const settleJob = (status) => {
|
|
4218
|
+
if (askJobs.get(id) !== job) return;
|
|
4219
|
+
job.status = status;
|
|
4220
|
+
job.graceTimer = setTimeout(() => {
|
|
4221
|
+
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
4222
|
+
}, ASK_LIMITS.jobGraceMs);
|
|
4223
|
+
job.graceTimer.unref?.();
|
|
4224
|
+
};
|
|
4225
|
+
turn.on('done', () => settleJob('done'));
|
|
4226
|
+
turn.on('error', () => settleJob('error'));
|
|
4227
|
+
// Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
|
|
4228
|
+
Promise.resolve()
|
|
4229
|
+
.then(() => turn.run())
|
|
4230
|
+
.catch((err) => {
|
|
4231
|
+
console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
|
|
4232
|
+
settleJob('error');
|
|
4233
|
+
});
|
|
4234
|
+
// `attachments` carries the store-minted ids so the sender's own echo can key
|
|
4235
|
+
// image thumbnails and the thread budget off them (the ask-message broadcast
|
|
4236
|
+
// may have raced ahead of this response, or been missed on a brand-new thread).
|
|
4237
|
+
res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId, attachments: echoAttachments });
|
|
4238
|
+
} catch (err) {
|
|
4239
|
+
// Only pre-reservation throws land here (`job` is block-scoped to the outer
|
|
4240
|
+
// try and every post-reservation failure returned from the inner catch), so
|
|
4241
|
+
// there is no slot to release.
|
|
4242
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4243
|
+
}
|
|
4244
|
+
});
|
|
4245
|
+
|
|
4246
|
+
// Idempotent stop (the /api/agents/generate/stop family): always {ok:true}
|
|
4247
|
+
// after the shape check; the costUsd:null rule lives in the turn (R-C).
|
|
4248
|
+
app.post('/api/ask/threads/:id/stop', (req, res) => {
|
|
4249
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
4250
|
+
if (!id) return;
|
|
4251
|
+
const job = askInFlight(id);
|
|
4252
|
+
if (job && job.turn && typeof job.turn.stop === 'function') {
|
|
4253
|
+
try { job.turn.stop(); } catch { /* best-effort */ }
|
|
4254
|
+
}
|
|
4255
|
+
res.json({ ok: true });
|
|
4256
|
+
});
|
|
4257
|
+
|
|
4258
|
+
/** R-B dual update. Flip in the STORE and, when the owning thread's turn is
|
|
4259
|
+
* still streaming, in the LIVE reducer (updateBlock re-emits the stamped
|
|
4260
|
+
* ask-card job frame) — otherwise finishMessage at turn end reverts the flip
|
|
4261
|
+
* with the reducer's stale copy. When no live reducer held the card (turn
|
|
4262
|
+
* over, or the card sits on an earlier message), re-broadcast the whole
|
|
4263
|
+
* message so tabs upsert the flipped block by message.id (§6.6 out-of-turn). */
|
|
4264
|
+
function flipCard(threadId, cardId, patch) {
|
|
4265
|
+
const block = askUpdateCardBlock(threadId, cardId, patch);
|
|
4266
|
+
if (!block) return null;
|
|
4267
|
+
const job = askInFlight(threadId);
|
|
4268
|
+
const live = job && job.turn && job.turn.reducer ? job.turn.reducer.updateBlock(cardId, patch) : null;
|
|
4269
|
+
if (!live) {
|
|
4270
|
+
const found = askFindCard(threadId, cardId);
|
|
4271
|
+
if (found) broadcast({ type: 'ask-message', threadId, message: found.message });
|
|
4272
|
+
}
|
|
4273
|
+
return block;
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
// D14 dismiss ("Not now" keeps a stub — the client renders state:'dismissed').
|
|
4277
|
+
app.post('/api/ask/threads/:id/cards/:cardId', (req, res) => {
|
|
4278
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
4279
|
+
if (!id) return;
|
|
4280
|
+
const cardId = askIdParam(res, req.params.cardId, 'card');
|
|
4281
|
+
if (!cardId) return;
|
|
4282
|
+
try {
|
|
4283
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
4284
|
+
if ((req.body || {}).state !== 'dismissed') return badRequest(res, 'state must be "dismissed"');
|
|
4285
|
+
const found = askFindCard(id, cardId);
|
|
4286
|
+
if (!found) return res.status(404).json({ error: 'card not found' });
|
|
4287
|
+
if (found.block.state !== 'proposed') {
|
|
4288
|
+
return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
4289
|
+
}
|
|
4290
|
+
const block = flipCard(id, cardId, { state: 'dismissed' });
|
|
4291
|
+
// Dismiss is terminal: the card's parked comment ids can never reach a run,
|
|
4292
|
+
// so drop them here exactly as the launch path does at its own success point
|
|
4293
|
+
// (:1155). Own try/catch — comment bookkeeping must never fail the dismiss.
|
|
4294
|
+
try { clearPendingCardComments(cardId); }
|
|
4295
|
+
catch (e) { console.error('[diff-comments] dismiss cleanup failed:', e && e.message ? e.message : e); }
|
|
4296
|
+
res.json({ block });
|
|
4297
|
+
} catch (err) {
|
|
4298
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4299
|
+
}
|
|
4300
|
+
});
|
|
4301
|
+
|
|
2753
4302
|
// ---------------------------------------------------------------------------
|
|
2754
4303
|
// /api/agents* -> agent registry + user-agent CRUD, delegated to
|
|
2755
4304
|
// src/core/agent-store.mjs (layered builtin + ~/.worca-cc/agents user pairs).
|
|
2756
4305
|
// GET returns palette render order (.order ascending) with origin stamped; the
|
|
2757
4306
|
// client builds draggable pills (colored dot + displayName + icon) from this.
|
|
2758
4307
|
// ---------------------------------------------------------------------------
|
|
2759
|
-
// Channel vocabulary for the UI editor/wizard: built-in CHANNEL_IDS first, then
|
|
2760
|
-
// every CUSTOM id any registry agent references (produces/consumes/
|
|
2761
|
-
// optionalConsumes/channelDefs[].id), appended sorted + deduped. Channels are an
|
|
2762
|
-
// open vocabulary — a closed list would silently strip custom ids on edit.
|
|
2763
|
-
function collectChannelIds(agents) {
|
|
2764
|
-
const customs = new Set();
|
|
2765
|
-
for (const a of Array.isArray(agents) ? agents : []) {
|
|
2766
|
-
if (!a) continue;
|
|
2767
|
-
const ids = [
|
|
2768
|
-
...(Array.isArray(a.produces) ? a.produces : []),
|
|
2769
|
-
...(Array.isArray(a.consumes) ? a.consumes : []),
|
|
2770
|
-
...(Array.isArray(a.optionalConsumes) ? a.optionalConsumes : []),
|
|
2771
|
-
...(Array.isArray(a.channelDefs) ? a.channelDefs.map((d) => d && d.id) : []),
|
|
2772
|
-
];
|
|
2773
|
-
for (const id of ids) {
|
|
2774
|
-
if (typeof id === 'string' && id && !CHANNEL_IDS.includes(id)) customs.add(id);
|
|
2775
|
-
}
|
|
2776
|
-
}
|
|
2777
|
-
return [...CHANNEL_IDS, ...[...customs].sort()];
|
|
2778
|
-
}
|
|
2779
4308
|
|
|
2780
4309
|
app.get('/api/agents', async (req, res) => {
|
|
2781
4310
|
try {
|
|
@@ -2783,7 +4312,11 @@ app.get('/api/agents', async (req, res) => {
|
|
|
2783
4312
|
// §6.6: workspace-only agents stay out of the Composer palette by default;
|
|
2784
4313
|
// the Agents management view passes ?all=1 to see them too.
|
|
2785
4314
|
const agents = isTruthy(req.query.all) ? all : all.filter((m) => m.scope !== 'workspace-only');
|
|
2786
|
-
|
|
4315
|
+
// mockWriterRoles drives ONE select in the agent form. It is a CLOSED list
|
|
4316
|
+
// (the mock switch in claude-runner.mjs), unlike the open channel vocabulary
|
|
4317
|
+
// it replaces in Task 12: an unknown mockRole is dropped by the registry
|
|
4318
|
+
// with a warning, never rejected.
|
|
4319
|
+
res.json({ agents, mockWriterRoles: [...MOCK_WRITER_ROLES] });
|
|
2787
4320
|
} catch (err) {
|
|
2788
4321
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2789
4322
|
}
|
|
@@ -2809,9 +4342,6 @@ function agentErrorStatus(code) {
|
|
|
2809
4342
|
function startAgentGen(input) {
|
|
2810
4343
|
const orch = createAgentGen({
|
|
2811
4344
|
...input,
|
|
2812
|
-
// Same open vocabulary as GET /api/agents (callers pass the registry union);
|
|
2813
|
-
// built-ins-only fallback keeps direct/_testing callers working.
|
|
2814
|
-
channels: Array.isArray(input.channels) && input.channels.length ? input.channels : CHANNEL_IDS,
|
|
2815
4345
|
claude: { permissionMode: 'acceptEdits', mock: isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK) },
|
|
2816
4346
|
});
|
|
2817
4347
|
// The engine mints its own genId (agen_<uuid>) and tags every emitted event
|
|
@@ -2862,7 +4392,7 @@ app.post('/api/agents/generate', async (req, res) => {
|
|
|
2862
4392
|
const genId = startAgentGen({
|
|
2863
4393
|
name, purpose: String(body.purpose || ''), details: String(body.details || ''),
|
|
2864
4394
|
expectedBefore: pick(body.expectedBefore), expectedAfter: pick(body.expectedAfter),
|
|
2865
|
-
userMarkdown,
|
|
4395
|
+
userMarkdown,
|
|
2866
4396
|
});
|
|
2867
4397
|
res.json({ genId });
|
|
2868
4398
|
} catch (err) {
|
|
@@ -3081,7 +4611,7 @@ app.post('/api/plugins/install', async (req, res) => {
|
|
|
3081
4611
|
repoUrl: body.repoUrl.trim(), subdir, name: body.name.trim(), sha: body.sha.trim(), marketplace,
|
|
3082
4612
|
});
|
|
3083
4613
|
reloadChatWorkers(body.name.trim());
|
|
3084
|
-
res.json(out); // { ok: true, inventory }
|
|
4614
|
+
res.json(out); // { ok: true, inventory, warnings, ignored }
|
|
3085
4615
|
} catch (err) {
|
|
3086
4616
|
sendPluginError(res, err);
|
|
3087
4617
|
}
|
|
@@ -3128,6 +4658,8 @@ app.delete('/api/plugins/:name', async (req, res) => {
|
|
|
3128
4658
|
if (!name) return;
|
|
3129
4659
|
const purge = isTruthy(req.query.purge) || !!(req.body && req.body.purge === true);
|
|
3130
4660
|
try {
|
|
4661
|
+
// uninstallPlugin also drops the plugin's source bindings (core-side, so
|
|
4662
|
+
// the CLI's `worca plugin remove` clears them identically).
|
|
3131
4663
|
await uninstallPlugin(name, { purge });
|
|
3132
4664
|
reloadChatWorkers(name);
|
|
3133
4665
|
res.json({ ok: true, purged: purge });
|
|
@@ -3165,17 +4697,38 @@ app.post('/api/plugins/:name/doctor', async (req, res) => {
|
|
|
3165
4697
|
// GET /api/plugins/:name/config -> per-source schema + redacted values. Secrets
|
|
3166
4698
|
// NEVER travel to the browser: redactedConfig replaces a stored secret with
|
|
3167
4699
|
// { set: true } (§7.6).
|
|
4700
|
+
// ?profile=<id> selects which configuration to echo (multi-profile sources);
|
|
4701
|
+
// absent = the default bucket, which is all a single-profile source ever uses.
|
|
3168
4702
|
app.get('/api/plugins/:name/config', (req, res) => {
|
|
3169
4703
|
const name = requirePlugin(req, res);
|
|
3170
4704
|
if (!name) return;
|
|
3171
4705
|
const manifest = readInstalledManifest(name);
|
|
3172
4706
|
if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
|
|
4707
|
+
const wanted = typeof req.query.profile === 'string' && req.query.profile ? req.query.profile : null;
|
|
4708
|
+
if (wanted && !isValidProfileId(wanted)) return badRequest(res, 'invalid profile id');
|
|
3173
4709
|
try {
|
|
3174
|
-
const
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
4710
|
+
const profiles = listProfiles(name);
|
|
4711
|
+
const sources = (manifest.taskSources || []).map((s) => {
|
|
4712
|
+
// For a multi-profile source, "which profile" is a real choice: echo the
|
|
4713
|
+
// requested one, else the first in the roster. A source with no profiles
|
|
4714
|
+
// yet has nothing to show — the UI's move is "create one", not a form.
|
|
4715
|
+
// A requested profile that is not in the roster is a caller error (a
|
|
4716
|
+
// typo'd URL) — echoing an empty form for it would let a Save quietly
|
|
4717
|
+
// create the typo as a real profile. Checked inside the map so the guard
|
|
4718
|
+
// only fires for sources that use profiles at all.
|
|
4719
|
+
if (s.multiProfile && wanted && !profiles.some((p) => p.id === wanted)) {
|
|
4720
|
+
throw Object.assign(new Error(`plugin "${name}" has no profile "${wanted}"`), { code: 'BAD_REQUEST' });
|
|
4721
|
+
}
|
|
4722
|
+
const profile = s.multiProfile ? (wanted || profiles[0]?.id || null) : null;
|
|
4723
|
+
return {
|
|
4724
|
+
id: s.id,
|
|
4725
|
+
schema: s.configSchema,
|
|
4726
|
+
multiProfile: s.multiProfile === true,
|
|
4727
|
+
profile,
|
|
4728
|
+
profiles: s.multiProfile ? profiles : [],
|
|
4729
|
+
values: s.multiProfile && !profile ? {} : redactedConfig(name, s.configSchema, profile),
|
|
4730
|
+
};
|
|
4731
|
+
});
|
|
3179
4732
|
const channels = (manifest.chatChannels || []).map((c) => ({
|
|
3180
4733
|
id: c.id,
|
|
3181
4734
|
displayName: c.displayName,
|
|
@@ -3196,7 +4749,78 @@ app.get('/api/plugins/:name/config', (req, res) => {
|
|
|
3196
4749
|
}
|
|
3197
4750
|
});
|
|
3198
4751
|
|
|
3199
|
-
//
|
|
4752
|
+
// POST /api/plugins/:name/profiles { sourceId, id, label } — create (or relabel)
|
|
4753
|
+
// a profile of a multi-profile source. Creating a profile is deliberately
|
|
4754
|
+
// separate from saving into it: the roster entry must exist BEFORE the config
|
|
4755
|
+
// form has anything to write to.
|
|
4756
|
+
app.post('/api/plugins/:name/profiles', (req, res) => {
|
|
4757
|
+
const name = requirePlugin(req, res);
|
|
4758
|
+
if (!name) return;
|
|
4759
|
+
const body = req.body || {};
|
|
4760
|
+
const manifest = readInstalledManifest(name);
|
|
4761
|
+
if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
|
|
4762
|
+
const source = (manifest.taskSources || []).find((s) => s.id === body.sourceId);
|
|
4763
|
+
if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
|
|
4764
|
+
if (!source.multiProfile) return badRequest(res, `task source "${source.id}" does not support profiles`);
|
|
4765
|
+
if (!isValidProfileId(body.id)) {
|
|
4766
|
+
return badRequest(res, 'profile id must be lowercase letters, digits and dashes');
|
|
4767
|
+
}
|
|
4768
|
+
// "default" is the implicit bucket every profile-less read/write shares
|
|
4769
|
+
// (chat channels, model secrets, migrated legacy config). Enrolled in the
|
|
4770
|
+
// roster it would become deletable like any member — and deleting it wipes
|
|
4771
|
+
// that shared bucket. createProfile throws too; 400 with the reason here.
|
|
4772
|
+
if (body.id === DEFAULT_PROFILE) {
|
|
4773
|
+
return badRequest(res, `profile id "${DEFAULT_PROFILE}" is reserved — pick another name`);
|
|
4774
|
+
}
|
|
4775
|
+
try {
|
|
4776
|
+
res.json({ ok: true, profile: createProfile(name, body.id, body.label) });
|
|
4777
|
+
} catch (err) {
|
|
4778
|
+
sendPluginError(res, err);
|
|
4779
|
+
}
|
|
4780
|
+
});
|
|
4781
|
+
|
|
4782
|
+
// DELETE /api/plugins/:name/profiles/:id?sourceId=… — drop a profile, its stored
|
|
4783
|
+
// config/secrets/state, and every project binding that named it (a binding
|
|
4784
|
+
// pointing at a deleted profile would otherwise resolve to nothing at run time).
|
|
4785
|
+
// Binding cleanup is PLUGIN-wide, not per-source: deleteProfile removes the
|
|
4786
|
+
// profile's buckets for the whole plugin, so a sibling source's binding naming
|
|
4787
|
+
// it would dangle just the same. sourceId is still required — it authorizes the
|
|
4788
|
+
// call against a source that actually uses profiles.
|
|
4789
|
+
app.delete('/api/plugins/:name/profiles/:id', (req, res) => {
|
|
4790
|
+
const name = requirePlugin(req, res);
|
|
4791
|
+
if (!name) return;
|
|
4792
|
+
const manifest = readInstalledManifest(name);
|
|
4793
|
+
if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
|
|
4794
|
+
const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : '';
|
|
4795
|
+
const sources = manifest.taskSources || [];
|
|
4796
|
+
const source = sources.find((s) => s.id === sourceId) || (sources.length === 1 ? sources[0] : null);
|
|
4797
|
+
if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
|
|
4798
|
+
// Mirror the POST guard: a single-profile source only has the implicit
|
|
4799
|
+
// 'default' bucket, and deleting THAT would wipe its entire config/secrets/
|
|
4800
|
+
// state. Same for ids not in the roster — deleteProfile would still drop
|
|
4801
|
+
// whatever buckets happen to share the id (e.g. migrated legacy data under
|
|
4802
|
+
// 'default'), so only roster members are deletable.
|
|
4803
|
+
if (!source.multiProfile) return badRequest(res, `task source "${source.id}" does not support profiles`);
|
|
4804
|
+
if (!isValidProfileId(req.params.id)) return badRequest(res, 'invalid profile id');
|
|
4805
|
+
// Reserved even if a pre-reservation roster enrolled it: deleting "default"
|
|
4806
|
+
// would strip the shared bucket (chat-channel config, model secrets,
|
|
4807
|
+
// migrated legacy data) out of all three files.
|
|
4808
|
+
if (req.params.id === DEFAULT_PROFILE) {
|
|
4809
|
+
return badRequest(res, `profile id "${DEFAULT_PROFILE}" is reserved — it cannot be deleted`);
|
|
4810
|
+
}
|
|
4811
|
+
if (!listProfiles(name).some((p) => p.id === req.params.id)) {
|
|
4812
|
+
return badRequest(res, `plugin "${name}" has no profile "${req.params.id}"`);
|
|
4813
|
+
}
|
|
4814
|
+
try {
|
|
4815
|
+
deleteProfile(name, req.params.id);
|
|
4816
|
+
const unbound = clearBindingsForProfile(name, req.params.id);
|
|
4817
|
+
res.json({ ok: true, unbound });
|
|
4818
|
+
} catch (err) {
|
|
4819
|
+
sendPluginError(res, err);
|
|
4820
|
+
}
|
|
4821
|
+
});
|
|
4822
|
+
|
|
4823
|
+
// PUT /api/plugins/:name/config { sourceId | channelId, values, profile? } ->
|
|
3200
4824
|
// writePluginConfig routes secret:true keys to data/secrets.json (0600,
|
|
3201
4825
|
// atomic). Request values are NEVER logged and NEVER echoed back (the response
|
|
3202
4826
|
// is a bare receipt). A channelId save also hot-restarts the channel worker.
|
|
@@ -3222,6 +4846,8 @@ app.put('/api/plugins/:name/config', (req, res) => {
|
|
|
3222
4846
|
}
|
|
3223
4847
|
}
|
|
3224
4848
|
let schema;
|
|
4849
|
+
let source = null;
|
|
4850
|
+
let profile = null;
|
|
3225
4851
|
if (typeof body.channelId === 'string' && body.channelId) {
|
|
3226
4852
|
const channel = (manifest.chatChannels || []).find((c) => c.id === body.channelId);
|
|
3227
4853
|
if (!channel) return badRequest(res, 'channelId does not match a chat channel of this plugin');
|
|
@@ -3231,12 +4857,22 @@ app.put('/api/plugins/:name/config', (req, res) => {
|
|
|
3231
4857
|
const sourceId = typeof body.sourceId === 'string' && body.sourceId
|
|
3232
4858
|
? body.sourceId
|
|
3233
4859
|
: (sources.length === 1 ? sources[0].id : '');
|
|
3234
|
-
|
|
4860
|
+
source = sources.find((s) => s.id === sourceId);
|
|
3235
4861
|
if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
|
|
4862
|
+
profile = typeof body.profile === 'string' && body.profile ? body.profile : null;
|
|
4863
|
+
if (profile && !isValidProfileId(profile)) return badRequest(res, 'invalid profile id');
|
|
4864
|
+
if (source.multiProfile && !profile) return badRequest(res, 'profile is required for this task source');
|
|
4865
|
+
// Saves go only into EXISTING roster members — mirroring the GET guard,
|
|
4866
|
+
// whose whole point is that a Save must not quietly mint a typo'd (or
|
|
4867
|
+
// just-deleted) id as a real profile with secrets stored under it.
|
|
4868
|
+
// Creation stays solely on POST /profiles.
|
|
4869
|
+
if (source.multiProfile && !listProfiles(name).some((p) => p.id === profile)) {
|
|
4870
|
+
return badRequest(res, `plugin "${name}" has no profile "${profile}" — create it first`);
|
|
4871
|
+
}
|
|
3236
4872
|
schema = source.configSchema;
|
|
3237
4873
|
}
|
|
3238
4874
|
try {
|
|
3239
|
-
writePluginConfig(name, schema, body.values);
|
|
4875
|
+
writePluginConfig(name, schema, body.values, profile);
|
|
3240
4876
|
reloadChatWorkers(name);
|
|
3241
4877
|
res.json({ ok: true });
|
|
3242
4878
|
} catch (err) {
|
|
@@ -3261,7 +4897,84 @@ app.get('/api/plugins/:name/model-env', (req, res) => {
|
|
|
3261
4897
|
if (typeof v === 'string') env[k] = v;
|
|
3262
4898
|
else secretKeys.push(k);
|
|
3263
4899
|
}
|
|
3264
|
-
|
|
4900
|
+
// `cost` rides along so "Edit a copy" starts from the plugin's pricing — a
|
|
4901
|
+
// copy that silently dropped it would repay the CLI's by-name figure.
|
|
4902
|
+
res.json({
|
|
4903
|
+
id: model.id, label: model.label, efforts: model.efforts, env, secretKeys,
|
|
4904
|
+
...(model.cost ? { cost: model.cost } : {}),
|
|
4905
|
+
});
|
|
4906
|
+
});
|
|
4907
|
+
|
|
4908
|
+
// ---------------------------------------------------------------------------
|
|
4909
|
+
// /api/source-bindings -> which PROFILE of a task source a project/workspace
|
|
4910
|
+
// pulls from. Set once per project; every run then resolves it silently, which
|
|
4911
|
+
// is the point — a per-run dropdown is how you start a pipeline against the
|
|
4912
|
+
// wrong tracker without noticing (see src/core/source-bindings.mjs).
|
|
4913
|
+
// ---------------------------------------------------------------------------
|
|
4914
|
+
|
|
4915
|
+
/** Shared scope parsing for the two binding routes. Accepts a project by key or
|
|
4916
|
+
* by path (the New Pipeline form knows the path, the Projects view the key). */
|
|
4917
|
+
function bindingScope(q = {}) {
|
|
4918
|
+
const workspaceId = typeof q.workspaceId === 'string' && q.workspaceId.trim() ? q.workspaceId.trim() : '';
|
|
4919
|
+
if (workspaceId) return { scopeType: 'workspace', scopeKey: workspaceId };
|
|
4920
|
+
const key = typeof q.projectKey === 'string' && q.projectKey.trim() ? q.projectKey.trim() : '';
|
|
4921
|
+
if (key) return { scopeType: 'project', scopeKey: key };
|
|
4922
|
+
const dir = typeof q.projectDir === 'string' && q.projectDir.trim() ? q.projectDir.trim() : '';
|
|
4923
|
+
if (dir) return { scopeType: 'project', scopeKey: projectKey(path.resolve(dir)) };
|
|
4924
|
+
return null;
|
|
4925
|
+
}
|
|
4926
|
+
|
|
4927
|
+
// GET /api/source-bindings?projectDir=…|projectKey=…|workspaceId=…
|
|
4928
|
+
// [&plugin=&sourceId=] -> { bindings: [...] } or, when a source is named,
|
|
4929
|
+
// the RESOLVED profile for it: { profile, via, candidates? }.
|
|
4930
|
+
app.get('/api/source-bindings', async (req, res) => {
|
|
4931
|
+
const scope = bindingScope(req.query);
|
|
4932
|
+
if (!scope) return badRequest(res, 'projectDir, projectKey or workspaceId is required');
|
|
4933
|
+
const plugin = typeof req.query.plugin === 'string' ? req.query.plugin.trim() : '';
|
|
4934
|
+
const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId.trim() : '';
|
|
4935
|
+
try {
|
|
4936
|
+
if (!plugin || !sourceId) return res.json({ ...scope, bindings: listBindingsForScope(scope.scopeType, scope.scopeKey) });
|
|
4937
|
+
// A workspace with no binding of its own inherits from its members when they
|
|
4938
|
+
// agree, so the member keys have to be resolved before asking.
|
|
4939
|
+
let memberKeys;
|
|
4940
|
+
if (scope.scopeType === 'workspace') {
|
|
4941
|
+
const ws = await readWorkspace(scope.scopeKey);
|
|
4942
|
+
memberKeys = ws ? ws.projectKeys : [];
|
|
4943
|
+
}
|
|
4944
|
+
res.json({
|
|
4945
|
+
...scope,
|
|
4946
|
+
...resolveProfile({ ...scope, plugin, sourceId, memberKeys, available: listProfileIds(plugin) }),
|
|
4947
|
+
});
|
|
4948
|
+
} catch (err) {
|
|
4949
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4950
|
+
}
|
|
4951
|
+
});
|
|
4952
|
+
|
|
4953
|
+
// PUT /api/source-bindings { projectDir|projectKey|workspaceId, plugin,
|
|
4954
|
+
// sourceId, profile } — profile:null clears the binding.
|
|
4955
|
+
app.put('/api/source-bindings', (req, res) => {
|
|
4956
|
+
const body = req.body || {};
|
|
4957
|
+
const scope = bindingScope(body);
|
|
4958
|
+
if (!scope) return badRequest(res, 'projectDir, projectKey or workspaceId is required');
|
|
4959
|
+
const plugin = typeof body.plugin === 'string' ? body.plugin.trim() : '';
|
|
4960
|
+
const sourceId = typeof body.sourceId === 'string' ? body.sourceId.trim() : '';
|
|
4961
|
+
if (!plugin || !sourceId) return badRequest(res, 'plugin and sourceId are required');
|
|
4962
|
+
const ref = { ...scope, plugin, sourceId };
|
|
4963
|
+
try {
|
|
4964
|
+
if (body.profile === null || body.profile === '') {
|
|
4965
|
+
clearBinding(ref);
|
|
4966
|
+
return res.json({ ok: true, ...scope, profile: null });
|
|
4967
|
+
}
|
|
4968
|
+
if (!isValidProfileId(body.profile)) return badRequest(res, 'invalid profile id');
|
|
4969
|
+
// Binding to a profile that does not exist would resolve to nothing at run
|
|
4970
|
+
// time — reject it here, where the user can still see why.
|
|
4971
|
+
if (!listProfileIds(plugin).includes(body.profile)) {
|
|
4972
|
+
return badRequest(res, `plugin "${plugin}" has no profile "${body.profile}"`);
|
|
4973
|
+
}
|
|
4974
|
+
res.json({ ok: true, ...scope, profile: setBinding(ref, body.profile) });
|
|
4975
|
+
} catch (err) {
|
|
4976
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4977
|
+
}
|
|
3265
4978
|
});
|
|
3266
4979
|
|
|
3267
4980
|
// ---------------------------------------------------------------------------
|
|
@@ -3303,8 +5016,21 @@ app.post('/api/sources/call', async (req, res) => {
|
|
|
3303
5016
|
if (input && typeof input.optionsFrom === 'string' && input.optionsFrom) allowed.add(input.optionsFrom);
|
|
3304
5017
|
}
|
|
3305
5018
|
if (!allowed.has(op)) return badRequest(res, `op "${op}" is not allowed for this source`);
|
|
5019
|
+
const profile = typeof body.profile === 'string' && body.profile ? body.profile : null;
|
|
5020
|
+
if (profile && !isValidProfileId(profile)) return badRequest(res, 'invalid profile id');
|
|
5021
|
+
if (source.multiProfile && !profile) return badRequest(res, 'profile is required for this task source');
|
|
5022
|
+
// Same submit-time guards as /api/run: a deleted profile must 400 here, not
|
|
5023
|
+
// fail deep in the connector against an empty bucket, and a profile on a
|
|
5024
|
+
// single-profile source would read (and persist state into) a phantom
|
|
5025
|
+
// bucket instead of the real config.
|
|
5026
|
+
if (source.multiProfile && !listProfileIds(plugin).includes(profile)) {
|
|
5027
|
+
return badRequest(res, `plugin "${plugin}" has no profile "${profile}"`);
|
|
5028
|
+
}
|
|
5029
|
+
if (!source.multiProfile && profile) {
|
|
5030
|
+
return badRequest(res, `task source "${sourceId}" does not use profiles — omit profile`);
|
|
5031
|
+
}
|
|
3306
5032
|
try {
|
|
3307
|
-
const result = await callSource({ plugin, sourceId, op, args });
|
|
5033
|
+
const result = await callSource({ plugin, sourceId, op, args, profile });
|
|
3308
5034
|
res.json({ ok: true, result });
|
|
3309
5035
|
} catch (err) {
|
|
3310
5036
|
if (err instanceof PluginOpError) {
|
|
@@ -3475,11 +5201,32 @@ app.post('/api/chat/test', async (req, res) => {
|
|
|
3475
5201
|
app.use((req, res, next) => {
|
|
3476
5202
|
if (req.method !== 'GET') return next();
|
|
3477
5203
|
if (req.path.startsWith('/api/') || req.path.startsWith('/ws')) return next();
|
|
5204
|
+
if (req.path === '/vendor' || req.path.startsWith('/vendor/')) return next();
|
|
3478
5205
|
res.sendFile(path.join(PUBLIC_DIR, 'index.html'), (err) => {
|
|
3479
5206
|
if (err) next();
|
|
3480
5207
|
});
|
|
3481
5208
|
});
|
|
3482
5209
|
|
|
5210
|
+
// ---------------------------------------------------------------------------
|
|
5211
|
+
// The LAST middleware, and the only GLOBAL error handler (`/vendor` keeps its
|
|
5212
|
+
// own path-scoped one): every failure answers { error } as JSON (MIN-108).
|
|
5213
|
+
// Without it express's default handler renders an HTML page carrying the thrown
|
|
5214
|
+
// stack — absolute node_modules paths included — for the two failures that happen
|
|
5215
|
+
// BEFORE any route runs: a malformed JSON body and a body past the cap. The
|
|
5216
|
+
// four-argument signature is what makes express treat this as an error handler,
|
|
5217
|
+
// so `next` stays even though only the headers-sent path uses it.
|
|
5218
|
+
// ---------------------------------------------------------------------------
|
|
5219
|
+
app.use((err, _req, res, next) => {
|
|
5220
|
+
if (res.headersSent) return next(err); // let express abort the stream
|
|
5221
|
+
if (err && err.type === 'entity.parse.failed') return res.status(400).json({ error: 'malformed JSON body' });
|
|
5222
|
+
if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'request body too large' });
|
|
5223
|
+
// Anything else: honour a body-parser 4xx (charset.unsupported / encoding.unsupported
|
|
5224
|
+
// are 415, request.aborted is 400) — a client error logged as a 500 misleads. Ours or
|
|
5225
|
+
// not, it is one line, no stack, never HTML.
|
|
5226
|
+
const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 500 ? err.status : 500;
|
|
5227
|
+
return res.status(status).json({ error: err && err.message ? err.message : 'internal error' });
|
|
5228
|
+
});
|
|
5229
|
+
|
|
3483
5230
|
/**
|
|
3484
5231
|
* Boot maintenance, in the PINNED order (§8.12):
|
|
3485
5232
|
* 1. reconcileStaleRunning — stamps every stale `running` row -> `interrupted`,
|
|
@@ -3503,12 +5250,12 @@ app.use((req, res, next) => {
|
|
|
3503
5250
|
* everything up to the first `await` — including the reconcile — still runs before
|
|
3504
5251
|
* `server.listen`, exactly as it did when this was an inline block.
|
|
3505
5252
|
*
|
|
3506
|
-
* @param {{log?: (scope:'run-root'|'legacy', level:string, msg:string) => void}} [args]
|
|
5253
|
+
* @param {{log?: (scope:'run-root'|'legacy'|'ask-worktrees', level:string, msg:string) => void}} [args]
|
|
3507
5254
|
* optional sink for the per-candidate lines both sweeps emit; omitted, each
|
|
3508
5255
|
* sweep keeps its own console default.
|
|
3509
5256
|
*/
|
|
3510
5257
|
export async function bootMaintenance({ log } = {}) {
|
|
3511
|
-
const summary = { reconciled: 0, runRoots: null, legacy: null };
|
|
5258
|
+
const summary = { reconciled: 0, sweptV1: 0, runRoots: null, legacy: null, ask: null, askWorktrees: null };
|
|
3512
5259
|
const sink = (scope) => (typeof log === 'function' ? (level, msg) => log(scope, level, msg) : undefined);
|
|
3513
5260
|
|
|
3514
5261
|
// Runs left 'running' by a previous process that died before writing a terminal
|
|
@@ -3521,6 +5268,16 @@ export async function bootMaintenance({ log } = {}) {
|
|
|
3521
5268
|
console.error(`[worca-ui] stale-run reconcile failed: ${err && err.message ? err.message : err}`);
|
|
3522
5269
|
}
|
|
3523
5270
|
|
|
5271
|
+
// A DB stamped past 24 by a divergent ladder can still hold v1 resume points
|
|
5272
|
+
// (crash-reconciled runs keep theirs). One idempotent sweep per boot.
|
|
5273
|
+
try {
|
|
5274
|
+
const swept = sweepV1Runs();
|
|
5275
|
+
summary.sweptV1 = swept.length;
|
|
5276
|
+
if (swept.length) console.log(`[worca-ui] retired ${swept.length} run(s) paused on the v1 engine`);
|
|
5277
|
+
} catch (err) {
|
|
5278
|
+
console.error(`[worca-ui] v1-run sweep failed: ${err && err.message ? err.message : err}`);
|
|
5279
|
+
}
|
|
5280
|
+
|
|
3524
5281
|
try {
|
|
3525
5282
|
const r = await sweepRunRoots({
|
|
3526
5283
|
worcaHome: worcaHome(), ...runRootSweepLookups(), log: sink('run-root'),
|
|
@@ -3562,6 +5319,34 @@ export async function bootMaintenance({ log } = {}) {
|
|
|
3562
5319
|
} catch (err) {
|
|
3563
5320
|
console.error(`[worca-ui] legacy worktree sweep failed: ${err && err.message ? err.message : err} — nothing was removed`);
|
|
3564
5321
|
}
|
|
5322
|
+
|
|
5323
|
+
// Ask Worca (§6.2): mark turns orphaned by a restart, sweep stale empty threads.
|
|
5324
|
+
try {
|
|
5325
|
+
const interrupted = sweepStreamingMessages();
|
|
5326
|
+
const emptyThreads = sweepEmptyThreads();
|
|
5327
|
+
summary.ask = { interrupted, emptyThreads };
|
|
5328
|
+
if (interrupted || emptyThreads) {
|
|
5329
|
+
console.log(`[worca-ui] ask sweep: ${interrupted} interrupted turn(s), ${emptyThreads} empty thread(s)`);
|
|
5330
|
+
}
|
|
5331
|
+
} catch (err) {
|
|
5332
|
+
summary.ask = { interrupted: 0, emptyThreads: 0 };
|
|
5333
|
+
console.error(`[worca-ui] ask sweep failed: ${err && err.message ? err.message : err}`);
|
|
5334
|
+
}
|
|
5335
|
+
|
|
5336
|
+
// Ask worktrees (P4 §5): reconcile ask_worktrees rows vs on-disk checkouts
|
|
5337
|
+
// both ways. Three-state inside the sweep: a DB failure aborts with nothing
|
|
5338
|
+
// removed. `sink('ask-worktrees')` is undefined on a log-less boot, which is
|
|
5339
|
+
// exactly the sweep's own default — never call sink(...) directly.
|
|
5340
|
+
try {
|
|
5341
|
+
const r = await sweepAskWorktrees({ log: sink('ask-worktrees') });
|
|
5342
|
+
summary.askWorktrees = r;
|
|
5343
|
+
if (r.removedDirs || r.prunedRows) {
|
|
5344
|
+
console.log(`[worca-ui] ask-worktree sweep: removed ${r.removedDirs} orphan dir(s), dropped ${r.prunedRows} stale row(s)`);
|
|
5345
|
+
}
|
|
5346
|
+
if (r.failed) console.error(`[worca-ui] ask-worktree sweep: ${r.failed} candidate(s) skipped`);
|
|
5347
|
+
} catch (err) {
|
|
5348
|
+
console.error(`[worca-ui] ask-worktree sweep failed: ${err && err.message ? err.message : err}`);
|
|
5349
|
+
}
|
|
3565
5350
|
return summary;
|
|
3566
5351
|
}
|
|
3567
5352
|
|
|
@@ -3606,4 +5391,9 @@ if (isMain) {
|
|
|
3606
5391
|
}
|
|
3607
5392
|
|
|
3608
5393
|
export { app, server, runs };
|
|
3609
|
-
export const _testing = {
|
|
5394
|
+
export const _testing = {
|
|
5395
|
+
wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
|
|
5396
|
+
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
|
|
5397
|
+
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
|
|
5398
|
+
emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
|
|
5399
|
+
};
|