@worca/app 1.2.0-rc.3 → 1.3.0-rc.2
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 +3736 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1589 -212
- 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 +447 -29
- 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 +1499 -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 +1304 -194
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 ──────────────────────────────────
|
|
@@ -183,7 +213,27 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
|
183
213
|
const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
|
|
184
214
|
const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
|
|
185
215
|
const require = createRequire(import.meta.url);
|
|
186
|
-
const
|
|
216
|
+
const PKG = require('../package.json');
|
|
217
|
+
const PKG_VERSION = PKG.version;
|
|
218
|
+
// Settings ▸ About identity, fixed at module load. repository.url is normalised
|
|
219
|
+
// from npm's git forms (git+https, ssh://git@, git://, scp-style git@host:path)
|
|
220
|
+
// to a browsable https URL; releaseUrl points at the tag the release workflow
|
|
221
|
+
// publishes from (.github/workflows/release-npm-app.yml: worca-app-v<version>).
|
|
222
|
+
const repoWebUrl = (raw) => String(raw || '')
|
|
223
|
+
.replace(/^git\+/, '')
|
|
224
|
+
.replace(/^ssh:\/\/git@/, 'https://')
|
|
225
|
+
.replace(/^git:\/\//, 'https://')
|
|
226
|
+
.replace(/^git@([^:/]+):/, 'https://$1/')
|
|
227
|
+
.replace(/\.git$/, '');
|
|
228
|
+
const APP_REPO_URL = repoWebUrl(PKG.repository && PKG.repository.url);
|
|
229
|
+
const APP_INFO = Object.freeze({
|
|
230
|
+
version: PKG_VERSION || '',
|
|
231
|
+
repoUrl: APP_REPO_URL,
|
|
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` : ''),
|
|
236
|
+
});
|
|
187
237
|
const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
|
|
188
238
|
// Primaries plus the sub-language grammars their instances register
|
|
189
239
|
// (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
|
|
@@ -439,6 +489,8 @@ function emitChanged(type, action) {
|
|
|
439
489
|
broadcast({ type, action: action || null });
|
|
440
490
|
}
|
|
441
491
|
|
|
492
|
+
metricsEvents.on('changed', (e) => emitChanged('team-metrics-changed', e && e.action ? e.action : null));
|
|
493
|
+
|
|
442
494
|
// Every comment mutation in THIS process (the REST routes below) pokes the open
|
|
443
495
|
// Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
|
|
444
496
|
// ordering concerns; the client refetches and repaints its CARDS, never the diff.
|
|
@@ -491,6 +543,44 @@ function resolvePending(entry, { id = null, reason = 'resolved' } = {}) {
|
|
|
491
543
|
return true;
|
|
492
544
|
}
|
|
493
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
|
+
|
|
494
584
|
function summarizeRuns() {
|
|
495
585
|
return [...runs.values()].map((r) => ({
|
|
496
586
|
runId: r.id,
|
|
@@ -591,6 +681,14 @@ function wireRun(entry) {
|
|
|
591
681
|
// ...and WHAT went wrong for an error-pause, reset alongside it.
|
|
592
682
|
entry.pauseDetail = (payload && payload.detail) || null;
|
|
593
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
|
+
}
|
|
594
692
|
if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
|
|
595
693
|
emitChanged('budget-changed');
|
|
596
694
|
}
|
|
@@ -848,6 +946,32 @@ app.use('/src/shared', (_req, res) => {
|
|
|
848
946
|
res.status(404).type('text/plain').send('Not found');
|
|
849
947
|
});
|
|
850
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
|
+
|
|
851
975
|
app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
|
|
852
976
|
|
|
853
977
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
@@ -1034,7 +1158,7 @@ function attachAskFollower(orch, { threadId, runId, cardId }) {
|
|
|
1034
1158
|
if (linkPatch.pipelineId && row && row.commentIds.length) {
|
|
1035
1159
|
try { stampSentRunId(row.commentIds, linkPatch.pipelineId); } catch { /* best effort */ }
|
|
1036
1160
|
}
|
|
1037
|
-
if (patch.cardFailed) {
|
|
1161
|
+
if (patch.cardFailed && cardId) {
|
|
1038
1162
|
flipCard(threadId, cardId, { state: 'failed', error: patch.cardFailed });
|
|
1039
1163
|
}
|
|
1040
1164
|
broadcast({
|
|
@@ -1062,13 +1186,82 @@ function attachAskFollower(orch, { threadId, runId, cardId }) {
|
|
|
1062
1186
|
set.add(follower);
|
|
1063
1187
|
}
|
|
1064
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
|
+
|
|
1065
1258
|
// ---------------------------------------------------------------------------
|
|
1066
1259
|
// POST /api/run -> start a new orchestration run
|
|
1067
1260
|
// body (single-project): { projectDir, prompt?, promptMarkdown?, title?, mock? }
|
|
1068
1261
|
// body (workspace): { workspaceId, prompt?, ... } — mutually exclusive with
|
|
1069
1262
|
// projectDir (§2.6). Single-project behavior is byte-identical.
|
|
1070
1263
|
// ---------------------------------------------------------------------------
|
|
1071
|
-
|
|
1264
|
+
const startRunHandler = async (req, res) => {
|
|
1072
1265
|
try {
|
|
1073
1266
|
const body = req.body || {};
|
|
1074
1267
|
|
|
@@ -1169,6 +1362,21 @@ app.post('/api/run', async (req, res) => {
|
|
|
1169
1362
|
return badRequest(res, err && err.message ? err.message : String(err));
|
|
1170
1363
|
}
|
|
1171
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
|
+
|
|
1172
1380
|
// Optional guardrailsId selects the named guardrail set that IS this run's
|
|
1173
1381
|
// policy (applied uniformly to every member — guardrails are per-run only).
|
|
1174
1382
|
// Absent/blank/null normalizes to 'permissive' — the empty policy,
|
|
@@ -1314,6 +1522,13 @@ app.post('/api/run', async (req, res) => {
|
|
|
1314
1522
|
|
|
1315
1523
|
const fileProblem = await promptFileProblem(effectiveSource, projectDir);
|
|
1316
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);
|
|
1317
1532
|
|
|
1318
1533
|
orch = await createOrchestratorFor({
|
|
1319
1534
|
projectDir,
|
|
@@ -1326,6 +1541,8 @@ app.post('/api/run', async (req, res) => {
|
|
|
1326
1541
|
template: workflowRow,
|
|
1327
1542
|
guardrailsId,
|
|
1328
1543
|
branch,
|
|
1544
|
+
humanInLoop,
|
|
1545
|
+
...(memoryScope ? { memoryScope } : {}),
|
|
1329
1546
|
claude: { permissionMode: 'acceptEdits', mock },
|
|
1330
1547
|
});
|
|
1331
1548
|
|
|
@@ -1402,7 +1619,8 @@ app.post('/api/run', async (req, res) => {
|
|
|
1402
1619
|
} catch (err) {
|
|
1403
1620
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
1404
1621
|
}
|
|
1405
|
-
}
|
|
1622
|
+
};
|
|
1623
|
+
app.post('/api/run', startRunHandler);
|
|
1406
1624
|
|
|
1407
1625
|
// ---------------------------------------------------------------------------
|
|
1408
1626
|
// Chat connectivity (chat-connectivity-design.md): persistent channel workers
|
|
@@ -1679,7 +1897,15 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
|
|
|
1679
1897
|
if (!projectDir) throw new ResumeError(400, { error: 'project for this pipeline is not onboarded on this machine' });
|
|
1680
1898
|
}
|
|
1681
1899
|
|
|
1682
|
-
|
|
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);
|
|
1683
1909
|
const runId = randomUUID();
|
|
1684
1910
|
const orch = await createOrchestratorFor({
|
|
1685
1911
|
projectDir,
|
|
@@ -1849,6 +2075,19 @@ app.get('/api/runs/:id/artifact', async (req, res) => {
|
|
|
1849
2075
|
}
|
|
1850
2076
|
});
|
|
1851
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
|
+
|
|
1852
2091
|
// Shared query-scope resolver for the retained-work routes (recovery-patch GET +
|
|
1853
2092
|
// discard POST). Returns null after writing the error response itself. The older
|
|
1854
2093
|
// DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
|
|
@@ -1949,6 +2188,24 @@ app.get('/api/history', async (_req, res) => {
|
|
|
1949
2188
|
// machine-wide history just to update a badge. Running is derived client-side from the
|
|
1950
2189
|
// in-memory runs map (live via WS), so it is not included here. Synchronous: the three
|
|
1951
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
|
+
|
|
1952
2209
|
app.get('/api/counts', (_req, res) => {
|
|
1953
2210
|
try {
|
|
1954
2211
|
res.json({
|
|
@@ -1975,6 +2232,154 @@ app.get('/api/stats', (req, res) => {
|
|
|
1975
2232
|
}
|
|
1976
2233
|
});
|
|
1977
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
|
+
|
|
1978
2383
|
// ---------------------------------------------------------------------------
|
|
1979
2384
|
// POST /api/history/pr -> enrich the skeleton with live PR state, pushed back
|
|
1980
2385
|
// over the WS as batched `history-pr` events (reuses broadcast(), the same
|
|
@@ -2130,7 +2535,27 @@ function commentsPatch(req, res, storeKey, id, cid) {
|
|
|
2130
2535
|
const raw = (req.body || {}).resolved;
|
|
2131
2536
|
if (typeof raw !== 'boolean') return badRequest(res, 'resolved must be a boolean');
|
|
2132
2537
|
res.json({ comment: setDiffCommentResolved(cid, raw) });
|
|
2133
|
-
} 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
|
+
}
|
|
2134
2559
|
}
|
|
2135
2560
|
|
|
2136
2561
|
function commentsDelete(res, storeKey, id, cid) {
|
|
@@ -2159,6 +2584,11 @@ app.delete('/api/history/:key/:id/comments/:cid', (req, res) => {
|
|
|
2159
2584
|
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2160
2585
|
commentsDelete(res, key, req.params.id, cid);
|
|
2161
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
|
+
});
|
|
2162
2592
|
|
|
2163
2593
|
app.get('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
|
|
2164
2594
|
const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
|
|
@@ -2178,6 +2608,11 @@ app.delete('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
|
|
|
2178
2608
|
const cid = commentIdParam(res, req.params.cid); if (!cid) return;
|
|
2179
2609
|
commentsDelete(res, key, req.params.runId, cid);
|
|
2180
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
|
+
});
|
|
2181
2616
|
|
|
2182
2617
|
// Unresolved counts for every run, for the History list pill. Its own endpoint
|
|
2183
2618
|
// rather than a field on /api/history: that response has a localStorage skeleton
|
|
@@ -2311,37 +2746,87 @@ app.post('/api/runs/:id/discard-worktree', async (req, res) => {
|
|
|
2311
2746
|
});
|
|
2312
2747
|
|
|
2313
2748
|
// ---------------------------------------------------------------------------
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
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.
|
|
2317
2754
|
// ---------------------------------------------------------------------------
|
|
2318
|
-
|
|
2319
|
-
const
|
|
2320
|
-
const
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
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
|
+
}
|
|
2325
2764
|
|
|
2326
|
-
|
|
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; }
|
|
2327
2772
|
let state = null;
|
|
2328
2773
|
try {
|
|
2329
|
-
if (typeof
|
|
2330
|
-
if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(
|
|
2331
|
-
|
|
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;
|
|
2332
2778
|
}
|
|
2333
|
-
const data = await readPipelineByKey(
|
|
2779
|
+
const data = await readPipelineByKey(src.projectKey, id);
|
|
2334
2780
|
state = data && data.state;
|
|
2335
2781
|
} else {
|
|
2336
|
-
const projectDir = resolveProjectDir(
|
|
2337
|
-
if (!projectDir)
|
|
2782
|
+
const projectDir = resolveProjectDir(src.projectDir);
|
|
2783
|
+
if (!projectDir) { badRequest(res, 'projectDir or projectKey is required'); return null; }
|
|
2338
2784
|
const data = await readPipeline(projectDir, id);
|
|
2339
2785
|
state = data && data.state;
|
|
2340
2786
|
}
|
|
2341
2787
|
} catch (err) {
|
|
2342
|
-
|
|
2788
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2789
|
+
return null;
|
|
2343
2790
|
}
|
|
2344
|
-
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;
|
|
2345
2830
|
|
|
2346
2831
|
const repoDir = state.projectDir;
|
|
2347
2832
|
const feature = state.branch && state.branch.feature;
|
|
@@ -2350,12 +2835,49 @@ app.post('/api/pr', async (req, res) => {
|
|
|
2350
2835
|
return badRequest(res, 'pipeline has no branch info to open a PR');
|
|
2351
2836
|
}
|
|
2352
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
|
+
|
|
2353
2873
|
// Push (idempotent) -> create PR -> read mergeability. All args are passed as
|
|
2354
|
-
// an argv array (no shell), so branch/source names cannot inject.
|
|
2355
|
-
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);
|
|
2356
2876
|
if (!pushed.ok) return res.status(500).json({ error: `git push failed: ${pushed.stderr}` });
|
|
2357
2877
|
|
|
2358
|
-
const pr = await createPr({
|
|
2878
|
+
const pr = await createPr({
|
|
2879
|
+
projectDir: repoDir, base: source, head: feature, title: state.title || feature, repo, headOwner,
|
|
2880
|
+
});
|
|
2359
2881
|
if (!pr.ok) return res.status(500).json({ error: `gh pr create failed: ${pr.error}` });
|
|
2360
2882
|
|
|
2361
2883
|
// Persist the PR facts we just learned, so History/stats survive a gh outage.
|
|
@@ -2364,8 +2886,12 @@ app.post('/api/pr', async (req, res) => {
|
|
|
2364
2886
|
if (pipelineIdForPr) {
|
|
2365
2887
|
persistPrState(pipelineIdForPr, { url: pr.url, number: parsePrNumber(pr.url), state: 'OPEN' });
|
|
2366
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
|
+
}
|
|
2367
2893
|
|
|
2368
|
-
const mergeable = await prMergeable({ projectDir: repoDir, head: feature });
|
|
2894
|
+
const mergeable = await prMergeable({ projectDir: repoDir, head: feature, repo, headOwner, prUrl: pr.url || null });
|
|
2369
2895
|
res.json({ ok: true, url: pr.url, mergeable, existed: !!pr.existed });
|
|
2370
2896
|
});
|
|
2371
2897
|
|
|
@@ -2404,7 +2930,11 @@ app.post('/api/pr/mergeable', async (req, res) => {
|
|
|
2404
2930
|
const feature = state && state.branch && state.branch.feature;
|
|
2405
2931
|
if (!repoDir || !feature) return res.json({ ok: true, mergeable: 'UNKNOWN' });
|
|
2406
2932
|
|
|
2407
|
-
|
|
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 });
|
|
2408
2938
|
res.json({ ok: true, mergeable });
|
|
2409
2939
|
} catch {
|
|
2410
2940
|
res.json({ ok: true, mergeable: 'UNKNOWN' }); // best-effort: never error the refresh
|
|
@@ -2457,6 +2987,9 @@ app.post('/api/projects', async (req, res) => {
|
|
|
2457
2987
|
try {
|
|
2458
2988
|
const projects = await addProject({ name: body.name, path: body.path });
|
|
2459
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 */ });
|
|
2460
2993
|
res.json({ projects });
|
|
2461
2994
|
} catch (err) {
|
|
2462
2995
|
// addProject only throws on validation (empty/duplicate/not-a-directory), so
|
|
@@ -2478,6 +3011,155 @@ app.delete('/api/projects', async (req, res) => {
|
|
|
2478
3011
|
}
|
|
2479
3012
|
});
|
|
2480
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
|
+
|
|
2481
3163
|
// ---------------------------------------------------------------------------
|
|
2482
3164
|
// Filesystem browsing for the add-project folder selector. Hybrid picker:
|
|
2483
3165
|
// POST /api/fs/pick-folder opens the native OS dialog (the server runs on the
|
|
@@ -2540,9 +3222,13 @@ app.post('/api/workspaces', async (req, res) => {
|
|
|
2540
3222
|
: [];
|
|
2541
3223
|
if (projectPaths.length < 2) return badRequest(res, 'a workspace needs at least 2 member projects');
|
|
2542
3224
|
try {
|
|
2543
|
-
|
|
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 });
|
|
2544
3230
|
emitChanged('workspaces-changed', 'created');
|
|
2545
|
-
res.status(201).json({ workspace });
|
|
3231
|
+
res.status(201).json({ workspace, metricsHomeAuto: !explicit && !!metricsProject });
|
|
2546
3232
|
} catch (err) {
|
|
2547
3233
|
const status = workspaceErrorStatus(err && err.code);
|
|
2548
3234
|
return res.status(status).json({ error: err && err.message ? err.message : String(err) });
|
|
@@ -2555,14 +3241,21 @@ app.patch('/api/workspaces/:id', async (req, res) => {
|
|
|
2555
3241
|
const body = req.body || {};
|
|
2556
3242
|
// Immutability (defense-in-depth, §2.3): the project set never changes via PATCH.
|
|
2557
3243
|
if ('projectPaths' in body || 'projectKeys' in body) {
|
|
2558
|
-
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');
|
|
2559
3245
|
}
|
|
2560
3246
|
// Pass through only the editable fields.
|
|
2561
3247
|
const patch = {};
|
|
2562
3248
|
if (typeof body.name === 'string') patch.name = body.name;
|
|
2563
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
|
+
}
|
|
2564
3256
|
try {
|
|
2565
3257
|
const workspace = await updateWorkspace(id, patch);
|
|
3258
|
+
if ('metricsProject' in patch) emitChanged('workspaces-changed', 'metrics-home');
|
|
2566
3259
|
res.json({ workspace });
|
|
2567
3260
|
} catch (err) {
|
|
2568
3261
|
const status = workspaceErrorStatus(err && err.code);
|
|
@@ -2777,6 +3470,10 @@ app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
|
|
|
2777
3470
|
// projectsRootDefault : what applies when projectsRoot is blank — the env tier
|
|
2778
3471
|
// when exported, else defaultRoot(). The UI placeholder.
|
|
2779
3472
|
// Additive; `default` keeps its `root` meaning.
|
|
3473
|
+
// app : { version, repoUrl, releaseUrl } — static identity for
|
|
3474
|
+
// the About card (APP_INFO, from package.json). GET-only:
|
|
3475
|
+
// it is not a setting, so POST keeps echoing
|
|
3476
|
+
// settingsState() + chat unchanged.
|
|
2780
3477
|
// POST /api/settings -> set either key and return the resulting full state.
|
|
2781
3478
|
// Only keys PRESENT in the body are written, so a projectsRoot-only POST can
|
|
2782
3479
|
// never reset `root` (and vice versa). An explicitly empty value still resets
|
|
@@ -2799,7 +3496,20 @@ const settingsState = () => ({
|
|
|
2799
3496
|
titleModel: storedTitleModel(), // the STORED id (the select), null = run's model
|
|
2800
3497
|
titleModelEffective: describeTitleModel(), // env override / stale id, for the hint line (#422)
|
|
2801
3498
|
hideBuiltinModels: hideBuiltinModels(),
|
|
2802
|
-
|
|
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
|
+
}
|
|
2803
3513
|
|
|
2804
3514
|
// ---------------------------------------------------------------------------
|
|
2805
3515
|
// Instance lifecycle (`worca ui status|stop|restart`, src/core/ui-instance.mjs)
|
|
@@ -2844,8 +3554,8 @@ app.post('/api/shutdown', (req, res) => {
|
|
|
2844
3554
|
setImmediate(() => uiControl.onShutdown('request'));
|
|
2845
3555
|
});
|
|
2846
3556
|
|
|
2847
|
-
app.get('/api/settings', (_req, res) => {
|
|
2848
|
-
res.json({ ...settingsState(), chat: chatPrefs() });
|
|
3557
|
+
app.get('/api/settings', async (_req, res) => {
|
|
3558
|
+
res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs(), app: APP_INFO });
|
|
2849
3559
|
});
|
|
2850
3560
|
|
|
2851
3561
|
app.get('/api/budget', (_req, res) => {
|
|
@@ -2860,6 +3570,9 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2860
3570
|
const hasDebugSpawnKey = has('debugSpawnEnabled');
|
|
2861
3571
|
const hasTitleModelKey = has('titleModel');
|
|
2862
3572
|
const hasHideBuiltinKey = has('hideBuiltinModels');
|
|
3573
|
+
const hasThemeKey = has('theme');
|
|
3574
|
+
const hasAutoKey = has('autoWorkflowModel');
|
|
3575
|
+
const autoModels = hasAutoKey ? await listModels('') : null;
|
|
2863
3576
|
// #422: the title model is a SELECT over the catalog, so an id that is not a
|
|
2864
3577
|
// catalog member is a client bug (or a stale option) — refuse it here rather
|
|
2865
3578
|
// than store an id resolveModelEnv could never route.
|
|
@@ -2892,6 +3605,8 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2892
3605
|
}
|
|
2893
3606
|
}
|
|
2894
3607
|
if (hasHideBuiltinKey) assertHideBuiltinModelsInput(body.hideBuiltinModels);
|
|
3608
|
+
if (hasThemeKey) assertThemeInput(body.theme);
|
|
3609
|
+
if (hasAutoKey) assertAutoWorkflowModelInput(body.autoWorkflowModel ?? '', autoModels);
|
|
2895
3610
|
// Root first: it is the one key whose setter can still fail AFTER the asserts
|
|
2896
3611
|
// above (an unusable path), so every other key's write must come after it or
|
|
2897
3612
|
// a mixed POST would answer 400 with those keys already applied on disk.
|
|
@@ -2912,11 +3627,13 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2912
3627
|
if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
|
|
2913
3628
|
if (hasTitleModelKey) await setTitleModel(titleModelInput);
|
|
2914
3629
|
if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
|
|
3630
|
+
if (hasThemeKey) await setTheme(body.theme);
|
|
3631
|
+
if (hasAutoKey) await setAutoWorkflowModel(body.autoWorkflowModel ?? '', { models: autoModels });
|
|
2915
3632
|
if (hasBudgetKey) emitChanged('budget-changed');
|
|
2916
3633
|
// Other open tabs repaint their Settings cards (a stale tab could otherwise
|
|
2917
3634
|
// "save" its old checkbox state over this one with no feedback to either).
|
|
2918
|
-
if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey) emitChanged('settings-changed');
|
|
2919
|
-
res.json({ ...settingsState(), chat: chatPrefs() });
|
|
3635
|
+
if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasAutoKey) emitChanged('settings-changed');
|
|
3636
|
+
res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs() });
|
|
2920
3637
|
} catch (err) {
|
|
2921
3638
|
// The setters throw only on an unusable path -> client error (400).
|
|
2922
3639
|
return badRequest(res, err && err.message ? err.message : String(err));
|
|
@@ -2995,7 +3712,7 @@ app.post('/api/config', async (req, res) => {
|
|
|
2995
3712
|
// validates model/effort against the effective catalog exactly like setStep
|
|
2996
3713
|
// (configurable-models-design.md §4.5) -> 400; setFeedbackCycles still COERCES
|
|
2997
3714
|
// maxCycles to >= 1 (it never throws).
|
|
2998
|
-
// 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? }
|
|
2999
3716
|
// ---------------------------------------------------------------------------
|
|
3000
3717
|
app.patch('/api/config', async (req, res) => {
|
|
3001
3718
|
const body = req.body || {};
|
|
@@ -3045,6 +3762,7 @@ app.patch('/api/config', async (req, res) => {
|
|
|
3045
3762
|
if (!isSafeWorkflowId(active)) return badRequest(res, 'invalid workflowId');
|
|
3046
3763
|
await setActiveWorkflow(projectDir, active);
|
|
3047
3764
|
}
|
|
3765
|
+
if (typeof body.humanInLoop === 'boolean') await setHumanInLoop(projectDir, body.humanInLoop);
|
|
3048
3766
|
const config = await readRunConfig(projectDir);
|
|
3049
3767
|
res.json({ config });
|
|
3050
3768
|
} catch (err) {
|
|
@@ -3416,9 +4134,9 @@ app.get('/api/workflows', async (req, res) => {
|
|
|
3416
4134
|
const all = await listWorkflows({ includeArchived: true });
|
|
3417
4135
|
return res.json({ workflows: all.filter((w) => w.archivedAt) });
|
|
3418
4136
|
}
|
|
3419
|
-
// CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, ...listWorkflows() ]. The
|
|
3420
|
-
// never
|
|
3421
|
-
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())] });
|
|
3422
4140
|
} catch (err) {
|
|
3423
4141
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3424
4142
|
}
|
|
@@ -3529,6 +4247,7 @@ app.delete('/api/workflows/:id', async (req, res) => {
|
|
|
3529
4247
|
const id = req.params.id;
|
|
3530
4248
|
// The built-in default is not in the user store and must never be deleted.
|
|
3531
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');
|
|
3532
4251
|
try {
|
|
3533
4252
|
const removed = await deleteWorkflow(id); // CONV-1: await
|
|
3534
4253
|
if (!removed) return res.status(404).json({ error: 'workflow not found' });
|
|
@@ -3715,12 +4434,58 @@ const askJobs = new Map(); // threadId -> {turn, messageId, userMessageId,
|
|
|
3715
4434
|
const askDeleting = new Set();
|
|
3716
4435
|
const askFollowers = new Map(); // threadId -> Set<{detach}>
|
|
3717
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
|
+
}
|
|
3718
4468
|
|
|
3719
4469
|
function askInFlight(threadId) {
|
|
3720
4470
|
const job = askJobs.get(threadId);
|
|
3721
4471
|
return job && job.status === 'running' ? job : null;
|
|
3722
4472
|
}
|
|
3723
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
|
+
|
|
3724
4489
|
function askRunningCount() {
|
|
3725
4490
|
let n = 0;
|
|
3726
4491
|
for (const job of askJobs.values()) if (job.status === 'running') n += 1;
|
|
@@ -3793,7 +4558,10 @@ app.get('/api/ask/threads', (req, res) => {
|
|
|
3793
4558
|
try {
|
|
3794
4559
|
const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
|
|
3795
4560
|
const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
|
|
3796
|
-
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
|
+
});
|
|
3797
4565
|
// total = EVERY saved chat (the History popover's meter), not the capped page above.
|
|
3798
4566
|
res.json({ threads, total: askCountThreads() });
|
|
3799
4567
|
} catch (err) {
|
|
@@ -3884,6 +4652,21 @@ app.get('/api/ask/threads/:id', (req, res) => {
|
|
|
3884
4652
|
}
|
|
3885
4653
|
});
|
|
3886
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
|
+
|
|
3887
4670
|
app.patch('/api/ask/threads/:id', (req, res) => {
|
|
3888
4671
|
const id = askIdParam(res, req.params.id, 'thread');
|
|
3889
4672
|
if (!id) return;
|
|
@@ -3929,6 +4712,9 @@ app.patch('/api/ask/threads/:id', (req, res) => {
|
|
|
3929
4712
|
// Returns { deleted, worktrees } — worktrees = rows removeThreadWorktrees removed.
|
|
3930
4713
|
async function deleteAskThreadFully(id) {
|
|
3931
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);
|
|
3932
4718
|
try {
|
|
3933
4719
|
const stopJob = () => {
|
|
3934
4720
|
const job = askJobs.get(id);
|
|
@@ -4098,6 +4884,12 @@ function askValidateScope(raw) {
|
|
|
4098
4884
|
return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
|
|
4099
4885
|
}
|
|
4100
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
|
+
|
|
4101
4893
|
/** Resolve the VALIDATED client context into the server-side shape
|
|
4102
4894
|
* buildContextHeader consumes (§6.5: server-resolved rows only — never
|
|
4103
4895
|
* client-supplied titles or paths). Every lookup is individually guarded:
|
|
@@ -4130,6 +4922,18 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
4130
4922
|
}
|
|
4131
4923
|
}
|
|
4132
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 */ }
|
|
4133
4937
|
try {
|
|
4134
4938
|
if (ctx.pipelineId) {
|
|
4135
4939
|
const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
|
|
@@ -4159,12 +4963,24 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
4159
4963
|
for (const m of askListMessages(threadId)) {
|
|
4160
4964
|
if (!Array.isArray(m.blocks)) continue;
|
|
4161
4965
|
for (const b of m.blocks) {
|
|
4162
|
-
if (b && b.kind === 'card')
|
|
4163
|
-
|
|
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
|
+
: {
|
|
4164
4981
|
id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
|
|
4165
4982
|
targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
|
|
4166
4983
|
});
|
|
4167
|
-
}
|
|
4168
4984
|
}
|
|
4169
4985
|
}
|
|
4170
4986
|
if (cards.length) out.cards = cards.slice(-ASK_LIMITS.headerCards);
|
|
@@ -4189,7 +5005,164 @@ function mockAskCard(ctx = {}, text = '') {
|
|
|
4189
5005
|
const target = ctx.workspaceId
|
|
4190
5006
|
? { workspaceId: ctx.workspaceId }
|
|
4191
5007
|
: { projectKey: ctx.projectKey || 'mock-project-00000000' };
|
|
4192
|
-
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 };
|
|
4193
5166
|
}
|
|
4194
5167
|
|
|
4195
5168
|
app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
@@ -4270,133 +5243,15 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
4270
5243
|
}
|
|
4271
5244
|
}
|
|
4272
5245
|
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
// composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
|
|
4276
|
-
// reads), so the route is macrotask-atomic and two POSTs cannot interleave
|
|
4277
|
-
// (empirically instrumented). The reservation is what keeps that true if
|
|
4278
|
-
// any of those readers ever becomes genuinely async: it is synchronous —
|
|
4279
|
-
// check-and-set cannot interleave — and runs BEFORE the first write, so a
|
|
4280
|
-
// loser leaves no rows.
|
|
4281
|
-
if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
|
|
4282
|
-
if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
|
|
4283
|
-
if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
|
|
4284
|
-
return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
|
|
4285
|
-
}
|
|
4286
|
-
const prev = askJobs.get(id);
|
|
4287
|
-
if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
|
|
4288
|
-
const job = {
|
|
4289
|
-
turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
|
|
4290
|
-
events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
|
|
4291
|
-
startedAt: new Date().toISOString(), graceTimer: null,
|
|
4292
|
-
};
|
|
4293
|
-
askJobs.set(id, job);
|
|
4294
|
-
|
|
4295
|
-
let asstMsg = null;
|
|
4296
|
-
let turn;
|
|
4297
|
-
let echoAttachments = [];
|
|
4298
|
-
try {
|
|
4299
|
-
// Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
|
|
4300
|
-
// `ctx` (pin-merged) rather than cv.context: the stored row is what restores
|
|
4301
|
-
// the selector on reopen and what the MCP child reads for tool defaulting.
|
|
4302
|
-
askUpdateThread(id, { context: ctx, model: mv.model, effort: mv.effort });
|
|
4303
|
-
// §7.4 — NOTHING is stamped on the row before the 202: the thread stays
|
|
4304
|
-
// untitled (the header reads "Ask Worca") until the D13 background title
|
|
4305
|
-
// announces itself. titleWasAuto gates that call: a title given at THREAD
|
|
4306
|
-
// CREATION is the user's, and the haiku call must never fire for it
|
|
4307
|
-
// (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
|
|
4308
|
-
// empty haiku result (turn.mjs _kickoffTitle), never written here.
|
|
4309
|
-
const titleWasAuto = thread.title == null;
|
|
4310
|
-
const deterministicTitle = titleWasAuto ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
|
|
4311
|
-
const userMsg = askAppendMessage(id, { role: 'user', text });
|
|
4312
|
-
job.userMessageId = userMsg.id;
|
|
4313
|
-
const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
|
|
4314
|
-
// The decoded binary bodies are on disk now. `files` is captured by this
|
|
4315
|
-
// scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
|
|
4316
|
-
// turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
|
|
4317
|
-
// reachable per running thread.
|
|
4318
|
-
for (const f of files) f.data = null;
|
|
4319
|
-
echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
|
|
4320
|
-
if (attRows.length) {
|
|
4321
|
-
// `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
|
|
4322
|
-
// (the UI keys image thumbnails off it, #398).
|
|
4323
|
-
askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })));
|
|
4324
|
-
}
|
|
4325
|
-
broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
|
|
4326
|
-
asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
|
|
4327
|
-
job.messageId = asstMsg.id;
|
|
4328
|
-
|
|
4329
|
-
// Prompt assembly (§6.5) — the route owns it; the turn only spawns.
|
|
4330
|
-
const catalog = await askBuildCatalog();
|
|
4331
|
-
const systemPrompt = askBuildSystemPrompt(catalog);
|
|
4332
|
-
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 }));
|
|
4333
|
-
const { inline, listed } = askSelectInlineAttachments(withText);
|
|
4334
|
-
const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
|
|
4335
|
-
const header = askBuildContextHeader(headerCtx);
|
|
4336
|
-
const prompt = askBuildTurnPrompt(header, text, inline);
|
|
4337
|
-
const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
|
|
4338
|
-
const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
|
|
4339
|
-
const attachmentNames = {};
|
|
4340
|
-
for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
|
|
4341
|
-
|
|
4342
|
-
turn = createAskTurn({
|
|
4343
|
-
threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
|
|
4344
|
-
prompt, systemPrompt, restoredPrompt,
|
|
4345
|
-
model: mv.model, effort: mv.effort,
|
|
4346
|
-
resumeSessionId: thread.sessionId || null,
|
|
4347
|
-
firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
|
|
4348
|
-
firstText: text,
|
|
4349
|
-
deterministicTitle,
|
|
4350
|
-
pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
|
|
4351
|
-
mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
|
|
4352
|
-
attachmentNames,
|
|
4353
|
-
deps: {
|
|
4354
|
-
onFrame: stampAskFrames(id, job),
|
|
4355
|
-
onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
|
|
4356
|
-
onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
|
|
4357
|
-
onWorktreeMutation: () => { emitAskWorktrees(id); },
|
|
4358
|
-
},
|
|
4359
|
-
});
|
|
4360
|
-
job.turn = turn;
|
|
4361
|
-
} catch (err) {
|
|
4362
|
-
// A write/assembly failure must release the reserved slot and never leave
|
|
4363
|
-
// a `streaming` row for the boot sweep to find.
|
|
4364
|
-
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
4365
|
-
if (asstMsg) {
|
|
4366
|
-
try {
|
|
4367
|
-
askFinishMessage(asstMsg.id, {
|
|
4368
|
-
text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
|
|
4369
|
-
status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
|
|
4370
|
-
});
|
|
4371
|
-
} catch { /* thread gone */ }
|
|
4372
|
-
}
|
|
4373
|
-
return res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4374
|
-
}
|
|
4375
|
-
const settleJob = (status) => {
|
|
4376
|
-
if (askJobs.get(id) !== job) return;
|
|
4377
|
-
job.status = status;
|
|
4378
|
-
job.graceTimer = setTimeout(() => {
|
|
4379
|
-
if (askJobs.get(id) === job) askJobs.delete(id);
|
|
4380
|
-
}, ASK_LIMITS.jobGraceMs);
|
|
4381
|
-
job.graceTimer.unref?.();
|
|
4382
|
-
};
|
|
4383
|
-
turn.on('done', () => settleJob('done'));
|
|
4384
|
-
turn.on('error', () => settleJob('error'));
|
|
4385
|
-
// Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
|
|
4386
|
-
Promise.resolve()
|
|
4387
|
-
.then(() => turn.run())
|
|
4388
|
-
.catch((err) => {
|
|
4389
|
-
console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
|
|
4390
|
-
settleJob('error');
|
|
4391
|
-
});
|
|
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 } : {}) });
|
|
4392
5248
|
// `attachments` carries the store-minted ids so the sender's own echo can key
|
|
4393
5249
|
// image thumbnails and the thread budget off them (the ask-message broadcast
|
|
4394
5250
|
// may have raced ahead of this response, or been missed on a brand-new thread).
|
|
4395
|
-
res.status(202).json({ userMessageId:
|
|
5251
|
+
res.status(202).json({ userMessageId: r.userMessageId, assistantMessageId: r.assistantMessageId, attachments: r.attachments });
|
|
4396
5252
|
} catch (err) {
|
|
4397
|
-
//
|
|
4398
|
-
//
|
|
4399
|
-
// 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.
|
|
4400
5255
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4401
5256
|
}
|
|
4402
5257
|
});
|
|
@@ -4423,7 +5278,11 @@ function flipCard(threadId, cardId, patch) {
|
|
|
4423
5278
|
const block = askUpdateCardBlock(threadId, cardId, patch);
|
|
4424
5279
|
if (!block) return null;
|
|
4425
5280
|
const job = askInFlight(threadId);
|
|
4426
|
-
|
|
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;
|
|
4427
5286
|
if (!live) {
|
|
4428
5287
|
const found = askFindCard(threadId, cardId);
|
|
4429
5288
|
if (found) broadcast({ type: 'ask-message', threadId, message: found.message });
|
|
@@ -4431,27 +5290,197 @@ function flipCard(threadId, cardId, patch) {
|
|
|
4431
5290
|
return block;
|
|
4432
5291
|
}
|
|
4433
5292
|
|
|
4434
|
-
//
|
|
4435
|
-
|
|
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) => {
|
|
4436
5414
|
const id = askIdParam(res, req.params.id, 'thread');
|
|
4437
5415
|
if (!id) return;
|
|
4438
5416
|
const cardId = askIdParam(res, req.params.cardId, 'card');
|
|
4439
5417
|
if (!cardId) return;
|
|
4440
5418
|
try {
|
|
4441
5419
|
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
4442
|
-
|
|
5420
|
+
const body = req.body || {};
|
|
4443
5421
|
const found = askFindCard(id, cardId);
|
|
4444
5422
|
if (!found) return res.status(404).json({ error: 'card not found' });
|
|
4445
|
-
if (found.block.
|
|
4446
|
-
|
|
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"');
|
|
4447
5467
|
}
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
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 });
|
|
4455
5484
|
} catch (err) {
|
|
4456
5485
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4457
5486
|
}
|
|
@@ -5247,6 +6276,86 @@ app.post('/api/pipelines/:id/report-result', async (req, res) => {
|
|
|
5247
6276
|
}
|
|
5248
6277
|
});
|
|
5249
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
|
+
|
|
5250
6359
|
// ---------------------------------------------------------------------------
|
|
5251
6360
|
// Install logic (mirrors scripts/install.mjs): copy agents/*.md and
|
|
5252
6361
|
// skills/worca/** into <projectDir>/.claude/...
|
|
@@ -5393,9 +6502,7 @@ app.use((req, res, next) => {
|
|
|
5393
6502
|
if (req.method !== 'GET') return next();
|
|
5394
6503
|
if (req.path.startsWith('/api/') || req.path.startsWith('/ws')) return next();
|
|
5395
6504
|
if (req.path === '/vendor' || req.path.startsWith('/vendor/')) return next();
|
|
5396
|
-
res
|
|
5397
|
-
if (err) next();
|
|
5398
|
-
});
|
|
6505
|
+
sendIndex(res);
|
|
5399
6506
|
});
|
|
5400
6507
|
|
|
5401
6508
|
// ---------------------------------------------------------------------------
|
|
@@ -5601,6 +6708,8 @@ if (isMain) {
|
|
|
5601
6708
|
try { channelHost.start(); } catch (err) {
|
|
5602
6709
|
console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
|
|
5603
6710
|
}
|
|
6711
|
+
try { startTeamMetricsBackground({ log: (m) => console.warn(m) }); }
|
|
6712
|
+
catch (err) { console.warn(`[worca-ui] team metrics background: ${err?.message || err}`); }
|
|
5604
6713
|
});
|
|
5605
6714
|
}
|
|
5606
6715
|
|
|
@@ -5610,5 +6719,6 @@ export const _testing = {
|
|
|
5610
6719
|
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
|
|
5611
6720
|
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
|
|
5612
6721
|
emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
|
|
6722
|
+
askTrackRun, liveRunEntry, liveDefragRun, memoryScopeKey, startRunHandler, emitMemoryChanged, askSystemPromptFor,
|
|
5613
6723
|
uiControl, bearerMatches,
|
|
5614
6724
|
};
|