@worca/app 1.2.0 → 1.3.0
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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
package/ui/server.mjs
CHANGED
|
@@ -21,19 +21,20 @@ import { preflightNode } from '../src/core/preflight-node.mjs';
|
|
|
21
21
|
import { createOrchestratorFor } from '../src/core/engine-select.mjs';
|
|
22
22
|
import {
|
|
23
23
|
listPipelines, readPipeline, listAllPipelines, readPipelineByKey,
|
|
24
|
-
enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState,
|
|
24
|
+
enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState, readPrState,
|
|
25
25
|
readRunLogText, readRunArtifactText, countPipelines, runRootSweepLookups, legacySweepLookups, slugify,
|
|
26
|
-
listArtifacts, lookupPipelineRow, findPipelineRowById, resolveIndexedArtifact, resolveIndexedArtifactForRow,
|
|
26
|
+
listArtifacts, listRunArtifacts, lookupPipelineRow, findPipelineRowById, readPipelineStateById, resolveIndexedArtifact, resolveIndexedArtifactForRow,
|
|
27
27
|
readPromptFile,
|
|
28
28
|
} from '../src/core/artifacts.mjs';
|
|
29
29
|
import { DIFF_PATCH_FILE } from '../src/core/results.mjs';
|
|
30
30
|
import { protectedSectionKeys } from '../src/core/diff-anchor.mjs';
|
|
31
31
|
import {
|
|
32
|
-
addDiffComment, listDiffComments, getDiffComment, setDiffCommentResolved,
|
|
32
|
+
addDiffComment, addDiffCommentReply, listDiffComments, getDiffComment, setDiffCommentResolved,
|
|
33
33
|
deleteDiffComment, unresolvedCounts, onDiffCommentsChanged, stampSentRunId,
|
|
34
34
|
peekPendingCardComments, clearPendingCardComments, DiffCommentError, DC_ID_RE,
|
|
35
35
|
} from '../src/core/diff-comments.mjs';
|
|
36
36
|
import { listProjects, addProject, removeProject, normalizeProjectPath, countProjects, worcaHome } from '../src/core/projects.mjs';
|
|
37
|
+
import { renderIndexHtml, INDEX_THEME_ANCHOR } from '../src/core/index-html.mjs';
|
|
37
38
|
import {
|
|
38
39
|
getWorcaRoot, setWorcaRoot, setProjectsRoot, defaultRoot,
|
|
39
40
|
rawProjectsRoot, defaultProjectsRoot, runRootMode,
|
|
@@ -44,6 +45,8 @@ import {
|
|
|
44
45
|
debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
|
|
45
46
|
titleModel as storedTitleModel, setTitleModel, assertTitleModelInput,
|
|
46
47
|
hideBuiltinModels, setHideBuiltinModels, assertHideBuiltinModelsInput,
|
|
48
|
+
theme as storedTheme, setTheme, assertThemeInput,
|
|
49
|
+
autoWorkflowModel as storedAutoWorkflowModel, setAutoWorkflowModel, assertAutoWorkflowModelInput,
|
|
47
50
|
} from '../src/core/settings.mjs';
|
|
48
51
|
import { describeTitleModel } from '../src/core/title.mjs';
|
|
49
52
|
import {
|
|
@@ -84,13 +87,20 @@ import { attachRunFollower } from '../src/core/ask/follow.mjs';
|
|
|
84
87
|
import { mockEnabled, MOCK_WRITER_ROLES } from '../src/core/claude-runner.mjs';
|
|
85
88
|
import { budgetStatus, readCostCapOverride, setCostCapOverride } from '../src/core/cost-budget.mjs';
|
|
86
89
|
import { getStats } from '../src/core/stats.mjs';
|
|
90
|
+
import {
|
|
91
|
+
enableTeamMetrics, setRecordMyRuns, flushSlug, flushAll, flushProject, scheduleFlush, discoverProject,
|
|
92
|
+
discoverAll, scanMembers, routeWorkspaceMembers, projectMetricsStatus, startTeamMetricsBackground,
|
|
93
|
+
metricsEvents, slugDirName, autoMetricsHome,
|
|
94
|
+
} from '../src/core/metrics/sync.mjs';
|
|
95
|
+
import { readScope, listScopes, parseScopeParam, aggregate, resolveRange, GROUP_BYS, PROJECT_KEY_RE as TM_PROJECT_KEY_RE } from '../src/core/metrics/read.mjs';
|
|
87
96
|
import { pickFolderNative } from '../src/core/folder-dialog.mjs';
|
|
88
97
|
import { listFolders } from '../src/core/fs-browse.mjs';
|
|
89
98
|
import {
|
|
90
99
|
readConfig, setStep, addCustomModel, removeCustomModel, listModels,
|
|
91
100
|
PREDEFINED_MODELS, agentSteps, EFFORTS, catalogHasModel,
|
|
92
|
-
readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, resetWorkflowConfig,
|
|
101
|
+
readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, setHumanInLoop, resetWorkflowConfig,
|
|
93
102
|
globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
|
|
103
|
+
readPrRemotePrefs, setPrRemotePrefs,
|
|
94
104
|
} from '../src/core/config.mjs';
|
|
95
105
|
import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
|
|
96
106
|
import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
|
|
@@ -105,9 +115,15 @@ import {
|
|
|
105
115
|
writeGuardrailSet, deleteGuardrailSet, isBuiltinGuardrailSetId,
|
|
106
116
|
} from '../src/core/guardrail-store.mjs';
|
|
107
117
|
import {
|
|
108
|
-
GRAPH_DEFAULT_WORKFLOW, listWorkflows, deleteWorkflow, isSafeWorkflowId,
|
|
109
|
-
setWorkflowNodeDefaults, workflowNodeDefaults, assertRunnableWorkflow, writeGraphWorkflow,
|
|
118
|
+
GRAPH_DEFAULT_WORKFLOW, AUTO_WORKFLOW_ID, GRAPH_MEMORY_DEFRAG_WORKFLOW, MEMORY_DEFRAG_WORKFLOW_ID, listWorkflows, deleteWorkflow, isSafeWorkflowId,
|
|
119
|
+
setWorkflowNodeDefaults, workflowNodeDefaults, assertRunnableWorkflow, writeGraphWorkflow, readWorkflow,
|
|
110
120
|
} from '../src/core/workflows.mjs';
|
|
121
|
+
import { mintAutoWorkflowId, sanitizeProposalAnswer } from '../src/core/auto/proposal.mjs';
|
|
122
|
+
import {
|
|
123
|
+
revalidateWorkflowProposal, applyTunables, workflowEventPrompt, workflowNoticeText,
|
|
124
|
+
} from '../src/core/ask/workflow-deps.mjs';
|
|
125
|
+
import { applyMetricsChange } from '../src/core/ask/metrics-deps.mjs';
|
|
126
|
+
import { metricsEventPrompt, metricsNoticeText } from '../src/core/ask/metrics-proposal.mjs';
|
|
111
127
|
import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
|
|
112
128
|
import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
|
|
113
129
|
import { exportWorkflow, exportWorkflowPlugin, ON_CONFLICT_MODES, RESOLUTION_CHOICES } from '../src/core/workflow-export.mjs';
|
|
@@ -118,7 +134,7 @@ import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
|
|
|
118
134
|
import {
|
|
119
135
|
listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
|
|
120
136
|
} from '../src/core/worktree.mjs';
|
|
121
|
-
import { hasGh, pushBranch, createPr, prMergeable } from '../src/core/git-info.mjs';
|
|
137
|
+
import { hasGh, pushBranch, createPr, createIssue, prMergeable, listRemotes, sameRepo } from '../src/core/git-info.mjs';
|
|
122
138
|
import { archivePipeline, discardRetainedWorktrees } from '../src/core/pipeline-delete.mjs';
|
|
123
139
|
import {
|
|
124
140
|
listWorkspaces, readWorkspace, createWorkspace,
|
|
@@ -126,7 +142,15 @@ import {
|
|
|
126
142
|
} from '../src/core/workspaces.mjs';
|
|
127
143
|
import { listWorkspacePipelines, readWorkspacePipeline } from '../src/core/artifacts.mjs';
|
|
128
144
|
import { generateOverview } from '../src/core/overview-agent.mjs';
|
|
129
|
-
import { projectKey } from '../src/core/store.mjs';
|
|
145
|
+
import { projectKey, PROJECT_KEY_RE } from '../src/core/store.mjs';
|
|
146
|
+
import { validateMemoryScope, withStoreLock } from '../src/core/memory-sync.mjs';
|
|
147
|
+
import {
|
|
148
|
+
memoryRoot, GLOBAL_SCOPE, projectScope, scopeKey, isValidMemoryName, MEMORY_NAME_HELP, memoryScopeReport,
|
|
149
|
+
readMemory, writeMemory, removeMemory, listSnapshots, restoreSnapshot,
|
|
150
|
+
} from '../src/core/memory-store.mjs';
|
|
151
|
+
import { memoryCaps } from '../src/core/settings.mjs';
|
|
152
|
+
import { onboardingPrefs, setOnboardingPrefs } from '../src/core/settings.mjs';
|
|
153
|
+
import { onboardingStatus } from '../src/core/onboarding.mjs'; // a THIRD settings import line (the two blocks above are unrelated readers)
|
|
130
154
|
import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
|
|
131
155
|
import { createAgentGen } from '../src/core/agent-gen.mjs';
|
|
132
156
|
import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
|
|
@@ -158,6 +182,12 @@ import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs'
|
|
|
158
182
|
import { normalizeManifest, validatePluginDir, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
|
|
159
183
|
import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
|
|
160
184
|
import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
|
|
185
|
+
import { resolveAutoModel, AUTO_MODEL_ENV } from '../src/core/auto/model.mjs';
|
|
186
|
+
import {
|
|
187
|
+
buildRunReport, buildIssueUrl, reportFilename, renderIssueBodyFull, issueTitle,
|
|
188
|
+
repoSlugFromBugsUrl, BUGS_URL,
|
|
189
|
+
} from '../src/core/run-report.mjs';
|
|
190
|
+
import { REPORT_REASON_IDS } from '../src/shared/report-reasons.mjs';
|
|
161
191
|
import { HLJS_GRAMMAR_IDS } from './public/hljs-loader.mjs';
|
|
162
192
|
|
|
163
193
|
// ── node:sqlite runtime guard + warning filter ──────────────────────────────────
|
|
@@ -200,6 +230,9 @@ const APP_INFO = Object.freeze({
|
|
|
200
230
|
version: PKG_VERSION || '',
|
|
201
231
|
repoUrl: APP_REPO_URL,
|
|
202
232
|
releaseUrl: APP_REPO_URL && PKG_VERSION ? `${APP_REPO_URL}/releases/tag/worca-app-v${PKG_VERSION}` : '',
|
|
233
|
+
// package.json bugs.url, via run-report.mjs so the Settings links and the
|
|
234
|
+
// prefilled issue URL can never disagree. Falls back to <repo>/issues.
|
|
235
|
+
bugsUrl: BUGS_URL || (APP_REPO_URL ? `${APP_REPO_URL}/issues` : ''),
|
|
203
236
|
});
|
|
204
237
|
const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
|
|
205
238
|
// Primaries plus the sub-language grammars their instances register
|
|
@@ -456,6 +489,8 @@ function emitChanged(type, action) {
|
|
|
456
489
|
broadcast({ type, action: action || null });
|
|
457
490
|
}
|
|
458
491
|
|
|
492
|
+
metricsEvents.on('changed', (e) => emitChanged('team-metrics-changed', e && e.action ? e.action : null));
|
|
493
|
+
|
|
459
494
|
// Every comment mutation in THIS process (the REST routes below) pokes the open
|
|
460
495
|
// Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
|
|
461
496
|
// ordering concerns; the client refetches and repaints its CARDS, never the diff.
|
|
@@ -508,6 +543,44 @@ function resolvePending(entry, { id = null, reason = 'resolved' } = {}) {
|
|
|
508
543
|
return true;
|
|
509
544
|
}
|
|
510
545
|
|
|
546
|
+
/** Statuses under which an entry no longer drives its pipeline (the resumeRun double-resume guard's list, :1713). */
|
|
547
|
+
const SETTLED_RUN = new Set(['done', 'stopped', 'error', 'paused', 'interrupted']);
|
|
548
|
+
/** A runs-Map PIPELINE entry by its UUID or by its 8-hex History id (scans / agentgens never match). */
|
|
549
|
+
function liveRunEntry(id) {
|
|
550
|
+
if (typeof id !== 'string' || !id) return null;
|
|
551
|
+
const isRun = (r) => r && r.orch && (r.kind === 'run' || r.kind === 'workspace-run' || r.kind == null);
|
|
552
|
+
const direct = runs.get(id);
|
|
553
|
+
if (isRun(direct)) return direct;
|
|
554
|
+
// D23: resumeRun evicts only the paused/interrupted lineage; a same-pipeline entry left done/stopped/error sits
|
|
555
|
+
// EARLIER in Map order — the entry still driving the pipeline wins, else the newest (last inserted).
|
|
556
|
+
let best = null;
|
|
557
|
+
for (const r of runs.values()) {
|
|
558
|
+
if (!isRun(r) || r.pipelineId !== id) continue;
|
|
559
|
+
if (!best || !SETTLED_RUN.has(String(r.status || '')) || SETTLED_RUN.has(String(best.status || ''))) best = r;
|
|
560
|
+
}
|
|
561
|
+
return best;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** 'global' | 'projects/<key>' — the store scope key a defragment run works on (memory-store.mjs scopeKey). */
|
|
565
|
+
function memoryScopeKey(memoryScope, projectDir) {
|
|
566
|
+
return memoryScope === 'global' ? 'global' : `projects/${projectKey(projectDir)}`;
|
|
567
|
+
}
|
|
568
|
+
/** The live (i.e. not SETTLED_RUN) defragment run on that scope key, or null. A paused defrag is
|
|
569
|
+
* NOT live — resumeRun refuses to resume it while another one runs.
|
|
570
|
+
* Amendment B20: "one live defragment run per scope" is enforced by THIS server process over ITS
|
|
571
|
+
* runs Map. A CLI-started defragment, or a second `worca ui` process on the same home, is not
|
|
572
|
+
* registered here; such a collision resolves like any two runs today — last sync wins and the
|
|
573
|
+
* loser's store copy is in `.history` (spec §5 concurrency). A DB-level check is impossible: the
|
|
574
|
+
* pipelines row carries no memoryScope. */
|
|
575
|
+
function liveDefragRun(scopeKeyStr) {
|
|
576
|
+
for (const e of runs.values()) {
|
|
577
|
+
if ((e.kind || 'run') !== 'run' || !e.orch?.memoryScope) continue;
|
|
578
|
+
if (SETTLED_RUN.has(String(e.status || ''))) continue;
|
|
579
|
+
if (memoryScopeKey(e.orch.memoryScope, e.projectDir) === scopeKeyStr) return e;
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
511
584
|
function summarizeRuns() {
|
|
512
585
|
return [...runs.values()].map((r) => ({
|
|
513
586
|
runId: r.id,
|
|
@@ -608,6 +681,14 @@ function wireRun(entry) {
|
|
|
608
681
|
// ...and WHAT went wrong for an error-pause, reset alongside it.
|
|
609
682
|
entry.pauseDetail = (payload && payload.detail) || null;
|
|
610
683
|
resolvePending(entry, { reason: entry.status });
|
|
684
|
+
// B29: ANY run that mounted memory may have synced into its scopes (P1 syncs the mount back
|
|
685
|
+
// at the run end on done, error, stopped and paused alike) — poke every mounted scope so open
|
|
686
|
+
// Memory views refetch. Never gated on `entry.status`: it is mirrored from the earlier `state`
|
|
687
|
+
// frame and already reads 'done' BEFORE _buildResults()/_stampDefrag() have run, while THIS
|
|
688
|
+
// event fires after both (so a defragment frame always follows its stamp).
|
|
689
|
+
if (orch.memory?.dirs?.length) {
|
|
690
|
+
for (const d of orch.memory.dirs) emitMemoryChanged(scopeKey(d.scope));
|
|
691
|
+
}
|
|
611
692
|
if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
|
|
612
693
|
emitChanged('budget-changed');
|
|
613
694
|
}
|
|
@@ -865,6 +946,32 @@ app.use('/src/shared', (_req, res) => {
|
|
|
865
946
|
res.status(404).type('text/plain').send('Not found');
|
|
866
947
|
});
|
|
867
948
|
|
|
949
|
+
// The shell is rendered, not static: the stored theme mode goes into
|
|
950
|
+
// <html data-theme> so the first paint is already dark or light (dark-mode
|
|
951
|
+
// design §5.2). Read per request (100 KB, local) so an index.html edit is live
|
|
952
|
+
// without a restart, exactly like static serving was. no-store: a theme change
|
|
953
|
+
// must never be served from the browser cache.
|
|
954
|
+
const INDEX_FILE = path.join(PUBLIC_DIR, 'index.html');
|
|
955
|
+
if (!fs.readFileSync(INDEX_FILE, 'utf8').includes(INDEX_THEME_ANCHOR)) {
|
|
956
|
+
throw new Error(`ui/public/index.html lost its theme anchor ${INDEX_THEME_ANCHOR}`);
|
|
957
|
+
}
|
|
958
|
+
function sendIndex(res) {
|
|
959
|
+
let html;
|
|
960
|
+
try { html = renderIndexHtml(fs.readFileSync(INDEX_FILE, 'utf8'), storedTheme()); }
|
|
961
|
+
catch (err) { return res.status(500).json({ error: err && err.message ? err.message : 'shell unavailable' }); }
|
|
962
|
+
res.set('Content-Type', 'text/html; charset=utf-8');
|
|
963
|
+
res.set('Cache-Control', 'no-store');
|
|
964
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
965
|
+
return res.send(html);
|
|
966
|
+
}
|
|
967
|
+
// `/index` (express.static's `extensions:['html']`) and `/Index.html` on a case-insensitive
|
|
968
|
+
// file system would otherwise reach the raw file: route every spelling here.
|
|
969
|
+
app.use((req, res, next) => {
|
|
970
|
+
if (req.method !== 'GET') return next();
|
|
971
|
+
if (req.path === '/' || /^\/index(\.html)?$/i.test(req.path)) return sendIndex(res);
|
|
972
|
+
return next();
|
|
973
|
+
});
|
|
974
|
+
|
|
868
975
|
app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
|
|
869
976
|
|
|
870
977
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
@@ -1051,7 +1158,7 @@ function attachAskFollower(orch, { threadId, runId, cardId }) {
|
|
|
1051
1158
|
if (linkPatch.pipelineId && row && row.commentIds.length) {
|
|
1052
1159
|
try { stampSentRunId(row.commentIds, linkPatch.pipelineId); } catch { /* best effort */ }
|
|
1053
1160
|
}
|
|
1054
|
-
if (patch.cardFailed) {
|
|
1161
|
+
if (patch.cardFailed && cardId) {
|
|
1055
1162
|
flipCard(threadId, cardId, { state: 'failed', error: patch.cardFailed });
|
|
1056
1163
|
}
|
|
1057
1164
|
broadcast({
|
|
@@ -1079,13 +1186,82 @@ function attachAskFollower(orch, { threadId, runId, cardId }) {
|
|
|
1079
1186
|
set.add(follower);
|
|
1080
1187
|
}
|
|
1081
1188
|
|
|
1189
|
+
/** An undetached follower for this (thread, runId) already exists — the proposal launch attached it, or an earlier track_run. */
|
|
1190
|
+
function askFollowerAttached(threadId, runId) {
|
|
1191
|
+
const set = askFollowers.get(threadId);
|
|
1192
|
+
return !!set && [...set].some((f) => f.runId === runId && !f.detached);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* track_run's parent-side half (the MCP child only resolves — src/core/ask/tools.mjs is write-free by contract):
|
|
1197
|
+
* resolve the run (live entry by UUID or 8-hex, else the pipelines table — the user-pinned scope first, like
|
|
1198
|
+
* resolveRow), link it to the thread ONCE per pipeline (D5: a run with no live UUID keys the row by its pipeline
|
|
1199
|
+
* id until a live run takes it over; the dedupe is application-level — the PK is (thread, run_id) and linkRun
|
|
1200
|
+
* throws on a collision), attach a follower to a live run ONCE per runId (D6), and hand back the progress card's
|
|
1201
|
+
* identity. Never throws; a failure is a model-readable {ok:false, error}.
|
|
1202
|
+
*/
|
|
1203
|
+
function askTrackRun(threadId, input, pin) {
|
|
1204
|
+
const raw = input && typeof input.id === 'string' ? input.id.trim() : '';
|
|
1205
|
+
if (!raw) return { ok: false, error: 'id is required' };
|
|
1206
|
+
let entry = liveRunEntry(raw);
|
|
1207
|
+
let state = null;
|
|
1208
|
+
if (entry) {
|
|
1209
|
+
if (!entry.pipelineId) return { ok: false, error: 'the run has no pipeline id yet — try again in a moment' };
|
|
1210
|
+
state = readPipelineStateById(entry.pipelineId);
|
|
1211
|
+
} else {
|
|
1212
|
+
const scopeKey = typeof input.projectKey === 'string' && input.projectKey ? input.projectKey
|
|
1213
|
+
: typeof input.workspaceId === 'string' && input.workspaceId ? `workspaces/${input.workspaceId}`
|
|
1214
|
+
: pin && pin.projectKey ? pin.projectKey : pin && pin.workspaceId ? `workspaces/${pin.workspaceId}` : null;
|
|
1215
|
+
const row = (scopeKey ? lookupPipelineRow(scopeKey, raw) : null) || findPipelineRowById(raw);
|
|
1216
|
+
if (!row) return { ok: false, error: 'run not found' };
|
|
1217
|
+
// The row lookups canonicalise (lower case, the `…-<8hex>` dir-name form); the runs-Map scan compares verbatim.
|
|
1218
|
+
// Re-ask with the canonical id so an uppercase or dir-name id still finds the live lineage.
|
|
1219
|
+
entry = liveRunEntry(row.id) || null;
|
|
1220
|
+
state = readPipelineStateById(row.id);
|
|
1221
|
+
}
|
|
1222
|
+
if (!state) return { ok: false, error: 'run not found' };
|
|
1223
|
+
const pipelineId = state.id;
|
|
1224
|
+
const isWs = !!((entry && entry.workspaceId) || state.target === 'workspace');
|
|
1225
|
+
const workspaceId = isWs ? ((entry && entry.workspaceId) || state.workspaceId || null) : null;
|
|
1226
|
+
const projKey = isWs ? null : ((entry && entry.projectDir) ? projectKey(entry.projectDir) : (state.projectKey || null));
|
|
1227
|
+
const label = isWs
|
|
1228
|
+
? (state.workspaceName || (Array.isArray(state.projects) ? state.projects.map((p) => p.projectName).filter(Boolean).join(' · ') : '') || '')
|
|
1229
|
+
: String((entry && entry.projectDir) || state.projectDir || '').split(/[\\/]/).filter(Boolean).pop() || (projKey || '');
|
|
1230
|
+
const title = (entry && entry.title) || state.title || pipelineId;
|
|
1231
|
+
const status = (entry && entry.status) || state.status || null;
|
|
1232
|
+
const liveRunId = entry ? entry.id : null;
|
|
1233
|
+
// ONE row per (thread, pipeline): by the live UUID first (a proposal launch whose block threw after askLinkRun leaves a
|
|
1234
|
+
// uuid row with pipeline_id NULL that no follower fills — adopt it), then by pipeline id. UUID-first also means the
|
|
1235
|
+
// patch below never moves a run_id onto a key that already exists (the (thread_id, run_id) PK would throw).
|
|
1236
|
+
const links = askListRunLinks(threadId);
|
|
1237
|
+
const existing = (liveRunId ? links.find((l) => l.runId === liveRunId) : null) || links.find((l) => l.pipelineId === pipelineId) || null;
|
|
1238
|
+
try {
|
|
1239
|
+
if (!existing) askLinkRun(threadId, { runId: liveRunId || pipelineId, pipelineId, status });
|
|
1240
|
+
else {
|
|
1241
|
+
const patch = { status };
|
|
1242
|
+
if (!existing.pipelineId) patch.pipelineId = pipelineId;
|
|
1243
|
+
if (liveRunId && existing.runId !== liveRunId) patch.runId = liveRunId;
|
|
1244
|
+
askUpdateRunLink(threadId, existing.runId, patch);
|
|
1245
|
+
}
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
return { ok: false, error: `could not link the run: ${err && err.message ? err.message : String(err)}` };
|
|
1248
|
+
}
|
|
1249
|
+
// Only a run that still drives its pipeline gets a follower: attachRunFollower subscribes unconditionally
|
|
1250
|
+
// (follow.mjs:111) and a settled orchestrator never emits again, so it would sit in askFollowers for the life
|
|
1251
|
+
// of the thread — and pin a paused lineage's dead orchestrator once resumeRun evicts the entry.
|
|
1252
|
+
if (entry && !SETTLED_RUN.has(String(entry.status || '')) && !askFollowerAttached(threadId, entry.id)) {
|
|
1253
|
+
attachAskFollower(entry.orch, { threadId, runId: entry.id, cardId: null });
|
|
1254
|
+
}
|
|
1255
|
+
return { ok: true, card: { type: 'progress', pipelineId, runId: liveRunId, projectKey: projKey, workspaceId, title, label, status } };
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1082
1258
|
// ---------------------------------------------------------------------------
|
|
1083
1259
|
// POST /api/run -> start a new orchestration run
|
|
1084
1260
|
// body (single-project): { projectDir, prompt?, promptMarkdown?, title?, mock? }
|
|
1085
1261
|
// body (workspace): { workspaceId, prompt?, ... } — mutually exclusive with
|
|
1086
1262
|
// projectDir (§2.6). Single-project behavior is byte-identical.
|
|
1087
1263
|
// ---------------------------------------------------------------------------
|
|
1088
|
-
|
|
1264
|
+
const startRunHandler = async (req, res) => {
|
|
1089
1265
|
try {
|
|
1090
1266
|
const body = req.body || {};
|
|
1091
1267
|
|
|
@@ -1186,6 +1362,21 @@ app.post('/api/run', async (req, res) => {
|
|
|
1186
1362
|
return badRequest(res, err && err.message ? err.message : String(err));
|
|
1187
1363
|
}
|
|
1188
1364
|
|
|
1365
|
+
// Auto workflow (spec D19): project targets only in v1. Before the workspace lookup,
|
|
1366
|
+
// so an Auto request for ANY workspace id answers 400, never 404.
|
|
1367
|
+
if (workflowId === AUTO_WORKFLOW_ID && hasWorkspace) {
|
|
1368
|
+
return badRequest(res, 'Auto workflow is not available for workspace targets yet');
|
|
1369
|
+
}
|
|
1370
|
+
// Agent memory (§7.3): the defragment run option — ONE gate for every entry point (the CLI
|
|
1371
|
+
// and Ask's proposal validator call the same helper). Before the target lookup, like Auto.
|
|
1372
|
+
if (body.memoryScope != null && typeof body.memoryScope !== 'string') return badRequest(res, 'memoryScope must be "global" or "project"');
|
|
1373
|
+
const memoryScope = typeof body.memoryScope === 'string' && body.memoryScope.trim() ? body.memoryScope.trim() : null;
|
|
1374
|
+
const scopeReason = validateMemoryScope({ workflowId, memoryScope, isWorkspace: !!hasWorkspace });
|
|
1375
|
+
if (scopeReason) return badRequest(res, scopeReason);
|
|
1376
|
+
// Human in the loop (spec D15): the body wins, else the project's stored
|
|
1377
|
+
// switch, else on. Resolved per target below (it needs the project dir).
|
|
1378
|
+
const bodyHumanInLoop = typeof body.humanInLoop === 'boolean' ? body.humanInLoop : null;
|
|
1379
|
+
|
|
1189
1380
|
// Optional guardrailsId selects the named guardrail set that IS this run's
|
|
1190
1381
|
// policy (applied uniformly to every member — guardrails are per-run only).
|
|
1191
1382
|
// Absent/blank/null normalizes to 'permissive' — the empty policy,
|
|
@@ -1331,6 +1522,13 @@ app.post('/api/run', async (req, res) => {
|
|
|
1331
1522
|
|
|
1332
1523
|
const fileProblem = await promptFileProblem(effectiveSource, projectDir);
|
|
1333
1524
|
if (fileProblem) return badRequest(res, fileProblem);
|
|
1525
|
+
// One live defragment run per scope (§7.3, amendment B20: this server process only).
|
|
1526
|
+
if (memoryScope) {
|
|
1527
|
+
const live = liveDefragRun(memoryScopeKey(memoryScope, projectDir));
|
|
1528
|
+
if (live) return res.status(409).json({ error: 'a defragment run for this memory scope is already live', runId: live.id });
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
const humanInLoop = bodyHumanInLoop ?? ((await readRunConfig(projectDir)).humanInLoop !== false);
|
|
1334
1532
|
|
|
1335
1533
|
orch = await createOrchestratorFor({
|
|
1336
1534
|
projectDir,
|
|
@@ -1343,6 +1541,8 @@ app.post('/api/run', async (req, res) => {
|
|
|
1343
1541
|
template: workflowRow,
|
|
1344
1542
|
guardrailsId,
|
|
1345
1543
|
branch,
|
|
1544
|
+
humanInLoop,
|
|
1545
|
+
...(memoryScope ? { memoryScope } : {}),
|
|
1346
1546
|
claude: { permissionMode: 'acceptEdits', mock },
|
|
1347
1547
|
});
|
|
1348
1548
|
|
|
@@ -1419,7 +1619,8 @@ app.post('/api/run', async (req, res) => {
|
|
|
1419
1619
|
} catch (err) {
|
|
1420
1620
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
1421
1621
|
}
|
|
1422
|
-
}
|
|
1622
|
+
};
|
|
1623
|
+
app.post('/api/run', startRunHandler);
|
|
1423
1624
|
|
|
1424
1625
|
// ---------------------------------------------------------------------------
|
|
1425
1626
|
// Chat connectivity (chat-connectivity-design.md): persistent channel workers
|
|
@@ -1696,7 +1897,15 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
|
|
|
1696
1897
|
if (!projectDir) throw new ResumeError(400, { error: 'project for this pipeline is not onboarded on this machine' });
|
|
1697
1898
|
}
|
|
1698
1899
|
|
|
1699
|
-
|
|
1900
|
+
// A paused defragment run is not live; another one on the same scope may have started since.
|
|
1901
|
+
// Resuming the first would sync its stale mount over the second's work (spec §5 concurrency).
|
|
1902
|
+
const rpScope = saved.resumePoint?.memoryScope || null;
|
|
1903
|
+
if (rpScope && !workspace) {
|
|
1904
|
+
const live = liveDefragRun(memoryScopeKey(rpScope, projectDir));
|
|
1905
|
+
if (live) throw new ResumeError(409, { error: 'a defragment run for this memory scope is already live', runId: live.id });
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
const effMock = mock ||isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK);
|
|
1700
1909
|
const runId = randomUUID();
|
|
1701
1910
|
const orch = await createOrchestratorFor({
|
|
1702
1911
|
projectDir,
|
|
@@ -1866,6 +2075,19 @@ app.get('/api/runs/:id/artifact', async (req, res) => {
|
|
|
1866
2075
|
}
|
|
1867
2076
|
});
|
|
1868
2077
|
|
|
2078
|
+
app.get('/api/runs/:id/artifacts', async (req, res) => {
|
|
2079
|
+
try {
|
|
2080
|
+
const row = findPipelineRowById(req.params.id);
|
|
2081
|
+
if (!row) return res.status(404).json({ error: 'pipeline not found' });
|
|
2082
|
+
// Cap the row set (each row costs a synchronous statSync for its byte size, on
|
|
2083
|
+
// the event loop) at the same ceiling the ask tool uses.
|
|
2084
|
+
const artifacts = await listRunArtifacts(row.id, { limit: ASK_LIMITS.artifactsListMaxLimit });
|
|
2085
|
+
res.json({ runId: row.id, artifacts });
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
|
|
1869
2091
|
// Shared query-scope resolver for the retained-work routes (recovery-patch GET +
|
|
1870
2092
|
// discard POST). Returns null after writing the error response itself. The older
|
|
1871
2093
|
// DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
|
|
@@ -1966,6 +2188,24 @@ app.get('/api/history', async (_req, res) => {
|
|
|
1966
2188
|
// machine-wide history just to update a badge. Running is derived client-side from the
|
|
1967
2189
|
// in-memory runs map (live via WS), so it is not included here. Synchronous: the three
|
|
1968
2190
|
// helpers are sync getDb().prepare(...).get() calls.
|
|
2191
|
+
// ---------------------------------------------------------------------------
|
|
2192
|
+
// Getting started (docs/getting-started.md). GET is the derived checklist —
|
|
2193
|
+
// eight ticks computed from the store + PATH, never stored — plus the two flags.
|
|
2194
|
+
// POST writes ONLY those flags ({hidden?, welcomeSeen?}; booleans; unknown keys
|
|
2195
|
+
// 400) and answers with the same full payload, so one round trip repaints.
|
|
2196
|
+
// ---------------------------------------------------------------------------
|
|
2197
|
+
app.get('/api/onboarding', async (_req, res) => {
|
|
2198
|
+
try { res.json(await onboardingStatus()); }
|
|
2199
|
+
catch (err) { res.status(500).json({ error: err && err.message ? err.message : String(err) }); }
|
|
2200
|
+
});
|
|
2201
|
+
app.post('/api/onboarding', async (req, res) => {
|
|
2202
|
+
try { await setOnboardingPrefs(req.body || {}); }
|
|
2203
|
+
catch (err) { return badRequest(res, err && err.message ? err.message : String(err)); }
|
|
2204
|
+
emitChanged('onboarding-changed');
|
|
2205
|
+
try { res.json(await onboardingStatus()); }
|
|
2206
|
+
catch (err) { res.status(500).json({ error: err && err.message ? err.message : String(err), ...onboardingPrefs() }); }
|
|
2207
|
+
});
|
|
2208
|
+
|
|
1969
2209
|
app.get('/api/counts', (_req, res) => {
|
|
1970
2210
|
try {
|
|
1971
2211
|
res.json({
|
|
@@ -1992,6 +2232,154 @@ app.get('/api/stats', (req, res) => {
|
|
|
1992
2232
|
}
|
|
1993
2233
|
});
|
|
1994
2234
|
|
|
2235
|
+
// ---- Team metrics (team-metrics-design.md §4.6–§4.10) ----------------------------------
|
|
2236
|
+
function metricsErrorStatus(code) {
|
|
2237
|
+
switch (code) {
|
|
2238
|
+
case 'BAD_REQUEST': case 'NO_ORIGIN': return 400;
|
|
2239
|
+
case 'DELEGATE_INVALID': return 409; // a configuration state, not a malformed request
|
|
2240
|
+
case 'NOT_FOUND': case 'NOT_ENABLED': return 404;
|
|
2241
|
+
case 'PUSH_REJECTED': case 'FETCH_FAILED': case 'REMOTE_UNREACHABLE': case 'PUSH_RETRIES_EXHAUSTED': return 502;
|
|
2242
|
+
case 'LOCK_TIMEOUT': return 503;
|
|
2243
|
+
default: return 500;
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
function sendMetricsError(res, err) {
|
|
2247
|
+
const code = (err && err.code) || 'INTERNAL';
|
|
2248
|
+
res.status(metricsErrorStatus(code)).json({
|
|
2249
|
+
error: err && err.message ? err.message : String(err), code,
|
|
2250
|
+
...(err && err.stderr ? { stderr: err.stderr } : {}),
|
|
2251
|
+
...(err && err.hint ? { hint: err.hint } : {}),
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
async function tmProject(req, res) {
|
|
2255
|
+
if (!TM_PROJECT_KEY_RE.test(req.params.key)) { badRequest(res, 'invalid project key'); return null; }
|
|
2256
|
+
const p = (await listProjects()).find((x) => x.key === req.params.key);
|
|
2257
|
+
if (!p) { res.status(404).json({ error: 'project not found', code: 'NOT_FOUND' }); return null; }
|
|
2258
|
+
return p;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
app.get('/api/team-metrics/scopes', async (req, res) => {
|
|
2262
|
+
try { res.json(await listScopes({ discover: req.query.discover === '1' })); }
|
|
2263
|
+
catch (err) { sendMetricsError(res, err); }
|
|
2264
|
+
});
|
|
2265
|
+
|
|
2266
|
+
app.get('/api/team-metrics', async (req, res) => {
|
|
2267
|
+
const scope = parseScopeParam(req.query.scope);
|
|
2268
|
+
if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
|
|
2269
|
+
const range = typeof req.query.range === 'string' && req.query.range ? req.query.range : 'this-month';
|
|
2270
|
+
const groupBy = typeof req.query.groupBy === 'string' && req.query.groupBy ? req.query.groupBy : 'workflow';
|
|
2271
|
+
const from = typeof req.query.from === 'string' ? req.query.from : null;
|
|
2272
|
+
const to = typeof req.query.to === 'string' ? req.query.to : null;
|
|
2273
|
+
try {
|
|
2274
|
+
resolveRange(range, { from, to }); // 400 before touching git
|
|
2275
|
+
if (!GROUP_BYS.includes(groupBy)) throw new RangeError(`unknown groupBy "${groupBy}"`);
|
|
2276
|
+
} catch (err) { return badRequest(res, err.message); }
|
|
2277
|
+
try {
|
|
2278
|
+
// defer=1 (the page): serve the worktree now and run a due fetch afterwards; the fetch's
|
|
2279
|
+
// `changed` event tells the page to reload. Other clients (Ask tools, CLI, tests) keep the
|
|
2280
|
+
// inline fetch and get fresh data in one round trip.
|
|
2281
|
+
const read = await readScope(scope, { refresh: req.query.refresh === '1', defer: req.query.defer === '1' });
|
|
2282
|
+
// Flush trigger: page open (§4.5). reason:'page-open' backs off for 60 s after a failed flush,
|
|
2283
|
+
// so a failing push cannot loop through flush-failed → WS → page reload → GET → flush.
|
|
2284
|
+
for (const s of read.sync) if (s.pending > 0) scheduleFlush(s.slug, { reason: 'page-open' });
|
|
2285
|
+
res.json({
|
|
2286
|
+
scope: read.scope,
|
|
2287
|
+
records: read.records,
|
|
2288
|
+
// The page re-aggregates client-side (§4.9 "one fetch serves the session") and asks with
|
|
2289
|
+
// aggregate=0: at 12k records the unused aggregate added ~3.9 MB to a ~9.9 MB response.
|
|
2290
|
+
// Other clients (and the API tests) still get it by default.
|
|
2291
|
+
aggregate: req.query.aggregate === '0' ? null : aggregate(read.records, { range, from, to, groupBy }),
|
|
2292
|
+
stats: read.stats,
|
|
2293
|
+
sync: read.sync,
|
|
2294
|
+
refresh: read.refresh,
|
|
2295
|
+
fetchError: read.fetchError,
|
|
2296
|
+
});
|
|
2297
|
+
} catch (err) { sendMetricsError(res, err); }
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
// "Push now" / "Retry": body { slug } flushes one outbox, { scope } flushes that scope's sinks,
|
|
2301
|
+
// an empty body flushes every outbox on this machine (same as `worca metrics push`).
|
|
2302
|
+
app.post('/api/team-metrics/flush', async (req, res) => {
|
|
2303
|
+
const body = req.body || {};
|
|
2304
|
+
try {
|
|
2305
|
+
let results;
|
|
2306
|
+
if (typeof body.slug === 'string' && body.slug) {
|
|
2307
|
+
// slugDirName throws BAD_REQUEST inside flushSlug, which would answer 200 with ok:false.
|
|
2308
|
+
try { slugDirName(body.slug); } catch { return badRequest(res, 'invalid slug'); }
|
|
2309
|
+
results = [await flushSlug(body.slug)];
|
|
2310
|
+
}
|
|
2311
|
+
else if (typeof body.scope === 'string' && body.scope) {
|
|
2312
|
+
const scope = parseScopeParam(body.scope);
|
|
2313
|
+
if (!scope) return badRequest(res, 'invalid scope');
|
|
2314
|
+
if (scope.kind === 'project') {
|
|
2315
|
+
const p = (await listProjects()).find((x) => x.key === scope.id);
|
|
2316
|
+
if (!p) return res.status(404).json({ error: 'project not found', code: 'NOT_FOUND' });
|
|
2317
|
+
results = [await flushProject(p.path)];
|
|
2318
|
+
} else {
|
|
2319
|
+
const sinks = (await readScope(scope)).sinks;
|
|
2320
|
+
results = [];
|
|
2321
|
+
for (const slug of sinks) results.push(await flushSlug(slug));
|
|
2322
|
+
}
|
|
2323
|
+
} else results = await flushAll();
|
|
2324
|
+
res.json({ results });
|
|
2325
|
+
} catch (err) { sendMetricsError(res, err); }
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
// Empty state "Check now" (§4.10): force discovery everywhere.
|
|
2329
|
+
app.post('/api/team-metrics/discover', async (_req, res) => {
|
|
2330
|
+
try { await discoverAll({ force: true }); res.json(await listScopes()); }
|
|
2331
|
+
catch (err) { sendMetricsError(res, err); }
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
app.get('/api/projects/:key/team-metrics', async (req, res) => {
|
|
2335
|
+
const p = await tmProject(req, res); if (!p) return;
|
|
2336
|
+
try { res.json({ status: await projectMetricsStatus(p, { discover: req.query.discover === '1' }) }); }
|
|
2337
|
+
catch (err) { sendMetricsError(res, err); }
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
app.patch('/api/projects/:key/team-metrics', async (req, res) => {
|
|
2341
|
+
const p = await tmProject(req, res); if (!p) return;
|
|
2342
|
+
const body = req.body || {};
|
|
2343
|
+
if (typeof body.record !== 'boolean') return badRequest(res, 'record must be a boolean');
|
|
2344
|
+
try { setRecordMyRuns(p.path, body.record); res.json({ status: await projectMetricsStatus(p) }); }
|
|
2345
|
+
catch (err) { sendMetricsError(res, err); }
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
app.post('/api/projects/:key/team-metrics/enable', async (req, res) => {
|
|
2349
|
+
const p = await tmProject(req, res); if (!p) return;
|
|
2350
|
+
const body = req.body || {};
|
|
2351
|
+
const mode = body.mode === 'delegate' ? 'delegate' : body.mode === 'here' || body.mode == null ? 'here' : null;
|
|
2352
|
+
if (!mode) return badRequest(res, 'mode must be "here" or "delegate"');
|
|
2353
|
+
if (mode === 'here' && body.attribution != null && body.attribution !== 'git-user' && body.attribution !== 'none') {
|
|
2354
|
+
return badRequest(res, 'attribution must be "git-user" or "none"');
|
|
2355
|
+
}
|
|
2356
|
+
try {
|
|
2357
|
+
const result = await enableTeamMetrics(p.path, { mode, attribution: body.attribution || 'git-user', delegateTo: body.delegateTo || null, change: body.change === true });
|
|
2358
|
+
res.json({ ...result, status: await projectMetricsStatus(p) });
|
|
2359
|
+
} catch (err) { sendMetricsError(res, err); }
|
|
2360
|
+
});
|
|
2361
|
+
|
|
2362
|
+
// Wizard step (§4.8). No POST /api/workspaces/:id route exists, so nothing shadows this path.
|
|
2363
|
+
app.post('/api/workspaces/metrics-scan', async (req, res) => {
|
|
2364
|
+
const paths = req.body && Array.isArray(req.body.projectPaths) ? req.body.projectPaths.filter((p) => typeof p === 'string' && p) : null;
|
|
2365
|
+
if (!paths || !paths.length) return badRequest(res, 'projectPaths must be a non-empty array of paths');
|
|
2366
|
+
if (paths.length > 50) return badRequest(res, 'at most 50 projectPaths');
|
|
2367
|
+
const normalized = paths.map((p) => resolveProjectDir(p)).filter(Boolean);
|
|
2368
|
+
// Same checks as POST /api/workspaces/scan (:~2948): never discover (and write a config row for) a junk path.
|
|
2369
|
+
for (const p of normalized) {
|
|
2370
|
+
if (!fs.existsSync(p)) return badRequest(res, `member path is missing: ${p}`);
|
|
2371
|
+
if (!isGitRepo(p)) return badRequest(res, `member is not a git repository: ${p}`);
|
|
2372
|
+
}
|
|
2373
|
+
try { res.json(await scanMembers(normalized)); }
|
|
2374
|
+
catch (err) { sendMetricsError(res, err); }
|
|
2375
|
+
});
|
|
2376
|
+
|
|
2377
|
+
app.post('/api/workspaces/:id/metrics-route', async (req, res) => {
|
|
2378
|
+
if (!WORKSPACE_KEY_RE.test(req.params.id)) return res.status(404).json({ error: 'workspace not found', code: 'NOT_FOUND' });
|
|
2379
|
+
try { res.json(await routeWorkspaceMembers(req.params.id)); }
|
|
2380
|
+
catch (err) { sendMetricsError(res, err); }
|
|
2381
|
+
});
|
|
2382
|
+
|
|
1995
2383
|
// ---------------------------------------------------------------------------
|
|
1996
2384
|
// POST /api/history/pr -> enrich the skeleton with live PR state, pushed back
|
|
1997
2385
|
// over the WS as batched `history-pr` events (reuses broadcast(), the same
|
|
@@ -2147,7 +2535,27 @@ function commentsPatch(req, res, storeKey, id, cid) {
|
|
|
2147
2535
|
const raw = (req.body || {}).resolved;
|
|
2148
2536
|
if (typeof raw !== 'boolean') return badRequest(res, 'resolved must be a boolean');
|
|
2149
2537
|
res.json({ comment: setDiffCommentResolved(cid, raw) });
|
|
2150
|
-
} catch (err) {
|
|
2538
|
+
} catch (err) {
|
|
2539
|
+
// A reply id: the store refuses (D2) and the browser shows the reason inline.
|
|
2540
|
+
if (err instanceof DiffCommentError) return badRequest(res, err.message);
|
|
2541
|
+
commentsFail(res, err);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
/** Reply inside a thread. The parent must belong to THIS run (commentOfRun); the
|
|
2546
|
+
* store enforces one level, the body cap and the author. No patch gate: a reply
|
|
2547
|
+
* anchors to nothing new, so there is no anchor to re-resolve and no patch to read
|
|
2548
|
+
* it from. (Not an archived-run affordance — archiving deletes a run's comments,
|
|
2549
|
+
* src/core/pipeline-delete.mjs.) */
|
|
2550
|
+
function commentsReply(req, res, storeKey, id, cid) {
|
|
2551
|
+
try {
|
|
2552
|
+
if (!commentOfRun(res, storeKey, id, cid)) return;
|
|
2553
|
+
const comment = addDiffCommentReply({ parentId: cid, body: (req.body || {}).body, author: 'user' });
|
|
2554
|
+
res.status(201).json({ comment });
|
|
2555
|
+
} catch (err) {
|
|
2556
|
+
if (err instanceof DiffCommentError) return badRequest(res, err.message);
|
|
2557
|
+
commentsFail(res, err);
|
|
2558
|
+
}
|
|
2151
2559
|
}
|
|
2152
2560
|
|
|
2153
2561
|
function commentsDelete(res, storeKey, id, cid) {
|
|
@@ -2176,6 +2584,11 @@ app.delete('/api/history/:key/:id/comments/:cid', (req, res) => {
|
|
|
2176
2584
|
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2177
2585
|
commentsDelete(res, key, req.params.id, cid);
|
|
2178
2586
|
});
|
|
2587
|
+
app.post('/api/history/:key/:id/comments/:cid/replies', (req, res) => {
|
|
2588
|
+
const key = commentsHistoryKey(res, req.params.key); if (!key) return;
|
|
2589
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2590
|
+
commentsReply(req, res, key, req.params.id, cid);
|
|
2591
|
+
});
|
|
2179
2592
|
|
|
2180
2593
|
app.get('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
|
|
2181
2594
|
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
@@ -2195,6 +2608,11 @@ app.delete('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
|
|
|
2195
2608
|
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2196
2609
|
commentsDelete(res, key, req.params.runId, cid);
|
|
2197
2610
|
});
|
|
2611
|
+
app.post('/api/workspaces/:id/runs/:runId/comments/:cid/replies', (req, res) => {
|
|
2612
|
+
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
2613
|
+
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2614
|
+
commentsReply(req, res, key, req.params.runId, cid);
|
|
2615
|
+
});
|
|
2198
2616
|
|
|
2199
2617
|
// Unresolved counts for every run, for the History list pill. Its own endpoint
|
|
2200
2618
|
// rather than a field on /api/history: that response has a localStorage skeleton
|
|
@@ -2328,37 +2746,87 @@ app.post('/api/runs/:id/discard-worktree', async (req, res) => {
|
|
|
2328
2746
|
});
|
|
2329
2747
|
|
|
2330
2748
|
// ---------------------------------------------------------------------------
|
|
2331
|
-
//
|
|
2332
|
-
//
|
|
2333
|
-
//
|
|
2749
|
+
// PR remotes (fork support). The ship-it dialog picks (1) the remote the feature
|
|
2750
|
+
// branch is pushed to and (2) the remote whose repo the PR is opened in — GitHub's
|
|
2751
|
+
// "compare across forks". Names are user-defined, so nothing is special-cased
|
|
2752
|
+
// beyond fallbacks: the project's remembered choice (when those remotes still
|
|
2753
|
+
// exist) -> `upstream` for the base when one exists -> `origin` -> first remote.
|
|
2334
2754
|
// ---------------------------------------------------------------------------
|
|
2335
|
-
|
|
2336
|
-
const
|
|
2337
|
-
const
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2755
|
+
function defaultPrRemotes(remotes, remembered) {
|
|
2756
|
+
const names = remotes.map((r) => r.name);
|
|
2757
|
+
const has = (n) => !!n && names.includes(n);
|
|
2758
|
+
const first = names[0] || null;
|
|
2759
|
+
const pushRemote = has(remembered?.pushRemote) ? remembered.pushRemote : (has('origin') ? 'origin' : first);
|
|
2760
|
+
const baseRemote = has(remembered?.baseRemote) ? remembered.baseRemote
|
|
2761
|
+
: (has('upstream') ? 'upstream' : (has('origin') ? 'origin' : first));
|
|
2762
|
+
return { pushRemote, baseRemote };
|
|
2763
|
+
}
|
|
2342
2764
|
|
|
2343
|
-
|
|
2765
|
+
// Resolve a pipeline for the PR routes (store key first, else project dir) from a
|
|
2766
|
+
// body or a query object. Writes the error response itself and returns null.
|
|
2767
|
+
// (/api/pr/mergeable keeps its own copy: its bad-key/not-found cases answer 200
|
|
2768
|
+
// UNKNOWN, not 404.)
|
|
2769
|
+
async function resolvePrPipeline(src, res) {
|
|
2770
|
+
const id = typeof src.id === 'string' ? src.id.trim() : '';
|
|
2771
|
+
if (!id) { badRequest(res, 'id is required'); return null; }
|
|
2344
2772
|
let state = null;
|
|
2345
2773
|
try {
|
|
2346
|
-
if (typeof
|
|
2347
|
-
if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(
|
|
2348
|
-
|
|
2774
|
+
if (typeof src.projectKey === 'string' && src.projectKey.trim()) {
|
|
2775
|
+
if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(src.projectKey)) {
|
|
2776
|
+
res.status(404).json({ error: 'pipeline not found' });
|
|
2777
|
+
return null;
|
|
2349
2778
|
}
|
|
2350
|
-
const data = await readPipelineByKey(
|
|
2779
|
+
const data = await readPipelineByKey(src.projectKey, id);
|
|
2351
2780
|
state = data && data.state;
|
|
2352
2781
|
} else {
|
|
2353
|
-
const projectDir = resolveProjectDir(
|
|
2354
|
-
if (!projectDir)
|
|
2782
|
+
const projectDir = resolveProjectDir(src.projectDir);
|
|
2783
|
+
if (!projectDir) { badRequest(res, 'projectDir or projectKey is required'); return null; }
|
|
2355
2784
|
const data = await readPipeline(projectDir, id);
|
|
2356
2785
|
state = data && data.state;
|
|
2357
2786
|
}
|
|
2358
2787
|
} catch (err) {
|
|
2359
|
-
|
|
2788
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2789
|
+
return null;
|
|
2360
2790
|
}
|
|
2361
|
-
if (!state)
|
|
2791
|
+
if (!state) { res.status(404).json({ error: 'pipeline not found' }); return null; }
|
|
2792
|
+
return { id, state };
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// ---------------------------------------------------------------------------
|
|
2796
|
+
// GET /api/pr/remotes?id=&projectKey=|projectDir= -> the project's git remotes for
|
|
2797
|
+
// the ship-it dialog plus the defaults POST /api/pr applies when the body names
|
|
2798
|
+
// none. Same pipeline resolution as POST /api/pr (the repo dir comes from the
|
|
2799
|
+
// pipeline's store_meta, never from the query). gh is not required here.
|
|
2800
|
+
// -> { ok, remotes:[{name,fetchUrl,pushUrl,host,owner,repo,slug}],
|
|
2801
|
+
// defaults:{pushRemote,baseRemote}, remembered:{pushRemote,baseRemote}|null }
|
|
2802
|
+
// ---------------------------------------------------------------------------
|
|
2803
|
+
app.get('/api/pr/remotes', async (req, res) => {
|
|
2804
|
+
const resolved = await resolvePrPipeline(req.query || {}, res);
|
|
2805
|
+
if (!resolved) return;
|
|
2806
|
+
const repoDir = resolved.state.projectDir; // null when store_meta is missing
|
|
2807
|
+
if (!repoDir) return badRequest(res, 'pipeline has no project directory');
|
|
2808
|
+
const rl = await listRemotes(repoDir);
|
|
2809
|
+
if (!rl.ok) return res.status(500).json({ error: `git remote failed: ${rl.error}` });
|
|
2810
|
+
const remembered = readPrRemotePrefs(repoDir);
|
|
2811
|
+
res.json({ ok: true, remotes: rl.remotes, defaults: defaultPrRemotes(rl.remotes, remembered), remembered });
|
|
2812
|
+
});
|
|
2813
|
+
|
|
2814
|
+
// ---------------------------------------------------------------------------
|
|
2815
|
+
// POST /api/pr -> push the pipeline's feature branch (if needed) and open a PR
|
|
2816
|
+
// against its source branch via the GitHub CLI. Mergeability is read back only
|
|
2817
|
+
// here (never during list rendering).
|
|
2818
|
+
// body: { id, projectDir?, projectKey?, pushRemote?, baseRemote? } — remote names
|
|
2819
|
+
// are validated against the repo's real remote list (never trusted from the body).
|
|
2820
|
+
// ---------------------------------------------------------------------------
|
|
2821
|
+
app.post('/api/pr', async (req, res) => {
|
|
2822
|
+
const body = req.body || {};
|
|
2823
|
+
if (!(typeof body.id === 'string' && body.id.trim())) return badRequest(res, 'id is required');
|
|
2824
|
+
if (!(await hasGh())) {
|
|
2825
|
+
return res.status(409).json({ error: 'GitHub CLI (gh) is not available' });
|
|
2826
|
+
}
|
|
2827
|
+
const resolved = await resolvePrPipeline(body, res);
|
|
2828
|
+
if (!resolved) return;
|
|
2829
|
+
const { id, state } = resolved;
|
|
2362
2830
|
|
|
2363
2831
|
const repoDir = state.projectDir;
|
|
2364
2832
|
const feature = state.branch && state.branch.feature;
|
|
@@ -2367,12 +2835,49 @@ app.post('/api/pr', async (req, res) => {
|
|
|
2367
2835
|
return badRequest(res, 'pipeline has no branch info to open a PR');
|
|
2368
2836
|
}
|
|
2369
2837
|
|
|
2838
|
+
// Remote selection. A named remote must exist; unnamed ones take the dialog's
|
|
2839
|
+
// defaults. With no usable remote list (no remotes, unparseable URLs) the legacy
|
|
2840
|
+
// argv applies: push `origin`, no --repo, bare head. A git failure is only fatal
|
|
2841
|
+
// when the body actually names a remote (otherwise legacy argv, as before).
|
|
2842
|
+
const named = (v) => !(v === undefined || v === null || v === '');
|
|
2843
|
+
const rl = await listRemotes(repoDir);
|
|
2844
|
+
if (!rl.ok && (named(body.pushRemote) || named(body.baseRemote))) {
|
|
2845
|
+
return res.status(500).json({ error: `git remote failed: ${rl.error}` });
|
|
2846
|
+
}
|
|
2847
|
+
const remotes = rl.ok ? rl.remotes : [];
|
|
2848
|
+
const byName = new Map(remotes.map((r) => [r.name, r]));
|
|
2849
|
+
const pick = (field, label) => {
|
|
2850
|
+
const v = body[field];
|
|
2851
|
+
if (!named(v)) return { name: null };
|
|
2852
|
+
if (typeof v !== 'string' || !byName.has(v.trim())) {
|
|
2853
|
+
return { error: `unknown ${label} remote: ${String(v).slice(0, 80)}` };
|
|
2854
|
+
}
|
|
2855
|
+
return { name: v.trim() };
|
|
2856
|
+
};
|
|
2857
|
+
const pushPick = pick('pushRemote', 'push');
|
|
2858
|
+
if (pushPick.error) return badRequest(res, pushPick.error);
|
|
2859
|
+
const basePick = pick('baseRemote', 'base');
|
|
2860
|
+
if (basePick.error) return badRequest(res, basePick.error);
|
|
2861
|
+
const defaults = defaultPrRemotes(remotes, readPrRemotePrefs(repoDir));
|
|
2862
|
+
const pushRemote = pushPick.name || defaults.pushRemote || 'origin';
|
|
2863
|
+
const baseRemote = basePick.name || defaults.baseRemote || 'origin';
|
|
2864
|
+
const pushR = byName.get(pushRemote) || null;
|
|
2865
|
+
const baseR = byName.get(baseRemote) || null;
|
|
2866
|
+
// Always target the chosen base repo explicitly (gh's default-repo guess prefers
|
|
2867
|
+
// a remote named upstream over origin); use the owner:branch head only when the
|
|
2868
|
+
// branch lives in a different repository than the PR (gh matches by head label).
|
|
2869
|
+
const repo = baseR?.slug || null;
|
|
2870
|
+
const crossRepo = !!(pushR?.slug && baseR?.slug && !sameRepo(pushR, baseR));
|
|
2871
|
+
const headOwner = crossRepo ? pushR.owner : null;
|
|
2872
|
+
|
|
2370
2873
|
// Push (idempotent) -> create PR -> read mergeability. All args are passed as
|
|
2371
|
-
// an argv array (no shell), so branch/source names cannot inject.
|
|
2372
|
-
const pushed = await pushBranch(repoDir, feature);
|
|
2874
|
+
// an argv array (no shell), so branch/remote/source names cannot inject.
|
|
2875
|
+
const pushed = await pushBranch(repoDir, feature, pushRemote);
|
|
2373
2876
|
if (!pushed.ok) return res.status(500).json({ error: `git push failed: ${pushed.stderr}` });
|
|
2374
2877
|
|
|
2375
|
-
const pr = await createPr({
|
|
2878
|
+
const pr = await createPr({
|
|
2879
|
+
projectDir: repoDir, base: source, head: feature, title: state.title || feature, repo, headOwner,
|
|
2880
|
+
});
|
|
2376
2881
|
if (!pr.ok) return res.status(500).json({ error: `gh pr create failed: ${pr.error}` });
|
|
2377
2882
|
|
|
2378
2883
|
// Persist the PR facts we just learned, so History/stats survive a gh outage.
|
|
@@ -2381,8 +2886,12 @@ app.post('/api/pr', async (req, res) => {
|
|
|
2381
2886
|
if (pipelineIdForPr) {
|
|
2382
2887
|
persistPrState(pipelineIdForPr, { url: pr.url, number: parsePrNumber(pr.url), state: 'OPEN' });
|
|
2383
2888
|
}
|
|
2889
|
+
// Remember the choice for this project (only once a PR was actually created).
|
|
2890
|
+
if (remotes.length) {
|
|
2891
|
+
try { await setPrRemotePrefs(repoDir, { pushRemote, baseRemote }); } catch { /* best-effort */ }
|
|
2892
|
+
}
|
|
2384
2893
|
|
|
2385
|
-
const mergeable = await prMergeable({ projectDir: repoDir, head: feature });
|
|
2894
|
+
const mergeable = await prMergeable({ projectDir: repoDir, head: feature, repo, headOwner, prUrl: pr.url || null });
|
|
2386
2895
|
res.json({ ok: true, url: pr.url, mergeable, existed: !!pr.existed });
|
|
2387
2896
|
});
|
|
2388
2897
|
|
|
@@ -2421,7 +2930,11 @@ app.post('/api/pr/mergeable', async (req, res) => {
|
|
|
2421
2930
|
const feature = state && state.branch && state.branch.feature;
|
|
2422
2931
|
if (!repoDir || !feature) return res.json({ ok: true, mergeable: 'UNKNOWN' });
|
|
2423
2932
|
|
|
2424
|
-
|
|
2933
|
+
// A persisted pr_url is repo-agnostic (a fork PR lives in the base repo, which
|
|
2934
|
+
// need not be gh's default for this checkout); the head selector is only the
|
|
2935
|
+
// fallback for rows that never recorded a PR.
|
|
2936
|
+
const prUrl = readPrState(state.id || id)?.url || null;
|
|
2937
|
+
const mergeable = await prMergeable({ projectDir: repoDir, head: feature, prUrl });
|
|
2425
2938
|
res.json({ ok: true, mergeable });
|
|
2426
2939
|
} catch {
|
|
2427
2940
|
res.json({ ok: true, mergeable: 'UNKNOWN' }); // best-effort: never error the refresh
|
|
@@ -2474,6 +2987,9 @@ app.post('/api/projects', async (req, res) => {
|
|
|
2474
2987
|
try {
|
|
2475
2988
|
const projects = await addProject({ name: body.name, path: body.path });
|
|
2476
2989
|
emitChanged('projects-changed', 'created');
|
|
2990
|
+
discoverProject(normalizeProjectPath(body.path), { force: true })
|
|
2991
|
+
.then(() => emitChanged('team-metrics-changed', 'discovered'))
|
|
2992
|
+
.catch(() => { /* offline or not a git repo: discovery retries hourly */ });
|
|
2477
2993
|
res.json({ projects });
|
|
2478
2994
|
} catch (err) {
|
|
2479
2995
|
// addProject only throws on validation (empty/duplicate/not-a-directory), so
|
|
@@ -2495,6 +3011,155 @@ app.delete('/api/projects', async (req, res) => {
|
|
|
2495
3011
|
}
|
|
2496
3012
|
});
|
|
2497
3013
|
|
|
3014
|
+
// ---------------------------------------------------------------------------
|
|
3015
|
+
// /api/memory/* -> worca's agent memory store (agent-memory-design.md §11). Thin
|
|
3016
|
+
// handlers over src/core/memory-store.mjs: names are validated BEFORE any fs call,
|
|
3017
|
+
// MemoryError codes map to statuses, every write snapshots (the store does it) and
|
|
3018
|
+
// broadcasts `memory-changed`. Two route families — `global` and `projects/:key` —
|
|
3019
|
+
// share ONE handler set through resolveMemoryScope().
|
|
3020
|
+
// ---------------------------------------------------------------------------
|
|
3021
|
+
function memoryHttpStatus(err) {
|
|
3022
|
+
const code = err && err.code;
|
|
3023
|
+
if (code === 'ENAME') return 400;
|
|
3024
|
+
if (code === 'ECASE' || code === 'EFULL') return 409;
|
|
3025
|
+
if (code === 'ETOOBIG') return 413;
|
|
3026
|
+
if (code === 'ENOSCOPE') return 404;
|
|
3027
|
+
return 500;
|
|
3028
|
+
}
|
|
3029
|
+
function memoryError(res, err) {
|
|
3030
|
+
const status = memoryHttpStatus(err);
|
|
3031
|
+
const message = String(err && err.message ? err.message : err).replace(/^memory: /, '');
|
|
3032
|
+
if (status === 500) console.error(`[worca-ui] memory: ${err && err.stack ? err.stack : message}`);
|
|
3033
|
+
return res.status(status).json({ error: status === 500 ? 'memory store error' : message });
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
/** Thread-less, seq-less frame (B5): an Ask tool, a REST write or a run that mounted memory
|
|
3037
|
+
* changed a scope; open Memory views refetch. Best effort. Exported through _testing. */
|
|
3038
|
+
function emitMemoryChanged(scopeKeyStr) {
|
|
3039
|
+
try { broadcast({ type: 'memory-changed', scope: scopeKeyStr }); return true; } catch { return false; }
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
/** { scope, key, project } for the global family or a REGISTERED project key; null ⇒ 404.
|
|
3043
|
+
* `family` is passed by registerMemoryRoutes rather than inferred (I2-#19): express populates
|
|
3044
|
+
* `req.params.key` only for the `projects/:key` prefix, so `req.params.key === undefined` WOULD
|
|
3045
|
+
* separate the two registrations — an implicit path-shape coincidence where one named argument
|
|
3046
|
+
* reads as intent. The two prefixes never collide: every second segment is a literal. */
|
|
3047
|
+
async function resolveMemoryScope(req, family) {
|
|
3048
|
+
if (family === 'global') return { scope: GLOBAL_SCOPE, key: 'global', project: null };
|
|
3049
|
+
const key = String(req.params.key || '');
|
|
3050
|
+
if (!PROJECT_KEY_RE.test(key)) return null; // the shape store.mjs#projectKey produces
|
|
3051
|
+
const p = (await listProjects()).find((x) => x.key === key);
|
|
3052
|
+
if (!p) return null;
|
|
3053
|
+
return { scope: projectScope(key), key: `projects/${key}`, project: { key: p.key, name: p.name, path: p.path } };
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
/** Start a defragment run through the ONE run handler (§7.3): the wrapper only builds the body.
|
|
3057
|
+
* startRunHandler reads `req.body` exactly once, at its top, so rewriting it here is sound. */
|
|
3058
|
+
async function defragRequest(req, res, { memoryScope, projectKey: key }) {
|
|
3059
|
+
if (!PROJECT_KEY_RE.test(String(key || ''))) return res.status(404).json({ error: 'project not found' });
|
|
3060
|
+
const p = (await listProjects()).find((x) => x.key === key);
|
|
3061
|
+
if (!p) return res.status(404).json({ error: 'project not found' });
|
|
3062
|
+
if (!p.exists) return badRequest(res, `project path is missing: ${p.path}`); // else startRunHandler would mkdir it
|
|
3063
|
+
req.body = {
|
|
3064
|
+
projectDir: p.path,
|
|
3065
|
+
prompt: memoryScope === 'global' ? 'Defragment global memory.' : `Defragment the memory of project ${p.name}.`,
|
|
3066
|
+
title: memoryScope === 'global' ? 'Memory defragment (global)' : `Memory defragment: ${p.name}`,
|
|
3067
|
+
workflowId: MEMORY_DEFRAG_WORKFLOW_ID,
|
|
3068
|
+
guardrailsId: 'normal',
|
|
3069
|
+
memoryScope,
|
|
3070
|
+
...(req.body && req.body.mock === true ? { mock: true } : {}),
|
|
3071
|
+
};
|
|
3072
|
+
return startRunHandler(req, res);
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
function registerMemoryRoutes(prefix, { family }) {
|
|
3076
|
+
const scoped = (handler) => async (req, res) => {
|
|
3077
|
+
try {
|
|
3078
|
+
const ctx = await resolveMemoryScope(req, family);
|
|
3079
|
+
if (!ctx) return res.status(404).json({ error: 'project not found' });
|
|
3080
|
+
return await handler(req, res, ctx);
|
|
3081
|
+
} catch (err) { return memoryError(res, err); }
|
|
3082
|
+
};
|
|
3083
|
+
const named = (req, res) => {
|
|
3084
|
+
const name = String(req.params.name || '');
|
|
3085
|
+
if (!isValidMemoryName(name)) { badRequest(res, `invalid memory name — ${MEMORY_NAME_HELP}`); return null; }
|
|
3086
|
+
return name;
|
|
3087
|
+
};
|
|
3088
|
+
/** B23: while a defragment run is live on this scope, that run's final sync would silently
|
|
3089
|
+
* overwrite a REST write ("the run's version wins", memory-sync.mjs) — refuse instead. */
|
|
3090
|
+
const defragLocked = (res, key) => {
|
|
3091
|
+
const live = liveDefragRun(key);
|
|
3092
|
+
if (!live) return false;
|
|
3093
|
+
res.status(409).json({ error: 'a defragment run is live on this memory scope — wait for it to finish', runId: live.id });
|
|
3094
|
+
return true;
|
|
3095
|
+
};
|
|
3096
|
+
const onError = (p, err) => console.warn(`[worca-ui] memory: ${p}: ${err && err.message ? err.message : err}`);
|
|
3097
|
+
|
|
3098
|
+
app.get(prefix, scoped(async (_req, res, { scope, key, project }) => {
|
|
3099
|
+
const report = await memoryScopeReport(memoryRoot(), scope, memoryCaps(), { onError });
|
|
3100
|
+
res.json({ scope: key, project, files: report.entries, state: report.state, health: report.health, defragRunId: liveDefragRun(key)?.id || null });
|
|
3101
|
+
}));
|
|
3102
|
+
app.get(`${prefix}/files/:name`, scoped(async (req, res, { scope }) => {
|
|
3103
|
+
const name = named(req, res); if (name === null) return;
|
|
3104
|
+
const f = await readMemory(memoryRoot(), scope, name);
|
|
3105
|
+
if (!f) return res.status(404).json({ error: 'memory file not found' });
|
|
3106
|
+
res.json({ name, text: f.text, meta: f.meta, body: f.body });
|
|
3107
|
+
}));
|
|
3108
|
+
app.put(`${prefix}/files/:name`, scoped(async (req, res, { scope, key }) => {
|
|
3109
|
+
const name = named(req, res); if (name === null) return;
|
|
3110
|
+
const text = req.body && typeof req.body.text === 'string' ? req.body.text : null;
|
|
3111
|
+
if (text === null) return badRequest(res, 'text (string) is required');
|
|
3112
|
+
if (defragLocked(res, key)) return;
|
|
3113
|
+
const r = await withStoreLock(memoryRoot(), () => writeMemory(memoryRoot(), scope, name, text, { source: 'user', caps: memoryCaps() }));
|
|
3114
|
+
emitMemoryChanged(key);
|
|
3115
|
+
res.json({ ok: true, name, created: r.created, bytes: r.bytes });
|
|
3116
|
+
}));
|
|
3117
|
+
app.delete(`${prefix}/files/:name`, scoped(async (req, res, { scope, key }) => {
|
|
3118
|
+
const name = named(req, res); if (name === null) return;
|
|
3119
|
+
if (defragLocked(res, key)) return;
|
|
3120
|
+
const removed = await withStoreLock(memoryRoot(), () => removeMemory(memoryRoot(), scope, name, { source: 'user' }));
|
|
3121
|
+
if (!removed) return res.status(404).json({ error: 'memory file not found' });
|
|
3122
|
+
emitMemoryChanged(key);
|
|
3123
|
+
res.json({ ok: true });
|
|
3124
|
+
}));
|
|
3125
|
+
app.get(`${prefix}/history`, scoped(async (_req, res, { scope }) => {
|
|
3126
|
+
const snapshots = (await listSnapshots(memoryRoot(), scope)).map((s) => ({ id: s.id, files: s.files }));
|
|
3127
|
+
res.json({ snapshots });
|
|
3128
|
+
}));
|
|
3129
|
+
app.post(`${prefix}/history/:id/restore`, scoped(async (req, res, { scope, key }) => {
|
|
3130
|
+
if (defragLocked(res, key)) return;
|
|
3131
|
+
await withStoreLock(memoryRoot(), () => restoreSnapshot(memoryRoot(), scope, String(req.params.id || ''), { source: 'user' })); // ENAME -> 400, ENOSCOPE -> 404
|
|
3132
|
+
emitMemoryChanged(key);
|
|
3133
|
+
res.json({ ok: true });
|
|
3134
|
+
}));
|
|
3135
|
+
app.post(`${prefix}/defragment`, async (req, res) => {
|
|
3136
|
+
try {
|
|
3137
|
+
if (family === 'projects') return await defragRequest(req, res, { memoryScope: 'project', projectKey: String(req.params.key || '') });
|
|
3138
|
+
const key = req.body && typeof req.body.projectKey === 'string' ? req.body.projectKey.trim() : '';
|
|
3139
|
+
if (!key) return badRequest(res, 'projectKey is required — a global defragment run is hosted by a project');
|
|
3140
|
+
return await defragRequest(req, res, { memoryScope: 'global', projectKey: key });
|
|
3141
|
+
} catch (err) { return memoryError(res, err); }
|
|
3142
|
+
});
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
// Every scope's health in one read. Fetched ON DEMAND — when the Memory tab or a Projects
|
|
3146
|
+
// expander opens, and on a `memory-changed` frame — never on a timer: each call reads and
|
|
3147
|
+
// hashes every file of every registered project's scope (I2-#16).
|
|
3148
|
+
app.get('/api/memory/health', async (_req, res) => {
|
|
3149
|
+
try {
|
|
3150
|
+
const caps = memoryCaps();
|
|
3151
|
+
const g = await memoryScopeReport(memoryRoot(), GLOBAL_SCOPE, caps);
|
|
3152
|
+
const projects = [];
|
|
3153
|
+
for (const p of await listProjects()) {
|
|
3154
|
+
const r = await memoryScopeReport(memoryRoot(), projectScope(p.key), caps);
|
|
3155
|
+
projects.push({ key: p.key, name: p.name, health: r.health, defragRunId: liveDefragRun(`projects/${p.key}`)?.id || null });
|
|
3156
|
+
}
|
|
3157
|
+
res.json({ global: { health: g.health, defragRunId: liveDefragRun('global')?.id || null }, projects });
|
|
3158
|
+
} catch (err) { return memoryError(res, err); }
|
|
3159
|
+
});
|
|
3160
|
+
registerMemoryRoutes('/api/memory/global', { family: 'global' });
|
|
3161
|
+
registerMemoryRoutes('/api/memory/projects/:key', { family: 'projects' });
|
|
3162
|
+
|
|
2498
3163
|
// ---------------------------------------------------------------------------
|
|
2499
3164
|
// Filesystem browsing for the add-project folder selector. Hybrid picker:
|
|
2500
3165
|
// POST /api/fs/pick-folder opens the native OS dialog (the server runs on the
|
|
@@ -2557,9 +3222,13 @@ app.post('/api/workspaces', async (req, res) => {
|
|
|
2557
3222
|
: [];
|
|
2558
3223
|
if (projectPaths.length < 2) return badRequest(res, 'a workspace needs at least 2 member projects');
|
|
2559
3224
|
try {
|
|
2560
|
-
|
|
3225
|
+
// No explicit home (the create wizard no longer asks): adopt the one member that already
|
|
3226
|
+
// records, if there is exactly one; every other case is "Choose…" on the workspace card.
|
|
3227
|
+
const explicit = typeof body.metricsProject === 'string' && body.metricsProject ? body.metricsProject : null;
|
|
3228
|
+
const metricsProject = explicit ?? await autoMetricsHome(projectPaths);
|
|
3229
|
+
const workspace = await createWorkspace({ name: body.name, projectPaths, description: body.description, metricsProject });
|
|
2561
3230
|
emitChanged('workspaces-changed', 'created');
|
|
2562
|
-
res.status(201).json({ workspace });
|
|
3231
|
+
res.status(201).json({ workspace, metricsHomeAuto: !explicit && !!metricsProject });
|
|
2563
3232
|
} catch (err) {
|
|
2564
3233
|
const status = workspaceErrorStatus(err && err.code);
|
|
2565
3234
|
return res.status(status).json({ error: err && err.message ? err.message : String(err) });
|
|
@@ -2572,14 +3241,21 @@ app.patch('/api/workspaces/:id', async (req, res) => {
|
|
|
2572
3241
|
const body = req.body || {};
|
|
2573
3242
|
// Immutability (defense-in-depth, §2.3): the project set never changes via PATCH.
|
|
2574
3243
|
if ('projectPaths' in body || 'projectKeys' in body) {
|
|
2575
|
-
return badRequest(res, 'a workspace project set is immutable; PATCH accepts only name/description');
|
|
3244
|
+
return badRequest(res, 'a workspace project set is immutable; PATCH accepts only name/description/metricsProject');
|
|
2576
3245
|
}
|
|
2577
3246
|
// Pass through only the editable fields.
|
|
2578
3247
|
const patch = {};
|
|
2579
3248
|
if (typeof body.name === 'string') patch.name = body.name;
|
|
2580
3249
|
if (typeof body.description === 'string') patch.description = body.description;
|
|
3250
|
+
if ('metricsProject' in body) {
|
|
3251
|
+
if (body.metricsProject !== null && typeof body.metricsProject !== 'string') {
|
|
3252
|
+
return badRequest(res, 'metricsProject must be a member project path or null');
|
|
3253
|
+
}
|
|
3254
|
+
patch.metricsProject = body.metricsProject;
|
|
3255
|
+
}
|
|
2581
3256
|
try {
|
|
2582
3257
|
const workspace = await updateWorkspace(id, patch);
|
|
3258
|
+
if ('metricsProject' in patch) emitChanged('workspaces-changed', 'metrics-home');
|
|
2583
3259
|
res.json({ workspace });
|
|
2584
3260
|
} catch (err) {
|
|
2585
3261
|
const status = workspaceErrorStatus(err && err.code);
|
|
@@ -2820,7 +3496,20 @@ const settingsState = () => ({
|
|
|
2820
3496
|
titleModel: storedTitleModel(), // the STORED id (the select), null = run's model
|
|
2821
3497
|
titleModelEffective: describeTitleModel(), // env override / stale id, for the hint line (#422)
|
|
2822
3498
|
hideBuiltinModels: hideBuiltinModels(),
|
|
2823
|
-
|
|
3499
|
+
theme: storedTheme(), // system | light | dark (dark-mode design §6)
|
|
3500
|
+
});
|
|
3501
|
+
|
|
3502
|
+
/** Settings ▸ Auto workflow model: the stored id + what the classifier will actually use
|
|
3503
|
+
* (env override > stored catalog id > the Sonnet-class default). Async because the
|
|
3504
|
+
* catalog is. */
|
|
3505
|
+
async function autoModelState() {
|
|
3506
|
+
const models = await listModels('');
|
|
3507
|
+
const stored = storedAutoWorkflowModel();
|
|
3508
|
+
const model = resolveAutoModel(models, { setting: stored });
|
|
3509
|
+
const source = process.env[AUTO_MODEL_ENV]?.trim() ? 'env'
|
|
3510
|
+
: (stored && model.toLowerCase() === stored.toLowerCase()) ? 'settings' : 'default';
|
|
3511
|
+
return { autoWorkflowModel: stored, autoWorkflowModelEffective: { model, source } };
|
|
3512
|
+
}
|
|
2824
3513
|
|
|
2825
3514
|
// ---------------------------------------------------------------------------
|
|
2826
3515
|
// Instance lifecycle (`worca ui status|stop|restart`, src/core/ui-instance.mjs)
|
|
@@ -2865,8 +3554,8 @@ app.post('/api/shutdown', (req, res) => {
|
|
|
2865
3554
|
setImmediate(() => uiControl.onShutdown('request'));
|
|
2866
3555
|
});
|
|
2867
3556
|
|
|
2868
|
-
app.get('/api/settings', (_req, res) => {
|
|
2869
|
-
res.json({ ...settingsState(), chat: chatPrefs(), app: APP_INFO });
|
|
3557
|
+
app.get('/api/settings', async (_req, res) => {
|
|
3558
|
+
res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs(), app: APP_INFO });
|
|
2870
3559
|
});
|
|
2871
3560
|
|
|
2872
3561
|
app.get('/api/budget', (_req, res) => {
|
|
@@ -2881,6 +3570,9 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2881
3570
|
const hasDebugSpawnKey = has('debugSpawnEnabled');
|
|
2882
3571
|
const hasTitleModelKey = has('titleModel');
|
|
2883
3572
|
const hasHideBuiltinKey = has('hideBuiltinModels');
|
|
3573
|
+
const hasThemeKey = has('theme');
|
|
3574
|
+
const hasAutoKey = has('autoWorkflowModel');
|
|
3575
|
+
const autoModels = hasAutoKey ? await listModels('') : null;
|
|
2884
3576
|
// #422: the title model is a SELECT over the catalog, so an id that is not a
|
|
2885
3577
|
// catalog member is a client bug (or a stale option) — refuse it here rather
|
|
2886
3578
|
// than store an id resolveModelEnv could never route.
|
|
@@ -2913,6 +3605,8 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2913
3605
|
}
|
|
2914
3606
|
}
|
|
2915
3607
|
if (hasHideBuiltinKey) assertHideBuiltinModelsInput(body.hideBuiltinModels);
|
|
3608
|
+
if (hasThemeKey) assertThemeInput(body.theme);
|
|
3609
|
+
if (hasAutoKey) assertAutoWorkflowModelInput(body.autoWorkflowModel ?? '', autoModels);
|
|
2916
3610
|
// Root first: it is the one key whose setter can still fail AFTER the asserts
|
|
2917
3611
|
// above (an unusable path), so every other key's write must come after it or
|
|
2918
3612
|
// a mixed POST would answer 400 with those keys already applied on disk.
|
|
@@ -2933,11 +3627,13 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2933
3627
|
if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
|
|
2934
3628
|
if (hasTitleModelKey) await setTitleModel(titleModelInput);
|
|
2935
3629
|
if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
|
|
3630
|
+
if (hasThemeKey) await setTheme(body.theme);
|
|
3631
|
+
if (hasAutoKey) await setAutoWorkflowModel(body.autoWorkflowModel ?? '', { models: autoModels });
|
|
2936
3632
|
if (hasBudgetKey) emitChanged('budget-changed');
|
|
2937
3633
|
// Other open tabs repaint their Settings cards (a stale tab could otherwise
|
|
2938
3634
|
// "save" its old checkbox state over this one with no feedback to either).
|
|
2939
|
-
if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey) emitChanged('settings-changed');
|
|
2940
|
-
res.json({ ...settingsState(), chat: chatPrefs() });
|
|
3635
|
+
if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasAutoKey) emitChanged('settings-changed');
|
|
3636
|
+
res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs() });
|
|
2941
3637
|
} catch (err) {
|
|
2942
3638
|
// The setters throw only on an unusable path -> client error (400).
|
|
2943
3639
|
return badRequest(res, err && err.message ? err.message : String(err));
|
|
@@ -3016,7 +3712,7 @@ app.post('/api/config', async (req, res) => {
|
|
|
3016
3712
|
// validates model/effort against the effective catalog exactly like setStep
|
|
3017
3713
|
// (configurable-models-design.md §4.5) -> 400; setFeedbackCycles still COERCES
|
|
3018
3714
|
// maxCycles to >= 1 (it never throws).
|
|
3019
|
-
// body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, wires?:{[wireId]:{maxCycles}}, activeWorkflowId? }
|
|
3715
|
+
// body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, wires?:{[wireId]:{maxCycles}}, activeWorkflowId?, humanInLoop? }
|
|
3020
3716
|
// ---------------------------------------------------------------------------
|
|
3021
3717
|
app.patch('/api/config', async (req, res) => {
|
|
3022
3718
|
const body = req.body || {};
|
|
@@ -3066,6 +3762,7 @@ app.patch('/api/config', async (req, res) => {
|
|
|
3066
3762
|
if (!isSafeWorkflowId(active)) return badRequest(res, 'invalid workflowId');
|
|
3067
3763
|
await setActiveWorkflow(projectDir, active);
|
|
3068
3764
|
}
|
|
3765
|
+
if (typeof body.humanInLoop === 'boolean') await setHumanInLoop(projectDir, body.humanInLoop);
|
|
3069
3766
|
const config = await readRunConfig(projectDir);
|
|
3070
3767
|
res.json({ config });
|
|
3071
3768
|
} catch (err) {
|
|
@@ -3437,9 +4134,9 @@ app.get('/api/workflows', async (req, res) => {
|
|
|
3437
4134
|
const all = await listWorkflows({ includeArchived: true });
|
|
3438
4135
|
return res.json({ workflows: all.filter((w) => w.archivedAt) });
|
|
3439
4136
|
}
|
|
3440
|
-
// CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, ...listWorkflows() ]. The
|
|
3441
|
-
// never
|
|
3442
|
-
res.json({ workflows: [GRAPH_DEFAULT_WORKFLOW, ...(await listWorkflows())] });
|
|
4137
|
+
// CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, GRAPH_MEMORY_DEFRAG_WORKFLOW, ...listWorkflows() ]. The
|
|
4138
|
+
// built-ins are never persisted rows (listWorkflows filters their ids), so none appears twice.
|
|
4139
|
+
res.json({ workflows: [GRAPH_DEFAULT_WORKFLOW, GRAPH_MEMORY_DEFRAG_WORKFLOW, ...(await listWorkflows())] });
|
|
3443
4140
|
} catch (err) {
|
|
3444
4141
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3445
4142
|
}
|
|
@@ -3550,6 +4247,7 @@ app.delete('/api/workflows/:id', async (req, res) => {
|
|
|
3550
4247
|
const id = req.params.id;
|
|
3551
4248
|
// The built-in default is not in the user store and must never be deleted.
|
|
3552
4249
|
if (id === 'wf_default') return badRequest(res, 'the default workflow cannot be deleted');
|
|
4250
|
+
if (id === MEMORY_DEFRAG_WORKFLOW_ID) return badRequest(res, 'the Memory defragment workflow cannot be deleted');
|
|
3553
4251
|
try {
|
|
3554
4252
|
const removed = await deleteWorkflow(id); // CONV-1: await
|
|
3555
4253
|
if (!removed) return res.status(404).json({ error: 'workflow not found' });
|
|
@@ -3736,12 +4434,58 @@ const askJobs = new Map(); // threadId -> {turn, messageId, userMessageId,
|
|
|
3736
4434
|
const askDeleting = new Set();
|
|
3737
4435
|
const askFollowers = new Map(); // threadId -> Set<{detach}>
|
|
3738
4436
|
const ASK_JOB_MAX_BUFFER = 5000; // same arithmetic as MAX_BUFFER: deltas dominate; eviction ⇒ client seq-gap re-sync
|
|
4437
|
+
const askDeferred = new Map(); // threadId → Array<() => Promise>: workflow-card event turns that arrived while a turn was running (PD5/PD26), FIFO
|
|
4438
|
+
|
|
4439
|
+
/** A `role:'system'` notice row + its broadcast — the shape attachAskFollower posts. */
|
|
4440
|
+
function postAskSystemNotice(threadId, text) {
|
|
4441
|
+
try {
|
|
4442
|
+
const m = askAppendMessage(threadId, { role: 'system', text, blocks: [{ kind: 'notice', text }] });
|
|
4443
|
+
broadcast({ type: 'ask-message', threadId, message: m });
|
|
4444
|
+
} catch { /* thread deleted */ }
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
/**
|
|
4448
|
+
* Start the oldest queued workflow-card event turn (PD26). A starter resolves {ok:false,status,error}
|
|
4449
|
+
* — it never throws — when the reservation fails at START time (403: the turn the user waited on spent
|
|
4450
|
+
* the last of the total cost window, `_complete` writes the ledger BEFORE `_emit('done')`; 429: the
|
|
4451
|
+
* global cap; 409: a typed message won the race). A failed starter posts a system notice and the NEXT
|
|
4452
|
+
* one is tried; a started turn's own settleJob drains the rest. The queue is re-read per iteration: a
|
|
4453
|
+
* starter pushed while `await next()` ran lives in a fresh array (the queue is re-created after the
|
|
4454
|
+
* delete below).
|
|
4455
|
+
*/
|
|
4456
|
+
async function drainAskDeferred(threadId) {
|
|
4457
|
+
for (;;) {
|
|
4458
|
+
const queue = askDeferred.get(threadId);
|
|
4459
|
+
if (!queue || !queue.length) return;
|
|
4460
|
+
const next = queue.shift();
|
|
4461
|
+
if (!queue.length) askDeferred.delete(threadId);
|
|
4462
|
+
let r = null;
|
|
4463
|
+
try { r = await next(); } catch (e) { r = { ok: false, error: e && e.message ? e.message : String(e) }; }
|
|
4464
|
+
if (r && r.ok) return; // its settleJob continues the chain
|
|
4465
|
+
postAskSystemNotice(threadId, `Ask Worca could not reply to the workflow card: ${(r && r.error) || 'unknown error'}`);
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
3739
4468
|
|
|
3740
4469
|
function askInFlight(threadId) {
|
|
3741
4470
|
const job = askJobs.get(threadId);
|
|
3742
4471
|
return job && job.status === 'running' ? job : null;
|
|
3743
4472
|
}
|
|
3744
4473
|
|
|
4474
|
+
/** How many live runs this thread still follows: undetached followers (askFollowers is the truth, the way
|
|
4475
|
+
* askInFlight reads askJobs — ask_run_links.status is written BY the follower and stays `running` across a
|
|
4476
|
+
* restart that dropped every follower) whose runs-Map entry exists and has not settled. */
|
|
4477
|
+
function askTrackingCount(threadId) {
|
|
4478
|
+
const set = askFollowers.get(threadId);
|
|
4479
|
+
if (!set) return 0;
|
|
4480
|
+
let n = 0;
|
|
4481
|
+
for (const f of set) {
|
|
4482
|
+
if (f.detached) continue;
|
|
4483
|
+
const entry = liveRunEntry(f.runId);
|
|
4484
|
+
if (entry && !SETTLED_RUN.has(String(entry.status || ''))) n += 1;
|
|
4485
|
+
}
|
|
4486
|
+
return n;
|
|
4487
|
+
}
|
|
4488
|
+
|
|
3745
4489
|
function askRunningCount() {
|
|
3746
4490
|
let n = 0;
|
|
3747
4491
|
for (const job of askJobs.values()) if (job.status === 'running') n += 1;
|
|
@@ -3814,7 +4558,10 @@ app.get('/api/ask/threads', (req, res) => {
|
|
|
3814
4558
|
try {
|
|
3815
4559
|
const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
|
|
3816
4560
|
const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
|
|
3817
|
-
const threads = askListThreads({ limit }).map((t) =>
|
|
4561
|
+
const threads = askListThreads({ limit }).map((t) => {
|
|
4562
|
+
const trackingRuns = askTrackingCount(t.id);
|
|
4563
|
+
return { ...t, inFlight: !!askInFlight(t.id), tracking: trackingRuns > 0, trackingRuns };
|
|
4564
|
+
});
|
|
3818
4565
|
// total = EVERY saved chat (the History popover's meter), not the capped page above.
|
|
3819
4566
|
res.json({ threads, total: askCountThreads() });
|
|
3820
4567
|
} catch (err) {
|
|
@@ -3905,6 +4652,21 @@ app.get('/api/ask/threads/:id', (req, res) => {
|
|
|
3905
4652
|
}
|
|
3906
4653
|
});
|
|
3907
4654
|
|
|
4655
|
+
// Ask Worca progress card hydration (plan D9): the run's detail state by pipeline id OR live run id, no store key.
|
|
4656
|
+
const ASK_RUN_REF_RE = /^(?:[0-9a-f]{8}|[0-9a-zA-Z-]{9,64})$/;
|
|
4657
|
+
app.get('/api/ask/runs/:id', (req, res) => {
|
|
4658
|
+
const id = String(req.params.id || '');
|
|
4659
|
+
if (!ASK_RUN_REF_RE.test(id)) return res.status(400).json({ error: 'invalid run id' });
|
|
4660
|
+
try {
|
|
4661
|
+
const entry = liveRunEntry(id);
|
|
4662
|
+
const state = readPipelineStateById(entry && entry.pipelineId ? entry.pipelineId : id);
|
|
4663
|
+
if (!state) return res.status(404).json({ error: 'run not found' });
|
|
4664
|
+
res.json({ state, live: entry ? { runId: entry.id, status: entry.status } : null });
|
|
4665
|
+
} catch (err) {
|
|
4666
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4667
|
+
}
|
|
4668
|
+
});
|
|
4669
|
+
|
|
3908
4670
|
app.patch('/api/ask/threads/:id', (req, res) => {
|
|
3909
4671
|
const id = askIdParam(res, req.params.id, 'thread');
|
|
3910
4672
|
if (!id) return;
|
|
@@ -3950,6 +4712,9 @@ app.patch('/api/ask/threads/:id', (req, res) => {
|
|
|
3950
4712
|
// Returns { deleted, worktrees } — worktrees = rows removeThreadWorktrees removed.
|
|
3951
4713
|
async function deleteAskThreadFully(id) {
|
|
3952
4714
|
askDeleting.add(id);
|
|
4715
|
+
// Outside the `if (job)` block below: a thread deleted while it had a queued
|
|
4716
|
+
// event turn but no live job entry would otherwise keep its queue forever.
|
|
4717
|
+
askDeferred.delete(id);
|
|
3953
4718
|
try {
|
|
3954
4719
|
const stopJob = () => {
|
|
3955
4720
|
const job = askJobs.get(id);
|
|
@@ -4119,6 +4884,12 @@ function askValidateScope(raw) {
|
|
|
4119
4884
|
return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
|
|
4120
4885
|
}
|
|
4121
4886
|
|
|
4887
|
+
/** The system prompt of ONE Ask turn: the rules and the catalog, byte-stable. Memory is mounted,
|
|
4888
|
+
* not rendered (native-rules revision) — see createAskTurn's memoryProject. */
|
|
4889
|
+
async function askSystemPromptFor(catalog) {
|
|
4890
|
+
return askBuildSystemPrompt(catalog);
|
|
4891
|
+
}
|
|
4892
|
+
|
|
4122
4893
|
/** Resolve the VALIDATED client context into the server-side shape
|
|
4123
4894
|
* buildContextHeader consumes (§6.5: server-resolved rows only — never
|
|
4124
4895
|
* client-supplied titles or paths). Every lookup is individually guarded:
|
|
@@ -4151,6 +4922,18 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
4151
4922
|
}
|
|
4152
4923
|
}
|
|
4153
4924
|
} catch { /* absent line */ }
|
|
4925
|
+
// The Team metrics page's selection (validated slugs); only the scope NAME is resolved here.
|
|
4926
|
+
try {
|
|
4927
|
+
if (ctx.tmScope) {
|
|
4928
|
+
const [kind, tmId] = ctx.tmScope.split(':');
|
|
4929
|
+
let name = null;
|
|
4930
|
+
if (kind === 'project') name = (await listProjects()).find((x) => x.key === tmId)?.name ?? null;
|
|
4931
|
+
else if (kind === 'workspace') name = (await readWorkspace(tmId))?.name ?? null;
|
|
4932
|
+
if (name != null) {
|
|
4933
|
+
out.teamMetrics = { kind, id: tmId, name, range: ctx.tmRange || 'this-month', groupBy: ctx.tmGroupBy || null, filter: ctx.tmFilter || null };
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4936
|
+
} catch { /* absent line */ }
|
|
4154
4937
|
try {
|
|
4155
4938
|
if (ctx.pipelineId) {
|
|
4156
4939
|
const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
|
|
@@ -4180,12 +4963,24 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
4180
4963
|
for (const m of askListMessages(threadId)) {
|
|
4181
4964
|
if (!Array.isArray(m.blocks)) continue;
|
|
4182
4965
|
for (const b of m.blocks) {
|
|
4183
|
-
if (b && b.kind === 'card')
|
|
4184
|
-
|
|
4966
|
+
if (!(b && b.kind === 'card')) continue;
|
|
4967
|
+
// P3 (PD25): a workflow card names the workflow it proposes and only owns a
|
|
4968
|
+
// workflowId once the user saved it; a run card keeps its pre-P3 line byte for byte.
|
|
4969
|
+
const wf = !!(b.card && b.card.type === 'workflow');
|
|
4970
|
+
if (wf && b.state === 'building') continue; // transient (no name yet) — never worth a header line
|
|
4971
|
+
if (b.card && b.card.type === 'metrics') {
|
|
4972
|
+
cards.push({ id: b.id, type: 'metrics', state: b.state, summary: b.card.summary || '' });
|
|
4973
|
+
continue;
|
|
4974
|
+
}
|
|
4975
|
+
cards.push(wf
|
|
4976
|
+
? {
|
|
4977
|
+
id: b.id, type: 'workflow', state: b.state, name: (b.card && b.card.name) || '',
|
|
4978
|
+
workflowId: b.workflowId || null, targetName: (b.card && b.card.projectName) || '',
|
|
4979
|
+
}
|
|
4980
|
+
: {
|
|
4185
4981
|
id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
|
|
4186
4982
|
targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
|
|
4187
4983
|
});
|
|
4188
|
-
}
|
|
4189
4984
|
}
|
|
4190
4985
|
}
|
|
4191
4986
|
if (cards.length) out.cards = cards.slice(-ASK_LIMITS.headerCards);
|
|
@@ -4210,7 +5005,164 @@ function mockAskCard(ctx = {}, text = '') {
|
|
|
4210
5005
|
const target = ctx.workspaceId
|
|
4211
5006
|
? { workspaceId: ctx.workspaceId }
|
|
4212
5007
|
: { projectKey: ctx.projectKey || 'mock-project-00000000' };
|
|
4213
|
-
return { ...target, workflowId: 'wf_default', guardrailsId: 'normal', brief: text.slice(0, 200) || 'Mock run'
|
|
5008
|
+
return { ...target, workflowId: 'wf_default', guardrailsId: 'normal', brief: text.slice(0, 200) || 'Mock run',
|
|
5009
|
+
note: 'Mock proposal — a fixed shape so the offline card can be exercised.' };
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
/**
|
|
5013
|
+
* The ONE turn starter (spec §8.4): the typed-message route and the workflow-card event path both land here.
|
|
5014
|
+
* SYNCHRONOUS until the first write — the §6.2.2 guards + the slot reservation run before any await, so two callers
|
|
5015
|
+
* cannot interleave (the route's earlier checks are only the fast-path 4xx). Resolves {ok:false,status,error} for the
|
|
5016
|
+
* 403/409/429 cases the message route maps to HTTP; {ok:true,…} once the turn is running.
|
|
5017
|
+
* @param {{threadId:string, thread:object, ctx:object, model:string, effort:string, text:string,
|
|
5018
|
+
* files?:Array, synthetic?:{notice:string}|null}} o
|
|
5019
|
+
* text what the MODEL gets as the user message: the typed text, or the `[worca event] …` line (synthetic)
|
|
5020
|
+
* synthetic the user row renders as a notice (never a bubble) and never titles the thread (PD6)
|
|
5021
|
+
*/
|
|
5022
|
+
async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, files = [], synthetic = null }) {
|
|
5023
|
+
// §6.2.2 ATOMIC re-check + slot reservation. Today every await between the
|
|
5024
|
+
// top 409/429 pair and here resolves in microtasks (validateModelEffort ->
|
|
5025
|
+
// composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
|
|
5026
|
+
// reads), so the route is macrotask-atomic and two POSTs cannot interleave
|
|
5027
|
+
// (empirically instrumented). The reservation is what keeps that true if
|
|
5028
|
+
// any of those readers ever becomes genuinely async: it is synchronous —
|
|
5029
|
+
// check-and-set cannot interleave — and runs BEFORE the first write, so a
|
|
5030
|
+
// loser leaves no rows.
|
|
5031
|
+
if (askDeleting.has(id)) return { ok: false, status: 409, error: 'thread is being deleted' };
|
|
5032
|
+
if (askInFlight(id)) return { ok: false, status: 409, error: 'turn in flight' };
|
|
5033
|
+
const budget = budgetStatus(); // P3: the event path needs the same gate the route head applies
|
|
5034
|
+
if (budget.blocked) return { ok: false, status: 403, error: 'total cost limit reached', budget };
|
|
5035
|
+
if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
|
|
5036
|
+
return { ok: false, status: 429, error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` };
|
|
5037
|
+
}
|
|
5038
|
+
const prev = askJobs.get(id);
|
|
5039
|
+
if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
|
|
5040
|
+
const job = {
|
|
5041
|
+
turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
|
|
5042
|
+
events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
|
|
5043
|
+
startedAt: new Date().toISOString(), graceTimer: null,
|
|
5044
|
+
};
|
|
5045
|
+
askJobs.set(id, job);
|
|
5046
|
+
|
|
5047
|
+
let asstMsg = null;
|
|
5048
|
+
let turn;
|
|
5049
|
+
let echoAttachments = [];
|
|
5050
|
+
try {
|
|
5051
|
+
// Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
|
|
5052
|
+
// `ctx` (pin-merged) rather than cv.context: the stored row is what restores
|
|
5053
|
+
// the selector on reopen and what the MCP child reads for tool defaulting.
|
|
5054
|
+
askUpdateThread(id, { context: ctx, model, effort });
|
|
5055
|
+
// §7.4 — NOTHING is stamped on the row before the 202: the thread stays
|
|
5056
|
+
// untitled (the header reads "Ask Worca") until the D13 background title
|
|
5057
|
+
// announces itself. titleWasAuto gates that call: a title given at THREAD
|
|
5058
|
+
// CREATION is the user's, and the haiku call must never fire for it
|
|
5059
|
+
// (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
|
|
5060
|
+
// empty haiku result (turn.mjs _kickoffTitle), never written here.
|
|
5061
|
+
const titleWasAuto = thread.title == null;
|
|
5062
|
+
const deterministicTitle = titleWasAuto && !synthetic ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
|
|
5063
|
+
// P3: a synthetic row keeps the EVENT as its text (buildRestoredPrompt replays it truthfully) and carries the
|
|
5064
|
+
// human notice as a block the panel renders instead of a bubble.
|
|
5065
|
+
const userMsg = askAppendMessage(id, synthetic
|
|
5066
|
+
? { role: 'user', text, blocks: [{ kind: 'notice', synthetic: true, text: synthetic.notice }] }
|
|
5067
|
+
: { role: 'user', text });
|
|
5068
|
+
job.userMessageId = userMsg.id;
|
|
5069
|
+
const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
|
|
5070
|
+
// The decoded binary bodies are on disk now. `files` is captured by this
|
|
5071
|
+
// scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
|
|
5072
|
+
// turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
|
|
5073
|
+
// reachable per running thread.
|
|
5074
|
+
for (const f of files) f.data = null;
|
|
5075
|
+
echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
|
|
5076
|
+
if (attRows.length) {
|
|
5077
|
+
// `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
|
|
5078
|
+
// (the UI keys image thumbnails off it, #398).
|
|
5079
|
+
// P3: keep the synthetic notice in front of the attachment blocks (unreachable today — event turns pass no files — but
|
|
5080
|
+
// an unconditional overwrite would silently drop the notice for a future caller).
|
|
5081
|
+
askSetMessageBlocks(userMsg.id, [
|
|
5082
|
+
...(synthetic ? [{ kind: 'notice', synthetic: true, text: synthetic.notice }] : []),
|
|
5083
|
+
...attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })),
|
|
5084
|
+
]);
|
|
5085
|
+
}
|
|
5086
|
+
broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
|
|
5087
|
+
asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model, effort });
|
|
5088
|
+
job.messageId = asstMsg.id;
|
|
5089
|
+
|
|
5090
|
+
// Prompt assembly (§6.5) — the route owns it; the turn only spawns. The header context
|
|
5091
|
+
// resolves the project FIRST so the turn can mount its memory (native rules).
|
|
5092
|
+
const catalog = await askBuildCatalog();
|
|
5093
|
+
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 }));
|
|
5094
|
+
const { inline, listed } = askSelectInlineAttachments(withText);
|
|
5095
|
+
const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
|
|
5096
|
+
const systemPrompt = await askSystemPromptFor(catalog);
|
|
5097
|
+
const header = askBuildContextHeader(headerCtx);
|
|
5098
|
+
const prompt = askBuildTurnPrompt(header, text, inline);
|
|
5099
|
+
const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
|
|
5100
|
+
const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
|
|
5101
|
+
const attachmentNames = {};
|
|
5102
|
+
for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
|
|
5103
|
+
|
|
5104
|
+
turn = createAskTurn({
|
|
5105
|
+
threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
|
|
5106
|
+
prompt, systemPrompt, restoredPrompt,
|
|
5107
|
+
model, effort,
|
|
5108
|
+
resumeSessionId: thread.sessionId || null,
|
|
5109
|
+
firstTurn: !synthetic && userMsg.seq === 1 && titleWasAuto, // P3: an event never titles the thread (D13 guard kept)
|
|
5110
|
+
firstText: text,
|
|
5111
|
+
deterministicTitle,
|
|
5112
|
+
pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
|
|
5113
|
+
memoryProject: headerCtx.project ? { key: headerCtx.project.key, name: headerCtx.project.name || '' } : null, // native-rules revision: the turn mounts global + this project through --add-dir
|
|
5114
|
+
mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
|
|
5115
|
+
attachmentNames,
|
|
5116
|
+
deps: {
|
|
5117
|
+
onFrame: stampAskFrames(id, job),
|
|
5118
|
+
onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
|
|
5119
|
+
onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
|
|
5120
|
+
onWorktreeMutation: () => { emitAskWorktrees(id); },
|
|
5121
|
+
// A remember/forget in the MCP child is the same scope change a REST write makes (B29).
|
|
5122
|
+
// The key is parsed out of worca's OWN tool result, never written by the model; shape-check
|
|
5123
|
+
// it anyway before it rides a broadcast (I2-#22).
|
|
5124
|
+
onMemoryMutation: ({ scope }) => {
|
|
5125
|
+
if (scope === 'global' || (typeof scope === 'string' && scope.startsWith('projects/') && PROJECT_KEY_RE.test(scope.slice('projects/'.length)))) emitMemoryChanged(scope);
|
|
5126
|
+
},
|
|
5127
|
+
trackRun: (input, { pin } = {}) => askTrackRun(id, input, pin ?? null),
|
|
5128
|
+
},
|
|
5129
|
+
});
|
|
5130
|
+
job.turn = turn;
|
|
5131
|
+
} catch (err) {
|
|
5132
|
+
// A write/assembly failure must release the reserved slot and never leave
|
|
5133
|
+
// a `streaming` row for the boot sweep to find.
|
|
5134
|
+
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
5135
|
+
if (asstMsg) {
|
|
5136
|
+
try {
|
|
5137
|
+
askFinishMessage(asstMsg.id, {
|
|
5138
|
+
text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
|
|
5139
|
+
status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
|
|
5140
|
+
});
|
|
5141
|
+
} catch { /* thread gone */ }
|
|
5142
|
+
}
|
|
5143
|
+
return { ok: false, status: 500, error: err && err.message ? err.message : String(err) };
|
|
5144
|
+
}
|
|
5145
|
+
const settleJob = (status) => {
|
|
5146
|
+
if (askJobs.get(id) !== job) return;
|
|
5147
|
+
job.status = status;
|
|
5148
|
+
job.graceTimer = setTimeout(() => {
|
|
5149
|
+
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
5150
|
+
}, ASK_LIMITS.jobGraceMs);
|
|
5151
|
+
job.graceTimer.unref?.();
|
|
5152
|
+
// P3 (PD5/PD26): the workflow-card events that arrived while this turn ran start now — status is already terminal,
|
|
5153
|
+
// so askInFlight() is null and the starter's own reservation succeeds; the started turn's settleJob continues the chain.
|
|
5154
|
+
Promise.resolve().then(() => drainAskDeferred(id)).catch((e) => console.error(`[worca-ui] deferred ask turn failed: ${e && e.message ? e.message : e}`));
|
|
5155
|
+
};
|
|
5156
|
+
turn.on('done', () => settleJob('done'));
|
|
5157
|
+
turn.on('error', () => settleJob('error'));
|
|
5158
|
+
// Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
|
|
5159
|
+
Promise.resolve()
|
|
5160
|
+
.then(() => turn.run())
|
|
5161
|
+
.catch((err) => {
|
|
5162
|
+
console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
|
|
5163
|
+
settleJob('error');
|
|
5164
|
+
});
|
|
5165
|
+
return { ok: true, job, userMessageId: job.userMessageId, assistantMessageId: job.messageId, attachments: echoAttachments };
|
|
4214
5166
|
}
|
|
4215
5167
|
|
|
4216
5168
|
app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
@@ -4291,133 +5243,15 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
4291
5243
|
}
|
|
4292
5244
|
}
|
|
4293
5245
|
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
// composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
|
|
4297
|
-
// reads), so the route is macrotask-atomic and two POSTs cannot interleave
|
|
4298
|
-
// (empirically instrumented). The reservation is what keeps that true if
|
|
4299
|
-
// any of those readers ever becomes genuinely async: it is synchronous —
|
|
4300
|
-
// check-and-set cannot interleave — and runs BEFORE the first write, so a
|
|
4301
|
-
// loser leaves no rows.
|
|
4302
|
-
if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
|
|
4303
|
-
if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
|
|
4304
|
-
if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
|
|
4305
|
-
return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
|
|
4306
|
-
}
|
|
4307
|
-
const prev = askJobs.get(id);
|
|
4308
|
-
if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
|
|
4309
|
-
const job = {
|
|
4310
|
-
turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
|
|
4311
|
-
events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
|
|
4312
|
-
startedAt: new Date().toISOString(), graceTimer: null,
|
|
4313
|
-
};
|
|
4314
|
-
askJobs.set(id, job);
|
|
4315
|
-
|
|
4316
|
-
let asstMsg = null;
|
|
4317
|
-
let turn;
|
|
4318
|
-
let echoAttachments = [];
|
|
4319
|
-
try {
|
|
4320
|
-
// Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
|
|
4321
|
-
// `ctx` (pin-merged) rather than cv.context: the stored row is what restores
|
|
4322
|
-
// the selector on reopen and what the MCP child reads for tool defaulting.
|
|
4323
|
-
askUpdateThread(id, { context: ctx, model: mv.model, effort: mv.effort });
|
|
4324
|
-
// §7.4 — NOTHING is stamped on the row before the 202: the thread stays
|
|
4325
|
-
// untitled (the header reads "Ask Worca") until the D13 background title
|
|
4326
|
-
// announces itself. titleWasAuto gates that call: a title given at THREAD
|
|
4327
|
-
// CREATION is the user's, and the haiku call must never fire for it
|
|
4328
|
-
// (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
|
|
4329
|
-
// empty haiku result (turn.mjs _kickoffTitle), never written here.
|
|
4330
|
-
const titleWasAuto = thread.title == null;
|
|
4331
|
-
const deterministicTitle = titleWasAuto ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
|
|
4332
|
-
const userMsg = askAppendMessage(id, { role: 'user', text });
|
|
4333
|
-
job.userMessageId = userMsg.id;
|
|
4334
|
-
const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
|
|
4335
|
-
// The decoded binary bodies are on disk now. `files` is captured by this
|
|
4336
|
-
// scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
|
|
4337
|
-
// turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
|
|
4338
|
-
// reachable per running thread.
|
|
4339
|
-
for (const f of files) f.data = null;
|
|
4340
|
-
echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
|
|
4341
|
-
if (attRows.length) {
|
|
4342
|
-
// `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
|
|
4343
|
-
// (the UI keys image thumbnails off it, #398).
|
|
4344
|
-
askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })));
|
|
4345
|
-
}
|
|
4346
|
-
broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
|
|
4347
|
-
asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
|
|
4348
|
-
job.messageId = asstMsg.id;
|
|
4349
|
-
|
|
4350
|
-
// Prompt assembly (§6.5) — the route owns it; the turn only spawns.
|
|
4351
|
-
const catalog = await askBuildCatalog();
|
|
4352
|
-
const systemPrompt = askBuildSystemPrompt(catalog);
|
|
4353
|
-
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 }));
|
|
4354
|
-
const { inline, listed } = askSelectInlineAttachments(withText);
|
|
4355
|
-
const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
|
|
4356
|
-
const header = askBuildContextHeader(headerCtx);
|
|
4357
|
-
const prompt = askBuildTurnPrompt(header, text, inline);
|
|
4358
|
-
const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
|
|
4359
|
-
const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
|
|
4360
|
-
const attachmentNames = {};
|
|
4361
|
-
for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
|
|
4362
|
-
|
|
4363
|
-
turn = createAskTurn({
|
|
4364
|
-
threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
|
|
4365
|
-
prompt, systemPrompt, restoredPrompt,
|
|
4366
|
-
model: mv.model, effort: mv.effort,
|
|
4367
|
-
resumeSessionId: thread.sessionId || null,
|
|
4368
|
-
firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
|
|
4369
|
-
firstText: text,
|
|
4370
|
-
deterministicTitle,
|
|
4371
|
-
pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
|
|
4372
|
-
mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
|
|
4373
|
-
attachmentNames,
|
|
4374
|
-
deps: {
|
|
4375
|
-
onFrame: stampAskFrames(id, job),
|
|
4376
|
-
onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
|
|
4377
|
-
onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
|
|
4378
|
-
onWorktreeMutation: () => { emitAskWorktrees(id); },
|
|
4379
|
-
},
|
|
4380
|
-
});
|
|
4381
|
-
job.turn = turn;
|
|
4382
|
-
} catch (err) {
|
|
4383
|
-
// A write/assembly failure must release the reserved slot and never leave
|
|
4384
|
-
// a `streaming` row for the boot sweep to find.
|
|
4385
|
-
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
4386
|
-
if (asstMsg) {
|
|
4387
|
-
try {
|
|
4388
|
-
askFinishMessage(asstMsg.id, {
|
|
4389
|
-
text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
|
|
4390
|
-
status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
|
|
4391
|
-
});
|
|
4392
|
-
} catch { /* thread gone */ }
|
|
4393
|
-
}
|
|
4394
|
-
return res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4395
|
-
}
|
|
4396
|
-
const settleJob = (status) => {
|
|
4397
|
-
if (askJobs.get(id) !== job) return;
|
|
4398
|
-
job.status = status;
|
|
4399
|
-
job.graceTimer = setTimeout(() => {
|
|
4400
|
-
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
4401
|
-
}, ASK_LIMITS.jobGraceMs);
|
|
4402
|
-
job.graceTimer.unref?.();
|
|
4403
|
-
};
|
|
4404
|
-
turn.on('done', () => settleJob('done'));
|
|
4405
|
-
turn.on('error', () => settleJob('error'));
|
|
4406
|
-
// Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
|
|
4407
|
-
Promise.resolve()
|
|
4408
|
-
.then(() => turn.run())
|
|
4409
|
-
.catch((err) => {
|
|
4410
|
-
console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
|
|
4411
|
-
settleJob('error');
|
|
4412
|
-
});
|
|
5246
|
+
const r = await startAskTurn({ threadId: id, thread, ctx, model: mv.model, effort: mv.effort, text, files });
|
|
5247
|
+
if (!r.ok) return res.status(r.status).json({ error: r.error, ...(r.budget ? { budget: r.budget } : {}) });
|
|
4413
5248
|
// `attachments` carries the store-minted ids so the sender's own echo can key
|
|
4414
5249
|
// image thumbnails and the thread budget off them (the ask-message broadcast
|
|
4415
5250
|
// may have raced ahead of this response, or been missed on a brand-new thread).
|
|
4416
|
-
res.status(202).json({ userMessageId:
|
|
5251
|
+
res.status(202).json({ userMessageId: r.userMessageId, assistantMessageId: r.assistantMessageId, attachments: r.attachments });
|
|
4417
5252
|
} catch (err) {
|
|
4418
|
-
//
|
|
4419
|
-
//
|
|
4420
|
-
// there is no slot to release.
|
|
5253
|
+
// startAskTurn never throws (it returns {ok:false,…}); only the route's own
|
|
5254
|
+
// pre-checks can land here, so there is no slot to release.
|
|
4421
5255
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4422
5256
|
}
|
|
4423
5257
|
});
|
|
@@ -4444,7 +5278,11 @@ function flipCard(threadId, cardId, patch) {
|
|
|
4444
5278
|
const block = askUpdateCardBlock(threadId, cardId, patch);
|
|
4445
5279
|
if (!block) return null;
|
|
4446
5280
|
const job = askInFlight(threadId);
|
|
4447
|
-
|
|
5281
|
+
// The store SHALLOW-merges `patch.card` on a workflow card; the reducer's
|
|
5282
|
+
// updateBlock replaces `card` wholesale — hand it the MERGED one (PD23),
|
|
5283
|
+
// else the live frame loses every key this patch did not name.
|
|
5284
|
+
const livePatch = patch && patch.card ? { ...patch, card: block.card } : patch;
|
|
5285
|
+
const live = job && job.turn && job.turn.reducer ? job.turn.reducer.updateBlock(cardId, livePatch) : null;
|
|
4448
5286
|
if (!live) {
|
|
4449
5287
|
const found = askFindCard(threadId, cardId);
|
|
4450
5288
|
if (found) broadcast({ type: 'ask-message', threadId, message: found.message });
|
|
@@ -4452,27 +5290,197 @@ function flipCard(threadId, cardId, patch) {
|
|
|
4452
5290
|
return block;
|
|
4453
5291
|
}
|
|
4454
5292
|
|
|
4455
|
-
//
|
|
4456
|
-
|
|
5293
|
+
// Card ids with a Save in flight: the mint + the row write await, so the
|
|
5294
|
+
// `state === 'proposed'` check alone would let two Saves both pass and write two rows.
|
|
5295
|
+
const askCardBusy = new Set();
|
|
5296
|
+
|
|
5297
|
+
/** Save a PROPOSED workflow card: adopt the twin (nothing written) or write a new origin:'auto' row (spec §8.4, PD9). */
|
|
5298
|
+
async function saveWorkflowCard(threadId, block, body = {}) {
|
|
5299
|
+
const card = block.card || {};
|
|
5300
|
+
const registry = loadAgentRegistry(AGENTS_DIR);
|
|
5301
|
+
const models = await listModels('');
|
|
5302
|
+
// A non-string `name` would be stringified by cleanText ("[object Object]" as the
|
|
5303
|
+
// workflow name) — only a string counts; anything else falls back to the card's own.
|
|
5304
|
+
const ans = sanitizeProposalAnswer(
|
|
5305
|
+
{ decision: 'accept', name: typeof body.name === 'string' ? body.name : undefined, nodes: body.nodes },
|
|
5306
|
+
{ proposal: card, models, registry },
|
|
5307
|
+
) || { name: card.name, nodes: {} };
|
|
5308
|
+
let workflowId; let name; let matched = false; let nodes = card.nodes;
|
|
5309
|
+
// `card.match` is a snapshot from proposal time; a row archived or deleted since
|
|
5310
|
+
// (composer delete) must not be "adopted" as a dangling id — re-check it still
|
|
5311
|
+
// reads (readWorkflow returns null for archived/missing rows; wf_default always reads).
|
|
5312
|
+
if (card.match && card.match.id && (await readWorkflow(card.match.id))) {
|
|
5313
|
+
workflowId = card.match.id; name = card.match.name; matched = true; // adopt: name/nodes ignored, row untouched
|
|
5314
|
+
} else {
|
|
5315
|
+
const r = await revalidateWorkflowProposal({
|
|
5316
|
+
shape: { ...card.shape, name: ans.name }, projectKey: card.projectKey, models, registry,
|
|
5317
|
+
});
|
|
5318
|
+
if (r.match) { workflowId = r.match.id; name = r.match.name; matched = true; } // a twin appeared since the proposal
|
|
5319
|
+
else {
|
|
5320
|
+
name = ans.name;
|
|
5321
|
+
workflowId = await mintAutoWorkflowId(name, async (wid) => !!(await readWorkflow(wid, { includeArchived: true })));
|
|
5322
|
+
await writeGraphWorkflow({ ...applyTunables(r.template, ans.nodes), id: workflowId, name, domain: 'coding', origin: 'auto' });
|
|
5323
|
+
nodes = { ...(card.nodes || {}) };
|
|
5324
|
+
for (const [nid, sel] of Object.entries(ans.nodes)) nodes[nid] = { ...(nodes[nid] || {}), ...sel };
|
|
5325
|
+
}
|
|
5326
|
+
}
|
|
5327
|
+
// `adopted` tells the saved card which line to show ("Uses your saved workflow"
|
|
5328
|
+
// vs "Saved as a new workflow, tagged Auto") — card.match is set on BOTH paths.
|
|
5329
|
+
const flipped = flipCard(threadId, block.id, {
|
|
5330
|
+
state: 'saved', workflowId, card: { name, nodes, match: { id: workflowId, name }, adopted: matched },
|
|
5331
|
+
});
|
|
5332
|
+
return { block: flipped, workflowId, name, matched };
|
|
5333
|
+
}
|
|
5334
|
+
|
|
5335
|
+
/**
|
|
5336
|
+
* The IMMEDIATE twin of drainAskDeferred's failure arm (PD29). The route answers the
|
|
5337
|
+
* flip and `turn:{error,status}` in one 200, but by then the flip frame has already
|
|
5338
|
+
* rebuilt the card element in every tab, so the panel's inline "Ask Worca could not
|
|
5339
|
+
* reply" lands on a detached node and the human sees a saved card and no reply at all.
|
|
5340
|
+
* The system notice row is the only channel that survives the rebuild; the response
|
|
5341
|
+
* keeps `turn.error` for API clients.
|
|
5342
|
+
*/
|
|
5343
|
+
function failedEventTurn(threadId, turn) {
|
|
5344
|
+
postAskSystemNotice(threadId, `Ask Worca could not reply to the workflow card: ${turn.error || 'unknown error'}`);
|
|
5345
|
+
return turn;
|
|
5346
|
+
}
|
|
5347
|
+
|
|
5348
|
+
/** Store the synthetic user-row notice and start (or queue) the assistant turn whose prompt is the event (spec §8.4, PD5/PD6/PD26). */
|
|
5349
|
+
async function startWorkflowEventTurn(threadId, block, { declined = false, thenRun = false } = {}) {
|
|
5350
|
+
const thread = askGetThread(threadId);
|
|
5351
|
+
if (!thread) return null;
|
|
5352
|
+
const card = block.card || {};
|
|
5353
|
+
const state = declined ? 'declined' : 'saved';
|
|
5354
|
+
const text = workflowEventPrompt({
|
|
5355
|
+
cardId: block.id, state, workflowId: block.workflowId, name: card.name, thenRun, projectKey: card.projectKey || '',
|
|
5356
|
+
});
|
|
5357
|
+
const notice = workflowNoticeText({ state, name: card.name, matched: !declined && card.adopted === true, thenRun });
|
|
5358
|
+
let mv = await validateModelEffort(thread.model, thread.effort);
|
|
5359
|
+
if (!mv.ok) {
|
|
5360
|
+
const d = (await askCatalog({ withSecrets: false })).default;
|
|
5361
|
+
if (!d) return failedEventTurn(threadId, { error: 'no model available', status: 503 });
|
|
5362
|
+
mv = { ok: true, ...d };
|
|
5363
|
+
}
|
|
5364
|
+
const start = () => startAskTurn({
|
|
5365
|
+
threadId, thread: askGetThread(threadId) || thread, ctx: thread.context || {},
|
|
5366
|
+
model: mv.model, effort: mv.effort, text, synthetic: { notice },
|
|
5367
|
+
});
|
|
5368
|
+
if (askInFlight(threadId)) {
|
|
5369
|
+
// PD5/PD26: the flip stands and the reply waits — settleJob drains the queue
|
|
5370
|
+
// when the running turn ends, and a starter that cannot reserve a slot then
|
|
5371
|
+
// posts a system notice rather than vanishing.
|
|
5372
|
+
if (!askDeferred.has(threadId)) askDeferred.set(threadId, []);
|
|
5373
|
+
askDeferred.get(threadId).push(start);
|
|
5374
|
+
return { deferred: true };
|
|
5375
|
+
}
|
|
5376
|
+
const r = await start();
|
|
5377
|
+
if (r.ok) return { assistantMessageId: r.assistantMessageId };
|
|
5378
|
+
return failedEventTurn(threadId, { error: r.error, status: r.status, ...(r.budget ? { budget: r.budget } : {}) });
|
|
5379
|
+
}
|
|
5380
|
+
|
|
5381
|
+
/** The metrics card's event turn: the synthetic notice row + the "[worca event] metrics card …" prompt (same queueing as workflow cards). */
|
|
5382
|
+
async function startMetricsEventTurn(threadId, block) {
|
|
5383
|
+
const thread = askGetThread(threadId);
|
|
5384
|
+
if (!thread) return null;
|
|
5385
|
+
const card = block.card || {};
|
|
5386
|
+
const state = block.state === 'declined' ? 'declined' : block.state === 'failed' ? 'failed' : 'applied';
|
|
5387
|
+
const result = card.result || null;
|
|
5388
|
+
const text = metricsEventPrompt({ cardId: block.id, state, card, result });
|
|
5389
|
+
const notice = metricsNoticeText({ state, card, result });
|
|
5390
|
+
let mv = await validateModelEffort(thread.model, thread.effort);
|
|
5391
|
+
if (!mv.ok) {
|
|
5392
|
+
const d = (await askCatalog({ withSecrets: false })).default;
|
|
5393
|
+
if (!d) return failedEventTurn(threadId, { error: 'no model available', status: 503 });
|
|
5394
|
+
mv = { ok: true, ...d };
|
|
5395
|
+
}
|
|
5396
|
+
const start = () => startAskTurn({
|
|
5397
|
+
threadId, thread: askGetThread(threadId) || thread, ctx: thread.context || {},
|
|
5398
|
+
model: mv.model, effort: mv.effort, text, synthetic: { notice },
|
|
5399
|
+
});
|
|
5400
|
+
if (askInFlight(threadId)) {
|
|
5401
|
+
if (!askDeferred.has(threadId)) askDeferred.set(threadId, []);
|
|
5402
|
+
askDeferred.get(threadId).push(start);
|
|
5403
|
+
return { deferred: true };
|
|
5404
|
+
}
|
|
5405
|
+
const r = await start();
|
|
5406
|
+
if (r.ok) return { assistantMessageId: r.assistantMessageId };
|
|
5407
|
+
return failedEventTurn(threadId, { error: r.error, status: r.status, ...(r.budget ? { budget: r.budget } : {}) });
|
|
5408
|
+
}
|
|
5409
|
+
|
|
5410
|
+
// D14 dismiss ("Not now" keeps a stub — the client renders state:'dismissed') for a RUN card;
|
|
5411
|
+
// the workflow-card state machine (spec §8.4) for a workflow one. The card is looked up BEFORE
|
|
5412
|
+
// the body is validated because the legal verb set depends on the card's type.
|
|
5413
|
+
app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
|
|
4457
5414
|
const id = askIdParam(res, req.params.id, 'thread');
|
|
4458
5415
|
if (!id) return;
|
|
4459
5416
|
const cardId = askIdParam(res, req.params.cardId, 'card');
|
|
4460
5417
|
if (!cardId) return;
|
|
4461
5418
|
try {
|
|
4462
5419
|
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
4463
|
-
|
|
5420
|
+
const body = req.body || {};
|
|
4464
5421
|
const found = askFindCard(id, cardId);
|
|
4465
5422
|
if (!found) return res.status(404).json({ error: 'card not found' });
|
|
4466
|
-
if (found.block.
|
|
4467
|
-
|
|
5423
|
+
if (found.block.card && found.block.card.type === 'metrics') {
|
|
5424
|
+
// Metrics card (docs/team-metrics.md "Ask Worca"): proposed → applied | failed | declined. The change is
|
|
5425
|
+
// the outward-facing part — a branch on origin, a marker on another repo, this machine's switch, the
|
|
5426
|
+
// workspace's home — so it happens HERE, behind the click, never in the model's tool.
|
|
5427
|
+
if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
|
|
5428
|
+
if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
5429
|
+
if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
|
|
5430
|
+
if (body.state === 'declined') {
|
|
5431
|
+
const block = flipCard(id, cardId, { state: 'declined' });
|
|
5432
|
+
if (!block) return res.status(409).json({ error: 'card vanished' });
|
|
5433
|
+
const turn = await startMetricsEventTurn(id, block);
|
|
5434
|
+
return res.json({ block, turn });
|
|
5435
|
+
}
|
|
5436
|
+
askCardBusy.add(cardId);
|
|
5437
|
+
let block;
|
|
5438
|
+
try {
|
|
5439
|
+
let result;
|
|
5440
|
+
try { result = await applyMetricsChange(found.block.card); }
|
|
5441
|
+
catch (err) {
|
|
5442
|
+
result = { ok: false, error: err && err.message ? err.message : String(err), code: (err && err.code) || 'ERROR', ...(err && err.hint ? { hint: err.hint } : {}), ...(err && err.stderr ? { stderr: String(err.stderr).slice(0, 2000) } : {}) };
|
|
5443
|
+
}
|
|
5444
|
+
block = flipCard(id, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
|
|
5445
|
+
} finally { askCardBusy.delete(cardId); }
|
|
5446
|
+
if (!block) return res.status(409).json({ error: 'card vanished' });
|
|
5447
|
+
const turn = await startMetricsEventTurn(id, block);
|
|
5448
|
+
return res.json({ block, turn });
|
|
5449
|
+
}
|
|
5450
|
+
if (!(found.block.card && found.block.card.type === 'workflow')) {
|
|
5451
|
+
if (body.state !== 'dismissed') return badRequest(res, 'state must be "dismissed"');
|
|
5452
|
+
if (found.block.state !== 'proposed') {
|
|
5453
|
+
return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
5454
|
+
}
|
|
5455
|
+
const block = flipCard(id, cardId, { state: 'dismissed' });
|
|
5456
|
+
// Dismiss is terminal: the card's parked comment ids can never reach a run,
|
|
5457
|
+
// so drop them here exactly as the launch path does at its own success point
|
|
5458
|
+
// (:1155). Own try/catch — comment bookkeeping must never fail the dismiss.
|
|
5459
|
+
try { clearPendingCardComments(cardId); }
|
|
5460
|
+
catch (e) { console.error('[diff-comments] dismiss cleanup failed:', e && e.message ? e.message : e); }
|
|
5461
|
+
return res.json({ block });
|
|
5462
|
+
}
|
|
5463
|
+
// Workflow card state machine (spec §8.4): proposed → saved | declined. A saved card has no
|
|
5464
|
+
// verb — its event turn proposes the run itself (thenRun) or offers one in chat.
|
|
5465
|
+
if (body.state !== 'saved' && body.state !== 'declined') {
|
|
5466
|
+
return badRequest(res, 'state must be "saved" or "declined"');
|
|
4468
5467
|
}
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
5468
|
+
if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
5469
|
+
if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being saved' });
|
|
5470
|
+
if (body.state === 'declined') {
|
|
5471
|
+
const block = flipCard(id, cardId, { state: 'declined' });
|
|
5472
|
+
if (!block) return res.status(409).json({ error: 'card vanished' }); // the thread was deleted between the lookup and the flip
|
|
5473
|
+
const turn = await startWorkflowEventTurn(id, block, { declined: true });
|
|
5474
|
+
return res.json({ block, turn });
|
|
5475
|
+
}
|
|
5476
|
+
askCardBusy.add(cardId);
|
|
5477
|
+
let saved;
|
|
5478
|
+
try { saved = await saveWorkflowCard(id, found.block, body); }
|
|
5479
|
+
finally { askCardBusy.delete(cardId); }
|
|
5480
|
+
// flipCard() null — the row (if written) stays, the card is gone with its thread.
|
|
5481
|
+
if (!saved.block) return res.status(409).json({ error: 'card vanished' });
|
|
5482
|
+
const turn = await startWorkflowEventTurn(id, saved.block, { thenRun: !!found.block.card.thenRun });
|
|
5483
|
+
res.json({ block: saved.block, turn });
|
|
4476
5484
|
} catch (err) {
|
|
4477
5485
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4478
5486
|
}
|
|
@@ -5268,6 +6276,86 @@ app.post('/api/pipelines/:id/report-result', async (req, res) => {
|
|
|
5268
6276
|
}
|
|
5269
6277
|
});
|
|
5270
6278
|
|
|
6279
|
+
// POST /api/pipelines/:id/report — the metadata-only run report a user can paste
|
|
6280
|
+
// into a GitHub issue. buildRunReport is a pure read-by-id (run-report.mjs) that
|
|
6281
|
+
// resolves BOTH project and workspace runs, so this one route covers both families
|
|
6282
|
+
// and needs no /api/workspaces twin. POST, not GET: the body carries the reporter's
|
|
6283
|
+
// free text and the three opt-in flags, which do not belong in a logged URL.
|
|
6284
|
+
// Worca makes NO network call here — it returns a URL the browser opens.
|
|
6285
|
+
// Like its report-result neighbour above, a malformed id 404s rather than 400s: a
|
|
6286
|
+
// stale bookmark must read as not-found (see resolveRunScope, :2006).
|
|
6287
|
+
|
|
6288
|
+
// The repo `gh issue create` targets, from the SAME bugs.url the prefilled link uses
|
|
6289
|
+
// (APP_INFO.bugsUrl, which falls back to <repo>/issues). Empty for a non-github.com
|
|
6290
|
+
// bugs.url, which makes /report-issue degrade to the browser link.
|
|
6291
|
+
const BUGS_SLUG = repoSlugFromBugsUrl(APP_INFO.bugsUrl);
|
|
6292
|
+
|
|
6293
|
+
/**
|
|
6294
|
+
* The preamble both report routes share: validate the reason, build the payload.
|
|
6295
|
+
* Returns null after ALREADY answering `res` (400 or 404), so callers just bail.
|
|
6296
|
+
*/
|
|
6297
|
+
async function reportPayloadOr(res, req) {
|
|
6298
|
+
const body = req.body || {};
|
|
6299
|
+
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
|
|
6300
|
+
if (!REPORT_REASON_IDS.includes(reason)) {
|
|
6301
|
+
badRequest(res, `reason must be one of: ${REPORT_REASON_IDS.join(', ')}`);
|
|
6302
|
+
return null;
|
|
6303
|
+
}
|
|
6304
|
+
const payload = await buildRunReport(req.params.id, {
|
|
6305
|
+
reason,
|
|
6306
|
+
expectation: typeof body.expectation === 'string' ? body.expectation : '',
|
|
6307
|
+
include: body.include,
|
|
6308
|
+
});
|
|
6309
|
+
if (!payload) {
|
|
6310
|
+
res.status(404).json({ error: 'pipeline not found' });
|
|
6311
|
+
return null;
|
|
6312
|
+
}
|
|
6313
|
+
return payload;
|
|
6314
|
+
}
|
|
6315
|
+
|
|
6316
|
+
/** The browser fallback: a prefilled issues/new URL plus the download filename. */
|
|
6317
|
+
function prefilledIssue(payload) {
|
|
6318
|
+
return { ...buildIssueUrl(payload, { bugsUrl: APP_INFO.bugsUrl }), filename: reportFilename(payload) };
|
|
6319
|
+
}
|
|
6320
|
+
|
|
6321
|
+
app.post('/api/pipelines/:id/report', async (req, res) => {
|
|
6322
|
+
try {
|
|
6323
|
+
const payload = await reportPayloadOr(res, req);
|
|
6324
|
+
if (!payload) return;
|
|
6325
|
+
res.json({ payload, issue: prefilledIssue(payload) });
|
|
6326
|
+
} catch (err) {
|
|
6327
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
6328
|
+
}
|
|
6329
|
+
});
|
|
6330
|
+
|
|
6331
|
+
// POST /api/pipelines/:id/report-issue — the same report, but worca FILES it through
|
|
6332
|
+
// `gh issue create` instead of handing the browser a prefilled link. That exists
|
|
6333
|
+
// because the link cannot carry the report: a prefilled `body=` dies at ~8 KB encoded
|
|
6334
|
+
// and half of all real runs serialize larger than that, so the browser path can only
|
|
6335
|
+
// ever ask the reporter to paste the JSON in by hand. A --body-file has no such cap.
|
|
6336
|
+
//
|
|
6337
|
+
// The payload is REBUILT here from the run id; the client's copy is never accepted.
|
|
6338
|
+
// The redaction contract (run-report.mjs) has exactly one enforcement point, and a
|
|
6339
|
+
// route that trusted a posted payload would be a second, weaker one.
|
|
6340
|
+
//
|
|
6341
|
+
// A gh failure is a 200 with ok:false, not a 5xx: the response carries the prefilled
|
|
6342
|
+
// link so the modal can degrade to today's copy-and-paste flow in one round trip.
|
|
6343
|
+
app.post('/api/pipelines/:id/report-issue', async (req, res) => {
|
|
6344
|
+
try {
|
|
6345
|
+
const payload = await reportPayloadOr(res, req);
|
|
6346
|
+
if (!payload) return;
|
|
6347
|
+
const filed = await createIssue({
|
|
6348
|
+
repo: BUGS_SLUG,
|
|
6349
|
+
title: issueTitle(payload),
|
|
6350
|
+
body: renderIssueBodyFull(payload),
|
|
6351
|
+
});
|
|
6352
|
+
if (filed.ok) return res.json({ ok: true, url: filed.url, labeled: filed.labeled });
|
|
6353
|
+
res.json({ ok: false, kind: filed.kind, error: filed.error, issue: prefilledIssue(payload) });
|
|
6354
|
+
} catch (err) {
|
|
6355
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
6356
|
+
}
|
|
6357
|
+
});
|
|
6358
|
+
|
|
5271
6359
|
// ---------------------------------------------------------------------------
|
|
5272
6360
|
// Install logic (mirrors scripts/install.mjs): copy agents/*.md and
|
|
5273
6361
|
// skills/worca/** into <projectDir>/.claude/...
|
|
@@ -5414,9 +6502,7 @@ app.use((req, res, next) => {
|
|
|
5414
6502
|
if (req.method !== 'GET') return next();
|
|
5415
6503
|
if (req.path.startsWith('/api/') || req.path.startsWith('/ws')) return next();
|
|
5416
6504
|
if (req.path === '/vendor' || req.path.startsWith('/vendor/')) return next();
|
|
5417
|
-
res
|
|
5418
|
-
if (err) next();
|
|
5419
|
-
});
|
|
6505
|
+
sendIndex(res);
|
|
5420
6506
|
});
|
|
5421
6507
|
|
|
5422
6508
|
// ---------------------------------------------------------------------------
|
|
@@ -5622,6 +6708,8 @@ if (isMain) {
|
|
|
5622
6708
|
try { channelHost.start(); } catch (err) {
|
|
5623
6709
|
console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
|
|
5624
6710
|
}
|
|
6711
|
+
try { startTeamMetricsBackground({ log: (m) => console.warn(m) }); }
|
|
6712
|
+
catch (err) { console.warn(`[worca-ui] team metrics background: ${err?.message || err}`); }
|
|
5625
6713
|
});
|
|
5626
6714
|
}
|
|
5627
6715
|
|
|
@@ -5631,5 +6719,6 @@ export const _testing = {
|
|
|
5631
6719
|
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
|
|
5632
6720
|
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
|
|
5633
6721
|
emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
|
|
6722
|
+
askTrackRun, liveRunEntry, liveDefragRun, memoryScopeKey, startRunHandler, emitMemoryChanged, askSystemPromptFor,
|
|
5634
6723
|
uiControl, bearerMatches,
|
|
5635
6724
|
};
|