@worca/app 1.1.1 → 1.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +72 -16
- 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/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 +286 -65
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,7 +90,7 @@ 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';
|
|
90
96
|
import { validateGuardrails } from '../src/core/guardrails.mjs';
|
|
@@ -484,6 +490,9 @@ function summarizeRuns() {
|
|
|
484
490
|
// 'cost_pipeline'/'cost_total') instead of showing a plain "Paused" card
|
|
485
491
|
// until the next event.
|
|
486
492
|
pauseReason: r.pauseReason || null,
|
|
493
|
+
// The clipped failure message behind reason 'error', or null — so a
|
|
494
|
+
// reload/reconnect restores the "Paused · error" detail, not a bare card.
|
|
495
|
+
pauseDetail: r.pauseDetail || null,
|
|
487
496
|
startedAt: r.startedAt,
|
|
488
497
|
pendingQuestion: r.pendingQuestion || null,
|
|
489
498
|
// kind discriminator so the client routes runs vs scans vs agent generations
|
|
@@ -564,14 +573,21 @@ function wireRun(entry) {
|
|
|
564
573
|
// Remember the pause reason for summarizeRuns (hello). Reset on every
|
|
565
574
|
// done so a later reasonless finish cannot leave a stale cost banner.
|
|
566
575
|
entry.pauseReason = (payload && payload.reason) || null;
|
|
576
|
+
// ...and WHAT went wrong for an error-pause, reset alongside it.
|
|
577
|
+
entry.pauseDetail = (payload && payload.detail) || null;
|
|
567
578
|
resolvePending(entry, { reason: entry.status });
|
|
568
579
|
if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
|
|
569
580
|
emitChanged('budget-changed');
|
|
570
581
|
}
|
|
571
582
|
}
|
|
572
583
|
if (name === 'error') {
|
|
573
|
-
|
|
574
|
-
|
|
584
|
+
// The launch-error channel (a failure BEFORE the pipeline row exists). A
|
|
585
|
+
// converted in-run failure pauses and emits no 'error'; never let a stray
|
|
586
|
+
// one demote a parked run.
|
|
587
|
+
if (entry.status !== 'paused' && entry.status !== 'pausing') {
|
|
588
|
+
entry.status = 'error';
|
|
589
|
+
resolvePending(entry, { reason: 'error' });
|
|
590
|
+
}
|
|
575
591
|
}
|
|
576
592
|
if (name === 'exec') {
|
|
577
593
|
entry.status = 'running';
|
|
@@ -738,6 +754,15 @@ app.use((req, res, next) => {
|
|
|
738
754
|
next();
|
|
739
755
|
});
|
|
740
756
|
|
|
757
|
+
// Ask attachments ride base64 inside the message JSON (§7.3), and a binary
|
|
758
|
+
// attachment (#398) may legitimately be 5 MB — several of them blow the app-wide
|
|
759
|
+
// 8mb cap below. Registered BEFORE the global parser on the ONE route that
|
|
760
|
+
// carries uploads (a body parsed here is skipped there): every other ask route
|
|
761
|
+
// reads a string field or nothing and keeps the 8mb window. 64mb covers
|
|
762
|
+
// maxFiles × maxBytesPerBinaryFile at base64's 4/3 inflation, so every
|
|
763
|
+
// over-budget upload still reaches the route's OWN clear 400/413, not a raw
|
|
764
|
+
// parser error.
|
|
765
|
+
app.post('/api/ask/threads/:id/messages', express.json({ limit: '64mb' }));
|
|
741
766
|
app.use(express.json({ limit: '8mb' }));
|
|
742
767
|
|
|
743
768
|
if (HLJS_ASSETS) {
|
|
@@ -2752,6 +2777,8 @@ const settingsState = () => ({
|
|
|
2752
2777
|
costLimitResetPeriod: costLimitResetPeriod(),
|
|
2753
2778
|
askMaxTurns: askMaxTurns(),
|
|
2754
2779
|
askMaxBudgetUsd: askMaxBudgetUsd(),
|
|
2780
|
+
debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
|
|
2781
|
+
debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
|
|
2755
2782
|
});
|
|
2756
2783
|
|
|
2757
2784
|
app.get('/api/settings', (_req, res) => {
|
|
@@ -2767,6 +2794,7 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2767
2794
|
const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
|
|
2768
2795
|
const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
|
|
2769
2796
|
const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
|
|
2797
|
+
const hasDebugSpawnKey = has('debugSpawnEnabled');
|
|
2770
2798
|
// Normalize the budget keys first, then validate them as a SET before ANY write.
|
|
2771
2799
|
// Each setter persists on its own, so a two-key POST whose second key is invalid
|
|
2772
2800
|
// used to answer 400 with the first key already on disk, no budget-changed
|
|
@@ -2787,6 +2815,15 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2787
2815
|
try {
|
|
2788
2816
|
assertCostLimitInputs(budget);
|
|
2789
2817
|
assertAskLimitInputs(ask);
|
|
2818
|
+
if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
|
|
2819
|
+
// Root first: it is the one key whose setter can still fail AFTER the asserts
|
|
2820
|
+
// above (an unusable path), so every other key's write must come after it or
|
|
2821
|
+
// a mixed POST would answer 400 with those keys already applied on disk.
|
|
2822
|
+
// Legacy contract: a POST that names NO known key clears root; the known
|
|
2823
|
+
// keys live beside their setters (SETTINGS_POST_KEYS), not in a list here.
|
|
2824
|
+
if (has('root') || !SETTINGS_POST_KEYS.some(has)) {
|
|
2825
|
+
await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
|
|
2826
|
+
}
|
|
2790
2827
|
if (has('chat')) await setChatPrefs(body.chat);
|
|
2791
2828
|
if (has('projectsRoot')) {
|
|
2792
2829
|
await setProjectsRoot(typeof body.projectsRoot === 'string' ? body.projectsRoot : '');
|
|
@@ -2796,12 +2833,11 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2796
2833
|
if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
|
|
2797
2834
|
if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
|
|
2798
2835
|
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
|
-
}
|
|
2836
|
+
if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
|
|
2804
2837
|
if (hasBudgetKey) emitChanged('budget-changed');
|
|
2838
|
+
// Other open tabs repaint their Settings cards (a stale tab could otherwise
|
|
2839
|
+
// "save" its old checkbox state over this one with no feedback to either).
|
|
2840
|
+
if (hasAskKey || hasDebugSpawnKey) emitChanged('settings-changed');
|
|
2805
2841
|
res.json({ ...settingsState(), chat: chatPrefs() });
|
|
2806
2842
|
} catch (err) {
|
|
2807
2843
|
// The setters throw only on an unusable path -> client error (400).
|
|
@@ -2987,8 +3023,7 @@ app.delete('/api/config/models', async (req, res) => {
|
|
|
2987
3023
|
// back means "keep" and is dropped from the write.
|
|
2988
3024
|
// ---------------------------------------------------------------------------
|
|
2989
3025
|
|
|
2990
|
-
const maskEnvValue = (v) =>
|
|
2991
|
-
(modelEnvRef(v) ? v : (v.length > 8 ? `••••••${v.slice(-4)}` : '••••••'));
|
|
3026
|
+
const maskEnvValue = (v) => (modelEnvRef(v) ? v : maskModelEnvValue(v));
|
|
2992
3027
|
const maskedGlobalModel = (m) => (m.env
|
|
2993
3028
|
? { ...m, env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) }
|
|
2994
3029
|
: m);
|
|
@@ -3563,6 +3598,29 @@ function stampAskFrames(threadId, job) {
|
|
|
3563
3598
|
};
|
|
3564
3599
|
}
|
|
3565
3600
|
|
|
3601
|
+
/** The narrow worktree envelope the snapshot GET and the `ask-worktrees` frame
|
|
3602
|
+
* share (P4 §10): never the full row — threadId/projectDir/updatedAt stay
|
|
3603
|
+
* server-side. Mirrors the list_worktrees MCP tool (src/core/ask/tools.mjs). */
|
|
3604
|
+
function askWorktreesEnvelope(threadId) {
|
|
3605
|
+
return askListWorktrees(threadId).map((w) => ({
|
|
3606
|
+
worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref,
|
|
3607
|
+
commit: w.commit, path: w.path, createdAt: w.createdAt,
|
|
3608
|
+
}));
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
/** Broadcast the thread's CURRENT worktrees as an out-of-turn frame (seq-less,
|
|
3612
|
+
* threadId-tagged, like ask-title). Fed by the turn's onWorktreeMutation hook —
|
|
3613
|
+
* the MCP child opened/removed/navigated a checkout this process never saw —
|
|
3614
|
+
* and by the manual DELETE route, so every tab's count and popover follow
|
|
3615
|
+
* without a snapshot GET. Best effort; false when the thread is gone. */
|
|
3616
|
+
function emitAskWorktrees(threadId) {
|
|
3617
|
+
try {
|
|
3618
|
+
if (!askGetThread(threadId)) return false;
|
|
3619
|
+
broadcast({ type: 'ask-worktrees', threadId, worktrees: askWorktreesEnvelope(threadId) });
|
|
3620
|
+
return true;
|
|
3621
|
+
} catch { return false; } // a poke is best effort
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3566
3624
|
/** 400 on shape (spec §8.1 — a DELIBERATE divergence from the house 404-on-
|
|
3567
3625
|
* malformed-param style), null-return contract like badRequest. */
|
|
3568
3626
|
function askIdParam(res, value, kind) {
|
|
@@ -3578,7 +3636,51 @@ app.get('/api/ask/threads', (req, res) => {
|
|
|
3578
3636
|
const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
|
|
3579
3637
|
const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
|
|
3580
3638
|
const threads = askListThreads({ limit }).map((t) => ({ ...t, inFlight: !!askInFlight(t.id) }));
|
|
3581
|
-
|
|
3639
|
+
// total = EVERY saved chat (the History popover's meter), not the capped page above.
|
|
3640
|
+
res.json({ threads, total: askCountThreads() });
|
|
3641
|
+
} catch (err) {
|
|
3642
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3643
|
+
}
|
|
3644
|
+
});
|
|
3645
|
+
|
|
3646
|
+
// Settings → "Delete all chat history": the counts the confirm dialog quotes,
|
|
3647
|
+
// read fresh right before it opens.
|
|
3648
|
+
app.get('/api/ask/history', (req, res) => {
|
|
3649
|
+
try {
|
|
3650
|
+
res.json({
|
|
3651
|
+
threads: askCountThreads(),
|
|
3652
|
+
worktrees: askCountWorktrees(),
|
|
3653
|
+
attachments: askCountAttachments(),
|
|
3654
|
+
inFlight: askRunningCount(),
|
|
3655
|
+
});
|
|
3656
|
+
} catch (err) {
|
|
3657
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3658
|
+
}
|
|
3659
|
+
});
|
|
3660
|
+
|
|
3661
|
+
// Bulk delete: every thread through deleteAskThreadFully, SEQUENTIALLY (each
|
|
3662
|
+
// worktree removal spawns git — never in parallel), best-effort per thread. The
|
|
3663
|
+
// ids come from listThreadIds (no cap), never from the LIMIT-ed listThreads.
|
|
3664
|
+
// One JSON at the end; then a seq-less out-of-turn frame so every open tab
|
|
3665
|
+
// drops its now-dead st.threadId (the panel would otherwise keep it until the
|
|
3666
|
+
// next 404).
|
|
3667
|
+
app.delete('/api/ask/threads', async (req, res) => {
|
|
3668
|
+
const removed = { threads: 0, worktrees: 0 };
|
|
3669
|
+
const failed = [];
|
|
3670
|
+
try {
|
|
3671
|
+
for (const id of askListThreadIds()) {
|
|
3672
|
+
try {
|
|
3673
|
+
const r = await deleteAskThreadFully(id);
|
|
3674
|
+
if (r.deleted) {
|
|
3675
|
+
removed.threads += 1;
|
|
3676
|
+
removed.worktrees += r.worktrees;
|
|
3677
|
+
} else failed.push(id);
|
|
3678
|
+
} catch {
|
|
3679
|
+
failed.push(id);
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
res.json({ ok: true, removed, failed });
|
|
3683
|
+
broadcast({ type: 'ask-history-cleared' });
|
|
3582
3684
|
} catch (err) {
|
|
3583
3685
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3584
3686
|
}
|
|
@@ -3614,12 +3716,9 @@ app.get('/api/ask/threads/:id', (req, res) => {
|
|
|
3614
3716
|
messages: askListMessages(id),
|
|
3615
3717
|
attachments: askListAttachments(id),
|
|
3616
3718
|
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
|
-
})),
|
|
3719
|
+
// P4 §10: the SAME narrow envelope the list_worktrees MCP tool and the
|
|
3720
|
+
// ask-worktrees frame carry — never the full row.
|
|
3721
|
+
worktrees: askWorktreesEnvelope(id),
|
|
3623
3722
|
inFlight: job && job.messageId ? { messageId: job.messageId } : null, // null while the slot is only reserved
|
|
3624
3723
|
});
|
|
3625
3724
|
} catch (err) {
|
|
@@ -3631,11 +3730,32 @@ app.patch('/api/ask/threads/:id', (req, res) => {
|
|
|
3631
3730
|
const id = askIdParam(res, req.params.id, 'thread');
|
|
3632
3731
|
if (!id) return;
|
|
3633
3732
|
try {
|
|
3634
|
-
const
|
|
3635
|
-
|
|
3636
|
-
|
|
3733
|
+
const body = req.body || {};
|
|
3734
|
+
const patch = {};
|
|
3735
|
+
// Title keeps its original contract exactly: a PATCH that names neither field
|
|
3736
|
+
// still earns the title error, so pre-#397 callers see identical behaviour.
|
|
3737
|
+
if (body.title !== undefined || body.scope === undefined) {
|
|
3738
|
+
const raw = body.title;
|
|
3739
|
+
if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
|
|
3740
|
+
return badRequest(res, 'title must be a non-empty string of at most 120 characters');
|
|
3741
|
+
}
|
|
3742
|
+
patch.title = raw.trim();
|
|
3743
|
+
}
|
|
3744
|
+
if (body.scope !== undefined) {
|
|
3745
|
+
// #397: the Ask panel's scope selector. Merged per field into the stored
|
|
3746
|
+
// context — the pin replaces only the target keys, so the last page
|
|
3747
|
+
// context (view, run, diff file) survives a selector change.
|
|
3748
|
+
const sv = askValidateScope(body.scope);
|
|
3749
|
+
if (!sv.ok) return badRequest(res, sv.error);
|
|
3750
|
+
const cur = askGetThread(id);
|
|
3751
|
+
if (!cur) return res.status(404).json({ error: 'thread not found' });
|
|
3752
|
+
const base = cur.context && typeof cur.context === 'object' && !Array.isArray(cur.context) ? { ...cur.context } : {};
|
|
3753
|
+
delete base.projectDir;
|
|
3754
|
+
delete base.projectKey;
|
|
3755
|
+
delete base.workspaceId;
|
|
3756
|
+
patch.context = { ...base, ...sv.scope };
|
|
3637
3757
|
}
|
|
3638
|
-
const thread = askUpdateThread(id,
|
|
3758
|
+
const thread = askUpdateThread(id, patch);
|
|
3639
3759
|
if (!thread) return res.status(404).json({ error: 'thread not found' });
|
|
3640
3760
|
res.json({ thread });
|
|
3641
3761
|
} catch (err) {
|
|
@@ -3645,13 +3765,13 @@ app.patch('/api/ask/threads/:id', (req, res) => {
|
|
|
3645
3765
|
|
|
3646
3766
|
// §7.5 order: abort the in-flight turn -> detach followers -> remove the chat's
|
|
3647
3767
|
// worktrees git-properly -> delete the row (tx + cascades) + rm -rf inside
|
|
3648
|
-
// deleteThread -> drop the job entry.
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3768
|
+
// deleteThread -> drop the job entry. Shared by the per-thread DELETE and the
|
|
3769
|
+
// bulk DELETE; askDeleting brackets the whole thing per id (POST /messages
|
|
3770
|
+
// refuses the thread while its delete is past the first await).
|
|
3771
|
+
// Returns { deleted, worktrees } — worktrees = rows removeThreadWorktrees removed.
|
|
3772
|
+
async function deleteAskThreadFully(id) {
|
|
3773
|
+
askDeleting.add(id);
|
|
3652
3774
|
try {
|
|
3653
|
-
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3654
|
-
askDeleting.add(id);
|
|
3655
3775
|
const stopJob = () => {
|
|
3656
3776
|
const job = askJobs.get(id);
|
|
3657
3777
|
if (job && job.turn && typeof job.turn.stop === 'function') {
|
|
@@ -3670,20 +3790,30 @@ app.delete('/api/ask/threads/:id', async (req, res) => {
|
|
|
3670
3790
|
// P4 §5: git-proper removal of every worktree BEFORE the row cascade — the
|
|
3671
3791
|
// rmSync inside askDeleteThread alone would leave stale `git worktree`
|
|
3672
3792
|
// registrations in the source repos. Never throws (best-effort per row).
|
|
3673
|
-
await askRemoveThreadWorktrees(id);
|
|
3793
|
+
const { removed } = await askRemoveThreadWorktrees(id);
|
|
3674
3794
|
// Re-read the job AFTER the await: askDeleting blocks new turns, but a turn
|
|
3675
3795
|
// that was already mid-start is stopped here rather than left running.
|
|
3676
3796
|
const job = stopJob();
|
|
3677
|
-
askDeleteThread(id);
|
|
3797
|
+
const deleted = askDeleteThread(id);
|
|
3678
3798
|
if (job) {
|
|
3679
3799
|
if (job.graceTimer) clearTimeout(job.graceTimer);
|
|
3680
3800
|
askJobs.delete(id);
|
|
3681
3801
|
}
|
|
3802
|
+
return { deleted, worktrees: removed };
|
|
3803
|
+
} finally {
|
|
3804
|
+
askDeleting.delete(id);
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
app.delete('/api/ask/threads/:id', async (req, res) => {
|
|
3809
|
+
const id = askIdParam(res, req.params.id, 'thread');
|
|
3810
|
+
if (!id) return;
|
|
3811
|
+
try {
|
|
3812
|
+
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3813
|
+
await deleteAskThreadFully(id);
|
|
3682
3814
|
res.json({ ok: true });
|
|
3683
3815
|
} catch (err) {
|
|
3684
3816
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3685
|
-
} finally {
|
|
3686
|
-
askDeleting.delete(id);
|
|
3687
3817
|
}
|
|
3688
3818
|
});
|
|
3689
3819
|
|
|
@@ -3697,6 +3827,7 @@ app.delete('/api/ask/threads/:id/worktrees/:wtId', async (req, res) => {
|
|
|
3697
3827
|
try {
|
|
3698
3828
|
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3699
3829
|
const out = await askRemoveWorktree({ threadId: id, wtId });
|
|
3830
|
+
emitAskWorktrees(id); // every open tab's count/popover follows the delete
|
|
3700
3831
|
res.json(out);
|
|
3701
3832
|
} catch (err) {
|
|
3702
3833
|
if (err && err.name === 'AskWorktreeError') return res.status(404).json({ error: err.message });
|
|
@@ -3711,11 +3842,34 @@ app.get('/api/ask/threads/:id/attachments/:attId', (req, res) => {
|
|
|
3711
3842
|
if (!attId) return;
|
|
3712
3843
|
try {
|
|
3713
3844
|
if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
|
|
3714
|
-
const att =
|
|
3715
|
-
|
|
3716
|
-
res.
|
|
3717
|
-
|
|
3718
|
-
|
|
3845
|
+
const att = askGetAttachment(id, attId);
|
|
3846
|
+
const file = att ? askAttachmentPath(id, attId) : null;
|
|
3847
|
+
if (!file) return res.status(404).json({ error: 'attachment not found' });
|
|
3848
|
+
// Text bodies serve as utf-8 text/plain (pre-#398, byte-for-byte: the body
|
|
3849
|
+
// was UTF-8-validated at upload and stored verbatim). Only sniff-verified
|
|
3850
|
+
// allowlisted mimes are ever stored (never scriptable markup like SVG/HTML),
|
|
3851
|
+
// so serving the real mime inline is safe — and it is what lets the
|
|
3852
|
+
// transcript render <img> thumbnails (#398).
|
|
3853
|
+
const type = att.kind === 'text' ? 'text/plain; charset=utf-8' : (att.mime || 'application/octet-stream');
|
|
3854
|
+
// Streamed, not readFileSync + send: a body is immutable under its
|
|
3855
|
+
// store-minted id, so a stat-based ETag/Last-Modified plus a year-long
|
|
3856
|
+
// private immutable cache replaces a 5 MB sync read and sha1 per request —
|
|
3857
|
+
// the transcript re-creates every <img> on each structural render.
|
|
3858
|
+
res.sendFile(path.basename(file), {
|
|
3859
|
+
root: path.dirname(file),
|
|
3860
|
+
dotfiles: 'deny',
|
|
3861
|
+
cacheControl: false,
|
|
3862
|
+
headers: {
|
|
3863
|
+
'Content-Type': type,
|
|
3864
|
+
'X-Content-Type-Options': 'nosniff',
|
|
3865
|
+
'Content-Disposition': 'inline',
|
|
3866
|
+
'Cache-Control': 'private, max-age=31536000, immutable',
|
|
3867
|
+
},
|
|
3868
|
+
}, (err) => {
|
|
3869
|
+
if (!err || res.headersSent) return;
|
|
3870
|
+
if (err.code === 'ENOENT' || err.status === 404) return res.status(404).json({ error: 'attachment not found' });
|
|
3871
|
+
res.status(500).json({ error: err.message || String(err) });
|
|
3872
|
+
});
|
|
3719
3873
|
} catch (err) {
|
|
3720
3874
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3721
3875
|
}
|
|
@@ -3755,12 +3909,44 @@ function askRunFromPipelineRow(row) {
|
|
|
3755
3909
|
};
|
|
3756
3910
|
}
|
|
3757
3911
|
|
|
3912
|
+
/** #397: the user-pinned scope of an ask context — {projectKey} | {workspaceId} | null. */
|
|
3913
|
+
function askPinnedScope(context) {
|
|
3914
|
+
if (!context || typeof context !== 'object' || context.pinned !== true) return null;
|
|
3915
|
+
if (typeof context.projectKey === 'string' && context.projectKey) return { projectKey: context.projectKey };
|
|
3916
|
+
if (typeof context.workspaceId === 'string' && context.workspaceId) return { workspaceId: context.workspaceId };
|
|
3917
|
+
return null;
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
/** #397 per-field merge: the pinned scope replaces the page context's TARGET keys
|
|
3921
|
+
* (projectDir/projectKey/workspaceId); view, run, pipeline and diff-file context
|
|
3922
|
+
* still follow the page. */
|
|
3923
|
+
function askApplyPin(ctx, pin) {
|
|
3924
|
+
const out = { ...ctx, pinned: true };
|
|
3925
|
+
delete out.projectDir;
|
|
3926
|
+
delete out.projectKey;
|
|
3927
|
+
delete out.workspaceId;
|
|
3928
|
+
return { ...out, ...pin };
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
/** #397 selector PATCH body: {pinned:false} | {pinned:true, projectKey|workspaceId}. */
|
|
3932
|
+
function askValidateScope(raw) {
|
|
3933
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'scope must be an object' };
|
|
3934
|
+
if (typeof raw.pinned !== 'boolean') return { ok: false, error: 'scope.pinned must be true or false' };
|
|
3935
|
+
if (!raw.pinned) return { ok: true, scope: { pinned: false } };
|
|
3936
|
+
const cv = validateClientContext({ projectKey: raw.projectKey, workspaceId: raw.workspaceId });
|
|
3937
|
+
if (!cv.ok) return { ok: false, error: cv.error.replace('context.', 'scope.') };
|
|
3938
|
+
const keys = ['projectKey', 'workspaceId'].filter((k) => cv.context[k]);
|
|
3939
|
+
if (keys.length !== 1) return { ok: false, error: 'scope needs exactly one of projectKey / workspaceId' };
|
|
3940
|
+
return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3758
3943
|
/** Resolve the VALIDATED client context into the server-side shape
|
|
3759
3944
|
* buildContextHeader consumes (§6.5: server-resolved rows only — never
|
|
3760
3945
|
* client-supplied titles or paths). Every lookup is individually guarded:
|
|
3761
3946
|
* a vanished row degrades to an absent header line, never a 500. */
|
|
3762
3947
|
async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
|
|
3763
3948
|
const out = { now: new Date().toISOString() };
|
|
3949
|
+
if (ctx.pinned === true) out.pinned = true; // #397: rendered as the [pinned by the user] marker
|
|
3764
3950
|
if (ctx.view) out.view = ctx.view;
|
|
3765
3951
|
if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
|
|
3766
3952
|
try {
|
|
@@ -3831,8 +4017,8 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
3831
4017
|
.filter((a) => !currentMessageId || a.messageId !== currentMessageId)
|
|
3832
4018
|
.slice(-ASK_LIMITS.headerAttachments)
|
|
3833
4019
|
.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];
|
|
4020
|
+
.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
|
|
4021
|
+
const atts = [...listedAttachments.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime })), ...earlier];
|
|
3836
4022
|
if (atts.length) out.attachments = atts.slice(0, ASK_LIMITS.headerAttachments);
|
|
3837
4023
|
} catch { /* absent lines */ }
|
|
3838
4024
|
return out;
|
|
@@ -3872,6 +4058,16 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3872
4058
|
if (!mv.ok) return badRequest(res, mv.error);
|
|
3873
4059
|
const cv = validateClientContext(body.context);
|
|
3874
4060
|
if (!cv.ok) return badRequest(res, cv.error);
|
|
4061
|
+
// #397: explicit pin beats page context, per field. A context carrying its own
|
|
4062
|
+
// `pinned` verdict is authoritative — the selector-aware client already merged
|
|
4063
|
+
// (true) or explicitly chose Auto (false). A context WITHOUT one comes from a
|
|
4064
|
+
// pre-selector tab, and inherits the thread's stored pin so a stale tab can
|
|
4065
|
+
// never silently unpin (or re-scope) the conversation.
|
|
4066
|
+
let ctx = cv.context;
|
|
4067
|
+
if (ctx.pinned === undefined) {
|
|
4068
|
+
const inherited = askPinnedScope(thread.context);
|
|
4069
|
+
if (inherited) ctx = askApplyPin(ctx, inherited);
|
|
4070
|
+
}
|
|
3875
4071
|
|
|
3876
4072
|
// §7.3 — validate EVERY attachment before ANY write (all-or-nothing).
|
|
3877
4073
|
const files = [];
|
|
@@ -3885,19 +4081,30 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3885
4081
|
const name = a && typeof a.name === 'string' ? a.name : '';
|
|
3886
4082
|
const dot = name.lastIndexOf('.');
|
|
3887
4083
|
const ext = dot === -1 ? '' : name.slice(dot).toLowerCase();
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
4084
|
+
// #398: the extension CLAIMS a type; text kinds are then proven by UTF-8
|
|
4085
|
+
// decoding (as before), binary kinds by their magic number — a body that
|
|
4086
|
+
// does not match its claim is refused here, before any write.
|
|
4087
|
+
const cls = askClassifyExtension(ext);
|
|
4088
|
+
if (!cls) return badRequest(res, `attachment type not allowed: ${name || '(unnamed)'}`);
|
|
3891
4089
|
const raw = typeof a.dataBase64 === 'string' ? a.dataBase64 : '';
|
|
3892
4090
|
const buf = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);
|
|
3893
4091
|
if (!buf.length) return badRequest(res, `attachment is empty or not valid base64: ${name}`);
|
|
3894
|
-
|
|
3895
|
-
|
|
4092
|
+
const cap = cls.kind === 'text' ? ASK_LIMITS.attachment.maxBytesPerFile : ASK_LIMITS.attachment.maxBytesPerBinaryFile;
|
|
4093
|
+
if (buf.length > cap) {
|
|
4094
|
+
return res.status(413).json({ error: `attachment over ${cap} bytes: ${name}` });
|
|
4095
|
+
}
|
|
4096
|
+
if (cls.kind !== 'text') {
|
|
4097
|
+
const sniffed = askSniffMime(buf);
|
|
4098
|
+
if (sniffed !== cls.mime) {
|
|
4099
|
+
return badRequest(res, `attachment content does not match its extension: ${name}`);
|
|
4100
|
+
}
|
|
4101
|
+
files.push({ name, kind: cls.kind, mime: cls.mime, data: buf, bytes: buf.length });
|
|
4102
|
+
continue;
|
|
3896
4103
|
}
|
|
3897
4104
|
let bodyText;
|
|
3898
4105
|
try { bodyText = dec.decode(buf); } catch { return badRequest(res, `attachment is not valid UTF-8: ${name}`); }
|
|
3899
4106
|
if (bodyText.includes('\u0000')) return badRequest(res, `attachment contains NUL bytes: ${name}`);
|
|
3900
|
-
files.push({ name, text: bodyText, bytes: buf.length });
|
|
4107
|
+
files.push({ name, kind: 'text', mime: cls.mime, text: bodyText, bytes: buf.length });
|
|
3901
4108
|
}
|
|
3902
4109
|
const total = askThreadAttachmentBytes(id) + files.reduce((s, f) => s + f.bytes, 0);
|
|
3903
4110
|
if (total > ASK_LIMITS.attachment.maxBytesPerThread) {
|
|
@@ -3929,24 +4136,33 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3929
4136
|
|
|
3930
4137
|
let asstMsg = null;
|
|
3931
4138
|
let turn;
|
|
4139
|
+
let echoAttachments = [];
|
|
3932
4140
|
try {
|
|
3933
4141
|
// 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
|
-
|
|
4142
|
+
// `ctx` (pin-merged) rather than cv.context: the stored row is what restores
|
|
4143
|
+
// the selector on reopen and what the MCP child reads for tool defaulting.
|
|
4144
|
+
askUpdateThread(id, { context: ctx, model: mv.model, effort: mv.effort });
|
|
4145
|
+
// §7.4 — NOTHING is stamped on the row before the 202: the thread stays
|
|
4146
|
+
// untitled (the header reads "Ask Worca") until the D13 background title
|
|
4147
|
+
// announces itself. titleWasAuto gates that call: a title given at THREAD
|
|
4148
|
+
// CREATION is the user's, and the haiku call must never fire for it
|
|
4149
|
+
// (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
|
|
4150
|
+
// empty haiku result (turn.mjs _kickoffTitle), never written here.
|
|
4151
|
+
const titleWasAuto = thread.title == null;
|
|
4152
|
+
const deterministicTitle = titleWasAuto ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
|
|
3945
4153
|
const userMsg = askAppendMessage(id, { role: 'user', text });
|
|
3946
4154
|
job.userMessageId = userMsg.id;
|
|
3947
|
-
const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, text: f.text }));
|
|
4155
|
+
const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
|
|
4156
|
+
// The decoded binary bodies are on disk now. `files` is captured by this
|
|
4157
|
+
// scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
|
|
4158
|
+
// turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
|
|
4159
|
+
// reachable per running thread.
|
|
4160
|
+
for (const f of files) f.data = null;
|
|
4161
|
+
echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
|
|
3948
4162
|
if (attRows.length) {
|
|
3949
|
-
|
|
4163
|
+
// `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
|
|
4164
|
+
// (the UI keys image thumbnails off it, #398).
|
|
4165
|
+
askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })));
|
|
3950
4166
|
}
|
|
3951
4167
|
broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
|
|
3952
4168
|
asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
|
|
@@ -3955,9 +4171,9 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3955
4171
|
// Prompt assembly (§6.5) — the route owns it; the turn only spawns.
|
|
3956
4172
|
const catalog = await askBuildCatalog();
|
|
3957
4173
|
const systemPrompt = askBuildSystemPrompt(catalog);
|
|
3958
|
-
const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, text: files[i].text }));
|
|
4174
|
+
const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime, text: files[i].text }));
|
|
3959
4175
|
const { inline, listed } = askSelectInlineAttachments(withText);
|
|
3960
|
-
const headerCtx = await resolveAskContext(id,
|
|
4176
|
+
const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
|
|
3961
4177
|
const header = askBuildContextHeader(headerCtx);
|
|
3962
4178
|
const prompt = askBuildTurnPrompt(header, text, inline);
|
|
3963
4179
|
const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
|
|
@@ -3973,12 +4189,14 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
3973
4189
|
firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
|
|
3974
4190
|
firstText: text,
|
|
3975
4191
|
deterministicTitle,
|
|
3976
|
-
|
|
4192
|
+
pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
|
|
4193
|
+
mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
|
|
3977
4194
|
attachmentNames,
|
|
3978
4195
|
deps: {
|
|
3979
4196
|
onFrame: stampAskFrames(id, job),
|
|
3980
4197
|
onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
|
|
3981
4198
|
onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
|
|
4199
|
+
onWorktreeMutation: () => { emitAskWorktrees(id); },
|
|
3982
4200
|
},
|
|
3983
4201
|
});
|
|
3984
4202
|
job.turn = turn;
|
|
@@ -4013,7 +4231,10 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
4013
4231
|
console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
|
|
4014
4232
|
settleJob('error');
|
|
4015
4233
|
});
|
|
4016
|
-
|
|
4234
|
+
// `attachments` carries the store-minted ids so the sender's own echo can key
|
|
4235
|
+
// image thumbnails and the thread budget off them (the ask-message broadcast
|
|
4236
|
+
// may have raced ahead of this response, or been missed on a brand-new thread).
|
|
4237
|
+
res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId, attachments: echoAttachments });
|
|
4017
4238
|
} catch (err) {
|
|
4018
4239
|
// Only pre-reservation throws land here (`job` is block-scoped to the outer
|
|
4019
4240
|
// try and every post-reservation failure returned from the inner catch), so
|
|
@@ -5173,6 +5394,6 @@ export { app, server, runs };
|
|
|
5173
5394
|
export const _testing = {
|
|
5174
5395
|
wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
|
|
5175
5396
|
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
|
|
5176
|
-
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, resolveAskContext, flipCard,
|
|
5177
|
-
emitDiffCommentsChanged,
|
|
5397
|
+
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
|
|
5398
|
+
emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
|
|
5178
5399
|
};
|