@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/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
|
|
@@ -205,6 +219,14 @@ worca --project /path/to/your/project --prompt "demo task" --mock --yes
|
|
|
205
219
|
Run `worca --help` for all subcommands (projects, plugins, marketplaces,
|
|
206
220
|
config, doctor) and flags.
|
|
207
221
|
|
|
222
|
+
Exit codes, for scripts and CI wrappers: `0` the run finished (or an
|
|
223
|
+
interactive run paused and you can resume it); `1` a hard error, a stop, or an
|
|
224
|
+
interactive pause an error forced; `2` a usage error; `3` a `--yes` run that
|
|
225
|
+
parked itself — auth, quota, a usage or cost limit, exhausted retries, or a
|
|
226
|
+
step error — with nobody attached to resume it. Nothing is discarded on a
|
|
227
|
+
pause: `worca resume <pipelineId>` picks the run up where it stopped, and the
|
|
228
|
+
cause is printed with the pause block on stdout.
|
|
229
|
+
|
|
208
230
|
### `/worca` skill (inside Claude Code)
|
|
209
231
|
|
|
210
232
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/app",
|
|
3
|
-
"version": "1.
|
|
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,13 +4,16 @@
|
|
|
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),
|
|
9
|
+
// and -v/-V/--version (also the bare word `version`).
|
|
8
10
|
//
|
|
9
11
|
// ESM, no external dependencies.
|
|
10
12
|
|
|
11
13
|
import { createInterface } from 'node:readline';
|
|
12
14
|
import { spawn } from 'node:child_process';
|
|
13
15
|
import { fstatSync } from 'node:fs';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
14
17
|
import { fileURLToPath } from 'node:url';
|
|
15
18
|
import { dirname, resolve, join, basename } from 'node:path';
|
|
16
19
|
import process from 'node:process';
|
|
@@ -25,6 +28,11 @@ import {
|
|
|
25
28
|
} from '../core/projects.mjs';
|
|
26
29
|
import { projectKey } from '../core/store.mjs';
|
|
27
30
|
import { formatExecLine, formatGateHeader, formatRunSummary } from './render.mjs';
|
|
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';
|
|
28
36
|
|
|
29
37
|
// ── node:sqlite runtime guard + warning filter ──────────────────────────────────
|
|
30
38
|
// Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
|
|
@@ -39,6 +47,18 @@ process.on('warning', (w) => {
|
|
|
39
47
|
if (w && w.name === 'ExperimentalWarning' && /SQLite/i.test(w.message)) return;
|
|
40
48
|
process.stderr.write(`${w?.stack || w?.message || w}\n`);
|
|
41
49
|
});
|
|
50
|
+
// ── --version ──────────────────────────────────────────────────────────────────
|
|
51
|
+
// Answered BEFORE the Node preflight and before any flag validation: "which worca is
|
|
52
|
+
// this?" is the first question asked when something else is broken, so it must work
|
|
53
|
+
// on an unsupported Node and alongside an otherwise-bad command line. The bare word
|
|
54
|
+
// `version` is only honoured in the subcommand slot (like `help`); the flags anywhere.
|
|
55
|
+
// Output is the GNU/gh/go form, `<prog> <semver>`, on stdout, exit 0.
|
|
56
|
+
const PKG_VERSION = createRequire(import.meta.url)('../../package.json').version;
|
|
57
|
+
const VERSION_FLAGS = new Set(['-v', '-V', '--version']);
|
|
58
|
+
if (process.argv[2] === 'version' || process.argv.slice(2).some((a) => VERSION_FLAGS.has(a))) {
|
|
59
|
+
process.stdout.write(`worca ${PKG_VERSION}\n`);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
42
62
|
// Fail fast on an unsupported Node / missing node:sqlite BEFORE any DB is opened.
|
|
43
63
|
preflightNode();
|
|
44
64
|
|
|
@@ -58,7 +78,8 @@ const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions',
|
|
|
58
78
|
|
|
59
79
|
/**
|
|
60
80
|
* Parse argv into a flags object. Supports "--flag value" and "--flag=value", plus the
|
|
61
|
-
* boolean flags --mock, --yes/--non-interactive, --ui, -h/--help.
|
|
81
|
+
* boolean flags --mock, --yes/--non-interactive, --ui, -h/--help. (-v/-V/--version
|
|
82
|
+
* never reach here: they are answered at module top, before the Node preflight.)
|
|
62
83
|
*/
|
|
63
84
|
function parseArgs(argv) {
|
|
64
85
|
const out = {
|
|
@@ -188,7 +209,7 @@ Usage:
|
|
|
188
209
|
worca --prompt "<task>" [--project <dir>] [options]
|
|
189
210
|
worca --file <task.md> [--project <dir>] [options]
|
|
190
211
|
worca "<task>" [--project <dir>] [options] (bare prompt; quote it)
|
|
191
|
-
worca --
|
|
212
|
+
worca ui [start|stop|restart|status] [--port <n>] [--open]
|
|
192
213
|
worca --install <targetDir> [--force]
|
|
193
214
|
|
|
194
215
|
Subcommands:
|
|
@@ -202,7 +223,10 @@ Subcommands:
|
|
|
202
223
|
disable|doctor|link|reimport|init|validate|exec. See: worca plugin help
|
|
203
224
|
marketplace <cmd> [...] Manage plugin marketplaces: add|list|refresh|remove. See: worca marketplace help
|
|
204
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
|
|
205
228
|
help Print this help (same as --help).
|
|
229
|
+
version Print the version (same as --version).
|
|
206
230
|
|
|
207
231
|
Options:
|
|
208
232
|
--project <dir> Target project directory (default: cwd)
|
|
@@ -219,9 +243,10 @@ Options:
|
|
|
219
243
|
--branch <name> Feature branch name (default: claude proposes one)
|
|
220
244
|
--mock Offline mock mode (no claude, no tokens)
|
|
221
245
|
--yes, --non-interactive Auto-answer clarify (first option) and gates (continue)
|
|
222
|
-
--ui
|
|
246
|
+
--ui Same as "worca ui start" (accepts --port, --open, --mock)
|
|
223
247
|
--install <targetDir> Copy agents + /worca skill into <targetDir>/.claude
|
|
224
248
|
-h, --help Show this help
|
|
249
|
+
-v, -V, --version Print the version (worca <semver>) and exit
|
|
225
250
|
`;
|
|
226
251
|
|
|
227
252
|
// ── terminal rendering ───────────────────────────────────────────────────────────
|
|
@@ -334,22 +359,27 @@ async function askGate(rl, issues, header) {
|
|
|
334
359
|
|
|
335
360
|
/**
|
|
336
361
|
* Ask the user how to handle a recoverable error (auth / rate-limit / quota /
|
|
337
|
-
* network). Shows the cause and
|
|
362
|
+
* network). Shows the cause and the row's options (failure-policy.mjs: Retry, plus
|
|
363
|
+
* what giving up does — pause or abort). Returns { decision } with the chosen
|
|
364
|
+
* option's id as the wire value.
|
|
338
365
|
*/
|
|
339
366
|
async function askRecovery(rl, recovery) {
|
|
340
367
|
const rec = recovery || {};
|
|
368
|
+
const options = Array.isArray(rec.options) && rec.options.length ? rec.options : promptOptions({ outcome: 'pause' });
|
|
341
369
|
out('');
|
|
342
370
|
out(c('yellow', c('bold', `Recoverable ${String(rec.cls || 'error').replace('_', ' ')} error — the pipeline could not reach the model.`)));
|
|
343
371
|
if (rec.message) out(c('gray', ` ${rec.message}`));
|
|
344
372
|
if (rec.cls === 'auth') out(c('gray', ' Fix: re-authenticate (claude setup-token or /login) in another terminal, then retry.'));
|
|
345
373
|
else out(c('gray', ' Fix: wait out the limit / restore connectivity / top up credit, then retry.'));
|
|
346
|
-
out(
|
|
347
|
-
out(' 2) Abort the run');
|
|
374
|
+
options.forEach((o, i) => out(` ${i + 1}) ${o.label}`));
|
|
348
375
|
let decision = '';
|
|
349
376
|
while (!decision) {
|
|
350
|
-
const raw = (await question(rl, c('cyan',
|
|
351
|
-
|
|
352
|
-
|
|
377
|
+
const raw = (await question(rl, c('cyan', `Choose [1-${options.length}]: `))).trim();
|
|
378
|
+
const byNumber = options[Number(raw) - 1];
|
|
379
|
+
if (byNumber) decision = byNumber.id;
|
|
380
|
+
else if (/^retry/i.test(raw)) decision = 'retry';
|
|
381
|
+
// 'pause' and 'abort' both mean give up; the option offered names the verdict.
|
|
382
|
+
else if (/^(pause|abort)/i.test(raw)) decision = options.find((o) => o.id !== 'retry')?.id || 'pause';
|
|
353
383
|
}
|
|
354
384
|
return { decision };
|
|
355
385
|
}
|
|
@@ -380,7 +410,16 @@ function stdinCanAnswer() {
|
|
|
380
410
|
/**
|
|
381
411
|
* Wire readline Q&A, log/phase rendering, and SIGINT pause/stop onto an
|
|
382
412
|
* orchestrator, then drive it. `start` launches run() or resume(). Returns the
|
|
383
|
-
* process exit code (
|
|
413
|
+
* process exit code (pauseExitCode, failure-policy.mjs):
|
|
414
|
+
* 0 done — and an INTERACTIVE pause the user chose or a limit/cap forced (they
|
|
415
|
+
* witnessed it and can resume);
|
|
416
|
+
* 1 a terminal error (a launch failure, an unrecoverable resume, a stop) and an
|
|
417
|
+
* INTERACTIVE pause an error forced;
|
|
418
|
+
* 2 a usage error (fail());
|
|
419
|
+
* 3 any pause under --yes — the run parked itself (auth/quota/usage limit,
|
|
420
|
+
* exhausted retries, an error) with nobody attached to resume it, so a
|
|
421
|
+
* wrapper must not read success. Under --yes a parked run's cause prints
|
|
422
|
+
* on STDOUT with the pause block; only a terminal error reaches stderr.
|
|
384
423
|
*/
|
|
385
424
|
async function attachAndDrive(orch, flags, start) {
|
|
386
425
|
// Refuse an unanswerable interactive run BEFORE start(). The orchestrator
|
|
@@ -557,7 +596,22 @@ async function attachAndDrive(orch, flags, start) {
|
|
|
557
596
|
for (const line of summary.slice(1)) out(line);
|
|
558
597
|
}
|
|
559
598
|
} else if (result?.status === 'paused') {
|
|
560
|
-
|
|
599
|
+
// An error-pause reads as a failure the user can pick up again: the cause on
|
|
600
|
+
// its own line, then the reassurance that nothing was thrown away.
|
|
601
|
+
if (result.reason === REASON.ERROR) {
|
|
602
|
+
out(c('red', c('bold', 'Pipeline paused after an error.')));
|
|
603
|
+
if (result.detail) out(c('red', ` ${result.detail}`));
|
|
604
|
+
out(c('yellow', 'Nothing was discarded: the worktree and the run position are kept.'));
|
|
605
|
+
} else if (result.reason === REASON.RECOVERABLE) {
|
|
606
|
+
out(c('yellow', c('bold', 'Pipeline paused on a recoverable error — resume once it clears.')));
|
|
607
|
+
if (result.detail) out(c('yellow', ` ${result.detail}`));
|
|
608
|
+
out(c('yellow', 'Nothing was discarded: the worktree and the run position are kept.'));
|
|
609
|
+
} else if (result?.reason) {
|
|
610
|
+
const label = describePauseReason(result.reason) || result.reason;
|
|
611
|
+
out(c('yellow', `Pipeline paused: ${label}${result.detail ? ` — ${result.detail}` : ''}`));
|
|
612
|
+
} else {
|
|
613
|
+
out(c('yellow', 'Pipeline paused.'));
|
|
614
|
+
}
|
|
561
615
|
out(`Resume with: ${c('bold', `worca resume ${orch.state.id}`)}`);
|
|
562
616
|
} else if (result?.status === 'stopped') {
|
|
563
617
|
out(c('yellow', 'Pipeline stopped.'));
|
|
@@ -569,16 +623,110 @@ async function attachAndDrive(orch, flags, start) {
|
|
|
569
623
|
}
|
|
570
624
|
// An unanswered question is a failure even if the run somehow settled `done`.
|
|
571
625
|
if (answerFailure) return 1;
|
|
572
|
-
|
|
626
|
+
if (result?.status === 'done') return 0;
|
|
627
|
+
// The exit code for a pause is a consequence of its reason (failure-policy.mjs):
|
|
628
|
+
// 0 only when someone is attached to resume it (interactive — pinned by the
|
|
629
|
+
// MAJ-7 Ctrl+C pitfall test) and no error forced it; 1 for an interactive
|
|
630
|
+
// error-pause; 3 under --yes, where every pause is the run parking ITSELF with
|
|
631
|
+
// nobody left to resume (0 would let a CI job go green on a run that did no
|
|
632
|
+
// work; 2 is fail()'s usage-error code).
|
|
633
|
+
if (result?.status === 'paused') return pauseExitCode(result.reason, flags.auto);
|
|
634
|
+
return 1;
|
|
573
635
|
}
|
|
574
636
|
|
|
575
637
|
// ── subcommands ──────────────────────────────────────────────────────────────────
|
|
576
638
|
|
|
577
|
-
|
|
578
|
-
|
|
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
|
+
|
|
579
719
|
const server = join(REPO_ROOT, 'ui', 'server.mjs');
|
|
580
|
-
out(c('cyan', `
|
|
581
|
-
|
|
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
|
+
}
|
|
582
730
|
return new Promise((res) => {
|
|
583
731
|
child.on('exit', (code) => res(code ?? 0));
|
|
584
732
|
child.on('error', (err) => {
|
|
@@ -588,6 +736,75 @@ function launchUi() {
|
|
|
588
736
|
});
|
|
589
737
|
}
|
|
590
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
|
+
|
|
591
808
|
/** Delegate to scripts/install.mjs, forwarding the target dir and any passthrough args. */
|
|
592
809
|
function runInstall(targetDir, passthrough) {
|
|
593
810
|
const script = join(REPO_ROOT, 'scripts', 'install.mjs');
|
|
@@ -1623,7 +1840,7 @@ async function cmdMarketplace(argv) {
|
|
|
1623
1840
|
|
|
1624
1841
|
// ── main ──────────────────────────────────────────────────────────────────────────
|
|
1625
1842
|
|
|
1626
|
-
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']);
|
|
1627
1844
|
|
|
1628
1845
|
/** Levenshtein distance, two-row. Only ever called on short argv tokens. */
|
|
1629
1846
|
function editDistance(a, b) {
|
|
@@ -1649,15 +1866,16 @@ function editDistance(a, b) {
|
|
|
1649
1866
|
*/
|
|
1650
1867
|
function nearestSubcommand(token) {
|
|
1651
1868
|
if (!token || /\s/.test(token)) return null;
|
|
1652
|
-
// 'help'
|
|
1653
|
-
//
|
|
1654
|
-
//
|
|
1655
|
-
|
|
1869
|
+
// 'help' and 'version' are spliced into both loops: they are real CLI arms (the
|
|
1870
|
+
// head of main() / the module top) but deliberately absent from the dispatch
|
|
1871
|
+
// table, so without them a typo of either (`worca hlep`, `worca versoin`) is
|
|
1872
|
+
// distance >= 3 from everything and runs as a PROMPT.
|
|
1873
|
+
for (const name of [...SUBCOMMANDS, 'help', 'version']) {
|
|
1656
1874
|
if (token.length >= 3 && name.length > token.length && name.startsWith(token)) return name;
|
|
1657
1875
|
}
|
|
1658
1876
|
let best = null;
|
|
1659
1877
|
let bestD = 3; // strictly less than 3 == distance <= 2
|
|
1660
|
-
for (const name of [...SUBCOMMANDS, 'help']) {
|
|
1878
|
+
for (const name of [...SUBCOMMANDS, 'help', 'version']) {
|
|
1661
1879
|
const d = editDistance(token, name);
|
|
1662
1880
|
if (d < bestD) { bestD = d; best = name; }
|
|
1663
1881
|
}
|
|
@@ -1680,6 +1898,12 @@ async function main() {
|
|
|
1680
1898
|
if (sub === 'plugin') return cmdPlugin(rest);
|
|
1681
1899
|
if (sub === 'marketplace') return cmdMarketplace(rest);
|
|
1682
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'));
|
|
1683
1907
|
}
|
|
1684
1908
|
|
|
1685
1909
|
const flags = parseArgs(process.argv.slice(2));
|
|
@@ -1696,10 +1920,6 @@ async function main() {
|
|
|
1696
1920
|
return runInstall(flags.install, passthrough);
|
|
1697
1921
|
}
|
|
1698
1922
|
|
|
1699
|
-
if (flags.ui) {
|
|
1700
|
-
return launchUi();
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
1923
|
if (flags.mock) {
|
|
1704
1924
|
process.env.WORCA_MOCK = '1';
|
|
1705
1925
|
}
|
package/src/core/artifacts.mjs
CHANGED
|
@@ -1519,6 +1519,7 @@ async function rowToHistoryEntry(row, repoDir = null, opts = {}) {
|
|
|
1519
1519
|
sourceBranch: source,
|
|
1520
1520
|
guardrailsId: row.guardrails_id ?? null,
|
|
1521
1521
|
pauseReason: row.pause_reason ?? null,
|
|
1522
|
+
pauseDetail: row.pause_detail ?? null,
|
|
1522
1523
|
retainedWork: retainedWorkFor(row),
|
|
1523
1524
|
survived,
|
|
1524
1525
|
added,
|
|
@@ -1574,7 +1575,8 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
|
|
|
1574
1575
|
const rows = getDb().prepare(`
|
|
1575
1576
|
SELECT id, project_key, target, title, status, started_at, updated_at, total_cost_usd, total_active_ms,
|
|
1576
1577
|
branch, workspace_meta, guardrails_id,
|
|
1577
|
-
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason
|
|
1578
|
+
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
|
|
1579
|
+
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
|
|
1578
1580
|
FROM pipelines
|
|
1579
1581
|
WHERE ${workspaceKey ? 'workspace_key = ?' : 'project_key = ?'} AND archived_at IS NULL
|
|
1580
1582
|
ORDER BY started_at DESC
|
|
@@ -1602,7 +1604,8 @@ export async function listAllPipelines(opts = {}, { batchSize = 16 } = {}) {
|
|
|
1602
1604
|
const rows = getDb().prepare(`
|
|
1603
1605
|
SELECT id, project_key, workspace_key, target, title, status, started_at, updated_at,
|
|
1604
1606
|
total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id,
|
|
1605
|
-
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason
|
|
1607
|
+
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
|
|
1608
|
+
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
|
|
1606
1609
|
FROM pipelines
|
|
1607
1610
|
WHERE archived_at IS NULL
|
|
1608
1611
|
ORDER BY COALESCE(updated_at, started_at) DESC, project_key, id
|
|
@@ -1785,6 +1788,11 @@ function rowToState(row) {
|
|
|
1785
1788
|
`).all(row.id).map(stepRowToStep),
|
|
1786
1789
|
subAgents: listSubAgents(row.id),
|
|
1787
1790
|
};
|
|
1791
|
+
// The pause cause rides resume_point (no column): expose it on the DETAIL payload
|
|
1792
|
+
// too, so a deep-linked History detail no longer waits for the LIST row.
|
|
1793
|
+
const rp = j(row.resume_point, null);
|
|
1794
|
+
state.pauseReason = typeof rp?.pauseReason === 'string' ? rp.pauseReason : null;
|
|
1795
|
+
state.pauseDetail = typeof rp?.pauseDetail === 'string' ? rp.pauseDetail : null;
|
|
1788
1796
|
const outcome = j(row.outcome, null);
|
|
1789
1797
|
if (outcome) {
|
|
1790
1798
|
state.engine = 2;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/core/ask/attachment-kind.mjs
|
|
2
|
+
// Attachment typing for the Ask Worca chat (issue #398): which extensions are
|
|
3
|
+
// accepted, what kind/mime each maps to, and content sniffing for the binary
|
|
4
|
+
// ones. Pure and synchronous — the single source of truth for the type table;
|
|
5
|
+
// limits.mjs re-exports the two extension lists and ui/server.mjs validates
|
|
6
|
+
// uploads with classifyExtension + sniffMime.
|
|
7
|
+
//
|
|
8
|
+
// Kinds: 'text' (UTF-8, inlineable into the turn prompt, redactable),
|
|
9
|
+
// 'image' (fed to the model via its Read tool on the stored file) and
|
|
10
|
+
// 'binary' (today only PDF — same Read-tool path, never inlined).
|
|
11
|
+
//
|
|
12
|
+
// The extension names the CLAIMED type; for binary kinds the claim is verified
|
|
13
|
+
// against the leading bytes (magic number) so a mislabeled body is refused at
|
|
14
|
+
// upload rather than stored wrong. SVG is deliberately absent: it is scriptable
|
|
15
|
+
// markup, and the download route serves attachment bodies with their real mime.
|
|
16
|
+
|
|
17
|
+
/** Extension -> {kind, mime} for the text kinds (the pre-#398 allowlist). */
|
|
18
|
+
const TEXT_TYPES = Object.freeze({
|
|
19
|
+
'.md': 'text/markdown',
|
|
20
|
+
'.markdown': 'text/markdown',
|
|
21
|
+
'.txt': 'text/plain',
|
|
22
|
+
'.json': 'application/json',
|
|
23
|
+
'.csv': 'text/csv',
|
|
24
|
+
'.log': 'text/plain',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Extension -> mime for the binary kinds. Every mime here MUST be sniffable. */
|
|
28
|
+
const BINARY_TYPES = Object.freeze({
|
|
29
|
+
'.png': 'image/png',
|
|
30
|
+
'.jpg': 'image/jpeg',
|
|
31
|
+
'.jpeg': 'image/jpeg',
|
|
32
|
+
'.gif': 'image/gif',
|
|
33
|
+
'.webp': 'image/webp',
|
|
34
|
+
'.pdf': 'application/pdf',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const TEXT_EXTENSIONS = Object.freeze(Object.keys(TEXT_TYPES));
|
|
38
|
+
export const BINARY_EXTENSIONS = Object.freeze(Object.keys(BINARY_TYPES));
|
|
39
|
+
|
|
40
|
+
const kindForMime = (mime) => (mime.startsWith('image/') ? 'image' : 'binary');
|
|
41
|
+
|
|
42
|
+
/** ISO 32000-1 §7.5.2 (implementation note 13): the `%PDF-` header may be
|
|
43
|
+
* preceded by up to 1024 bytes of junk (a UTF-8 BOM, print-driver or mail-
|
|
44
|
+
* gateway preamble). Acrobat and pdf.js accept such files, so the sniff does too. */
|
|
45
|
+
const PDF_HEADER_WINDOW = 1024;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Classify a lower-cased extension (with the leading dot) into {kind, mime},
|
|
49
|
+
* or null when it is not on either allowlist.
|
|
50
|
+
*/
|
|
51
|
+
export function classifyExtension(ext) {
|
|
52
|
+
if (typeof ext !== 'string') return null;
|
|
53
|
+
const e = ext.toLowerCase();
|
|
54
|
+
if (Object.prototype.hasOwnProperty.call(TEXT_TYPES, e)) return { kind: 'text', mime: TEXT_TYPES[e] };
|
|
55
|
+
if (Object.prototype.hasOwnProperty.call(BINARY_TYPES, e)) return { kind: kindForMime(BINARY_TYPES[e]), mime: BINARY_TYPES[e] };
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sniff the real mime of a binary body from its magic number, or null when the
|
|
61
|
+
* bytes match none of the accepted binary types. Text kinds are validated by
|
|
62
|
+
* UTF-8 decoding instead (ui/server.mjs), never sniffed here.
|
|
63
|
+
*/
|
|
64
|
+
export function sniffMime(buf) {
|
|
65
|
+
if (!Buffer.isBuffer(buf) || buf.length < 3) return null;
|
|
66
|
+
if (buf.length >= 8
|
|
67
|
+
&& buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47
|
|
68
|
+
&& buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a) return 'image/png';
|
|
69
|
+
// SOI (FF D8) followed by the first marker's FF and its marker byte (>= 0xC0:
|
|
70
|
+
// APPn/DQT/SOFn/…) — a bare 3-byte FF D8 FF stub is not a JPEG anything can open.
|
|
71
|
+
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff && buf[3] >= 0xc0) return 'image/jpeg';
|
|
72
|
+
if (buf.length >= 6) {
|
|
73
|
+
const head6 = buf.toString('latin1', 0, 6);
|
|
74
|
+
if (head6 === 'GIF87a' || head6 === 'GIF89a') return 'image/gif';
|
|
75
|
+
}
|
|
76
|
+
if (buf.length >= 12
|
|
77
|
+
&& buf.toString('latin1', 0, 4) === 'RIFF'
|
|
78
|
+
&& buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
|
|
79
|
+
if (buf.length >= 5) {
|
|
80
|
+
const at = buf.toString('latin1', 0, Math.min(buf.length, PDF_HEADER_WINDOW + 5)).indexOf('%PDF-');
|
|
81
|
+
if (at !== -1 && at <= PDF_HEADER_WINDOW) return 'application/pdf';
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The on-disk extension for a stored body: derived from the SNIFFED mime (or
|
|
87
|
+
* '.txt' for text kinds), never from the user-supplied name — the path stays a
|
|
88
|
+
* function of row data the store minted (store.mjs traversal guard). */
|
|
89
|
+
export function extensionForAttachment(kind, mime) {
|
|
90
|
+
if (kind === 'text' || kind == null) return '.txt';
|
|
91
|
+
for (const [ext, m] of Object.entries(BINARY_TYPES)) {
|
|
92
|
+
if (m === mime) return ext; // first match: '.jpg' wins over '.jpeg' for image/jpeg
|
|
93
|
+
}
|
|
94
|
+
return '.bin';
|
|
95
|
+
}
|