@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/src/core/settings.mjs
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
// contextMaxBytesPerFile — §5.4 per-source-file inlining cap.
|
|
12
12
|
// contextMaxBytesTotal — §5.4 total memory budget.
|
|
13
13
|
// skillMount — §5.6 'copy' (default) | 'symlink' (opt-in).
|
|
14
|
+
// debugSpawnEnabled — the stored spawn-diagnostics preference (a UI checkbox).
|
|
15
|
+
// claude-runner.mjs reads it fresh on every spawn through
|
|
16
|
+
// effectiveDebugSpawn(), so it applies to the UI server AND
|
|
17
|
+
// to CLI runs with no restart. A NON-EMPTY WORCA_DEBUG_SPAWN
|
|
18
|
+
// in the process environment overrides it (power-user
|
|
19
|
+
// override); this module never writes process.env.
|
|
14
20
|
// pipelineCostLimitUsd — per-pipeline lifetime USD spend cap; unset = no limit.
|
|
15
21
|
// totalCostLimitUsd — windowed all-pipelines USD spend cap; unset = no limit.
|
|
16
22
|
// costLimitResetPeriod — total-budget window, 'weekly' | 'monthly' (default).
|
|
@@ -42,7 +48,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
|
42
48
|
import { join, resolve } from 'node:path';
|
|
43
49
|
import { homedir } from 'node:os';
|
|
44
50
|
import { randomBytes } from 'node:crypto';
|
|
45
|
-
import { EFFORTS, isReservedModelEnvKey, assertModelCost } from './model-env.mjs';
|
|
51
|
+
import { EFFORTS, isReservedModelEnvKey, assertModelCost, envFlag } from './model-env.mjs';
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
54
|
* The real OS home base, honoring HOME/USERPROFILE so tests can sandbox it.
|
|
@@ -526,6 +532,75 @@ export async function setCostLimitResetPeriod(input) {
|
|
|
526
532
|
return { costLimitResetPeriod: costLimitResetPeriod() };
|
|
527
533
|
}
|
|
528
534
|
|
|
535
|
+
// ── The keys POST /api/settings understands ──────────────────────────────────
|
|
536
|
+
// The route keeps a legacy contract: a body naming NONE of these clears root
|
|
537
|
+
// (test/settings-projects-root.test.mjs "a bodyless POST resets root"). Every
|
|
538
|
+
// setter's key is listed HERE, beside the setters, so a new key cannot forget
|
|
539
|
+
// to join a hand-maintained exclusion list in the route and wipe the root on
|
|
540
|
+
// its first save.
|
|
541
|
+
export const SETTINGS_POST_KEYS = Object.freeze([
|
|
542
|
+
'root', 'projectsRoot', 'chat',
|
|
543
|
+
'pipelineCostLimitUsd', 'totalCostLimitUsd', 'costLimitResetPeriod',
|
|
544
|
+
'askMaxTurns', 'askMaxBudgetUsd',
|
|
545
|
+
'debugSpawnEnabled',
|
|
546
|
+
]);
|
|
547
|
+
|
|
548
|
+
// ── Spawn-debug diagnostics toggle (the stored side of WORCA_DEBUG_SPAWN) ────
|
|
549
|
+
// Like every other stored setting (skillMount, the cost caps, the ask caps) this
|
|
550
|
+
// is READ AT USE TIME: claude-runner.mjs#debugSpawnEnabled calls
|
|
551
|
+
// effectiveDebugSpawn() on every spawn, so a UI save reaches the very next spawn
|
|
552
|
+
// in this process and in any CLI process with no restart, and nothing here ever
|
|
553
|
+
// mutates process.env (a runtime env write would leak an explicit
|
|
554
|
+
// WORCA_DEBUG_SPAWN=0 into every inherited child env and break the runner's
|
|
555
|
+
// "OFF ⇒ byte-identical spawn env" invariant).
|
|
556
|
+
export const DEFAULT_DEBUG_SPAWN_ENABLED = false;
|
|
557
|
+
|
|
558
|
+
const isBool = (v) => typeof v === 'boolean';
|
|
559
|
+
|
|
560
|
+
/** @throws {Error} unless `input` is a boolean (the route and the setter share this). */
|
|
561
|
+
export function assertDebugSpawnInput(input) {
|
|
562
|
+
if (!isBool(input)) throw new Error('debugSpawnEnabled must be true or false');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** STORED spawn-debug preference: boolean, default OFF. Invalid stored value ⇒ OFF (loudly). */
|
|
566
|
+
export function debugSpawnEnabled() {
|
|
567
|
+
const v = readSettings().debugSpawnEnabled;
|
|
568
|
+
if (v === undefined) return DEFAULT_DEBUG_SPAWN_ENABLED;
|
|
569
|
+
if (isBool(v)) return v;
|
|
570
|
+
console.warn(`[worca] invalid debugSpawnEnabled ${JSON.stringify(v)} — using the default (${DEFAULT_DEBUG_SPAWN_ENABLED})`);
|
|
571
|
+
return DEFAULT_DEBUG_SPAWN_ENABLED;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* What the runner will actually do on the next spawn, and why. ONE precedence
|
|
576
|
+
* rule, shared by the runner gate and the settings API: a NON-EMPTY
|
|
577
|
+
* WORCA_DEBUG_SPAWN in the environment wins (parsed with the envFlag rule, so an
|
|
578
|
+
* exported "0"/"false" is an explicit OFF override), otherwise the stored
|
|
579
|
+
* preference applies. An empty export (`export WORCA_DEBUG_SPAWN=` in a profile
|
|
580
|
+
* or a dotenv template) is NOT an override — the runner would read it as OFF
|
|
581
|
+
* while the UI showed the stored value checked, with nothing explaining why.
|
|
582
|
+
* @returns {{enabled: boolean, source: 'env'|'settings'}}
|
|
583
|
+
*/
|
|
584
|
+
export function effectiveDebugSpawn() {
|
|
585
|
+
const v = process.env.WORCA_DEBUG_SPAWN;
|
|
586
|
+
if (v !== undefined && v !== '') return { enabled: envFlag('WORCA_DEBUG_SPAWN'), source: 'env' };
|
|
587
|
+
return { enabled: debugSpawnEnabled(), source: 'settings' };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Persist the preference. Nothing else: the runner reads it back per spawn, so
|
|
592
|
+
* the change is live everywhere without touching this process's environment.
|
|
593
|
+
* @throws {Error} unless `input` is a boolean.
|
|
594
|
+
*/
|
|
595
|
+
export async function setDebugSpawnEnabled(input) {
|
|
596
|
+
assertDebugSpawnInput(input);
|
|
597
|
+
const settings = readSettings();
|
|
598
|
+
if (input === DEFAULT_DEBUG_SPAWN_ENABLED) delete settings.debugSpawnEnabled;
|
|
599
|
+
else settings.debugSpawnEnabled = input;
|
|
600
|
+
await persistSettings(settings);
|
|
601
|
+
return { debugSpawnEnabled: debugSpawnEnabled() };
|
|
602
|
+
}
|
|
603
|
+
|
|
529
604
|
// ---------------------------------------------------------------------------
|
|
530
605
|
// Global model catalog (configurable-models-design.md §4.1). Stored entries are
|
|
531
606
|
// MINIMAL — label only when it differs from id, efforts only when a proper
|
package/ui/public/app.js
CHANGED
|
@@ -626,6 +626,12 @@ function handleServerMessage(msg) {
|
|
|
626
626
|
refreshBudget();
|
|
627
627
|
return;
|
|
628
628
|
}
|
|
629
|
+
// Another tab saved a Settings card: repaint ours from the server so a stale
|
|
630
|
+
// checkbox/field cannot be "saved" back over the change.
|
|
631
|
+
if (msg.type === 'settings-changed') {
|
|
632
|
+
loadSettings();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
629
635
|
if (msg.type === 'projects-changed') {
|
|
630
636
|
refreshAllCounts();
|
|
631
637
|
if (currentView() === 'projects') loadProjectsView();
|
|
@@ -770,6 +776,7 @@ function onHello(msg) {
|
|
|
770
776
|
kind: r0.kind || 'run',
|
|
771
777
|
pipelineId: r0.pipelineId || null,
|
|
772
778
|
pauseReason: r0.pauseReason || null,
|
|
779
|
+
pauseDetail: r0.pauseDetail || null,
|
|
773
780
|
workspaceId: r0.workspaceId || undefined,
|
|
774
781
|
projectNames: Array.isArray(r0.projectNames) && r0.projectNames.length ? r0.projectNames : undefined,
|
|
775
782
|
});
|
|
@@ -898,6 +905,7 @@ function nowHMS() {
|
|
|
898
905
|
function makeRun({
|
|
899
906
|
runId, title, projectDir, status = 'running', startedAt, local = false,
|
|
900
907
|
pendingQuestion = null, kind = 'run', pipelineId = null, pauseReason = null,
|
|
908
|
+
pauseDetail = null,
|
|
901
909
|
workspaceId = undefined, workspaceName = undefined, projectNames = null,
|
|
902
910
|
}) {
|
|
903
911
|
return {
|
|
@@ -912,6 +920,7 @@ function makeRun({
|
|
|
912
920
|
pipelineId, // matches a History row id once persisted; used to hide lingerers from History
|
|
913
921
|
pauseReason, // why it paused, or null — ANY orchestrator pause code rides here
|
|
914
922
|
// (e.g. 'usage_limit'); only the cost pair renders a cost banner
|
|
923
|
+
pauseDetail, // the human-readable cause behind an 'error' pause, or null
|
|
915
924
|
workspaceId,
|
|
916
925
|
workspaceName,
|
|
917
926
|
// Stable ordering key: assigned once per runId, never bumped by activity
|
|
@@ -4054,7 +4063,8 @@ function renderGateBody(r, panel, pq) {
|
|
|
4054
4063
|
}
|
|
4055
4064
|
|
|
4056
4065
|
// Recovery prompt: a node hit a recoverable error (auth / rate-limit / quota /
|
|
4057
|
-
// network). Show the cause and let the user fix it then Retry, or
|
|
4066
|
+
// network). Show the cause and let the user fix it then Retry, or park the run
|
|
4067
|
+
// with Pause run — nothing here ends a run, only Stop does.
|
|
4058
4068
|
function renderRecoveryBody(r, panel, pq) {
|
|
4059
4069
|
const rec = pq.recovery || {};
|
|
4060
4070
|
const intro = document.createElement('div');
|
|
@@ -4062,7 +4072,7 @@ function renderRecoveryBody(r, panel, pq) {
|
|
|
4062
4072
|
const hint = rec.cls === 'auth'
|
|
4063
4073
|
? 'Re-authenticate (e.g. run `claude setup-token` or `/login`), then Retry.'
|
|
4064
4074
|
: 'Fix the problem (wait out a limit, restore connectivity, top up credit), then Retry.';
|
|
4065
|
-
intro.textContent = `This step could not reach the model. ${hint}
|
|
4075
|
+
intro.textContent = `This step could not reach the model. ${hint} Or pause the run and come back later — only Stop ends it.`;
|
|
4066
4076
|
panel.appendChild(intro);
|
|
4067
4077
|
|
|
4068
4078
|
if (rec.message) {
|
|
@@ -4074,15 +4084,21 @@ function renderRecoveryBody(r, panel, pq) {
|
|
|
4074
4084
|
|
|
4075
4085
|
const foot = document.createElement('div');
|
|
4076
4086
|
foot.className = 'qpanel-foot gate-actions';
|
|
4077
|
-
|
|
4078
|
-
abort.
|
|
4079
|
-
|
|
4080
|
-
|
|
4087
|
+
// The give-up option comes from the failure policy's row (options ride the
|
|
4088
|
+
// prompt): 'pause' parks the run, 'abort' ends it. Older payloads carry none.
|
|
4089
|
+
const giveUp = (Array.isArray(rec.options) && rec.options.find((o) => o && o.id !== 'retry')) || { id: 'pause' };
|
|
4090
|
+
const pause = document.createElement('button');
|
|
4091
|
+
pause.type = 'button';
|
|
4092
|
+
pause.className = giveUp.id === 'abort' ? 'btn recovery-abort' : 'btn recovery-pause';
|
|
4093
|
+
pause.textContent = giveUp.id === 'abort' ? 'Abort run' : 'Pause run';
|
|
4094
|
+
pause.title = giveUp.id === 'abort'
|
|
4095
|
+
? 'End the run here with this error. The worktree is torn down.'
|
|
4096
|
+
: 'Park the run here with this error as the reason. Nothing is discarded — Resume retries the step later.';
|
|
4081
4097
|
const retry = document.createElement('button');
|
|
4082
4098
|
retry.type = 'button';
|
|
4083
4099
|
retry.className = 'btn btn-primary recovery-retry';
|
|
4084
4100
|
retry.textContent = 'Retry';
|
|
4085
|
-
foot.append(
|
|
4101
|
+
foot.append(pause, retry);
|
|
4086
4102
|
panel.appendChild(foot);
|
|
4087
4103
|
}
|
|
4088
4104
|
|
|
@@ -4269,6 +4285,9 @@ function onDone(r, msg) {
|
|
|
4269
4285
|
// unconditionally so a later reasonless done clears it, matching the server's
|
|
4270
4286
|
// own entry.pauseReason reset in wireRun.
|
|
4271
4287
|
r.pauseReason = msg.reason || null;
|
|
4288
|
+
// An 'error' pause also carries the cause it parked on; assigned unconditionally
|
|
4289
|
+
// for the same reason as the code above — a later reasonless done must clear it.
|
|
4290
|
+
r.pauseDetail = msg.detail || null;
|
|
4272
4291
|
finishRun(r, msg.status || 'done');
|
|
4273
4292
|
// Nothing else picks up the FINAL spend delta: a non-cost `done` broadcasts no
|
|
4274
4293
|
// budget-changed, and startBudgetTick refetches only while runs are live. Without
|
|
@@ -7682,9 +7701,11 @@ async function loadSettings() {
|
|
|
7682
7701
|
paintSettings(data);
|
|
7683
7702
|
paintBudgetSettings(data);
|
|
7684
7703
|
paintAskSettings(data);
|
|
7704
|
+
paintDebugSpawnSettings(data);
|
|
7685
7705
|
paintBudgetReadout();
|
|
7686
7706
|
refreshBudget();
|
|
7687
7707
|
paintChatSettings(data.chat);
|
|
7708
|
+
loadAskHistory();
|
|
7688
7709
|
setSettingsMsg('');
|
|
7689
7710
|
} catch (e) { setSettingsMsg(e.message, 'err'); }
|
|
7690
7711
|
}
|
|
@@ -7962,10 +7983,27 @@ if (el.budgetReset) {
|
|
|
7962
7983
|
}
|
|
7963
7984
|
|
|
7964
7985
|
// ---- Ask Worca limits card (budget-card pattern above) ---------------------
|
|
7965
|
-
|
|
7966
|
-
|
|
7986
|
+
// Shared by the small Settings cards (Ask Worca, Spawn diagnostics): one hint
|
|
7987
|
+
// setter and one "POST /api/settings → parse → paint or show the error" routine,
|
|
7988
|
+
// so a fix to the fetch/parse/error path lands in every card at once.
|
|
7989
|
+
function setHintMsg(id, text, kind) {
|
|
7990
|
+
const n = document.getElementById(id);
|
|
7967
7991
|
if (n) { n.textContent = text || ''; n.className = `hint${kind ? ` ${kind}` : ''}`; }
|
|
7968
7992
|
}
|
|
7993
|
+
async function postSettingsCard(body, { setMsg, paint, savedText = 'Saved.' }) {
|
|
7994
|
+
setMsg('');
|
|
7995
|
+
let res;
|
|
7996
|
+
try {
|
|
7997
|
+
res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
7998
|
+
} catch (e) { setMsg(e.message || 'network error', 'err'); return; }
|
|
7999
|
+
const data = await safeJson(res);
|
|
8000
|
+
if (!res.ok) { setMsg(data.error || `HTTP ${res.status}`, 'err'); return; }
|
|
8001
|
+
// A 2xx with an unparsable body yields {} — leave the card as the user set it
|
|
8002
|
+
// rather than painting every field as "unset".
|
|
8003
|
+
if (Object.keys(data).length) paint(data);
|
|
8004
|
+
setMsg(savedText);
|
|
8005
|
+
}
|
|
8006
|
+
function setAskLimitsMsg(text, kind) { setHintMsg('askLimitsMsg', text, kind); }
|
|
7969
8007
|
function paintAskSettings(data) {
|
|
7970
8008
|
const turns = document.getElementById('askMaxTurns');
|
|
7971
8009
|
const budget = document.getElementById('askMaxBudgetUsd');
|
|
@@ -7976,17 +8014,8 @@ function paintAskSettings(data) {
|
|
|
7976
8014
|
budget.disabled = noCap.checked;
|
|
7977
8015
|
budget.value = data.askMaxBudgetUsd == null ? '' : String(data.askMaxBudgetUsd);
|
|
7978
8016
|
}
|
|
7979
|
-
|
|
7980
|
-
setAskLimitsMsg
|
|
7981
|
-
let res = null;
|
|
7982
|
-
try {
|
|
7983
|
-
res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
7984
|
-
} catch { setAskLimitsMsg('network error', 'err'); return; }
|
|
7985
|
-
let data = null;
|
|
7986
|
-
try { data = await res.json(); } catch { data = null; }
|
|
7987
|
-
if (!res.ok) { setAskLimitsMsg((data && data.error) || `save failed (${res.status})`, 'err'); return; }
|
|
7988
|
-
paintAskSettings(data || {});
|
|
7989
|
-
setAskLimitsMsg('Saved.');
|
|
8017
|
+
function postAskLimits(body) {
|
|
8018
|
+
return postSettingsCard(body, { setMsg: setAskLimitsMsg, paint: paintAskSettings });
|
|
7990
8019
|
}
|
|
7991
8020
|
function saveAskLimits() {
|
|
7992
8021
|
const turnsRaw = document.getElementById('askMaxTurns').value.trim();
|
|
@@ -8014,6 +8043,108 @@ document.getElementById('askNoCap')?.addEventListener('change', () => {
|
|
|
8014
8043
|
if (budget) budget.disabled = document.getElementById('askNoCap').checked;
|
|
8015
8044
|
});
|
|
8016
8045
|
|
|
8046
|
+
// ---- Ask Worca chat history (same card, same hint pattern) -----------------
|
|
8047
|
+
// The counts line paints with the settings view; the destructive button refetches
|
|
8048
|
+
// GET /api/ask/history so the dialog quotes what is about to go, then one bulk
|
|
8049
|
+
// DELETE /api/ask/threads. The server broadcasts ask-history-cleared afterwards
|
|
8050
|
+
// — the ask panel resets itself off that frame, nothing to do here.
|
|
8051
|
+
function setAskHistoryMsg(text, kind) {
|
|
8052
|
+
const n = document.getElementById('askHistoryMsg');
|
|
8053
|
+
if (n) { n.textContent = text || ''; n.className = `hint${kind ? ` ${kind}` : ''}`; }
|
|
8054
|
+
}
|
|
8055
|
+
function askHistoryCount(n, one, many = `${one}s`) {
|
|
8056
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
8057
|
+
}
|
|
8058
|
+
function normalizeAskHistory(data) {
|
|
8059
|
+
const num = (v) => (Number.isInteger(v) && v > 0 ? v : 0);
|
|
8060
|
+
return { threads: num(data?.threads), worktrees: num(data?.worktrees), attachments: num(data?.attachments), inFlight: num(data?.inFlight) };
|
|
8061
|
+
}
|
|
8062
|
+
function paintAskHistory(counts) {
|
|
8063
|
+
const line = document.getElementById('askHistoryCounts');
|
|
8064
|
+
const btn = document.getElementById('askHistoryDelete');
|
|
8065
|
+
if (!line || !btn) return;
|
|
8066
|
+
line.textContent = counts.threads
|
|
8067
|
+
? `${askHistoryCount(counts.threads, 'chat')} · ${askHistoryCount(counts.worktrees, 'worktree')}`
|
|
8068
|
+
: 'No saved chats.';
|
|
8069
|
+
btn.disabled = counts.threads === 0;
|
|
8070
|
+
}
|
|
8071
|
+
async function fetchAskHistory() {
|
|
8072
|
+
const res = await fetch('/api/ask/history');
|
|
8073
|
+
const data = await safeJson(res);
|
|
8074
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
8075
|
+
return normalizeAskHistory(data);
|
|
8076
|
+
}
|
|
8077
|
+
async function loadAskHistory() {
|
|
8078
|
+
if (!document.getElementById('askHistoryCounts')) return;
|
|
8079
|
+
try { paintAskHistory(await fetchAskHistory()); } catch (e) { setAskHistoryMsg(e.message, 'err'); }
|
|
8080
|
+
}
|
|
8081
|
+
// One bullet per non-zero count; the in-progress line only when a turn is live.
|
|
8082
|
+
function askHistoryConfirmMessage(c) {
|
|
8083
|
+
const lines = ['This permanently deletes all Ask Worca chat history:'];
|
|
8084
|
+
if (c.threads) lines.push(`• ${askHistoryCount(c.threads, 'chat thread')} and ${c.threads === 1 ? 'its transcript' : 'their transcripts'}`);
|
|
8085
|
+
if (c.worktrees) {
|
|
8086
|
+
lines.push(`• ${askHistoryCount(c.worktrees, 'git worktree')} checked out for ${c.threads === 1 ? 'that chat' : 'those chats'} (removed from ${c.worktrees === 1 ? 'its source repo' : 'their source repos'})`);
|
|
8087
|
+
}
|
|
8088
|
+
if (c.attachments) lines.push(`• ${askHistoryCount(c.attachments, 'attachment')}`);
|
|
8089
|
+
if (c.inFlight) lines.push(`• ${askHistoryCount(c.inFlight, 'chat')} currently in progress will be stopped`);
|
|
8090
|
+
lines.push('Runs started from these chats are not affected. This cannot be undone.');
|
|
8091
|
+
return lines.join('\n');
|
|
8092
|
+
}
|
|
8093
|
+
async function deleteAskHistory() {
|
|
8094
|
+
setAskHistoryMsg('');
|
|
8095
|
+
let counts;
|
|
8096
|
+
try { counts = await fetchAskHistory(); } catch (e) { setAskHistoryMsg(e.message, 'err'); return; }
|
|
8097
|
+
paintAskHistory(counts);
|
|
8098
|
+
if (!counts.threads) return; // emptied meanwhile — nothing to confirm
|
|
8099
|
+
const ok = await confirmModal({
|
|
8100
|
+
title: 'Delete all chat history?',
|
|
8101
|
+
message: askHistoryConfirmMessage(counts),
|
|
8102
|
+
confirmLabel: 'Delete everything',
|
|
8103
|
+
danger: true,
|
|
8104
|
+
});
|
|
8105
|
+
if (!ok) return;
|
|
8106
|
+
let res = null;
|
|
8107
|
+
try {
|
|
8108
|
+
res = await fetch('/api/ask/threads', { method: 'DELETE' });
|
|
8109
|
+
} catch { setAskHistoryMsg('network error', 'err'); return; }
|
|
8110
|
+
const data = await safeJson(res);
|
|
8111
|
+
if (!res.ok) { setAskHistoryMsg(data.error || `HTTP ${res.status}`, 'err'); await loadAskHistory(); return; }
|
|
8112
|
+
const removed = normalizeAskHistory(data.removed);
|
|
8113
|
+
const failed = Array.isArray(data.failed) ? data.failed.length : 0;
|
|
8114
|
+
const summary = `Deleted ${askHistoryCount(removed.threads, 'chat')} and ${askHistoryCount(removed.worktrees, 'worktree')}.`;
|
|
8115
|
+
if (failed) setAskHistoryMsg(`${summary} ${askHistoryCount(failed, 'chat')} could not be removed.`, 'err');
|
|
8116
|
+
else setAskHistoryMsg(summary);
|
|
8117
|
+
await loadAskHistory();
|
|
8118
|
+
}
|
|
8119
|
+
document.getElementById('askHistoryDelete')?.addEventListener('click', deleteAskHistory);
|
|
8120
|
+
|
|
8121
|
+
// ---- Spawn-debug diagnostics card (shares the Ask card's helpers above) ----
|
|
8122
|
+
function setDebugSpawnMsg(text, kind) { setHintMsg('debugSpawnMsg', text, kind); }
|
|
8123
|
+
// The checkbox is the STORED preference; the note says when the environment
|
|
8124
|
+
// overrides it (a non-empty WORCA_DEBUG_SPAWN at launch), so an operator never
|
|
8125
|
+
// sees an unchecked box while diagnostics are flowing — or the reverse.
|
|
8126
|
+
function paintDebugSpawnSettings(data) {
|
|
8127
|
+
const cb = document.getElementById('debugSpawnEnabled');
|
|
8128
|
+
if (!cb) return;
|
|
8129
|
+
cb.checked = !!data.debugSpawnEnabled;
|
|
8130
|
+
const eff = data.debugSpawnEffective;
|
|
8131
|
+
const envOverride = !!eff && eff.source === 'env';
|
|
8132
|
+
setHintMsg('debugSpawnEnvNote', envOverride
|
|
8133
|
+
? `WORCA_DEBUG_SPAWN is set in the environment: diagnostics are ${eff.enabled ? 'ON' : 'OFF'} regardless of this setting.`
|
|
8134
|
+
: '', envOverride ? 'warn' : '');
|
|
8135
|
+
}
|
|
8136
|
+
function postDebugSpawn(body) {
|
|
8137
|
+
return postSettingsCard(body, {
|
|
8138
|
+
setMsg: setDebugSpawnMsg, paint: paintDebugSpawnSettings,
|
|
8139
|
+
savedText: 'Saved. Applies to the next spawn — no restart needed.',
|
|
8140
|
+
});
|
|
8141
|
+
}
|
|
8142
|
+
function saveDebugSpawn() {
|
|
8143
|
+
postDebugSpawn({ debugSpawnEnabled: document.getElementById('debugSpawnEnabled').checked });
|
|
8144
|
+
}
|
|
8145
|
+
document.getElementById('debugSpawnSave')?.addEventListener('click', saveDebugSpawn);
|
|
8146
|
+
document.getElementById('debugSpawnReset')?.addEventListener('click', () => postDebugSpawn({ debugSpawnEnabled: false }));
|
|
8147
|
+
|
|
8017
8148
|
// Browse… for the projects root: native OS dialog, in-app modal fallback —
|
|
8018
8149
|
// the same two endpoints the add-project Browse button uses (app.js:3793).
|
|
8019
8150
|
if (el.settingsProjectsRootBrowse) {
|
|
@@ -9435,7 +9566,7 @@ if (runListEl) {
|
|
|
9435
9566
|
|
|
9436
9567
|
// qpanel actions. Resolve the run per-card via the enclosing .run-card so
|
|
9437
9568
|
// delegation works for any dynamically-built card.
|
|
9438
|
-
const qbtn = e.target.closest && e.target.closest('.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-abort');
|
|
9569
|
+
const qbtn = e.target.closest && e.target.closest('.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-pause, .qpanel .recovery-abort');
|
|
9439
9570
|
if (qbtn) {
|
|
9440
9571
|
const card = qbtn.closest('.run-card');
|
|
9441
9572
|
const runId = card && card.dataset.runId;
|
|
@@ -9444,6 +9575,7 @@ if (runListEl) {
|
|
|
9444
9575
|
if (qbtn.classList.contains('gate-continue')) postAnswer(r, { decision: 'continue' });
|
|
9445
9576
|
else if (qbtn.classList.contains('gate-another')) postAnswer(r, { decision: 'another' });
|
|
9446
9577
|
else if (qbtn.classList.contains('recovery-retry')) postAnswer(r, { decision: 'retry' });
|
|
9578
|
+
else if (qbtn.classList.contains('recovery-pause')) postAnswer(r, { decision: 'pause' });
|
|
9447
9579
|
else if (qbtn.classList.contains('recovery-abort')) postAnswer(r, { decision: 'abort' });
|
|
9448
9580
|
else submitAnswer(r, qbtn.closest('.qpanel'));
|
|
9449
9581
|
}
|
|
@@ -10288,12 +10420,19 @@ const PAUSED_STATUSES = ['paused', 'pausing', 'interrupted'];
|
|
|
10288
10420
|
// Disable a history Resume button while a total-budget pause is still blocked by
|
|
10289
10421
|
// the current window. Shared by setupHdActions (first paint) and
|
|
10290
10422
|
// refreshHistResumeGating (every later budget change).
|
|
10291
|
-
function applyHistResumeGate(btn, pauseReason, budget) {
|
|
10423
|
+
function applyHistResumeGate(btn, pauseReason, budget, pauseDetail = '') {
|
|
10292
10424
|
const totalBlocked = pauseReason === 'cost_total' && !!(budget && budget.blocked);
|
|
10293
10425
|
btn.disabled = totalBlocked;
|
|
10294
|
-
|
|
10295
|
-
|
|
10296
|
-
|
|
10426
|
+
if (totalBlocked) {
|
|
10427
|
+
btn.title = `Total budget reached — blocked until ${fmtResetAtLocal(budget.windowEndMs)} or a higher total limit`;
|
|
10428
|
+
} else if (pauseReason === 'error' || pauseReason === 'recoverable') {
|
|
10429
|
+
// An error pause is never gated — the tooltip carries the cause instead.
|
|
10430
|
+
btn.title = pauseReason === 'recoverable'
|
|
10431
|
+
? `Paused on a recoverable error${pauseDetail ? `: ${pauseDetail}` : ''} — resume once it clears`
|
|
10432
|
+
: `Paused after an error${pauseDetail ? `: ${pauseDetail}` : ''} — fix the cause, then resume`;
|
|
10433
|
+
} else {
|
|
10434
|
+
btn.title = '';
|
|
10435
|
+
}
|
|
10297
10436
|
}
|
|
10298
10437
|
|
|
10299
10438
|
// Re-gate the mounted history Resume button from the dataset.pauseReason stamp
|
|
@@ -10312,7 +10451,7 @@ function refreshHistResumeGating() {
|
|
|
10312
10451
|
// otherwise a `cost_total` block that lands later leaves the button enabled and
|
|
10313
10452
|
// the user clicks into a guaranteed 403. Re-gate, then restore the D3 error
|
|
10314
10453
|
// title when gating did not take the button away.
|
|
10315
|
-
applyHistResumeGate(btn, root.dataset.pauseReason || '', budgetState.budget);
|
|
10454
|
+
applyHistResumeGate(btn, root.dataset.pauseReason || '', budgetState.budget, root.dataset.pauseDetail || '');
|
|
10316
10455
|
if (btn.dataset.resumeState === 'error' && btn.dataset.resumeError && !btn.disabled) {
|
|
10317
10456
|
btn.title = btn.dataset.resumeError;
|
|
10318
10457
|
}
|
|
@@ -10434,14 +10573,21 @@ function buildHistCard(projectDir, p, ghAvailable = false) {
|
|
|
10434
10573
|
// Pause note. Resume + its budget gating live on the detail page now; the
|
|
10435
10574
|
// dataset stamp survives for parity/debugging.
|
|
10436
10575
|
const pauseReason = typeof p.pauseReason === 'string' ? p.pauseReason : '';
|
|
10576
|
+
const pauseDetail = typeof p.pauseDetail === 'string' ? p.pauseDetail : '';
|
|
10437
10577
|
if (pauseReason) node.dataset.pauseReason = pauseReason;
|
|
10578
|
+
if (pauseDetail) node.dataset.pauseDetail = pauseDetail;
|
|
10438
10579
|
const noteEl = node.querySelector('.hist-pausenote');
|
|
10439
|
-
const
|
|
10440
|
-
|
|
10441
|
-
|
|
10580
|
+
const parked = PAUSED_STATUSES.includes(String(p.status || '').toLowerCase());
|
|
10581
|
+
const costPaused = parked && pauseReason.startsWith('cost_');
|
|
10582
|
+
const errorPaused = parked && (pauseReason === 'error' || pauseReason === 'recoverable');
|
|
10583
|
+
noteEl.hidden = !(costPaused || errorPaused);
|
|
10442
10584
|
noteEl.textContent = costPaused
|
|
10443
|
-
? (pauseReason === 'cost_total' ? 'paused · total budget' : 'paused · cost limit')
|
|
10585
|
+
? (pauseReason === 'cost_total' ? 'paused · total budget' : 'paused · cost limit')
|
|
10586
|
+
: (errorPaused ? (pauseReason === 'recoverable' ? 'paused · recoverable' : 'paused · error') : '');
|
|
10587
|
+
// The cause is too long for the caption line — it rides as the tooltip.
|
|
10588
|
+
noteEl.title = errorPaused ? pauseDetail : '';
|
|
10444
10589
|
noteEl.classList.toggle('total', costPaused && pauseReason === 'cost_total');
|
|
10590
|
+
noteEl.classList.toggle('error', errorPaused);
|
|
10445
10591
|
|
|
10446
10592
|
renderRetainedWork(node, p); // badge only — the card has no banner node
|
|
10447
10593
|
setupPrButton(node, projectDir, p, ghAvailable);
|
|
@@ -11466,14 +11612,48 @@ function hdRetainedFor(record, st) {
|
|
|
11466
11612
|
return { retained: derived, provisional: true };
|
|
11467
11613
|
}
|
|
11468
11614
|
|
|
11615
|
+
// Error-pause banner (D11): the cause, and the promise that nothing was discarded.
|
|
11616
|
+
function renderErrorPauseBanner(detail, reason = 'error') {
|
|
11617
|
+
const recoverable = reason === 'recoverable';
|
|
11618
|
+
const el = document.createElement('div');
|
|
11619
|
+
el.className = 'pause-error-banner';
|
|
11620
|
+
el.dataset.reason = reason;
|
|
11621
|
+
const b = document.createElement('b');
|
|
11622
|
+
b.textContent = recoverable ? 'Paused on a recoverable error' : 'Paused after an error';
|
|
11623
|
+
const text = document.createElement('div');
|
|
11624
|
+
text.className = 'peb-text';
|
|
11625
|
+
text.textContent = detail || 'The run hit an error it could not recover from.';
|
|
11626
|
+
const hint = document.createElement('div');
|
|
11627
|
+
hint.className = 'peb-hint';
|
|
11628
|
+
hint.textContent = recoverable
|
|
11629
|
+
? 'The run could not reach the model and parked itself. Nothing was discarded — once the cause clears (re-login, connectivity, credit, a rate limit), Resume retries the step.'
|
|
11630
|
+
: 'Nothing was discarded: the worktree and the run position are kept. Fix the cause, then Resume.';
|
|
11631
|
+
el.append(b, text, hint);
|
|
11632
|
+
return el;
|
|
11633
|
+
}
|
|
11634
|
+
|
|
11469
11635
|
function paintHdBanners(screen, record, data) {
|
|
11470
11636
|
const st = data.state;
|
|
11471
11637
|
const banners = screen.querySelector('.hd-banners');
|
|
11472
11638
|
|
|
11473
|
-
//
|
|
11474
|
-
//
|
|
11475
|
-
|
|
11639
|
+
// Pause banners. The LIST row is authoritative, but a deep link has only the
|
|
11640
|
+
// stub until the row lands — so fall back to the DETAIL payload, which now
|
|
11641
|
+
// carries both keys too. Rebuild idempotently instead of once.
|
|
11642
|
+
const pauseReason = typeof record.pauseReason === 'string' ? record.pauseReason
|
|
11643
|
+
: (typeof st.pauseReason === 'string' ? st.pauseReason : '');
|
|
11476
11644
|
if (pauseReason) screen.dataset.pauseReason = pauseReason; else delete screen.dataset.pauseReason;
|
|
11645
|
+
const pauseDetail = typeof record.pauseDetail === 'string' ? record.pauseDetail
|
|
11646
|
+
: (typeof st.pauseDetail === 'string' ? st.pauseDetail : '');
|
|
11647
|
+
if (pauseDetail) screen.dataset.pauseDetail = pauseDetail; else delete screen.dataset.pauseDetail;
|
|
11648
|
+
// Keyed on the detail so a corrected cause replaces the banner instead of stacking.
|
|
11649
|
+
const wantErr = (pauseReason === 'error' || pauseReason === 'recoverable') && HD_RESUMABLE.has(String(st.status || '').toLowerCase());
|
|
11650
|
+
const oldErr = banners.querySelector('.pause-error-banner');
|
|
11651
|
+
if (oldErr && (!wantErr || oldErr.dataset.detail !== pauseDetail || oldErr.dataset.reason !== pauseReason)) oldErr.remove();
|
|
11652
|
+
if (wantErr && !banners.querySelector('.pause-error-banner')) {
|
|
11653
|
+
const errBanner = renderErrorPauseBanner(pauseDetail, pauseReason);
|
|
11654
|
+
errBanner.dataset.detail = pauseDetail;
|
|
11655
|
+
banners.prepend(errBanner);
|
|
11656
|
+
}
|
|
11477
11657
|
// Rebuild the cost banner ONLY when the reason actually changed. An
|
|
11478
11658
|
// unconditional remove+rebuild detaches the `.cb-override` button mid-flight:
|
|
11479
11659
|
// that click awaits confirmModal then resumePipeline, and ANY paintHistory()
|
|
@@ -11585,7 +11765,7 @@ function setupHdActions(screen, record, data) {
|
|
|
11585
11765
|
const resumeBtn = screen.querySelector('.hd-resume');
|
|
11586
11766
|
if (HD_RESUMABLE.has(status) && st.resumable !== false) {
|
|
11587
11767
|
resumeBtn.hidden = false;
|
|
11588
|
-
applyHistResumeGate(resumeBtn, screen.dataset.pauseReason || '', budgetState.budget);
|
|
11768
|
+
applyHistResumeGate(resumeBtn, screen.dataset.pauseReason || '', budgetState.budget, screen.dataset.pauseDetail || '');
|
|
11589
11769
|
resumeBtn.addEventListener('click', () => {
|
|
11590
11770
|
const r = hdCurrentRecord(record); // never the load-time object
|
|
11591
11771
|
resumePipeline(r, r.projectDir || null, resumeBtn);
|
|
@@ -13486,6 +13666,21 @@ function rdStateCopy(r, stepName) {
|
|
|
13486
13666
|
// line and the banner above the graph never disagree.
|
|
13487
13667
|
if (r.pauseReason === 'cost_pipeline') return 'Paused — pipeline cost limit reached.';
|
|
13488
13668
|
if (r.pauseReason === 'cost_total') return 'Paused — total budget reached.';
|
|
13669
|
+
if (r.pauseReason === 'error') {
|
|
13670
|
+
const why = r.pauseDetail ? `: ${r.pauseDetail}` : '';
|
|
13671
|
+
return `Paused after an error${why}. Fix the cause, then Resume — the worktree and progress are kept.`;
|
|
13672
|
+
}
|
|
13673
|
+
if (r.pauseReason === 'recoverable') {
|
|
13674
|
+
const why = r.pauseDetail ? ` (${r.pauseDetail})` : '';
|
|
13675
|
+
return `Paused on a recoverable error${why}. Once it clears, Resume retries the step — the worktree and progress are kept.`;
|
|
13676
|
+
}
|
|
13677
|
+
if (r.pauseReason === 'usage_limit') {
|
|
13678
|
+
return `Paused — session/usage limit reached${r.pauseDetail ? ` (${r.pauseDetail})` : ''}. Resume after the reset.`;
|
|
13679
|
+
}
|
|
13680
|
+
if (r.pauseReason && (r.status === 'paused' || r.status === 'pausing' || r.status === 'interrupted')) {
|
|
13681
|
+
// A legacy reason is the orchestrator's own text (a pre-policy session/usage-limit line).
|
|
13682
|
+
return `Paused — ${r.pauseReason}. Resume once it clears.`;
|
|
13683
|
+
}
|
|
13489
13684
|
if (r.status === 'paused' || r.status === 'pausing' || r.status === 'interrupted') {
|
|
13490
13685
|
return 'Paused by you. Agents in flight finished their checkpoint; nothing new is dispatched.';
|
|
13491
13686
|
}
|
|
@@ -14274,10 +14469,14 @@ function statusPill(r) {
|
|
|
14274
14469
|
// A cost pause names its cause so the pill alone explains why the run parked.
|
|
14275
14470
|
if (r.pauseReason === 'cost_pipeline') return { family: 'amber', text: 'Paused · cost limit' };
|
|
14276
14471
|
if (r.pauseReason === 'cost_total') return { family: 'amber', text: 'Paused · total budget' };
|
|
14472
|
+
// An error pause is parked and resumable (never dead), so it stays in the amber family.
|
|
14473
|
+
if (r.pauseReason === 'error') return { family: 'amber', text: 'Paused · error' };
|
|
14474
|
+
if (r.pauseReason === 'recoverable') return { family: 'amber', text: 'Paused · recoverable' };
|
|
14475
|
+
if (r.pauseReason === 'usage_limit') return { family: 'amber', text: 'Paused · usage limit' };
|
|
14277
14476
|
return { family: 'amber', text: 'Paused' };
|
|
14278
14477
|
}
|
|
14279
14478
|
// Same family as `paused`: an interrupted run is parked and resumable, and
|
|
14280
|
-
// PAUSED_STATUSES (app.js:
|
|
14479
|
+
// PAUSED_STATUSES (app.js:10286) already treats it that way.
|
|
14281
14480
|
if (r.status === 'interrupted') return { family: 'amber', text: 'Interrupted' };
|
|
14282
14481
|
if (r.pendingQuestion != null) return { family: 'amber', text: 'Paused · awaiting answers' };
|
|
14283
14482
|
if (r.status === 'starting') return { family: 'peach', text: 'Starting' };
|
|
@@ -14857,9 +15056,16 @@ function paintRunCard(r) {
|
|
|
14857
15056
|
const totalBlocked = r.pauseReason === 'cost_total' && budgetState.budget?.blocked;
|
|
14858
15057
|
if (resumeBtn) {
|
|
14859
15058
|
resumeBtn.disabled = !!totalBlocked;
|
|
14860
|
-
|
|
14861
|
-
|
|
14862
|
-
|
|
15059
|
+
if (totalBlocked) {
|
|
15060
|
+
resumeBtn.title = `Total budget reached — blocked until ${fmtResetAtLocal(budgetState.budget.windowEndMs)} or a higher total limit`;
|
|
15061
|
+
} else if (r.pauseReason === 'error' || r.pauseReason === 'recoverable') {
|
|
15062
|
+
// The cause the run parked on, so the card alone explains what to fix.
|
|
15063
|
+
resumeBtn.title = r.pauseReason === 'recoverable'
|
|
15064
|
+
? `Paused on a recoverable error${r.pauseDetail ? `: ${r.pauseDetail}` : ''} — resume once it clears`
|
|
15065
|
+
: `Paused after an error${r.pauseDetail ? `: ${r.pauseDetail}` : ''} — fix the cause, then resume`;
|
|
15066
|
+
} else {
|
|
15067
|
+
resumeBtn.title = stockResumeTitle();
|
|
15068
|
+
}
|
|
14863
15069
|
}
|
|
14864
15070
|
}
|
|
14865
15071
|
|
|
@@ -15273,6 +15479,19 @@ function paintRdBanners(screen, r) {
|
|
|
15273
15479
|
banners.prepend(fresh); // above the retained-work banner
|
|
15274
15480
|
}
|
|
15275
15481
|
|
|
15482
|
+
// ---- error-pause banner (D11) ----
|
|
15483
|
+
// Same conditional-rebuild shape as paintHdBanners': keyed on the detail so a
|
|
15484
|
+
// repaint neither stacks a second banner nor freezes a corrected cause.
|
|
15485
|
+
const errPaused = isPaused(r) && (r.pauseReason === 'error' || r.pauseReason === 'recoverable');
|
|
15486
|
+
const errDetail = r.pauseDetail || '';
|
|
15487
|
+
const oldErr = banners.querySelector('.pause-error-banner');
|
|
15488
|
+
if (oldErr && (!errPaused || oldErr.dataset.detail !== errDetail)) oldErr.remove();
|
|
15489
|
+
if (errPaused && !banners.querySelector('.pause-error-banner')) {
|
|
15490
|
+
const errBanner = renderErrorPauseBanner(errDetail);
|
|
15491
|
+
errBanner.dataset.detail = errDetail;
|
|
15492
|
+
banners.prepend(errBanner);
|
|
15493
|
+
}
|
|
15494
|
+
|
|
15276
15495
|
// ---- retained work (D11) ----
|
|
15277
15496
|
// renderRetainedWork only READS `p.retainedWork`, so a derived carrier is fine
|
|
15278
15497
|
// for the paint; every MUTATING helper below gets the same carrier so the
|
|
@@ -15319,11 +15538,12 @@ el.runDetail?.addEventListener('click', (e) => {
|
|
|
15319
15538
|
if (override) { confirmCostOverride(r.runId, override); return; } // async, fire-and-forget
|
|
15320
15539
|
if (e.target.closest && e.target.closest('.cb-settings')) { location.hash = 'settings'; return; }
|
|
15321
15540
|
const qbtn = e.target.closest && e.target.closest(
|
|
15322
|
-
'.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-abort');
|
|
15541
|
+
'.qpanel .btn-go, .qpanel .gate-continue, .qpanel .gate-another, .qpanel .recovery-retry, .qpanel .recovery-pause, .qpanel .recovery-abort');
|
|
15323
15542
|
if (!qbtn) return;
|
|
15324
15543
|
if (qbtn.classList.contains('gate-continue')) postAnswer(r, { decision: 'continue' });
|
|
15325
15544
|
else if (qbtn.classList.contains('gate-another')) postAnswer(r, { decision: 'another' });
|
|
15326
15545
|
else if (qbtn.classList.contains('recovery-retry')) postAnswer(r, { decision: 'retry' });
|
|
15546
|
+
else if (qbtn.classList.contains('recovery-pause')) postAnswer(r, { decision: 'pause' });
|
|
15327
15547
|
else if (qbtn.classList.contains('recovery-abort')) postAnswer(r, { decision: 'abort' });
|
|
15328
15548
|
else submitAnswer(r, qbtn.closest('.qpanel'));
|
|
15329
15549
|
});
|