@worca/app 1.2.0-rc.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 +15 -1
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +176 -12
- package/src/core/ui-instance.mjs +235 -0
- package/ui/server.mjs +91 -15
package/README.md
CHANGED
|
@@ -177,7 +177,7 @@ Requirements:
|
|
|
177
177
|
### Web UI
|
|
178
178
|
|
|
179
179
|
```bash
|
|
180
|
-
worca --ui
|
|
180
|
+
worca ui # start it (worca --ui does the same)
|
|
181
181
|
```
|
|
182
182
|
|
|
183
183
|
Open the printed URL (default `http://localhost:4317`), add a project, and
|
|
@@ -186,6 +186,20 @@ a task from a plugin source like GitHub Issues), pick a workflow and
|
|
|
186
186
|
guardrails, and run. Answer clarify questions and loop gates as they come —
|
|
187
187
|
in the browser or from chat.
|
|
188
188
|
|
|
189
|
+
The UI is one process per machine. Starting it while it is already up is not
|
|
190
|
+
an error — Worca prints the URL and how to restart it, and exits 0:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
worca ui status # is it running? (exit 0 = yes, 1 = no)
|
|
194
|
+
worca ui restart # stop the running one, start it again
|
|
195
|
+
worca ui stop # stop it gracefully
|
|
196
|
+
worca ui --port 4318 --open # another port; open the browser when up
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`--port` (or the `PORT` env var) picks the port; `stop`, `restart` and
|
|
200
|
+
`status` remember the port of the last started UI, so they usually need no
|
|
201
|
+
flag. See `worca ui help`.
|
|
202
|
+
|
|
189
203
|
### CLI
|
|
190
204
|
|
|
191
205
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/app",
|
|
3
|
-
"version": "1.2.0-rc.
|
|
3
|
+
"version": "1.2.0-rc.2",
|
|
4
4
|
"description": "Worca — deterministic multi-agent pipeline that drives Claude Code (headless) through Plan -> Refine -> Implement -> Review, with a CLI, an installable /worca skill, and a web UI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sinisha Djukic",
|
package/src/cli/worca-cc.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// CLI entry point. Parses flags, creates a core orchestrator, subscribes to its events,
|
|
5
5
|
// renders a phase tracker + streamed agent logs to the terminal, and drives interactive
|
|
6
6
|
// Q&A (clarify) and loop gates via node:readline. Supports --yes (auto), --mock,
|
|
7
|
-
// --install <dir> (delegates to scripts/install.mjs),
|
|
7
|
+
// --install <dir> (delegates to scripts/install.mjs), ui start|stop|restart|status
|
|
8
|
+
// (--ui is an alias of `ui start`; see cmdUi),
|
|
8
9
|
// and -v/-V/--version (also the bare word `version`).
|
|
9
10
|
//
|
|
10
11
|
// ESM, no external dependencies.
|
|
@@ -28,6 +29,10 @@ import {
|
|
|
28
29
|
import { projectKey } from '../core/store.mjs';
|
|
29
30
|
import { formatExecLine, formatGateHeader, formatRunSummary } from './render.mjs';
|
|
30
31
|
import { pauseExitCode, describePauseReason, promptOptions, REASON } from '../core/failure-policy.mjs';
|
|
32
|
+
import { effectiveDebugSpawn } from '../core/settings.mjs';
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_UI_HOST, DEFAULT_UI_PORT, probeUi, stopUi, readUiInstance, uiUrl, waitForUiState,
|
|
35
|
+
} from '../core/ui-instance.mjs';
|
|
31
36
|
|
|
32
37
|
// ── node:sqlite runtime guard + warning filter ──────────────────────────────────
|
|
33
38
|
// Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
|
|
@@ -204,7 +209,7 @@ Usage:
|
|
|
204
209
|
worca --prompt "<task>" [--project <dir>] [options]
|
|
205
210
|
worca --file <task.md> [--project <dir>] [options]
|
|
206
211
|
worca "<task>" [--project <dir>] [options] (bare prompt; quote it)
|
|
207
|
-
worca --
|
|
212
|
+
worca ui [start|stop|restart|status] [--port <n>] [--open]
|
|
208
213
|
worca --install <targetDir> [--force]
|
|
209
214
|
|
|
210
215
|
Subcommands:
|
|
@@ -218,6 +223,8 @@ Subcommands:
|
|
|
218
223
|
disable|doctor|link|reimport|init|validate|exec. See: worca plugin help
|
|
219
224
|
marketplace <cmd> [...] Manage plugin marketplaces: add|list|refresh|remove. See: worca marketplace help
|
|
220
225
|
config [get|set|unset] Budget & cost-limit settings
|
|
226
|
+
ui [start|stop|restart|status]
|
|
227
|
+
Run the web UI (default http://localhost:4317). See: worca ui help
|
|
221
228
|
help Print this help (same as --help).
|
|
222
229
|
version Print the version (same as --version).
|
|
223
230
|
|
|
@@ -236,7 +243,7 @@ Options:
|
|
|
236
243
|
--branch <name> Feature branch name (default: claude proposes one)
|
|
237
244
|
--mock Offline mock mode (no claude, no tokens)
|
|
238
245
|
--yes, --non-interactive Auto-answer clarify (first option) and gates (continue)
|
|
239
|
-
--ui
|
|
246
|
+
--ui Same as "worca ui start" (accepts --port, --open, --mock)
|
|
240
247
|
--install <targetDir> Copy agents + /worca skill into <targetDir>/.claude
|
|
241
248
|
-h, --help Show this help
|
|
242
249
|
-v, -V, --version Print the version (worca <semver>) and exit
|
|
@@ -629,11 +636,97 @@ async function attachAndDrive(orch, flags, start) {
|
|
|
629
636
|
|
|
630
637
|
// ── subcommands ──────────────────────────────────────────────────────────────────
|
|
631
638
|
|
|
632
|
-
|
|
633
|
-
|
|
639
|
+
// ── web UI lifecycle ─────────────────────────────────────────────────────────────
|
|
640
|
+
//
|
|
641
|
+
// The UI is a singleton over the machine-wide store, so `worca ui` never blindly
|
|
642
|
+
// binds: it probes the port first (src/core/ui-instance.mjs). A Worca UI already
|
|
643
|
+
// answering there is the EXPECTED state — print where it is and how to restart
|
|
644
|
+
// it, exit 0. Only a port held by some other program is an error (exit 1).
|
|
645
|
+
|
|
646
|
+
const UI_HELP = `worca ui — the web UI server
|
|
647
|
+
|
|
648
|
+
Usage:
|
|
649
|
+
worca ui [start] [--port <n>] [--open] [--mock] Start the UI (default http://localhost:${DEFAULT_UI_PORT})
|
|
650
|
+
worca ui stop [--port <n>] Stop the running UI gracefully
|
|
651
|
+
worca ui restart [--port <n>] [--open] [--mock] Stop it if running, then start it again
|
|
652
|
+
worca ui status [--port <n>] Report whether it is running (exit 0 = running, 1 = not)
|
|
653
|
+
worca ui help Show this help
|
|
654
|
+
|
|
655
|
+
Options:
|
|
656
|
+
--port <n> Port to bind (start) or to look at (stop/restart/status).
|
|
657
|
+
Default: the PORT env var, then ${DEFAULT_UI_PORT}. stop/restart/status
|
|
658
|
+
also read the port of the last started UI (<worca home>/ui.json).
|
|
659
|
+
--open Open the UI in your browser once it is up
|
|
660
|
+
--mock Start in offline mock mode (same as WORCA_MOCK=1)
|
|
661
|
+
|
|
662
|
+
\`worca --ui\` is an alias of \`worca ui start\`.
|
|
663
|
+
`;
|
|
664
|
+
|
|
665
|
+
/** Bind/probe port for a `worca ui` verb: --port > (instance file) > PORT env > default. */
|
|
666
|
+
function resolveUiPort(a, { preferInstanceFile = false } = {}) {
|
|
667
|
+
if (a.port !== undefined) {
|
|
668
|
+
const n = Number(a.port);
|
|
669
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) fail(`--port must be an integer between 1 and 65535, got: ${a.port}`);
|
|
670
|
+
return { port: n, host: process.env.WORCA_HOST || DEFAULT_UI_HOST };
|
|
671
|
+
}
|
|
672
|
+
if (preferInstanceFile) {
|
|
673
|
+
const inst = readUiInstance();
|
|
674
|
+
if (inst) return { port: inst.port, host: inst.host || process.env.WORCA_HOST || DEFAULT_UI_HOST };
|
|
675
|
+
}
|
|
676
|
+
const env = Number(process.env.PORT);
|
|
677
|
+
const port = Number.isInteger(env) && env > 0 ? env : DEFAULT_UI_PORT;
|
|
678
|
+
return { port, host: process.env.WORCA_HOST || DEFAULT_UI_HOST };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Best-effort `open`/`start`/`xdg-open`; never throws, never keeps the CLI alive. */
|
|
682
|
+
function openBrowser(url) {
|
|
683
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
684
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
685
|
+
: ['xdg-open', [url]];
|
|
686
|
+
try {
|
|
687
|
+
const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
688
|
+
p.on('error', () => {});
|
|
689
|
+
p.unref();
|
|
690
|
+
} catch { /* no browser opener on this box — the URL is printed anyway */ }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Spawn ui/server.mjs on `port` and inherit its stdio. Resolves with its exit code. */
|
|
694
|
+
async function uiStart(a) {
|
|
695
|
+
const { port, host } = resolveUiPort(a);
|
|
696
|
+
const url = uiUrl({ host, port });
|
|
697
|
+
const probe = await probeUi({ host, port });
|
|
698
|
+
if (probe.state === 'worca') {
|
|
699
|
+
out(`Worca UI is already running at ${c('bold', url)}`);
|
|
700
|
+
out('');
|
|
701
|
+
out(` Open it: ${url}`);
|
|
702
|
+
out(` Restart it: ${c('bold', 'worca ui restart')}`);
|
|
703
|
+
out(` Another port: ${c('bold', `worca ui --port ${port + 1}`)}`);
|
|
704
|
+
if (a.open) openBrowser(url);
|
|
705
|
+
return 0;
|
|
706
|
+
}
|
|
707
|
+
if (probe.state === 'busy') {
|
|
708
|
+
process.stderr.write(`worca: port ${port} is in use by another program, so the Worca UI cannot start.\n\n`
|
|
709
|
+
+ ` Pick a free port: worca ui --port ${port + 1} (or set PORT)\n`);
|
|
710
|
+
return 1;
|
|
711
|
+
}
|
|
712
|
+
// A UI started earlier on some OTHER port is worth a note, not a refusal.
|
|
713
|
+
const inst = readUiInstance();
|
|
714
|
+
if (inst && inst.port !== port) {
|
|
715
|
+
const other = await probeUi({ host: inst.host, port: inst.port });
|
|
716
|
+
if (other.state === 'worca') out(c('gray', `Note: another Worca UI is running at ${uiUrl({ host: inst.host, port: inst.port })}`));
|
|
717
|
+
}
|
|
718
|
+
|
|
634
719
|
const server = join(REPO_ROOT, 'ui', 'server.mjs');
|
|
635
|
-
out(c('cyan', `
|
|
636
|
-
|
|
720
|
+
out(c('cyan', `Starting Worca UI on ${url}`));
|
|
721
|
+
if (effectiveDebugSpawn().enabled) out(c('gray', ` node ${server}`));
|
|
722
|
+
const env = { ...process.env, PORT: String(port) };
|
|
723
|
+
if (a.mock) env.WORCA_MOCK = '1';
|
|
724
|
+
const child = spawn(process.execPath, [server], { stdio: 'inherit', env });
|
|
725
|
+
if (a.open) {
|
|
726
|
+
waitForUiState({ host, port, states: ['worca'], timeoutMs: 20000 })
|
|
727
|
+
.then((r) => { if (r) openBrowser(url); })
|
|
728
|
+
.catch(() => {});
|
|
729
|
+
}
|
|
637
730
|
return new Promise((res) => {
|
|
638
731
|
child.on('exit', (code) => res(code ?? 0));
|
|
639
732
|
child.on('error', (err) => {
|
|
@@ -643,6 +736,75 @@ function launchUi() {
|
|
|
643
736
|
});
|
|
644
737
|
}
|
|
645
738
|
|
|
739
|
+
/** Stop the UI on the resolved port. Idempotent: "not running" exits 0. */
|
|
740
|
+
async function uiStop(a, { quiet = false } = {}) {
|
|
741
|
+
const { port, host } = resolveUiPort(a, { preferInstanceFile: true });
|
|
742
|
+
const r = await stopUi({ host, port });
|
|
743
|
+
switch (r.status) {
|
|
744
|
+
case 'stopped':
|
|
745
|
+
out(`Stopped Worca UI on port ${port}${r.pid ? ` (pid ${r.pid})` : ''}.`);
|
|
746
|
+
return 0;
|
|
747
|
+
case 'not-running':
|
|
748
|
+
if (!quiet) out(`Worca UI is not running on port ${port}.`);
|
|
749
|
+
return 0;
|
|
750
|
+
case 'busy':
|
|
751
|
+
process.stderr.write(`worca: port ${port} is in use by another program, not a Worca UI — nothing to stop.\n`);
|
|
752
|
+
return 1;
|
|
753
|
+
case 'timeout':
|
|
754
|
+
process.stderr.write(`worca: the Worca UI on port ${port}${r.pid ? ` (pid ${r.pid})` : ''} did not exit in time.\n`);
|
|
755
|
+
return 1;
|
|
756
|
+
default:
|
|
757
|
+
process.stderr.write(`worca: could not stop the Worca UI on port ${port}: ${r.reason || r.status}\n`);
|
|
758
|
+
return 1;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function uiStatus(a) {
|
|
763
|
+
const { port, host } = resolveUiPort(a, { preferInstanceFile: true });
|
|
764
|
+
const probe = await probeUi({ host, port });
|
|
765
|
+
if (probe.state === 'worca') {
|
|
766
|
+
const info = probe.info || {};
|
|
767
|
+
const detail = [info.pid ? `pid ${info.pid}` : null, info.version ? `v${info.version}` : null].filter(Boolean).join(', ');
|
|
768
|
+
out(`Worca UI is running at ${c('bold', uiUrl({ host, port }))}${detail ? ` (${detail})` : ''}`);
|
|
769
|
+
return 0;
|
|
770
|
+
}
|
|
771
|
+
if (probe.state === 'busy') {
|
|
772
|
+
out(`Worca UI is not running on port ${port} (the port is in use by another program).`);
|
|
773
|
+
return 1;
|
|
774
|
+
}
|
|
775
|
+
out(`Worca UI is not running on port ${port}.`);
|
|
776
|
+
return 1;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** `worca ui [start|stop|restart|status|help] [--port <n>] [--open] [--mock]` */
|
|
780
|
+
async function cmdUi(argv) {
|
|
781
|
+
const verbs = new Set(['start', 'stop', 'restart', 'status']);
|
|
782
|
+
let verb = 'start';
|
|
783
|
+
let rest = argv;
|
|
784
|
+
if (argv[0] === 'help' || argv[0] === '--help' || argv[0] === '-h') {
|
|
785
|
+
process.stdout.write(UI_HELP);
|
|
786
|
+
return 0;
|
|
787
|
+
}
|
|
788
|
+
if (argv[0] && !argv[0].startsWith('-')) {
|
|
789
|
+
if (!verbs.has(argv[0])) fail(`unknown ui command "${argv[0]}" — expected one of: start, stop, restart, status (see: worca ui help)`);
|
|
790
|
+
verb = argv[0];
|
|
791
|
+
rest = argv.slice(1);
|
|
792
|
+
}
|
|
793
|
+
const a = pluginArgs(rest, ['--port'], ['--open', '--mock']);
|
|
794
|
+
if (a._.length) fail(`unexpected argument: ${a._[0]} (see: worca ui help)`);
|
|
795
|
+
if (verb === 'stop') return uiStop(a);
|
|
796
|
+
if (verb === 'status') return uiStatus(a);
|
|
797
|
+
if (verb === 'restart') {
|
|
798
|
+
// Pin the port stop resolved (possibly from the instance file) so start reuses it.
|
|
799
|
+
const { port } = resolveUiPort(a, { preferInstanceFile: true });
|
|
800
|
+
a.port = String(port);
|
|
801
|
+
const code = await uiStop(a, { quiet: true });
|
|
802
|
+
if (code !== 0) return code;
|
|
803
|
+
return uiStart(a);
|
|
804
|
+
}
|
|
805
|
+
return uiStart(a);
|
|
806
|
+
}
|
|
807
|
+
|
|
646
808
|
/** Delegate to scripts/install.mjs, forwarding the target dir and any passthrough args. */
|
|
647
809
|
function runInstall(targetDir, passthrough) {
|
|
648
810
|
const script = join(REPO_ROOT, 'scripts', 'install.mjs');
|
|
@@ -1678,7 +1840,7 @@ async function cmdMarketplace(argv) {
|
|
|
1678
1840
|
|
|
1679
1841
|
// ── main ──────────────────────────────────────────────────────────────────────────
|
|
1680
1842
|
|
|
1681
|
-
const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config']);
|
|
1843
|
+
const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui']);
|
|
1682
1844
|
|
|
1683
1845
|
/** Levenshtein distance, two-row. Only ever called on short argv tokens. */
|
|
1684
1846
|
function editDistance(a, b) {
|
|
@@ -1736,6 +1898,12 @@ async function main() {
|
|
|
1736
1898
|
if (sub === 'plugin') return cmdPlugin(rest);
|
|
1737
1899
|
if (sub === 'marketplace') return cmdMarketplace(rest);
|
|
1738
1900
|
if (sub === 'config') return cmdConfig(rest);
|
|
1901
|
+
if (sub === 'ui') return cmdUi(rest);
|
|
1902
|
+
}
|
|
1903
|
+
// `worca --ui [...]` is the historical spelling of `worca ui start [...]`; hand the
|
|
1904
|
+
// remaining tokens to the ui parser so --port/--open/--mock work with either.
|
|
1905
|
+
if (process.argv.slice(2).includes('--ui')) {
|
|
1906
|
+
return cmdUi(process.argv.slice(2).filter((t) => t !== '--ui'));
|
|
1739
1907
|
}
|
|
1740
1908
|
|
|
1741
1909
|
const flags = parseArgs(process.argv.slice(2));
|
|
@@ -1752,10 +1920,6 @@ async function main() {
|
|
|
1752
1920
|
return runInstall(flags.install, passthrough);
|
|
1753
1921
|
}
|
|
1754
1922
|
|
|
1755
|
-
if (flags.ui) {
|
|
1756
|
-
return launchUi();
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
1923
|
if (flags.mock) {
|
|
1760
1924
|
process.env.WORCA_MOCK = '1';
|
|
1761
1925
|
}
|
|
@@ -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
|
+
}
|
package/ui/server.mjs
CHANGED
|
@@ -93,6 +93,9 @@ import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core
|
|
|
93
93
|
import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
|
|
94
94
|
import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
|
|
95
95
|
import { testModel } from '../src/core/model-test.mjs';
|
|
96
|
+
import {
|
|
97
|
+
DEFAULT_UI_PORT, UI_HEALTH_NAME, newUiToken, writeUiInstance, removeUiInstance, uiUrl,
|
|
98
|
+
} from '../src/core/ui-instance.mjs';
|
|
96
99
|
import { validateGuardrails } from '../src/core/guardrails.mjs';
|
|
97
100
|
import {
|
|
98
101
|
listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
|
|
@@ -174,6 +177,7 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
|
174
177
|
const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
|
|
175
178
|
const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
|
|
176
179
|
const require = createRequire(import.meta.url);
|
|
180
|
+
const PKG_VERSION = require('../package.json').version;
|
|
177
181
|
const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
|
|
178
182
|
// Primaries plus the sub-language grammars their instances register
|
|
179
183
|
// (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
|
|
@@ -212,7 +216,7 @@ const ASK_VENDOR_ASSETS = {
|
|
|
212
216
|
dompurify: resolveEsmAsset('dompurify'),
|
|
213
217
|
};
|
|
214
218
|
|
|
215
|
-
const PORT = Number(process.env.PORT) ||
|
|
219
|
+
const PORT = Number(process.env.PORT) || DEFAULT_UI_PORT;
|
|
216
220
|
// Bind to loopback by default (S1). Power users who knowingly want LAN exposure
|
|
217
221
|
// can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
|
|
218
222
|
// applies unless they also front it with auth.
|
|
@@ -275,6 +279,11 @@ const MAX_BUFFER = 5000;
|
|
|
275
279
|
const app = express();
|
|
276
280
|
const server = http.createServer(app);
|
|
277
281
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
282
|
+
// ws re-emits the http server's 'error' on the WebSocketServer. With no listener
|
|
283
|
+
// here, an EADDRINUSE on listen() became an unhandled 'error' event and a full
|
|
284
|
+
// stack trace; the http server's own handler (isMain below) is the one that
|
|
285
|
+
// reports it, so this side of the pair only has to not throw.
|
|
286
|
+
wss.on('error', () => {});
|
|
278
287
|
|
|
279
288
|
/** All currently connected sockets. */
|
|
280
289
|
const sockets = new Set();
|
|
@@ -2781,6 +2790,49 @@ const settingsState = () => ({
|
|
|
2781
2790
|
debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
|
|
2782
2791
|
});
|
|
2783
2792
|
|
|
2793
|
+
// ---------------------------------------------------------------------------
|
|
2794
|
+
// Instance lifecycle (`worca ui status|stop|restart`, src/core/ui-instance.mjs)
|
|
2795
|
+
// ---------------------------------------------------------------------------
|
|
2796
|
+
// `uiControl` is set by the boot block below when the server owns a port. Under
|
|
2797
|
+
// test (app imported, no bind) it stays empty: /api/health still answers, and
|
|
2798
|
+
// /api/shutdown refuses with 503 rather than exiting the test runner.
|
|
2799
|
+
const uiControl = { token: null, onShutdown: null, startedAt: null };
|
|
2800
|
+
const startedAtIso = () => uiControl.startedAt || null;
|
|
2801
|
+
|
|
2802
|
+
app.get('/api/health', (req, res) => {
|
|
2803
|
+
const addr = req.socket && req.socket.localPort;
|
|
2804
|
+
res.json({
|
|
2805
|
+
name: UI_HEALTH_NAME,
|
|
2806
|
+
version: PKG_VERSION,
|
|
2807
|
+
pid: process.pid,
|
|
2808
|
+
host: HOST,
|
|
2809
|
+
port: addr || PORT,
|
|
2810
|
+
startedAt: startedAtIso(),
|
|
2811
|
+
});
|
|
2812
|
+
});
|
|
2813
|
+
|
|
2814
|
+
/** Constant-time bearer check; `expected` is the boot-time token from ui.json. */
|
|
2815
|
+
function bearerMatches(header, expected) {
|
|
2816
|
+
if (!expected || typeof header !== 'string') return false;
|
|
2817
|
+
const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
2818
|
+
if (!m) return false;
|
|
2819
|
+
const a = Buffer.from(m[1]);
|
|
2820
|
+
const b = Buffer.from(expected);
|
|
2821
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
app.post('/api/shutdown', (req, res) => {
|
|
2825
|
+
if (!uiControl.token || typeof uiControl.onShutdown !== 'function') {
|
|
2826
|
+
return res.status(503).json({ error: 'shutdown is only available on a server started with `worca ui`' });
|
|
2827
|
+
}
|
|
2828
|
+
if (!bearerMatches(req.headers.authorization, uiControl.token)) {
|
|
2829
|
+
return res.status(401).json({ error: 'shutdown requires the bearer token from the instance file' });
|
|
2830
|
+
}
|
|
2831
|
+
res.status(202).json({ ok: true, pid: process.pid });
|
|
2832
|
+
// Answer first, exit on the next tick so the 202 actually leaves the socket.
|
|
2833
|
+
setImmediate(() => uiControl.onShutdown('request'));
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2784
2836
|
app.get('/api/settings', (_req, res) => {
|
|
2785
2837
|
res.json({ ...settingsState(), chat: chatPrefs() });
|
|
2786
2838
|
});
|
|
@@ -5364,30 +5416,53 @@ if (isMain) {
|
|
|
5364
5416
|
console.error(`[worca-ui] boot maintenance failed: ${err && err.message ? err.message : err}`);
|
|
5365
5417
|
});
|
|
5366
5418
|
|
|
5419
|
+
// A port that is already taken is an EXPECTED state (the UI is usually already
|
|
5420
|
+
// up), not a crash: one line, no stack, exit 1. `worca ui` probes the port
|
|
5421
|
+
// before spawning this process and prints the friendlier "already running"
|
|
5422
|
+
// block itself; this branch is for `node ui/server.mjs` run by hand or a race.
|
|
5367
5423
|
server.on('error', (err) => {
|
|
5424
|
+
if (err && err.code === 'EADDRINUSE') {
|
|
5425
|
+
console.error(`[worca-ui] port ${PORT} is already in use — is the UI already running?`);
|
|
5426
|
+
console.error(`[worca-ui] check with \`worca ui status\`, restart with \`worca ui restart\`, or pick a port: \`worca ui --port <n>\``);
|
|
5427
|
+
process.exit(1);
|
|
5428
|
+
}
|
|
5368
5429
|
console.error(`[worca-ui] server error: ${err && err.message ? err.message : err}`);
|
|
5369
5430
|
});
|
|
5370
5431
|
|
|
5432
|
+
// Channel workers must die with the server (design §9: persistent-process
|
|
5433
|
+
// hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit. The
|
|
5434
|
+
// same path serves POST /api/shutdown (`worca ui stop`), which exits 0.
|
|
5435
|
+
let shuttingDown = false;
|
|
5436
|
+
let wroteInstanceFile = false;
|
|
5437
|
+
const exitCodeFor = (signal) => (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 0);
|
|
5438
|
+
const shutdown = (signal) => {
|
|
5439
|
+
if (shuttingDown) return;
|
|
5440
|
+
shuttingDown = true;
|
|
5441
|
+
channelHost.stop().finally(() => process.exit(exitCodeFor(signal)));
|
|
5442
|
+
};
|
|
5443
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
5444
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
5445
|
+
// 'exit' handlers must be synchronous; removeUiInstance is. `ifPid` keeps an
|
|
5446
|
+
// old server exiting late from deleting the file a newer one just wrote.
|
|
5447
|
+
process.on('exit', () => { if (wroteInstanceFile) removeUiInstance({ ifPid: process.pid }); });
|
|
5448
|
+
|
|
5371
5449
|
server.listen(PORT, HOST, () => {
|
|
5372
|
-
const
|
|
5373
|
-
const url =
|
|
5450
|
+
const port = server.address().port;
|
|
5451
|
+
const url = uiUrl({ host: HOST, port });
|
|
5374
5452
|
console.log(`[worca-ui] listening on ${url} (bound to ${HOST})`);
|
|
5375
|
-
|
|
5453
|
+
uiControl.token = newUiToken();
|
|
5454
|
+
uiControl.onShutdown = shutdown;
|
|
5455
|
+
uiControl.startedAt = new Date().toISOString();
|
|
5456
|
+
writeUiInstance({
|
|
5457
|
+
pid: process.pid, host: HOST, port, token: uiControl.token,
|
|
5458
|
+
version: PKG_VERSION, startedAt: uiControl.startedAt,
|
|
5459
|
+
}).then(() => { wroteInstanceFile = true; }, (err) => {
|
|
5460
|
+
console.error(`[worca-ui] could not write the instance file (\`worca ui stop\` will fall back to a signal): ${err && err.message ? err.message : err}`);
|
|
5461
|
+
});
|
|
5376
5462
|
try { channelHost.start(); } catch (err) {
|
|
5377
5463
|
console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
|
|
5378
5464
|
}
|
|
5379
5465
|
});
|
|
5380
|
-
|
|
5381
|
-
// Channel workers must die with the server (design §9: persistent-process
|
|
5382
|
-
// hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit.
|
|
5383
|
-
let shuttingDown = false;
|
|
5384
|
-
const shutdownChat = (signal) => {
|
|
5385
|
-
if (shuttingDown) return;
|
|
5386
|
-
shuttingDown = true;
|
|
5387
|
-
channelHost.stop().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
|
|
5388
|
-
};
|
|
5389
|
-
process.on('SIGINT', () => shutdownChat('SIGINT'));
|
|
5390
|
-
process.on('SIGTERM', () => shutdownChat('SIGTERM'));
|
|
5391
5466
|
}
|
|
5392
5467
|
|
|
5393
5468
|
export { app, server, runs };
|
|
@@ -5396,4 +5471,5 @@ export const _testing = {
|
|
|
5396
5471
|
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
|
|
5397
5472
|
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
|
|
5398
5473
|
emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
|
|
5474
|
+
uiControl, bearerMatches,
|
|
5399
5475
|
};
|