@worca/app 1.1.1 → 1.2.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 +23 -1
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +247 -27
- package/src/core/artifacts.mjs +10 -2
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/events.mjs +42 -3
- package/src/core/ask/follow.mjs +10 -4
- package/src/core/ask/limits.mjs +6 -3
- package/src/core/ask/prompt.mjs +37 -12
- package/src/core/ask/spawn.mjs +6 -3
- package/src/core/ask/store.mjs +89 -11
- package/src/core/ask/tool-deps.mjs +27 -3
- package/src/core/ask/tools.mjs +41 -10
- package/src/core/ask/turn.mjs +58 -12
- package/src/core/chat/command-router.mjs +8 -4
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +120 -18
- package/src/core/config.mjs +46 -3
- package/src/core/db.mjs +92 -9
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/graph/scheduler.mjs +8 -1
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +68 -0
- package/src/core/orchestrator.mjs +128 -35
- package/src/core/plugin-shim.mjs +3 -3
- package/src/core/run-harness.mjs +410 -61
- package/src/core/settings.mjs +76 -1
- package/src/core/ui-instance.mjs +235 -0
- package/ui/public/app.js +259 -39
- package/ui/public/ask-model.mjs +60 -7
- package/ui/public/ask-panel.mjs +314 -65
- package/ui/public/index.html +42 -0
- package/ui/public/style.css +28 -0
- package/ui/server.mjs +377 -80
package/ui/server.mjs
CHANGED
|
@@ -41,19 +41,22 @@ import {
|
|
|
41
41
|
setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
|
|
42
42
|
askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
|
|
43
43
|
chatPrefs, setChatPrefs,
|
|
44
|
+
debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
|
|
44
45
|
} from '../src/core/settings.mjs';
|
|
45
46
|
import {
|
|
46
47
|
ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
|
|
47
48
|
listThreads as askListThreads, updateThread as askUpdateThread,
|
|
48
49
|
deleteThread as askDeleteThread, sweepEmptyThreads, sweepStreamingMessages,
|
|
50
|
+
countThreads as askCountThreads, listThreadIds as askListThreadIds,
|
|
51
|
+
countWorktrees as askCountWorktrees, countAttachments as askCountAttachments,
|
|
49
52
|
appendMessage as askAppendMessage, getMessage as askGetMessage,
|
|
50
53
|
listMessages as askListMessages, setMessageBlocks as askSetMessageBlocks,
|
|
51
54
|
findCard as askFindCard, updateCardBlock as askUpdateCardBlock,
|
|
52
55
|
addAttachment as askAddAttachment, listAttachments as askListAttachments,
|
|
53
|
-
|
|
56
|
+
getAttachment as askGetAttachment, attachmentPath as askAttachmentPath, threadAttachmentBytes as askThreadAttachmentBytes,
|
|
54
57
|
linkRun as askLinkRun, updateRunLink as askUpdateRunLink, listRunLinks as askListRunLinks,
|
|
55
58
|
findRunLinksByPipeline as askFindRunLinksByPipeline,
|
|
56
|
-
|
|
59
|
+
finishMessage as askFinishMessage,
|
|
57
60
|
} from '../src/core/ask/store.mjs';
|
|
58
61
|
import { sanitizeTitle as askSanitizeTitle } from '../src/core/title.mjs';
|
|
59
62
|
import { ASK_LIMITS } from '../src/core/ask/limits.mjs';
|
|
@@ -64,6 +67,9 @@ import {
|
|
|
64
67
|
buildTurnPrompt as askBuildTurnPrompt, buildRestoredPrompt as askBuildRestoredPrompt,
|
|
65
68
|
selectInlineAttachments as askSelectInlineAttachments, validateClientContext,
|
|
66
69
|
} from '../src/core/ask/prompt.mjs';
|
|
70
|
+
import {
|
|
71
|
+
classifyExtension as askClassifyExtension, sniffMime as askSniffMime,
|
|
72
|
+
} from '../src/core/ask/attachment-kind.mjs';
|
|
67
73
|
import {
|
|
68
74
|
listAskWorktrees as askListWorktrees,
|
|
69
75
|
removeAskWorktree as askRemoveWorktree,
|
|
@@ -84,9 +90,12 @@ import {
|
|
|
84
90
|
globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
|
|
85
91
|
} from '../src/core/config.mjs';
|
|
86
92
|
import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
|
|
87
|
-
import { modelEnvRef, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
|
|
93
|
+
import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
|
|
88
94
|
import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
|
|
89
95
|
import { testModel } from '../src/core/model-test.mjs';
|
|
96
|
+
import {
|
|
97
|
+
DEFAULT_UI_PORT, UI_HEALTH_NAME, newUiToken, writeUiInstance, removeUiInstance, uiUrl,
|
|
98
|
+
} from '../src/core/ui-instance.mjs';
|
|
90
99
|
import { validateGuardrails } from '../src/core/guardrails.mjs';
|
|
91
100
|
import {
|
|
92
101
|
listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
|
|
@@ -168,6 +177,7 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
|
168
177
|
const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
|
|
169
178
|
const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
|
|
170
179
|
const require = createRequire(import.meta.url);
|
|
180
|
+
const PKG_VERSION = require('../package.json').version;
|
|
171
181
|
const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
|
|
172
182
|
// Primaries plus the sub-language grammars their instances register
|
|
173
183
|
// (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
|
|
@@ -206,7 +216,7 @@ const ASK_VENDOR_ASSETS = {
|
|
|
206
216
|
dompurify: resolveEsmAsset('dompurify'),
|
|
207
217
|
};
|
|
208
218
|
|
|
209
|
-
const PORT = Number(process.env.PORT) ||
|
|
219
|
+
const PORT = Number(process.env.PORT) || DEFAULT_UI_PORT;
|
|
210
220
|
// Bind to loopback by default (S1). Power users who knowingly want LAN exposure
|
|
211
221
|
// can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
|
|
212
222
|
// applies unless they also front it with auth.
|
|
@@ -269,6 +279,11 @@ const MAX_BUFFER = 5000;
|
|
|
269
279
|
const app = express();
|
|
270
280
|
const server = http.createServer(app);
|
|
271
281
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
282
|
+
// ws re-emits the http server's 'error' on the WebSocketServer. With no listener
|
|
283
|
+
// here, an EADDRINUSE on listen() became an unhandled 'error' event and a full
|
|
284
|
+
// stack trace; the http server's own handler (isMain below) is the one that
|
|
285
|
+
// reports it, so this side of the pair only has to not throw.
|
|
286
|
+
wss.on('error', () => {});
|
|
272
287
|
|
|
273
288
|
/** All currently connected sockets. */
|
|
274
289
|
const sockets = new Set();
|
|
@@ -484,6 +499,9 @@ function summarizeRuns() {
|
|
|
484
499
|
// 'cost_pipeline'/'cost_total') instead of showing a plain "Paused" card
|
|
485
500
|
// until the next event.
|
|
486
501
|
pauseReason: r.pauseReason || null,
|
|
502
|
+
// The clipped failure message behind reason 'error', or null — so a
|
|
503
|
+
// reload/reconnect restores the "Paused · error" detail, not a bare card.
|
|
504
|
+
pauseDetail: r.pauseDetail || null,
|
|
487
505
|
startedAt: r.startedAt,
|
|
488
506
|
pendingQuestion: r.pendingQuestion || null,
|
|
489
507
|
// kind discriminator so the client routes runs vs scans vs agent generations
|
|
@@ -564,14 +582,21 @@ function wireRun(entry) {
|
|
|
564
582
|
// Remember the pause reason for summarizeRuns (hello). Reset on every
|
|
565
583
|
// done so a later reasonless finish cannot leave a stale cost banner.
|
|
566
584
|
entry.pauseReason = (payload && payload.reason) || null;
|
|
585
|
+
// ...and WHAT went wrong for an error-pause, reset alongside it.
|
|
586
|
+
entry.pauseDetail = (payload && payload.detail) || null;
|
|
567
587
|
resolvePending(entry, { reason: entry.status });
|
|
568
588
|
if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
|
|
569
589
|
emitChanged('budget-changed');
|
|
570
590
|
}
|
|
571
591
|
}
|
|
572
592
|
if (name === 'error') {
|
|
573
|
-
|
|
574
|
-
|
|
593
|
+
// The launch-error channel (a failure BEFORE the pipeline row exists). A
|
|
594
|
+
// converted in-run failure pauses and emits no 'error'; never let a stray
|
|
595
|
+
// one demote a parked run.
|
|
596
|
+
if (entry.status !== 'paused' && entry.status !== 'pausing') {
|
|
597
|
+
entry.status = 'error';
|
|
598
|
+
resolvePending(entry, { reason: 'error' });
|
|
599
|
+
}
|
|
575
600
|
}
|
|
576
601
|
if (name === 'exec') {
|
|
577
602
|
entry.status = 'running';
|
|
@@ -738,6 +763,15 @@ app.use((req, res, next) => {
|
|
|
738
763
|
next();
|
|
739
764
|
});
|
|
740
765
|
|
|
766
|
+
// Ask attachments ride base64 inside the message JSON (§7.3), and a binary
|
|
767
|
+
// attachment (#398) may legitimately be 5 MB — several of them blow the app-wide
|
|
768
|
+
// 8mb cap below. Registered BEFORE the global parser on the ONE route that
|
|
769
|
+
// carries uploads (a body parsed here is skipped there): every other ask route
|
|
770
|
+
// reads a string field or nothing and keeps the 8mb window. 64mb covers
|
|
771
|
+
// maxFiles × maxBytesPerBinaryFile at base64's 4/3 inflation, so every
|
|
772
|
+
// over-budget upload still reaches the route's OWN clear 400/413, not a raw
|
|
773
|
+
// parser error.
|
|
774
|
+
app.post('/api/ask/threads/:id/messages', express.json({ limit: '64mb' }));
|
|
741
775
|
app.use(express.json({ limit: '8mb' }));
|
|
742
776
|
|
|
743
777
|
if (HLJS_ASSETS) {
|
|
@@ -2752,6 +2786,51 @@ const settingsState = () => ({
|
|
|
2752
2786
|
costLimitResetPeriod: costLimitResetPeriod(),
|
|
2753
2787
|
askMaxTurns: askMaxTurns(),
|
|
2754
2788
|
askMaxBudgetUsd: askMaxBudgetUsd(),
|
|
2789
|
+
debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
|
|
2790
|
+
debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
// ---------------------------------------------------------------------------
|
|
2794
|
+
// Instance lifecycle (`worca ui status|stop|restart`, src/core/ui-instance.mjs)
|
|
2795
|
+
// ---------------------------------------------------------------------------
|
|
2796
|
+
// `uiControl` is set by the boot block below when the server owns a port. Under
|
|
2797
|
+
// test (app imported, no bind) it stays empty: /api/health still answers, and
|
|
2798
|
+
// /api/shutdown refuses with 503 rather than exiting the test runner.
|
|
2799
|
+
const uiControl = { token: null, onShutdown: null, startedAt: null };
|
|
2800
|
+
const startedAtIso = () => uiControl.startedAt || null;
|
|
2801
|
+
|
|
2802
|
+
app.get('/api/health', (req, res) => {
|
|
2803
|
+
const addr = req.socket && req.socket.localPort;
|
|
2804
|
+
res.json({
|
|
2805
|
+
name: UI_HEALTH_NAME,
|
|
2806
|
+
version: PKG_VERSION,
|
|
2807
|
+
pid: process.pid,
|
|
2808
|
+
host: HOST,
|
|
2809
|
+
port: addr || PORT,
|
|
2810
|
+
startedAt: startedAtIso(),
|
|
2811
|
+
});
|
|
2812
|
+
});
|
|
2813
|
+
|
|
2814
|
+
/** Constant-time bearer check; `expected` is the boot-time token from ui.json. */
|
|
2815
|
+
function bearerMatches(header, expected) {
|
|
2816
|
+
if (!expected || typeof header !== 'string') return false;
|
|
2817
|
+
const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
2818
|
+
if (!m) return false;
|
|
2819
|
+
const a = Buffer.from(m[1]);
|
|
2820
|
+
const b = Buffer.from(expected);
|
|
2821
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
app.post('/api/shutdown', (req, res) => {
|
|
2825
|
+
if (!uiControl.token || typeof uiControl.onShutdown !== 'function') {
|
|
2826
|
+
return res.status(503).json({ error: 'shutdown is only available on a server started with `worca ui`' });
|
|
2827
|
+
}
|
|
2828
|
+
if (!bearerMatches(req.headers.authorization, uiControl.token)) {
|
|
2829
|
+
return res.status(401).json({ error: 'shutdown requires the bearer token from the instance file' });
|
|
2830
|
+
}
|
|
2831
|
+
res.status(202).json({ ok: true, pid: process.pid });
|
|
2832
|
+
// Answer first, exit on the next tick so the 202 actually leaves the socket.
|
|
2833
|
+
setImmediate(() => uiControl.onShutdown('request'));
|
|
2755
2834
|
});
|
|
2756
2835
|
|
|
2757
2836
|
app.get('/api/settings', (_req, res) => {
|
|
@@ -2767,6 +2846,7 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2767
2846
|
const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
|
|
2768
2847
|
const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
|
|
2769
2848
|
const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
|
|
2849
|
+
const hasDebugSpawnKey = has('debugSpawnEnabled');
|
|
2770
2850
|
// Normalize the budget keys first, then validate them as a SET before ANY write.
|
|
2771
2851
|
// Each setter persists on its own, so a two-key POST whose second key is invalid
|
|
2772
2852
|
// used to answer 400 with the first key already on disk, no budget-changed
|
|
@@ -2787,6 +2867,15 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2787
2867
|
try {
|
|
2788
2868
|
assertCostLimitInputs(budget);
|
|
2789
2869
|
assertAskLimitInputs(ask);
|
|
2870
|
+
if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
|
|
2871
|
+
// Root first: it is the one key whose setter can still fail AFTER the asserts
|
|
2872
|
+
// above (an unusable path), so every other key's write must come after it or
|
|
2873
|
+
// a mixed POST would answer 400 with those keys already applied on disk.
|
|
2874
|
+
// Legacy contract: a POST that names NO known key clears root; the known
|
|
2875
|
+
// keys live beside their setters (SETTINGS_POST_KEYS), not in a list here.
|
|
2876
|
+
if (has('root') || !SETTINGS_POST_KEYS.some(has)) {
|
|
2877
|
+
await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
|
|
2878
|
+
}
|
|
2790
2879
|
if (has('chat')) await setChatPrefs(body.chat);
|
|
2791
2880
|
if (has('projectsRoot')) {
|
|
2792
2881
|
await setProjectsRoot(typeof body.projectsRoot === 'string' ? body.projectsRoot : '');
|
|
@@ -2796,12 +2885,11 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2796
2885
|
if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
|
|
2797
2886
|
if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
|
|
2798
2887
|
if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
|
|
2799
|
-
|
|
2800
|
-
// keys must not trip it — a budget-only or ask-only save would otherwise wipe the root.
|
|
2801
|
-
if (has('root') || !(has('projectsRoot') || hasBudgetKey || hasAskKey || has('chat'))) {
|
|
2802
|
-
await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
|
|
2803
|
-
}
|
|
2888
|
+
if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
|
|
2804
2889
|
if (hasBudgetKey) emitChanged('budget-changed');
|
|
2890
|
+
// Other open tabs repaint their Settings cards (a stale tab could otherwise
|
|
2891
|
+
// "save" its old checkbox state over this one with no feedback to either).
|
|
2892
|
+
if (hasAskKey || hasDebugSpawnKey) emitChanged('settings-changed');
|
|
2805
2893
|
res.json({ ...settingsState(), chat: chatPrefs() });
|
|
2806
2894
|
} catch (err) {
|
|
2807
2895
|
// The setters throw only on an unusable path -> client error (400).
|
|
@@ -2987,8 +3075,7 @@ app.delete('/api/config/models', async (req, res) => {
|
|
|
2987
3075
|
// back means "keep" and is dropped from the write.
|
|
2988
3076
|
// ---------------------------------------------------------------------------
|
|
2989
3077
|
|
|
2990
|
-
const maskEnvValue = (v) =>
|
|
2991
|
-
(modelEnvRef(v) ? v : (v.length > 8 ? `••••••${v.slice(-4)}` : '••••••'));
|
|
3078
|
+
const maskEnvValue = (v) => (modelEnvRef(v) ? v : maskModelEnvValue(v));
|
|
2992
3079
|
const maskedGlobalModel = (m) => (m.env
|
|
2993
3080
|
? { ...m, env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) }
|
|
2994
3081
|
: m);
|
|
@@ -3563,6 +3650,29 @@ function stampAskFrames(threadId, job) {
|
|
|
3563
3650
|
};
|
|
3564
3651
|
}
|
|
3565
3652
|
|
|
3653
|
+
/** The narrow worktree envelope the snapshot GET and the `ask-worktrees` frame
|
|
3654
|
+
* share (P4 §10): never the full row — threadId/projectDir/updatedAt stay
|
|
3655
|
+
* server-side. Mirrors the list_worktrees MCP tool (src/core/ask/tools.mjs). */
|
|
3656
|
+
function askWorktreesEnvelope(threadId) {
|
|
3657
|
+
return askListWorktrees(threadId).map((w) => ({
|
|
3658
|
+
worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref,
|
|
3659
|
+
commit: w.commit, path: w.path, createdAt: w.createdAt,
|
|
3660
|
+
}));
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
/** Broadcast the thread's CURRENT worktrees as an out-of-turn frame (seq-less,
|
|
3664
|
+
* threadId-tagged, like ask-title). Fed by the turn's onWorktreeMutation hook —
|
|
3665
|
+
* the MCP child opened/removed/navigated a checkout this process never saw —
|
|
3666
|
+
* and by the manual DELETE route, so every tab's count and popover follow
|
|
3667
|
+
* without a snapshot GET. Best effort; false when the thread is gone. */
|
|
3668
|
+
function emitAskWorktrees(threadId) {
|
|
3669
|
+
try {
|
|
3670
|
+
if (!askGetThread(threadId)) return false;
|
|
3671
|
+
broadcast({ type: 'ask-worktrees', threadId, worktrees: askWorktreesEnvelope(threadId) });
|
|
3672
|
+
return true;
|
|
3673
|
+
} catch { return false; } // a poke is best effort
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3566
3676
|
/** 400 on shape (spec §8.1 — a DELIBERATE divergence from the house 404-on-
|
|
3567
3677
|
* malformed-param style), null-return contract like badRequest. */
|
|
3568
3678
|
function askIdParam(res, value, kind) {
|
|
@@ -3578,7 +3688,51 @@ app.get('/api/ask/threads', (req, res) => {
|
|
|
3578
3688
|
const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
|
|
3579
3689
|
const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
|
|
3580
3690
|
const threads = askListThreads({ limit }).map((t) => ({ ...t, inFlight: !!askInFlight(t.id) }));
|
|
3581
|
-
|
|
3691
|
+
// total = EVERY saved chat (the History popover's meter), not the capped page above.
|
|
3692
|
+
res.json({ threads, total: askCountThreads() });
|
|
3693
|
+
} catch (err) {
|
|
3694
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3695
|
+
}
|
|
3696
|
+
});
|
|
3697
|
+
|
|
3698
|
+
// Settings → "Delete all chat history": the counts the confirm dialog quotes,
|
|
3699
|
+
// read fresh right before it opens.
|
|
3700
|
+
app.get('/api/ask/history', (req, res) => {
|
|
3701
|
+
try {
|
|
3702
|
+
res.json({
|
|
3703
|
+
threads: askCountThreads(),
|
|
3704
|
+
worktrees: askCountWorktrees(),
|
|
3705
|
+
attachments: askCountAttachments(),
|
|
3706
|
+
inFlight: askRunningCount(),
|
|
3707
|
+
});
|
|
3708
|
+
} catch (err) {
|
|
3709
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3710
|
+
}
|
|
3711
|
+
});
|
|
3712
|
+
|
|
3713
|
+
// Bulk delete: every thread through deleteAskThreadFully, SEQUENTIALLY (each
|
|
3714
|
+
// worktree removal spawns git — never in parallel), best-effort per thread. The
|
|
3715
|
+
// ids come from listThreadIds (no cap), never from the LIMIT-ed listThreads.
|
|
3716
|
+
// One JSON at the end; then a seq-less out-of-turn frame so every open tab
|
|
3717
|
+
// drops its now-dead st.threadId (the panel would otherwise keep it until the
|
|
3718
|
+
// next 404).
|
|
3719
|
+
app.delete('/api/ask/threads', async (req, res) => {
|
|
3720
|
+
const removed = { threads: 0, worktrees: 0 };
|
|
3721
|
+
const failed = [];
|
|
3722
|
+
try {
|
|
3723
|
+
for (const id of askListThreadIds()) {
|
|
3724
|
+
try {
|
|
3725
|
+
const r = await deleteAskThreadFully(id);
|
|
3726
|
+
if (r.deleted) {
|
|
3727
|
+
removed.threads += 1;
|
|
3728
|
+
removed.worktrees += r.worktrees;
|
|
3729
|
+
} else failed.push(id);
|
|
3730
|
+
} catch {
|
|
3731
|
+
failed.push(id);
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
res.json({ ok: true, removed, failed });
|
|
3735
|
+
broadcast({ type: 'ask-history-cleared' });
|
|
3582
3736
|
} catch (err) {
|
|
3583
3737
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3584
3738
|
}
|
|
@@ -3614,12 +3768,9 @@ app.get('/api/ask/threads/:id', (req, res) => {
|
|
|
3614
3768
|
messages: askListMessages(id),
|
|
3615
3769
|
attachments: askListAttachments(id),
|
|
3616
3770
|
runLinks: askListRunLinks(id),
|
|
3617
|
-
// P4 §10: the SAME narrow envelope the list_worktrees MCP tool
|
|
3618
|
-
// never the full row
|
|
3619
|
-
worktrees:
|
|
3620
|
-
worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref,
|
|
3621
|
-
commit: w.commit, path: w.path, createdAt: w.createdAt,
|
|
3622
|
-
})),
|
|
3771
|
+
// P4 §10: the SAME narrow envelope the list_worktrees MCP tool and the
|
|
3772
|
+
// ask-worktrees frame carry — never the full row.
|
|
3773
|
+
worktrees: askWorktreesEnvelope(id),
|
|
3623
3774
|
inFlight: job && job.messageId ? { messageId: job.messageId } : null, // null while the slot is only reserved
|
|
3624
3775
|
});
|
|
3625
3776
|
} catch (err) {
|
|
@@ -3631,11 +3782,32 @@ app.patch('/api/ask/threads/:id', (req, res) => {
|
|
|
3631
3782
|
const id = askIdParam(res, req.params.id, 'thread');
|
|
3632
3783
|
if (!id) return;
|
|
3633
3784
|
try {
|
|
3634
|
-
const
|
|
3635
|
-
|
|
3636
|
-
|
|
3785
|
+
const body = req.body || {};
|
|
3786
|
+
const patch = {};
|
|
3787
|
+
// Title keeps its original contract exactly: a PATCH that names neither field
|
|
3788
|
+
// still earns the title error, so pre-#397 callers see identical behaviour.
|
|
3789
|
+
if (body.title !== undefined || body.scope === undefined) {
|
|
3790
|
+
const raw = body.title;
|
|
3791
|
+
if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
|
|
3792
|
+
return badRequest(res, 'title must be a non-empty string of at most 120 characters');
|
|
3793
|
+
}
|
|
3794
|
+
patch.title = raw.trim();
|
|
3795
|
+
}
|
|
3796
|
+
if (body.scope !== undefined) {
|
|
3797
|
+
// #397: the Ask panel's scope selector. Merged per field into the stored
|
|
3798
|
+
// context — the pin replaces only the target keys, so the last page
|
|
3799
|
+
// context (view, run, diff file) survives a selector change.
|
|
3800
|
+
const sv = askValidateScope(body.scope);
|
|
3801
|
+
if (!sv.ok) return badRequest(res, sv.error);
|
|
3802
|
+
const cur = askGetThread(id);
|
|
3803
|
+
if (!cur) return res.status(404).json({ error: 'thread not found' });
|
|
3804
|
+
const base = cur.context && typeof cur.context === 'object' && !Array.isArray(cur.context) ? { ...cur.context } : {};
|
|
3805
|
+
delete base.projectDir;
|
|
3806
|
+
delete base.projectKey;
|
|
3807
|
+
delete base.workspaceId;
|
|
3808
|
+
patch.context = { ...base, ...sv.scope };
|
|
3637
3809
|
}
|
|
3638
|
-
const thread = askUpdateThread(id,
|
|
3810
|
+
const thread = askUpdateThread(id, patch);
|
|
3639
3811
|
if (!thread) return res.status(404).json({ error: 'thread not found' });
|
|
3640
3812
|
res.json({ thread });
|
|
3641
3813
|
} catch (err) {
|
|
@@ -3645,13 +3817,13 @@ app.patch('/api/ask/threads/:id', (req, res) => {
|
|
|
3645
3817
|
|
|
3646
3818
|
// §7.5 order: abort the in-flight turn -> detach followers -> remove the chat's
|
|
3647
3819
|
// worktrees git-properly -> delete the row (tx + cascades) + rm -rf inside
|
|
3648
|
-
// deleteThread -> drop the job entry.
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3820
|
+
// deleteThread -> drop the job entry. Shared by the per-thread DELETE and the
|
|
3821
|
+
// bulk DELETE; askDeleting brackets the whole thing per id (POST /messages
|
|
3822
|
+
// refuses the thread while its delete is past the first await).
|
|
3823
|
+
// Returns { deleted, worktrees } — worktrees = rows removeThreadWorktrees removed.
|
|
3824
|
+
async function deleteAskThreadFully(id) {
|
|
3825
|
+
askDeleting.add(id);
|
|
3652
3826
|
try {
|
|
3653
|
-
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3654
|
-
askDeleting.add(id);
|
|
3655
3827
|
const stopJob = () => {
|
|
3656
3828
|
const job = askJobs.get(id);
|
|
3657
3829
|
if (job && job.turn && typeof job.turn.stop === 'function') {
|
|
@@ -3670,20 +3842,30 @@ app.delete('/api/ask/threads/:id', async (req, res) => {
|
|
|
3670
3842
|
// P4 §5: git-proper removal of every worktree BEFORE the row cascade — the
|
|
3671
3843
|
// rmSync inside askDeleteThread alone would leave stale `git worktree`
|
|
3672
3844
|
// registrations in the source repos. Never throws (best-effort per row).
|
|
3673
|
-
await askRemoveThreadWorktrees(id);
|
|
3845
|
+
const { removed } = await askRemoveThreadWorktrees(id);
|
|
3674
3846
|
// Re-read the job AFTER the await: askDeleting blocks new turns, but a turn
|
|
3675
3847
|
// that was already mid-start is stopped here rather than left running.
|
|
3676
3848
|
const job = stopJob();
|
|
3677
|
-
askDeleteThread(id);
|
|
3849
|
+
const deleted = askDeleteThread(id);
|
|
3678
3850
|
if (job) {
|
|
3679
3851
|
if (job.graceTimer) clearTimeout(job.graceTimer);
|
|
3680
3852
|
askJobs.delete(id);
|
|
3681
3853
|
}
|
|
3854
|
+
return { deleted, worktrees: removed };
|
|
3855
|
+
} finally {
|
|
3856
|
+
askDeleting.delete(id);
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
app.delete('/api/ask/threads/:id', async (req, res) => {
|
|
3861
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3862
|
+
if (!id) return;
|
|
3863
|
+
try {
|
|
3864
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3865
|
+
await deleteAskThreadFully(id);
|
|
3682
3866
|
res.json({ ok: true });
|
|
3683
3867
|
} catch (err) {
|
|
3684
3868
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3685
|
-
} finally {
|
|
3686
|
-
askDeleting.delete(id);
|
|
3687
3869
|
}
|
|
3688
3870
|
});
|
|
3689
3871
|
|
|
@@ -3697,6 +3879,7 @@ app.delete('/api/ask/threads/:id/worktrees/:wtId', async (req, res) => {
|
|
|
3697
3879
|
try {
|
|
3698
3880
|
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3699
3881
|
const out = await askRemoveWorktree({ threadId: id, wtId });
|
|
3882
|
+
emitAskWorktrees(id); // every open tab's count/popover follows the delete
|
|
3700
3883
|
res.json(out);
|
|
3701
3884
|
} catch (err) {
|
|
3702
3885
|
if (err && err.name === 'AskWorktreeError') return res.status(404).json({ error: err.message });
|
|
@@ -3711,11 +3894,34 @@ app.get('/api/ask/threads/:id/attachments/:attId', (req, res) => {
|
|
|
3711
3894
|
if (!attId) return;
|
|
3712
3895
|
try {
|
|
3713
3896
|
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3714
|
-
const att =
|
|
3715
|
-
|
|
3716
|
-
res.
|
|
3717
|
-
|
|
3718
|
-
|
|
3897
|
+
const att = askGetAttachment(id, attId);
|
|
3898
|
+
const file = att ? askAttachmentPath(id, attId) : null;
|
|
3899
|
+
if (!file) return res.status(404).json({ error: 'attachment not found' });
|
|
3900
|
+
// Text bodies serve as utf-8 text/plain (pre-#398, byte-for-byte: the body
|
|
3901
|
+
// was UTF-8-validated at upload and stored verbatim). Only sniff-verified
|
|
3902
|
+
// allowlisted mimes are ever stored (never scriptable markup like SVG/HTML),
|
|
3903
|
+
// so serving the real mime inline is safe — and it is what lets the
|
|
3904
|
+
// transcript render <img> thumbnails (#398).
|
|
3905
|
+
const type = att.kind === 'text' ? 'text/plain; charset=utf-8' : (att.mime || 'application/octet-stream');
|
|
3906
|
+
// Streamed, not readFileSync + send: a body is immutable under its
|
|
3907
|
+
// store-minted id, so a stat-based ETag/Last-Modified plus a year-long
|
|
3908
|
+
// private immutable cache replaces a 5 MB sync read and sha1 per request —
|
|
3909
|
+
// the transcript re-creates every <img> on each structural render.
|
|
3910
|
+
res.sendFile(path.basename(file), {
|
|
3911
|
+
root: path.dirname(file),
|
|
3912
|
+
dotfiles: 'deny',
|
|
3913
|
+
cacheControl: false,
|
|
3914
|
+
headers: {
|
|
3915
|
+
'Content-Type': type,
|
|
3916
|
+
'X-Content-Type-Options': 'nosniff',
|
|
3917
|
+
'Content-Disposition': 'inline',
|
|
3918
|
+
'Cache-Control': 'private, max-age=31536000, immutable',
|
|
3919
|
+
},
|
|
3920
|
+
}, (err) => {
|
|
3921
|
+
if (!err || res.headersSent) return;
|
|
3922
|
+
if (err.code === 'ENOENT' || err.status === 404) return res.status(404).json({ error: 'attachment not found' });
|
|
3923
|
+
res.status(500).json({ error: err.message || String(err) });
|
|
3924
|
+
});
|
|
3719
3925
|
} catch (err) {
|
|
3720
3926
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3721
3927
|
}
|
|
@@ -3755,12 +3961,44 @@ function askRunFromPipelineRow(row) {
|
|
|
3755
3961
|
};
|
|
3756
3962
|
}
|
|
3757
3963
|
|
|
3964
|
+
/** #397: the user-pinned scope of an ask context — {projectKey} | {workspaceId} | null. */
|
|
3965
|
+
function askPinnedScope(context) {
|
|
3966
|
+
if (!context || typeof context !== 'object' || context.pinned !== true) return null;
|
|
3967
|
+
if (typeof context.projectKey === 'string' && context.projectKey) return { projectKey: context.projectKey };
|
|
3968
|
+
if (typeof context.workspaceId === 'string' && context.workspaceId) return { workspaceId: context.workspaceId };
|
|
3969
|
+
return null;
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
/** #397 per-field merge: the pinned scope replaces the page context's TARGET keys
|
|
3973
|
+
* (projectDir/projectKey/workspaceId); view, run, pipeline and diff-file context
|
|
3974
|
+
* still follow the page. */
|
|
3975
|
+
function askApplyPin(ctx, pin) {
|
|
3976
|
+
const out = { ...ctx, pinned: true };
|
|
3977
|
+
delete out.projectDir;
|
|
3978
|
+
delete out.projectKey;
|
|
3979
|
+
delete out.workspaceId;
|
|
3980
|
+
return { ...out, ...pin };
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
/** #397 selector PATCH body: {pinned:false} | {pinned:true, projectKey|workspaceId}. */
|
|
3984
|
+
function askValidateScope(raw) {
|
|
3985
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'scope must be an object' };
|
|
3986
|
+
if (typeof raw.pinned !== 'boolean') return { ok: false, error: 'scope.pinned must be true or false' };
|
|
3987
|
+
if (!raw.pinned) return { ok: true, scope: { pinned: false } };
|
|
3988
|
+
const cv = validateClientContext({ projectKey: raw.projectKey, workspaceId: raw.workspaceId });
|
|
3989
|
+
if (!cv.ok) return { ok: false, error: cv.error.replace('context.', 'scope.') };
|
|
3990
|
+
const keys = ['projectKey', 'workspaceId'].filter((k) => cv.context[k]);
|
|
3991
|
+
if (keys.length !== 1) return { ok: false, error: 'scope needs exactly one of projectKey / workspaceId' };
|
|
3992
|
+
return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3758
3995
|
/** Resolve the VALIDATED client context into the server-side shape
|
|
3759
3996
|
* buildContextHeader consumes (§6.5: server-resolved rows only — never
|
|
3760
3997
|
* client-supplied titles or paths). Every lookup is individually guarded:
|
|
3761
3998
|
* a vanished row degrades to an absent header line, never a 500. */
|
|
3762
3999
|
async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
|
|
3763
4000
|
const out = { now: new Date().toISOString() };
|
|
4001
|
+
if (ctx.pinned === true) out.pinned = true; // #397: rendered as the [pinned by the user] marker
|
|
3764
4002
|
if (ctx.view) out.view = ctx.view;
|
|
3765
4003
|
if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
|
|
3766
4004
|
try {
|
|
@@ -3831,8 +4069,8 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
3831
4069
|
.filter((a) => !currentMessageId || a.messageId !== currentMessageId)
|
|
3832
4070
|
.slice(-ASK_LIMITS.headerAttachments)
|
|
3833
4071
|
.reverse()
|
|
3834
|
-
.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes }));
|
|
3835
|
-
const atts = [...listedAttachments.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes })), ...earlier];
|
|
4072
|
+
.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
|
|
4073
|
+
const atts = [...listedAttachments.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime })), ...earlier];
|
|
3836
4074
|
if (atts.length) out.attachments = atts.slice(0, ASK_LIMITS.headerAttachments);
|
|
3837
4075
|
} catch { /* absent lines */ }
|
|
3838
4076
|
return out;
|
|
@@ -3872,6 +4110,16 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3872
4110
|
if (!mv.ok) return badRequest(res, mv.error);
|
|
3873
4111
|
const cv = validateClientContext(body.context);
|
|
3874
4112
|
if (!cv.ok) return badRequest(res, cv.error);
|
|
4113
|
+
// #397: explicit pin beats page context, per field. A context carrying its own
|
|
4114
|
+
// `pinned` verdict is authoritative — the selector-aware client already merged
|
|
4115
|
+
// (true) or explicitly chose Auto (false). A context WITHOUT one comes from a
|
|
4116
|
+
// pre-selector tab, and inherits the thread's stored pin so a stale tab can
|
|
4117
|
+
// never silently unpin (or re-scope) the conversation.
|
|
4118
|
+
let ctx = cv.context;
|
|
4119
|
+
if (ctx.pinned === undefined) {
|
|
4120
|
+
const inherited = askPinnedScope(thread.context);
|
|
4121
|
+
if (inherited) ctx = askApplyPin(ctx, inherited);
|
|
4122
|
+
}
|
|
3875
4123
|
|
|
3876
4124
|
// §7.3 — validate EVERY attachment before ANY write (all-or-nothing).
|
|
3877
4125
|
const files = [];
|
|
@@ -3885,19 +4133,30 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3885
4133
|
const name = a && typeof a.name === 'string' ? a.name : '';
|
|
3886
4134
|
const dot = name.lastIndexOf('.');
|
|
3887
4135
|
const ext = dot === -1 ? '' : name.slice(dot).toLowerCase();
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
4136
|
+
// #398: the extension CLAIMS a type; text kinds are then proven by UTF-8
|
|
4137
|
+
// decoding (as before), binary kinds by their magic number — a body that
|
|
4138
|
+
// does not match its claim is refused here, before any write.
|
|
4139
|
+
const cls = askClassifyExtension(ext);
|
|
4140
|
+
if (!cls) return badRequest(res, `attachment type not allowed: ${name || '(unnamed)'}`);
|
|
3891
4141
|
const raw = typeof a.dataBase64 === 'string' ? a.dataBase64 : '';
|
|
3892
4142
|
const buf = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);
|
|
3893
4143
|
if (!buf.length) return badRequest(res, `attachment is empty or not valid base64: ${name}`);
|
|
3894
|
-
|
|
3895
|
-
|
|
4144
|
+
const cap = cls.kind === 'text' ? ASK_LIMITS.attachment.maxBytesPerFile : ASK_LIMITS.attachment.maxBytesPerBinaryFile;
|
|
4145
|
+
if (buf.length > cap) {
|
|
4146
|
+
return res.status(413).json({ error: `attachment over ${cap} bytes: ${name}` });
|
|
4147
|
+
}
|
|
4148
|
+
if (cls.kind !== 'text') {
|
|
4149
|
+
const sniffed = askSniffMime(buf);
|
|
4150
|
+
if (sniffed !== cls.mime) {
|
|
4151
|
+
return badRequest(res, `attachment content does not match its extension: ${name}`);
|
|
4152
|
+
}
|
|
4153
|
+
files.push({ name, kind: cls.kind, mime: cls.mime, data: buf, bytes: buf.length });
|
|
4154
|
+
continue;
|
|
3896
4155
|
}
|
|
3897
4156
|
let bodyText;
|
|
3898
4157
|
try { bodyText = dec.decode(buf); } catch { return badRequest(res, `attachment is not valid UTF-8: ${name}`); }
|
|
3899
4158
|
if (bodyText.includes('\u0000')) return badRequest(res, `attachment contains NUL bytes: ${name}`);
|
|
3900
|
-
files.push({ name, text: bodyText, bytes: buf.length });
|
|
4159
|
+
files.push({ name, kind: 'text', mime: cls.mime, text: bodyText, bytes: buf.length });
|
|
3901
4160
|
}
|
|
3902
4161
|
const total = askThreadAttachmentBytes(id) + files.reduce((s, f) => s + f.bytes, 0);
|
|
3903
4162
|
if (total > ASK_LIMITS.attachment.maxBytesPerThread) {
|
|
@@ -3929,24 +4188,33 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3929
4188
|
|
|
3930
4189
|
let asstMsg = null;
|
|
3931
4190
|
let turn;
|
|
4191
|
+
let echoAttachments = [];
|
|
3932
4192
|
try {
|
|
3933
4193
|
// Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
4194
|
+
// `ctx` (pin-merged) rather than cv.context: the stored row is what restores
|
|
4195
|
+
// the selector on reopen and what the MCP child reads for tool defaulting.
|
|
4196
|
+
askUpdateThread(id, { context: ctx, model: mv.model, effort: mv.effort });
|
|
4197
|
+
// §7.4 — NOTHING is stamped on the row before the 202: the thread stays
|
|
4198
|
+
// untitled (the header reads "Ask Worca") until the D13 background title
|
|
4199
|
+
// announces itself. titleWasAuto gates that call: a title given at THREAD
|
|
4200
|
+
// CREATION is the user's, and the haiku call must never fire for it
|
|
4201
|
+
// (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
|
|
4202
|
+
// empty haiku result (turn.mjs _kickoffTitle), never written here.
|
|
4203
|
+
const titleWasAuto = thread.title == null;
|
|
4204
|
+
const deterministicTitle = titleWasAuto ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
|
|
3945
4205
|
const userMsg = askAppendMessage(id, { role: 'user', text });
|
|
3946
4206
|
job.userMessageId = userMsg.id;
|
|
3947
|
-
const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, text: f.text }));
|
|
4207
|
+
const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
|
|
4208
|
+
// The decoded binary bodies are on disk now. `files` is captured by this
|
|
4209
|
+
// scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
|
|
4210
|
+
// turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
|
|
4211
|
+
// reachable per running thread.
|
|
4212
|
+
for (const f of files) f.data = null;
|
|
4213
|
+
echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
|
|
3948
4214
|
if (attRows.length) {
|
|
3949
|
-
|
|
4215
|
+
// `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
|
|
4216
|
+
// (the UI keys image thumbnails off it, #398).
|
|
4217
|
+
askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })));
|
|
3950
4218
|
}
|
|
3951
4219
|
broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
|
|
3952
4220
|
asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
|
|
@@ -3955,9 +4223,9 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3955
4223
|
// Prompt assembly (§6.5) — the route owns it; the turn only spawns.
|
|
3956
4224
|
const catalog = await askBuildCatalog();
|
|
3957
4225
|
const systemPrompt = askBuildSystemPrompt(catalog);
|
|
3958
|
-
const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, text: files[i].text }));
|
|
4226
|
+
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 }));
|
|
3959
4227
|
const { inline, listed } = askSelectInlineAttachments(withText);
|
|
3960
|
-
const headerCtx = await resolveAskContext(id,
|
|
4228
|
+
const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
|
|
3961
4229
|
const header = askBuildContextHeader(headerCtx);
|
|
3962
4230
|
const prompt = askBuildTurnPrompt(header, text, inline);
|
|
3963
4231
|
const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
|
|
@@ -3973,12 +4241,14 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3973
4241
|
firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
|
|
3974
4242
|
firstText: text,
|
|
3975
4243
|
deterministicTitle,
|
|
3976
|
-
|
|
4244
|
+
pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
|
|
4245
|
+
mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
|
|
3977
4246
|
attachmentNames,
|
|
3978
4247
|
deps: {
|
|
3979
4248
|
onFrame: stampAskFrames(id, job),
|
|
3980
4249
|
onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
|
|
3981
4250
|
onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
|
|
4251
|
+
onWorktreeMutation: () => { emitAskWorktrees(id); },
|
|
3982
4252
|
},
|
|
3983
4253
|
});
|
|
3984
4254
|
job.turn = turn;
|
|
@@ -4013,7 +4283,10 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
4013
4283
|
console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
|
|
4014
4284
|
settleJob('error');
|
|
4015
4285
|
});
|
|
4016
|
-
|
|
4286
|
+
// `attachments` carries the store-minted ids so the sender's own echo can key
|
|
4287
|
+
// image thumbnails and the thread budget off them (the ask-message broadcast
|
|
4288
|
+
// may have raced ahead of this response, or been missed on a brand-new thread).
|
|
4289
|
+
res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId, attachments: echoAttachments });
|
|
4017
4290
|
} catch (err) {
|
|
4018
4291
|
// Only pre-reservation throws land here (`job` is block-scoped to the outer
|
|
4019
4292
|
// try and every post-reservation failure returned from the inner catch), so
|
|
@@ -5143,36 +5416,60 @@ if (isMain) {
|
|
|
5143
5416
|
console.error(`[worca-ui] boot maintenance failed: ${err && err.message ? err.message : err}`);
|
|
5144
5417
|
});
|
|
5145
5418
|
|
|
5419
|
+
// A port that is already taken is an EXPECTED state (the UI is usually already
|
|
5420
|
+
// up), not a crash: one line, no stack, exit 1. `worca ui` probes the port
|
|
5421
|
+
// before spawning this process and prints the friendlier "already running"
|
|
5422
|
+
// block itself; this branch is for `node ui/server.mjs` run by hand or a race.
|
|
5146
5423
|
server.on('error', (err) => {
|
|
5424
|
+
if (err && err.code === 'EADDRINUSE') {
|
|
5425
|
+
console.error(`[worca-ui] port ${PORT} is already in use — is the UI already running?`);
|
|
5426
|
+
console.error(`[worca-ui] check with \`worca ui status\`, restart with \`worca ui restart\`, or pick a port: \`worca ui --port <n>\``);
|
|
5427
|
+
process.exit(1);
|
|
5428
|
+
}
|
|
5147
5429
|
console.error(`[worca-ui] server error: ${err && err.message ? err.message : err}`);
|
|
5148
5430
|
});
|
|
5149
5431
|
|
|
5432
|
+
// Channel workers must die with the server (design §9: persistent-process
|
|
5433
|
+
// hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit. The
|
|
5434
|
+
// same path serves POST /api/shutdown (`worca ui stop`), which exits 0.
|
|
5435
|
+
let shuttingDown = false;
|
|
5436
|
+
let wroteInstanceFile = false;
|
|
5437
|
+
const exitCodeFor = (signal) => (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 0);
|
|
5438
|
+
const shutdown = (signal) => {
|
|
5439
|
+
if (shuttingDown) return;
|
|
5440
|
+
shuttingDown = true;
|
|
5441
|
+
channelHost.stop().finally(() => process.exit(exitCodeFor(signal)));
|
|
5442
|
+
};
|
|
5443
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
5444
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
5445
|
+
// 'exit' handlers must be synchronous; removeUiInstance is. `ifPid` keeps an
|
|
5446
|
+
// old server exiting late from deleting the file a newer one just wrote.
|
|
5447
|
+
process.on('exit', () => { if (wroteInstanceFile) removeUiInstance({ ifPid: process.pid }); });
|
|
5448
|
+
|
|
5150
5449
|
server.listen(PORT, HOST, () => {
|
|
5151
|
-
const
|
|
5152
|
-
const url =
|
|
5450
|
+
const port = server.address().port;
|
|
5451
|
+
const url = uiUrl({ host: HOST, port });
|
|
5153
5452
|
console.log(`[worca-ui] listening on ${url} (bound to ${HOST})`);
|
|
5154
|
-
|
|
5453
|
+
uiControl.token = newUiToken();
|
|
5454
|
+
uiControl.onShutdown = shutdown;
|
|
5455
|
+
uiControl.startedAt = new Date().toISOString();
|
|
5456
|
+
writeUiInstance({
|
|
5457
|
+
pid: process.pid, host: HOST, port, token: uiControl.token,
|
|
5458
|
+
version: PKG_VERSION, startedAt: uiControl.startedAt,
|
|
5459
|
+
}).then(() => { wroteInstanceFile = true; }, (err) => {
|
|
5460
|
+
console.error(`[worca-ui] could not write the instance file (\`worca ui stop\` will fall back to a signal): ${err && err.message ? err.message : err}`);
|
|
5461
|
+
});
|
|
5155
5462
|
try { channelHost.start(); } catch (err) {
|
|
5156
5463
|
console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
|
|
5157
5464
|
}
|
|
5158
5465
|
});
|
|
5159
|
-
|
|
5160
|
-
// Channel workers must die with the server (design §9: persistent-process
|
|
5161
|
-
// hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit.
|
|
5162
|
-
let shuttingDown = false;
|
|
5163
|
-
const shutdownChat = (signal) => {
|
|
5164
|
-
if (shuttingDown) return;
|
|
5165
|
-
shuttingDown = true;
|
|
5166
|
-
channelHost.stop().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
|
|
5167
|
-
};
|
|
5168
|
-
process.on('SIGINT', () => shutdownChat('SIGINT'));
|
|
5169
|
-
process.on('SIGTERM', () => shutdownChat('SIGTERM'));
|
|
5170
5466
|
}
|
|
5171
5467
|
|
|
5172
5468
|
export { app, server, runs };
|
|
5173
5469
|
export const _testing = {
|
|
5174
5470
|
wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
|
|
5175
5471
|
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
|
|
5176
|
-
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, resolveAskContext, flipCard,
|
|
5177
|
-
emitDiffCommentsChanged,
|
|
5472
|
+
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
|
|
5473
|
+
emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
|
|
5474
|
+
uiControl, bearerMatches,
|
|
5178
5475
|
};
|