@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.
@@ -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
+ }