@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/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
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// src/core/ui-instance.mjs
|
|
2
|
+
// The web UI is a singleton over the machine-wide store, so `worca ui` needs to
|
|
3
|
+
// know whether one is already up before it spawns another — and needs a way to
|
|
4
|
+
// stop the one that is. This module is the CLI side of that lifecycle; the server
|
|
5
|
+
// side (GET /api/health, POST /api/shutdown, the instance file) lives in
|
|
6
|
+
// ui/server.mjs.
|
|
7
|
+
//
|
|
8
|
+
// Discovery is the Jupyter runtime-file pattern: the server writes
|
|
9
|
+
// <worcaHome>/ui.json ({ pid, host, port, token, version, startedAt }) once it is
|
|
10
|
+
// listening and removes it on exit. The file is a HINT, never the truth — a
|
|
11
|
+
// crashed server leaves it behind, so every decision re-probes the port:
|
|
12
|
+
//
|
|
13
|
+
// probeUi({ port }) -> { state: 'worca', info } a Worca UI answered /api/health
|
|
14
|
+
// -> { state: 'busy' } something else owns the port
|
|
15
|
+
// -> { state: 'free' } nothing is listening
|
|
16
|
+
//
|
|
17
|
+
// Stopping goes through POST /api/shutdown with the file's bearer token so the
|
|
18
|
+
// server runs its graceful path (chat channel workers die cleanly) on every
|
|
19
|
+
// platform — a bare signal is a hard kill on Windows. The signal is the fallback
|
|
20
|
+
// when the token is unavailable (file missing, or a server too old to have one).
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import fsp from 'node:fs/promises';
|
|
24
|
+
import process from 'node:process';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
import { worcaHome } from './projects.mjs';
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_UI_PORT = 4317;
|
|
31
|
+
export const DEFAULT_UI_HOST = '127.0.0.1';
|
|
32
|
+
/** What GET /api/health must report as `name` for the occupant to count as a Worca UI. */
|
|
33
|
+
export const UI_HEALTH_NAME = '@worca/app';
|
|
34
|
+
|
|
35
|
+
/** Absolute path of the instance file for the current worcaHome. */
|
|
36
|
+
export function uiInstanceFile() {
|
|
37
|
+
return join(worcaHome(), 'ui.json');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A fresh shutdown token (hex, 32 bytes of entropy). */
|
|
41
|
+
export function newUiToken() {
|
|
42
|
+
return randomBytes(32).toString('hex');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Persist the running instance's coordinates. Atomic (tmp + rename) and 0600:
|
|
47
|
+
* the token authorizes a shutdown, so it must not be world-readable.
|
|
48
|
+
*/
|
|
49
|
+
export async function writeUiInstance({ pid, host, port, token, version, startedAt }) {
|
|
50
|
+
const file = uiInstanceFile();
|
|
51
|
+
await fsp.mkdir(join(file, '..'), { recursive: true });
|
|
52
|
+
const tmp = `${file}.${pid}.tmp`;
|
|
53
|
+
const body = JSON.stringify({ pid, host, port, token, version, startedAt }, null, 2) + '\n';
|
|
54
|
+
await fsp.writeFile(tmp, body, { mode: 0o600 });
|
|
55
|
+
await fsp.rename(tmp, file);
|
|
56
|
+
return file;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The instance file's contents, or null when missing/corrupt/not an object. */
|
|
60
|
+
export function readUiInstance() {
|
|
61
|
+
try {
|
|
62
|
+
const data = JSON.parse(fs.readFileSync(uiInstanceFile(), 'utf8'));
|
|
63
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
64
|
+
const port = Number(data.port);
|
|
65
|
+
if (!Number.isInteger(port) || port <= 0) return null;
|
|
66
|
+
return { ...data, port, pid: Number(data.pid) || null };
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove the instance file. With `ifPid`, only when the file still belongs to
|
|
74
|
+
* that process — an old server exiting late must not delete the file a newer
|
|
75
|
+
* one just wrote. Synchronous so it is usable from a process 'exit' handler.
|
|
76
|
+
*/
|
|
77
|
+
export function removeUiInstance({ ifPid } = {}) {
|
|
78
|
+
const file = uiInstanceFile();
|
|
79
|
+
try {
|
|
80
|
+
if (ifPid !== undefined) {
|
|
81
|
+
const cur = readUiInstance();
|
|
82
|
+
if (cur && cur.pid && cur.pid !== ifPid) return false;
|
|
83
|
+
}
|
|
84
|
+
fs.unlinkSync(file);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Host as it appears inside a URL (IPv6 literals need brackets). */
|
|
92
|
+
export function urlHost(host) {
|
|
93
|
+
if (!host) return 'localhost';
|
|
94
|
+
if (host === '127.0.0.1' || host === '::1' || host === '[::1]' || host === 'localhost') return 'localhost';
|
|
95
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The URL a browser should open for a UI bound to host:port. */
|
|
99
|
+
export function uiUrl({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT } = {}) {
|
|
100
|
+
return `http://${urlHost(host)}:${port}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Host to CONNECT to (bind-any addresses are not dialable). */
|
|
104
|
+
function dialHost(host) {
|
|
105
|
+
if (!host || host === '0.0.0.0' || host === '::') return DEFAULT_UI_HOST;
|
|
106
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every error code nested in a fetch failure (Node wraps them in `cause`, sometimes an AggregateError). */
|
|
110
|
+
function errorCodes(err) {
|
|
111
|
+
const out = new Set();
|
|
112
|
+
const walk = (e, depth) => {
|
|
113
|
+
if (!e || depth > 4) return;
|
|
114
|
+
if (typeof e.code === 'string') out.add(e.code);
|
|
115
|
+
if (e.cause) walk(e.cause, depth + 1);
|
|
116
|
+
if (Array.isArray(e.errors)) for (const inner of e.errors) walk(inner, depth + 1);
|
|
117
|
+
};
|
|
118
|
+
walk(err, 0);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** GET a JSON object from the UI, or null (non-2xx, non-JSON, non-object). Network errors propagate. */
|
|
123
|
+
async function getJson(url, signal) {
|
|
124
|
+
const res = await fetch(url, { signal, headers: { accept: 'application/json' } });
|
|
125
|
+
if (!res.ok) return null;
|
|
126
|
+
try {
|
|
127
|
+
const data = await res.json();
|
|
128
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Ask host:port whether a Worca UI is listening there.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<{state:'worca', info:object} | {state:'busy'} | {state:'free'}>}
|
|
138
|
+
* 'busy' covers every occupant that is not a Worca UI: a non-JSON answer, a
|
|
139
|
+
* different `name`, a hang (timeout) or a reset. Only a clean connection
|
|
140
|
+
* refusal is 'free'. A Worca UI from before /api/health existed is recognised
|
|
141
|
+
* by its settings route and reported with `info.legacy = true` (no pid, no
|
|
142
|
+
* token — it cannot be stopped from here, only from its own terminal).
|
|
143
|
+
*/
|
|
144
|
+
export async function probeUi({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT, timeoutMs = 1500 } = {}) {
|
|
145
|
+
const ctl = new AbortController();
|
|
146
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
147
|
+
const base = `http://${dialHost(host)}:${port}`;
|
|
148
|
+
try {
|
|
149
|
+
const info = await getJson(`${base}/api/health`, ctl.signal);
|
|
150
|
+
if (info) return info.name === UI_HEALTH_NAME ? { state: 'worca', info } : { state: 'busy' };
|
|
151
|
+
const legacy = await getJson(`${base}/api/settings`, ctl.signal);
|
|
152
|
+
if (legacy && typeof legacy.projectsRootDefault === 'string' && 'askMaxTurns' in legacy) {
|
|
153
|
+
return { state: 'worca', info: { name: UI_HEALTH_NAME, legacy: true } };
|
|
154
|
+
}
|
|
155
|
+
return { state: 'busy' };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const codes = errorCodes(err);
|
|
158
|
+
if (codes.has('ECONNREFUSED')) return { state: 'free' };
|
|
159
|
+
return { state: 'busy' };
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
166
|
+
|
|
167
|
+
/** Poll until probeUi reports `state` (or any of `states`), or give up after timeoutMs. */
|
|
168
|
+
export async function waitForUiState({ host, port, states, timeoutMs = 10000, intervalMs = 100 } = {}) {
|
|
169
|
+
const want = new Set(Array.isArray(states) ? states : [states]);
|
|
170
|
+
const deadline = Date.now() + timeoutMs;
|
|
171
|
+
for (;;) {
|
|
172
|
+
const r = await probeUi({ host, port, timeoutMs: Math.min(1500, Math.max(200, deadline - Date.now())) });
|
|
173
|
+
if (want.has(r.state)) return r;
|
|
174
|
+
if (Date.now() >= deadline) return null;
|
|
175
|
+
await sleep(intervalMs);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True when a process with that pid exists (signal 0 probes without killing). */
|
|
180
|
+
export function processAlive(pid) {
|
|
181
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
182
|
+
try { process.kill(pid, 0); return true; } catch (err) { return err && err.code === 'EPERM'; }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Stop the Worca UI on host:port, gracefully when possible.
|
|
187
|
+
*
|
|
188
|
+
* Order: (1) POST /api/shutdown with the instance file's token — the server
|
|
189
|
+
* answers 202 and exits through its signal path; (2) if that is refused or no
|
|
190
|
+
* token is known, SIGTERM the pid the health probe reported; (3) wait for the
|
|
191
|
+
* port to free up. Idempotent: a port with no Worca UI is `notRunning`, not an
|
|
192
|
+
* error. The instance file is cleaned up whenever the port ends up free.
|
|
193
|
+
*
|
|
194
|
+
* @returns {Promise<{status:'stopped', method:'request'|'signal', pid:number|null}
|
|
195
|
+
* |{status:'not-running'}
|
|
196
|
+
* |{status:'busy'}
|
|
197
|
+
* |{status:'failed', pid:number|null, reason:string}
|
|
198
|
+
* |{status:'timeout', pid:number|null}>}
|
|
199
|
+
*/
|
|
200
|
+
export async function stopUi({ host = DEFAULT_UI_HOST, port = DEFAULT_UI_PORT, token, timeoutMs = 10000 } = {}) {
|
|
201
|
+
const probe = await probeUi({ host, port });
|
|
202
|
+
if (probe.state === 'free') { removeUiInstance(); return { status: 'not-running' }; }
|
|
203
|
+
if (probe.state === 'busy') return { status: 'busy' };
|
|
204
|
+
if (probe.info.legacy) {
|
|
205
|
+
return { status: 'failed', pid: null, reason: 'it is an older Worca UI without shutdown support — stop it from its own terminal (Ctrl+C) and start again' };
|
|
206
|
+
}
|
|
207
|
+
const pid = Number(probe.info.pid) || null;
|
|
208
|
+
|
|
209
|
+
const file = readUiInstance();
|
|
210
|
+
const bearer = token || (file && file.port === port ? file.token : null);
|
|
211
|
+
let method = null;
|
|
212
|
+
|
|
213
|
+
if (bearer) {
|
|
214
|
+
try {
|
|
215
|
+
const res = await fetch(`http://${dialHost(host)}:${port}/api/shutdown`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { authorization: `Bearer ${bearer}`, accept: 'application/json' },
|
|
218
|
+
signal: AbortSignal.timeout(3000),
|
|
219
|
+
});
|
|
220
|
+
if (res.status === 202 || res.status === 200) method = 'request';
|
|
221
|
+
} catch {
|
|
222
|
+
// The server may drop the connection while exiting — the wait below decides.
|
|
223
|
+
method = 'request';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!method && pid) {
|
|
227
|
+
try { process.kill(pid, 'SIGTERM'); method = 'signal'; } catch { /* already gone, or not ours */ }
|
|
228
|
+
}
|
|
229
|
+
if (!method) return { status: 'failed', pid, reason: 'no shutdown token in the instance file and no pid to signal' };
|
|
230
|
+
|
|
231
|
+
const freed = await waitForUiState({ host, port, states: ['free'], timeoutMs });
|
|
232
|
+
if (!freed) return { status: 'timeout', pid };
|
|
233
|
+
removeUiInstance();
|
|
234
|
+
return { status: 'stopped', method, pid };
|
|
235
|
+
}
|