@worca/app 1.5.0-rc.3 → 1.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +7 -1
  2. package/docker/compose.broker.yml +79 -0
  3. package/docker/compose.isolation.yml +40 -0
  4. package/package.json +2 -1
  5. package/src/broker/config.mjs +200 -0
  6. package/src/broker/copilot.mjs +124 -0
  7. package/src/broker/limits.mjs +88 -0
  8. package/src/broker/main.mjs +129 -0
  9. package/src/broker/scrub.mjs +45 -0
  10. package/src/broker/service.mjs +558 -0
  11. package/src/broker/slots.mjs +180 -0
  12. package/src/broker/store.mjs +171 -0
  13. package/src/broker/tokens.mjs +116 -0
  14. package/src/broker/ui/page.css +54 -0
  15. package/src/broker/ui/page.html +25 -0
  16. package/src/broker/ui/page.mjs +202 -0
  17. package/src/broker/ui-server.mjs +217 -0
  18. package/src/broker/usage.mjs +108 -0
  19. package/src/broker/vault.mjs +37 -0
  20. package/src/cli/models.mjs +46 -14
  21. package/src/cli/render.mjs +21 -0
  22. package/src/cli/runs.mjs +296 -0
  23. package/src/cli/worca-cc.mjs +14 -2
  24. package/src/core/agent-pool.mjs +76 -0
  25. package/src/core/artifacts.mjs +40 -8
  26. package/src/core/ask/events.mjs +11 -0
  27. package/src/core/ask/html-text.mjs +113 -0
  28. package/src/core/ask/limits.mjs +5 -0
  29. package/src/core/ask/mcp-stdio.mjs +74 -20
  30. package/src/core/ask/prompt.mjs +25 -6
  31. package/src/core/ask/spawn.mjs +55 -5
  32. package/src/core/ask/store.mjs +1 -1
  33. package/src/core/ask/tools.mjs +66 -1
  34. package/src/core/ask/turn.mjs +52 -3
  35. package/src/core/ask/web-access.mjs +26 -0
  36. package/src/core/ask/web-deps.mjs +56 -0
  37. package/src/core/ask/web-fetch.mjs +271 -0
  38. package/src/core/ask/web-proposal.mjs +76 -0
  39. package/src/core/auto/classify.mjs +20 -8
  40. package/src/core/auto/runnable.mjs +28 -0
  41. package/src/core/billing.mjs +53 -0
  42. package/src/core/bridge/errors.mjs +77 -5
  43. package/src/core/bridge/openrouter.mjs +59 -0
  44. package/src/core/bridge/provider-ops.mjs +165 -9
  45. package/src/core/bridge/providers/endpoint.mjs +112 -7
  46. package/src/core/bridge/registry.mjs +13 -0
  47. package/src/core/bridge/server.mjs +16 -3
  48. package/src/core/bridge/telemetry.mjs +44 -12
  49. package/src/core/bridge/translate/common.mjs +31 -0
  50. package/src/core/bridge/translate/request.mjs +4 -1
  51. package/src/core/bridge/translate/response.mjs +3 -0
  52. package/src/core/bridge/translate/schema-keywords.mjs +75 -0
  53. package/src/core/bridge/translate/stream.mjs +50 -4
  54. package/src/core/bridge/upstream.mjs +102 -17
  55. package/src/core/broker-boot.mjs +57 -0
  56. package/src/core/broker-client.mjs +206 -0
  57. package/src/core/broker-guard.mjs +112 -0
  58. package/src/core/broker-routing.mjs +138 -0
  59. package/src/core/claude-auth.mjs +29 -0
  60. package/src/core/claude-runner.mjs +200 -9
  61. package/src/core/config.mjs +9 -12
  62. package/src/core/failure-policy.mjs +4 -2
  63. package/src/core/git-info.mjs +49 -5
  64. package/src/core/github-credentials.mjs +44 -1
  65. package/src/core/graph/script-runner.mjs +3 -1
  66. package/src/core/list-prices.mjs +29 -0
  67. package/src/core/mcp-secrets.mjs +80 -0
  68. package/src/core/metrics/sync.mjs +2 -1
  69. package/src/core/model-env.mjs +72 -0
  70. package/src/core/model-test.mjs +17 -5
  71. package/src/core/onboarding.mjs +12 -6
  72. package/src/core/openrouter-free.mjs +159 -0
  73. package/src/core/orchestrator.mjs +100 -12
  74. package/src/core/policy/effective.mjs +18 -1
  75. package/src/core/policy/local.mjs +4 -1
  76. package/src/core/policy/registry.mjs +12 -2
  77. package/src/core/preflight.mjs +116 -0
  78. package/src/core/recoverable-error.mjs +95 -0
  79. package/src/core/recovery-backoff.mjs +84 -0
  80. package/src/core/redact.mjs +25 -0
  81. package/src/core/run-context.mjs +6 -0
  82. package/src/core/run-harness.mjs +112 -30
  83. package/src/core/run-report.mjs +2 -1
  84. package/src/core/settings.mjs +90 -5
  85. package/src/core/title.mjs +7 -2
  86. package/src/core/web-allowlist.mjs +95 -0
  87. package/ui/public/app.js +244 -43
  88. package/ui/public/ask-model.mjs +1 -0
  89. package/ui/public/ask-panel.mjs +160 -23
  90. package/ui/public/bridge-view.mjs +175 -6
  91. package/ui/public/chat-settings-view.mjs +52 -1
  92. package/ui/public/credential-badges.mjs +63 -0
  93. package/ui/public/credentials-view.mjs +57 -0
  94. package/ui/public/index.html +33 -6
  95. package/ui/public/models-view.mjs +19 -1
  96. package/ui/public/openrouter-free-view.mjs +118 -0
  97. package/ui/public/stats-view.mjs +56 -0
  98. package/ui/public/style.css +99 -50
  99. package/ui/public/team-policy-view.mjs +2 -1
  100. package/ui/public/ws-seq.mjs +24 -0
  101. package/ui/server.mjs +319 -23
@@ -10,7 +10,7 @@ import { createInterface } from 'node:readline';
10
10
  import {
11
11
  providersState, beginCopilotLogin, pollCopilotLogin, copilotLogout, acknowledgeTerms,
12
12
  copilotModelsForImport, importCopilotModels, testProviderConnection, patchProvider,
13
- endpointModelsForImport, importEndpointModels,
13
+ endpointModelsForImport, importEndpointModels, OPENROUTER_BASE_URL,
14
14
  } from '../core/bridge/provider-ops.mjs';
15
15
  import { listModels } from '../core/config.mjs';
16
16
  import { isTranslatedApi } from '../core/model-env.mjs';
@@ -26,13 +26,21 @@ Usage:
26
26
  worca models import copilot [--all | --pick <id,id,…>] [--yes]
27
27
  Add Copilot models to the catalog
28
28
  worca models import openai [--base-url <url>] [--all | --pick <id,id,…>] [--yes]
29
+ [--search <text>] [--free] [--tools] [--min-context <n|64k>]
29
30
  Add what an OpenAI-compatible endpoint serves
30
- (llama.cpp, Ollama, LM Studio, vLLM, a gateway)
31
- worca models test <provider> Reachability + auth check (copilot|openai|anthropic)
31
+ (llama.cpp, Ollama, LM Studio, vLLM, OpenRouter, a gateway);
32
+ the filters narrow the listing and --all
33
+ worca models import openrouter … The same, against https://openrouter.ai/api/v1
34
+ worca models test <provider> Reachability + auth check (copilot|openai|anthropic|openrouter);
35
+ for OpenRouter also the key's credit and free-model allowance
32
36
  worca models set <provider> <key>=<value> …
33
37
  Patch a provider: accountType, maxConcurrent, baseUrl, apiKey
38
+ worca models set openrouter apiKey='\${OPENROUTER_KEY}'
39
+ The openai provider, pointed at OpenRouter
34
40
 
35
41
  Notes:
42
+ OpenRouter is the openai (OpenAI-compatible) provider with OpenRouter's base URL — one key and
43
+ one concurrency cap. A model can still point elsewhere on its own Connection.
36
44
  Copilot: GitHub allows Copilot only through supported clients and has suspended
37
45
  access for automated use. \`login\` prints the notice; --accept-terms records the
38
46
  acknowledgement non-interactively. Pipelines are automated, high-volume use.
@@ -122,8 +130,13 @@ const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.un
122
130
  * @returns {Promise<number>} exit code
123
131
  */
124
132
  export async function cmdModels(argv, { out, c, fail, sleep: wait = sleep }) {
125
- const args = modelsArgs(argv, ['--pick', '--base-url'], ['--accept-terms', '--all', '--yes', '-h', '--help'], fail);
133
+ const args = modelsArgs(argv, ['--pick', '--base-url', '--search', '--min-context'], ['--accept-terms', '--all', '--yes', '--free', '--tools', '-h', '--help'], fail);
126
134
  const [verb, ...rest] = args._;
135
+ // `openrouter` is not a provider of its own: it is the openai provider at OpenRouter's URL, so
136
+ // every verb below sees `openai` and a base URL that defaults to OpenRouter's.
137
+ const viaOpenRouter = rest[0] === 'openrouter';
138
+ if (viaOpenRouter) rest[0] = 'openai';
139
+ const baseUrlArg = args['base-url'] || (viaOpenRouter ? OPENROUTER_BASE_URL : '');
127
140
  if (!verb || args.help || args.h || verb === 'help') { out(MODELS_HELP); return 0; }
128
141
 
129
142
  if (verb === 'list') {
@@ -174,23 +187,36 @@ export async function cmdModels(argv, { out, c, fail, sleep: wait = sleep }) {
174
187
  if (verb === 'import' && provider === 'openai') {
175
188
  // §8.4 for a server you run: the endpoint is asked what it serves, and only a window it
176
189
  // really serves becomes a prompt limit — the rest is printed for the user to judge.
190
+ let minCtx = 0;
191
+ if (args['min-context'] !== undefined) {
192
+ const mm = /^(\d+(?:\.\d+)?)([km]?)$/i.exec(String(args['min-context']).trim());
193
+ if (!mm) return fail('--min-context takes a token count: 65536, 64k or 1m');
194
+ minCtx = Math.round(Number(mm[1]) * ({ k: 1000, m: 1e6 }[mm[2].toLowerCase()] || 1));
195
+ }
177
196
  let out0;
178
- try { out0 = await endpointModelsForImport({ baseUrl: args['base-url'] || '' }); } catch (err) { return fail(err.message || String(err)); }
197
+ try { out0 = await endpointModelsForImport({ baseUrl: baseUrlArg }); } catch (err) { return fail(err.message || String(err)); }
179
198
  const fmtCtx = (m) => (m.servedContext ? `${Math.round(m.servedContext / 1000)}k` : m.trainedContext ? `?/${Math.round(m.trainedContext / 1000)}k` : '-');
199
+ // A hosted catalog is hundreds of rows: the filters narrow the listing AND what --all takes.
200
+ const q = String(args.search || '').trim().toLowerCase();
201
+ const shown = out0.models.filter((m) => (!q || m.id.toLowerCase().includes(q) || String(m.name || '').toLowerCase().includes(q))
202
+ && (!args.free || m.free === true) && (!args.tools || m.toolCalls === true) && (!minCtx || (m.servedContext || 0) >= minCtx));
203
+ const hosted = out0.server === 'openrouter';
180
204
  let ids;
181
205
  if (args.pick) ids = String(args.pick).split(',').map((x) => x.trim()).filter(Boolean);
182
- else if (args.all) ids = out0.models.filter((m) => m.importable).map((m) => m.id);
206
+ else if (args.all) ids = shown.filter((m) => m.importable).map((m) => m.id);
183
207
  else {
184
- out(`${out0.serverLabel} at ${out0.baseUrl} (pass --all, or --pick id,id,…):`);
185
- for (const m of out0.models) {
186
- out(` ${m.id.padEnd(34)} ${fmtCtx(m).padStart(7)} ${m.toolCalls ? 'tools' : m.toolCalls === null ? 'tools?' : ' '} ${m.vision ? 'vision' : ' '}${m.loaded === true ? ' loaded' : ''}${m.inCatalog ? ' (in catalog)' : ''}${m.importable ? '' : ` (${m.blocked})`}`);
208
+ out(`${out0.serverLabel} at ${out0.baseUrl} (pass --all, or --pick id,id,…)${shown.length < out0.models.length ? ` — ${shown.length} of ${out0.models.length} match` : ''}:`);
209
+ // One id column as wide as the longest shown id (OpenRouter's run past 34).
210
+ const idWidth = Math.max(34, ...shown.map((m) => m.id.length));
211
+ for (const m of shown) {
212
+ out(` ${m.id.padEnd(idWidth)} ${fmtCtx(m).padStart(7)} ${m.toolCalls ? 'tools' : m.toolCalls === null ? 'tools?' : ' '} ${m.vision ? 'vision' : ' '}${m.loaded === true ? ' loaded' : ''}${hosted && m.detail ? ` ${m.detail}` : ''}${m.inCatalog ? ' (in catalog)' : ''}${m.importable ? '' : ` (${m.blocked})`}`);
187
213
  }
188
214
  for (const w of out0.warnings) out(c('yellow', ` ! ${w}`));
189
215
  return 0;
190
216
  }
191
217
  for (const w of out0.warnings) out(c('yellow', `! ${w}`));
192
218
  if (!(await confirm(`Import ${ids.length} model${ids.length === 1 ? '' : 's'} into the catalog?`, args.yes, { c }))) return fail('cancelled');
193
- const r = await importEndpointModels(ids, { baseUrl: args['base-url'] || '' });
219
+ const r = await importEndpointModels(ids, { baseUrl: baseUrlArg });
194
220
  for (const id of r.created) out(` + ${id}`);
195
221
  for (const id of r.updated) out(` ~ ${id} (upstream refreshed)`);
196
222
  for (const sk of r.skipped) out(` - ${sk.id} (skipped: ${sk.why})`);
@@ -222,10 +248,15 @@ export async function cmdModels(argv, { out, c, fail, sleep: wait = sleep }) {
222
248
  }
223
249
 
224
250
  if (verb === 'test') {
225
- if (!provider) return fail('usage: worca models test <copilot|openai|anthropic>');
226
- const r = await testProviderConnection(provider);
227
- if (r.ok) { out(c('green', `✓ ${provider} reachable${r.models != null ? ` — ${r.models} models listed` : ''}`)); return 0; }
228
- out(c('red', `✗ ${provider}: ${r.message}`));
251
+ if (!provider) return fail('usage: worca models test <copilot|openai|anthropic|openrouter>');
252
+ const label = viaOpenRouter ? 'openrouter' : provider;
253
+ const r = await testProviderConnection(provider, viaOpenRouter ? { baseUrl: baseUrlArg } : {});
254
+ if (r.ok) {
255
+ out(c('green', `✓ ${label} reachable${r.models != null ? ` — ${r.models} models listed` : ''}`));
256
+ if (r.detail) out(` key: ${r.detail}`);
257
+ return 0;
258
+ }
259
+ out(c('red', `✗ ${label}: ${r.message}`));
229
260
  return 1;
230
261
  }
231
262
 
@@ -238,6 +269,7 @@ export async function cmdModels(argv, { out, c, fail, sleep: wait = sleep }) {
238
269
  const k = kv.slice(0, i); const v = kv.slice(i + 1);
239
270
  patch[k] = k === 'maxConcurrent' ? Number(v) : v;
240
271
  }
272
+ if (viaOpenRouter && patch.baseUrl === undefined) patch.baseUrl = OPENROUTER_BASE_URL;
241
273
  if (!Object.keys(patch).length) return fail('nothing to set');
242
274
  try { await patchProvider(provider, patch); } catch (err) { return fail(err.message || String(err)); }
243
275
  for (const line of formatProviders(await providersState())) out(line);
@@ -122,6 +122,27 @@ export function formatTotals({ executions = 0, activeMs = 0, costUsd = 0 } = {})
122
122
  return `${executions} execution${executions === 1 ? '' : 's'} · ${fmtDur(activeMs)} active · ${usd(costUsd)}`;
123
123
  }
124
124
 
125
+ /** The count-only twin of formatRunSummary's execution rows: v2 stepper rows that
126
+ * carry a real executionId (bookends excluded). null for a v1/no-stepper run —
127
+ * callers omit the count rather than claim one. */
128
+ export function executionCount(state) {
129
+ const st = state || {};
130
+ if (!(st.stepper && st.stepper.version === 2)) return null;
131
+ return (Array.isArray(st.steps) ? st.steps : [])
132
+ .filter((s) => s && s.executionId && !BOOKEND_EXECUTION_IDS.includes(s.executionId)).length;
133
+ }
134
+
135
+ /** The loop-delivery total the History Overview's DURATION sub-line renders
136
+ * (ui/public/graph/run-decor.mjs): the sum over the stepper's LOOP wires of the
137
+ * run's wireDeliveries count. null without a v2 stepper. */
138
+ export function loopDeliveries(state) {
139
+ const st = state || {};
140
+ if (!(st.stepper && st.stepper.version === 2)) return null;
141
+ const wires = (st.stepper.graph && st.stepper.graph.wires) || [];
142
+ const deliveries = st.wireDeliveries && typeof st.wireDeliveries === 'object' ? st.wireDeliveries : {};
143
+ return wires.reduce((a, w) => a + (w.loop ? (Number(deliveries[w.id]) || 0) : 0), 0);
144
+ }
145
+
125
146
  /**
126
147
  * The v2 run summary the CLI prints under `Pipeline complete.` — pure, so the
127
148
  * quiescence arm and the executions count are testable without spawning a run.
@@ -0,0 +1,296 @@
1
+ // src/cli/runs.mjs
2
+ // `worca runs` — list and inspect pipeline runs from the terminal (issue #481).
3
+ //
4
+ // The CLI could START, RESUME and SCHEDULE runs but not SEE them: the run list
5
+ // lived only in the web UI (ui/server.mjs /api/runs + /api/history) and the
6
+ // store. This module reads the same store directly — listAllPipelines() for the
7
+ // list (the exact reader /api/history uses, in `lite` form: no git/gh spawns),
8
+ // a prefix lookup on the pipelines table for the detail view — so listing works
9
+ // with NO Worca server up. Only the `worca ui` deep link at the bottom of the
10
+ // detail view needs a server (and it is just a URL, not a request).
11
+
12
+ import { basename } from 'node:path';
13
+
14
+ import { getDb } from '../core/db.mjs';
15
+ import { listAllPipelines, readPipelineByKey, readStoreMeta } from '../core/artifacts.mjs';
16
+ import { readUiInstance } from '../core/ui-instance.mjs';
17
+ import { fmtDur, executionCount, loopDeliveries } from './render.mjs';
18
+ import { formatInstant } from '../shared/schedule/recurrence.mjs';
19
+
20
+ export const RUNS_HELP = `worca runs — list and inspect pipeline runs
21
+
22
+ Usage:
23
+ worca runs All runs across projects, newest first
24
+ worca runs --status <s> Filter by status: created | running | paused |
25
+ stopped | interrupted | done
26
+ worca runs --project <name> Filter by project name or key
27
+ worca runs <id> One run in detail (any unique prefix)
28
+ worca runs show <id> One run in detail (same as "worca runs <id>")
29
+ worca runs --json Machine-readable output (list and detail)
30
+
31
+ Reads come straight from the Worca store — no Worca server needs to be up.
32
+ A paused run shows its pause reason next to the status; the detail view adds
33
+ the full message.
34
+ `;
35
+
36
+ /** The statuses a pipelines row carries today (writeState / reconcileStaleRunning). */
37
+ const STATUSES = ['created', 'running', 'paused', 'stopped', 'interrupted', 'done'];
38
+
39
+ const STATUS_COLOR = { running: 'cyan', paused: 'yellow', interrupted: 'yellow', done: 'green', stopped: 'red' };
40
+
41
+ const usd = (n) => `$${(Number(n) || 0).toFixed(2)}`;
42
+
43
+ /** "just now" / "5m ago" / "2h ago" / "3d ago" — the list's time column. */
44
+ function ago(iso) {
45
+ const ms = Date.parse(iso);
46
+ if (!Number.isFinite(ms)) return '—';
47
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
48
+ if (s < 60) return 'just now';
49
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
50
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
51
+ return `${Math.floor(s / 86400)}d ago`;
52
+ }
53
+
54
+ const oneLine = (s, max = 60) => {
55
+ const line = String(s || '').split('\n').map((l) => l.trim()).find(Boolean) || '';
56
+ return line.length > max ? `${line.slice(0, max)}…` : line;
57
+ };
58
+
59
+ /** `--status paused` and `--status=paused` both land here; null when unset. */
60
+ function valueFlag(rest, name) {
61
+ const i = rest.indexOf(`--${name}`);
62
+ const inline = rest.find((a) => a.startsWith(`--${name}=`));
63
+ if (inline !== undefined) return inline.slice(name.length + 3);
64
+ if (i !== -1) {
65
+ const v = rest[i + 1];
66
+ if (v === undefined || v.startsWith('-')) return null; // a flag, not a value
67
+ return v;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * `<id or prefix>` -> the one pipelines row, straight from the DB (the
74
+ * resolveAfterId idiom: LIKE with escaped metacharacters, capped at 21 so the
75
+ * ambiguity count stays exact). `unknownVerb` only changes the not-found
76
+ * message: a bare `worca runs lst` must not read as "your runs are gone".
77
+ */
78
+ function resolveRunRef(ref, fail, { unknownVerb = false } = {}) {
79
+ const q = String(ref || '').trim();
80
+ if (!q) fail('an id is required (see: worca runs list)');
81
+ const hits = getDb().prepare(
82
+ "SELECT id FROM pipelines WHERE id LIKE ? ESCAPE '\\' ORDER BY COALESCE(updated_at, started_at) DESC LIMIT 21",
83
+ ).all(`${q.replace(/[\\%_]/g, '\\$&')}%`);
84
+ if (!hits.length) {
85
+ fail(unknownVerb
86
+ ? `no run matches "${q}", and "${q}" is not a known verb either (see: worca runs help)`
87
+ : `no run matches "${q}" (see: worca runs list)`);
88
+ }
89
+ if (hits.length > 1) fail(`"${q}" matches ${hits.length > 20 ? 'more than 20' : hits.length} runs — use a longer id`);
90
+ return hits[0].id;
91
+ }
92
+
93
+ /** The store key a row lives under (workspaces route to their own subtree). */
94
+ const storeKeyOf = (row) => (row.target === 'workspace' && row.workspace_key ? row.workspace_key : row.project_key);
95
+
96
+ /** The list/detail "project" label: the registered name, else the raw store key. */
97
+ function projectLabel(row) {
98
+ const m = readStoreMeta(storeKeyOf(row));
99
+ const name = m && (m.name || m.workspaceName);
100
+ return name ? String(name) : storeKeyOf(row);
101
+ }
102
+
103
+ /** `worca ui`'s URL for one run's detail screen (app.js routes `#history/<key>/<id>`). */
104
+ function uiUrl(row) {
105
+ const inst = readUiInstance();
106
+ if (!inst) return null;
107
+ const host = !inst.host || inst.host === '0.0.0.0' ? 'localhost' : inst.host;
108
+ return `http://${host}:${inst.port}/#history/${encodeURIComponent(storeKeyOf(row))}/${row.id}`;
109
+ }
110
+
111
+ // ── list ────────────────────────────────────────────────────────────────────────
112
+
113
+ async function runsList(rest, { out, c, fail }) {
114
+ // `--status`/`--project` given but valueless fail here, not silently as "unset".
115
+ for (const name of ['status', 'project']) {
116
+ if (rest.includes(`--${name}`) && valueFlag(rest, name) === null && !rest.some((a) => a.startsWith(`--${name}=`))) {
117
+ fail(`--${name} needs a value (see: worca runs help)`);
118
+ }
119
+ }
120
+ const status = valueFlag(rest, 'status');
121
+ const project = valueFlag(rest, 'project');
122
+ const json = rest.includes('--json');
123
+ const unknown = rest.filter((a) => a.startsWith('-')
124
+ && !['--json', '--status', '--project'].includes(a)
125
+ && !a.startsWith('--status=') && !a.startsWith('--project='));
126
+ if (unknown.length) fail(`unknown option(s): ${unknown.join(' ')} — see: worca runs help`);
127
+ if (status !== null && !STATUSES.includes(String(status).toLowerCase())) {
128
+ fail(`--status must be one of ${STATUSES.join(', ')}, got: ${status}`);
129
+ }
130
+
131
+ const entries = (await listAllPipelines({ lite: true })) || [];
132
+ const q = String(project || '').toLowerCase();
133
+ const runs = entries.filter((e) => {
134
+ if (status !== null && String(e.status || '').toLowerCase() !== String(status).toLowerCase()) return false;
135
+ if (q && !`${e.projectName}`.toLowerCase().includes(q) && !`${e.projectKey}`.toLowerCase().includes(q)) return false;
136
+ return true;
137
+ });
138
+
139
+ if (json) { out(JSON.stringify(runs, null, 2)); return 0; }
140
+ if (!runs.length) {
141
+ out(entries.length
142
+ ? 'No runs match the given filters (see: worca runs help).'
143
+ : 'No pipeline runs yet. Start one with: worca --prompt "<task>"');
144
+ return 0;
145
+ }
146
+ const rows = [];
147
+ for (const r of runs) {
148
+ const st = r.pauseReason
149
+ ? `${r.status} (${String(r.pauseReason).replace(/_/g, ' ')})`
150
+ : String(r.status || 'unknown');
151
+ rows.push({ id: r.id, status: st, color: STATUS_COLOR[r.status] || '', started: ago(r.startedAt), project: oneLine(r.projectName || r.projectKey || '—', 24), title: oneLine(r.title) });
152
+ }
153
+ // Padded columns, widths from the data (capped where a value can be long).
154
+ // Widths are computed on PLAIN text; the status cell is colored after padding,
155
+ // so ANSI codes never break the alignment.
156
+ const HEADER = ['ID', 'STATUS', 'STARTED', 'PROJECT', 'TITLE'];
157
+ const cell = (r) => [r.id, r.status, r.started, r.project, r.title];
158
+ const width = (i) => Math.max(HEADER[i].length, ...rows.map((r) => cell(r)[i].length));
159
+ const [wId, wStatus, wStarted, wProject] = [width(0), width(1), width(2), width(3)];
160
+ out(c('bold', ` ${'ID'.padEnd(wId)} ${'STATUS'.padEnd(wStatus)} ${'STARTED'.padEnd(wStarted)} ${'PROJECT'.padEnd(wProject)} TITLE`));
161
+ for (const r of rows) {
162
+ const [id, status, started, project, title] = cell(r);
163
+ out(` ${id.padEnd(wId)} ${c(r.color, status.padEnd(wStatus))} ${started.padEnd(wStarted)} ${project.padEnd(wProject)} ${title}`);
164
+ }
165
+ return 0;
166
+ }
167
+
168
+ // ── detail ──────────────────────────────────────────────────────────────────────
169
+
170
+ async function runsShow(argv, { out, c, fail, unknownVerb = false }) {
171
+ const json = argv.includes('--json');
172
+ const unknown = argv.filter((a) => a.startsWith('-') && a !== '--json');
173
+ if (unknown.length) fail(`unknown option(s): ${unknown.join(' ')} — see: worca runs help`);
174
+ const ref = argv.find((a) => !a.startsWith('-'));
175
+ const id = resolveRunRef(ref, fail, { unknownVerb });
176
+
177
+ // One row by id — the same columns listAllPipelines SELECTs, plus phase/cycle
178
+ // for the detail view. pauseReason/pauseDetail live in the resume_point JSON.
179
+ const row = getDb().prepare(`
180
+ SELECT id, project_key, workspace_key, target, title, status, phase, cycle,
181
+ started_at, updated_at, total_cost_usd, total_active_ms, started_by, prompt, branch,
182
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
183
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
184
+ FROM pipelines WHERE id = ?
185
+ `).get(id);
186
+ if (!row) fail(`no run matches "${ref}" (see: worca runs list)`);
187
+
188
+ // The same full read the web UI's detail screen does (run-report readPipelineByKey),
189
+ // for the two fields it shows that the DB row alone cannot answer: the persisted
190
+ // diff summary (+A −R, results.json) and the End result. Nulls mean "not there".
191
+ const full = await readPipelineByKey(storeKeyOf(row), id).catch(() => null);
192
+ const sums = full && full.results && full.results.summary ? full.results.summary : null;
193
+ const added = sums ? sums.linesAdded : null;
194
+ const removed = sums ? sums.linesRemoved : null;
195
+ const st = (full && full.state) || {};
196
+ const resultPath = st.endReached === true && st.result && st.result.path ? String(st.result.path) : null;
197
+ const completed = st.endReached === true;
198
+ // The identity convention (identity.mjs, as schedule.mjs' person()): 'local' is
199
+ // the machine, never shown — only a real person's name is worth a line.
200
+ const startedBy = typeof row.started_by === 'string' && row.started_by && row.started_by !== 'local' ? row.started_by : null;
201
+ const executions = executionCount(st);
202
+ const loops = loopDeliveries(st);
203
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
204
+ const startedClock = Number.isFinite(Date.parse(row.started_at))
205
+ ? formatInstant(Date.parse(row.started_at), tz)
206
+ : null;
207
+
208
+ const branch = (() => {
209
+ try { const b = JSON.parse(row.branch); return b && typeof b === 'object' ? b : null; } catch { return null; }
210
+ })();
211
+ const link = uiUrl(row);
212
+
213
+ if (json) {
214
+ out(JSON.stringify({
215
+ id: row.id,
216
+ title: row.title ?? row.id,
217
+ project: { key: storeKeyOf(row), name: projectLabel(row) },
218
+ status: row.status,
219
+ phase: row.phase,
220
+ cycle: row.cycle,
221
+ pauseReason: row.pause_reason ?? null,
222
+ pauseDetail: row.pause_detail ?? null,
223
+ startedAt: row.started_at ?? null,
224
+ updatedAt: row.updated_at ?? null,
225
+ costUsd: Number(row.total_cost_usd) || 0,
226
+ activeMs: Number(row.total_active_ms) || 0,
227
+ executions,
228
+ loopDeliveries: loops,
229
+ sourceBranch: (branch && branch.source) || null,
230
+ featureBranch: (branch && branch.feature) || null,
231
+ linesAdded: added,
232
+ linesRemoved: removed,
233
+ resultPath,
234
+ startedBy,
235
+ uiUrl: link,
236
+ }, null, 2));
237
+ return 0;
238
+ }
239
+
240
+ out(c('bold', row.title || row.id));
241
+ out(` id ${row.id}`);
242
+ out(` project ${projectLabel(row)}${row.target === 'workspace' ? ` (${basename(String(row.workspace_key || ''))})` : ''}`);
243
+ const where = row.pause_detail ? ` — ${row.pause_detail}` : '';
244
+ out(` status ${row.status}${row.pause_reason ? ` (${String(row.pause_reason).replace(/_/g, ' ')})${where}` : ''}`);
245
+ // The cycle scalar is harness-local (run-harness _phase) — the UI never shows
246
+ // it either; it surfaces loop traffic as "N loop deliveries" instead. JSON keeps it.
247
+ out(` phase ${row.phase || '—'}`);
248
+ out(` started ${ago(row.started_at)}${startedClock ? ` (${startedClock})` : ''}`);
249
+ const updatedClock = Number.isFinite(Date.parse(row.updated_at))
250
+ ? formatInstant(Date.parse(row.updated_at), tz)
251
+ : null;
252
+ if (row.updated_at) out(` updated ${ago(row.updated_at)}${updatedClock ? ` (${updatedClock})` : ''}`);
253
+ // The DURATION sub-line's counts, the web UI's wording (app.js histCountsLine):
254
+ // `9 executions · 2 loop deliveries`.
255
+ const counts = [];
256
+ if (executions != null) counts.push(`${executions} execution${executions === 1 ? '' : 's'}`);
257
+ if (loops != null) counts.push(`${loops} loop deliver${loops === 1 ? 'y' : 'ies'}`);
258
+ out(` duration ${fmtDur(row.total_active_ms)}${counts.length ? ` · ${counts.join(' · ')}` : ''}`);
259
+ // COST's sub-line, the web UI's wording (app.js: `across ${steps.length} steps`).
260
+ const steps = Array.isArray(st.steps) ? st.steps : [];
261
+ out(` cost ${usd(row.total_cost_usd)}${steps.length ? ` · across ${steps.length} step${steps.length === 1 ? '' : 's'}` : ''}`);
262
+ if (branch && (branch.feature || branch.source)) {
263
+ // The web UI's order (hd-base: `dev → worca-cc/…`), for one mental model.
264
+ const b = branch.source && branch.feature
265
+ ? `${branch.source} → ${branch.feature}`
266
+ : (branch.feature || `from ${branch.source}`);
267
+ out(` branch ${b}`);
268
+ }
269
+ if (added != null && removed != null) out(` changes +${added} -${removed}`);
270
+ if (resultPath) out(` result ${resultPath}`);
271
+ else if (completed) out(' result completed');
272
+ if (startedBy) out(` by ${startedBy}`);
273
+ const task = oneLine(row.prompt, 100);
274
+ if (task) out(` task ${task}`);
275
+ if (link) out(c('gray', ` open ${link}`)); // only when a Worca server is recorded — no nag line otherwise
276
+ return 0;
277
+ }
278
+
279
+ // ── dispatch ────────────────────────────────────────────────────────────────────
280
+
281
+ /**
282
+ * `worca runs` — the bare command lists; a known verb dispatches; anything else
283
+ * is read as a run id (unique prefix). Unknown input never falls through to a
284
+ * full list: it resolves as an id first and the combined error names BOTH
285
+ * readings, so a typo'd verb cannot masquerade as a missing run.
286
+ * @returns {Promise<number>} exit code
287
+ */
288
+ export async function cmdRuns(argv, { out, c, fail }) {
289
+ const verb = argv[0];
290
+ const rest = argv.slice(1);
291
+ if (verb === 'help' || verb === '--help' || verb === '-h') { process.stdout.write(RUNS_HELP); return 0; }
292
+ if (verb === 'list') return runsList(rest, { out, c, fail });
293
+ if (verb === 'show') return runsShow(rest, { out, c, fail });
294
+ if (!verb || verb.startsWith('-')) return runsList(argv, { out, c, fail }); // flags belong to the list
295
+ return runsShow(argv, { out, c, fail, unknownVerb: true }); // a run id (or a typo)
296
+ }
@@ -38,6 +38,7 @@ import { collectAnswer } from '../shared/forms/answer.mjs';
38
38
  import { pauseExitCode, describePauseReason, promptOptions, REASON } from '../core/failure-policy.mjs';
39
39
  import { effectiveDebugSpawn } from '../core/settings.mjs';
40
40
  import { SCHEDULE_VALUE_FLAGS, wantsSchedule, readScheduleFlags, createFromFlags, waitAndRun, cmdSchedule } from './schedule.mjs';
41
+ import { cmdRuns } from './runs.mjs';
41
42
  import { cmdModels } from './models.mjs';
42
43
  import { cmdContainer } from './container.mjs';
43
44
  import {
@@ -259,6 +260,8 @@ Subcommands:
259
260
  resume <pipelineId> Continue a paused pipeline (re-attaches Claude sessions).
260
261
  [--ignore-cost-cap] Resume past this pipeline's cost cap (persists on the run).
261
262
  [--past-team-cap] Continue past a TEAM cap (soft; recorded to team metrics). Add --reason "<why>".
263
+ runs [list|show|<id>] List pipeline runs across projects, or show one in detail
264
+ (any unique prefix; --json for machines). See: worca runs help
262
265
  doctor Reconcile crashed runs and sweep leftover run roots.
263
266
  plugin <cmd> [...] Manage plugins: add|install|list|update|remove|purge|enable|
264
267
  disable|doctor|link|reimport|init|validate|exec. See: worca plugin help
@@ -277,7 +280,10 @@ Subcommands:
277
280
  See: worca models help
278
281
  container <cmd> [...] Run Worca in a container: init|up|down|status|logs|pull|login|shell|run|where.
279
282
  See: worca container help (docs/docker.md)
280
- help Print this help (same as --help).
283
+ broker [serve|secrets|revoke --person <email>]
284
+ The credential broker: holds model keys outside worca's container
285
+ (docs/credential-broker.md)
286
+ help Print this help (same as --help).
281
287
  version Print the version (same as --version).
282
288
 
283
289
  Options:
@@ -3104,7 +3110,7 @@ async function drainMetricsFlushes() {
3104
3110
 
3105
3111
  // ── main ──────────────────────────────────────────────────────────────────────────
3106
3112
 
3107
- const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow', 'metrics', 'script', 'policy', 'schedule', 'models', 'container']);
3113
+ const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'runs', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow', 'metrics', 'script', 'policy', 'schedule', 'models', 'container', 'broker']);
3108
3114
 
3109
3115
  /** Levenshtein distance, two-row. Only ever called on short argv tokens. */
3110
3116
  function editDistance(a, b) {
@@ -3158,6 +3164,7 @@ async function main() {
3158
3164
  if (sub === 'list') return cmdList();
3159
3165
  if (sub === 'remove') return cmdRemove(rest);
3160
3166
  if (sub === 'resume') return cmdResume(rest);
3167
+ if (sub === 'runs') return cmdRuns(rest, { out, c, fail });
3161
3168
  if (sub === 'doctor') return cmdDoctor();
3162
3169
  if (sub === 'plugin') return cmdPlugin(rest);
3163
3170
  if (sub === 'marketplace') return cmdMarketplace(rest);
@@ -3170,6 +3177,11 @@ async function main() {
3170
3177
  if (sub === 'schedule') return cmdSchedule(rest, { out, c, fail });
3171
3178
  if (sub === 'models') return cmdModels(rest, { out, c, fail });
3172
3179
  if (sub === 'container') return cmdContainer(rest, { out, c, fail });
3180
+ if (sub === 'broker') {
3181
+ // Loaded lazily: the broker is its own process and needs none of the core graph.
3182
+ const { runBrokerCli } = await import('../broker/main.mjs');
3183
+ return runBrokerCli(rest);
3184
+ }
3173
3185
  }
3174
3186
  // `worca --ui [...]` is the historical spelling of `worca ui start [...]`; hand the
3175
3187
  // remaining tokens to the ui parser so --port/--open/--mock work with either.
@@ -0,0 +1,76 @@
1
+ // src/core/agent-pool.mjs
2
+ // One agent user per signed-in person (credential broker, docs/credential-broker.md).
3
+ // With a single `worca-agent`, every person's agents share one uid and can read each
4
+ // other's processes — including the broker token in a live spawn's environment. The
5
+ // entrypoint creates a pool (worca-agent-01..16, WORCA_AGENT_POOL) and a HOME for each
6
+ // (WORCA_AGENT_HOMES/<user>); this module gives each person a user of their own, for good:
7
+ // Claude Code keeps a person's sessions in that user's HOME, so a resumed chat or run must
8
+ // land on the same one. Assignments are stored in <worca home>/agent-pool.json.
9
+ //
10
+ // Nobody in particular ('local', no signed-in person) keeps the shared WORCA_AGENT_USER.
11
+ // More people than users: the rest share by a stable hash, with one warning — still
12
+ // better than one uid for everyone, and the operator can raise the pool.
13
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
+ import { createHash } from 'node:crypto';
15
+ import { isAbsolute, join } from 'node:path';
16
+ import { agentIdentity } from './agent-user.mjs';
17
+ import { worcaHome } from './projects.mjs';
18
+
19
+ const USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
20
+ let warnedFull = false;
21
+
22
+ /** {users, homes} when a pool is configured, else null. Pure: reads only `env`. */
23
+ export function agentPool(env = process.env) {
24
+ const users = String(env.WORCA_AGENT_POOL || '').split(',').map((s) => s.trim()).filter((u) => USER_RE.test(u));
25
+ const homes = String(env.WORCA_AGENT_HOMES || '').trim();
26
+ if (!users.length || !isAbsolute(homes)) return null;
27
+ return { users, homes };
28
+ }
29
+
30
+ /**
31
+ * The pool user for `person` given the current assignments. Pure.
32
+ * @returns {{user:string, assignments:Record<string,string>, shared:boolean}}
33
+ */
34
+ export function assignPoolUser(person, users, assignments = {}) {
35
+ if (assignments[person] && users.includes(assignments[person])) return { user: assignments[person], assignments, shared: false };
36
+ const taken = new Set(Object.values(assignments));
37
+ const free = users.find((u) => !taken.has(u));
38
+ if (free) return { user: free, assignments: { ...assignments, [person]: free }, shared: false };
39
+ const i = createHash('sha256').update(person).digest().readUInt32BE(0) % users.length;
40
+ return { user: users[i], assignments, shared: true };
41
+ }
42
+
43
+ function stateFile() { return join(worcaHome(), 'agent-pool.json'); }
44
+
45
+ function readAssignments(file) {
46
+ try { return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')).people || {}) : {}; } catch { return {}; }
47
+ }
48
+
49
+ /**
50
+ * The agent identity for work `person` owns: their pool user (and its HOME), or the shared
51
+ * agent user when there is no pool or no person, or null when agents run as the server.
52
+ * @param {string|null} person a lower-cased email, 'local', or null
53
+ */
54
+ export function agentIdentityFor(person, env = process.env, { file = null } = {}) {
55
+ const base = agentIdentity(env);
56
+ if (!base) return null;
57
+ const pool = agentPool(env);
58
+ const who = typeof person === 'string' ? person.trim().toLowerCase() : '';
59
+ if (!pool || !who || who === 'local' || !who.includes('@')) return base;
60
+ const f = file || stateFile();
61
+ const before = readAssignments(f);
62
+ const r = assignPoolUser(who, pool.users, before);
63
+ if (r.assignments !== before) {
64
+ try {
65
+ const tmp = `${f}.tmp-${process.pid}`;
66
+ writeFileSync(tmp, JSON.stringify({ people: r.assignments }, null, 2), { mode: 0o600 });
67
+ renameSync(tmp, f);
68
+ } catch (err) { console.warn(`[worca] agent pool: could not save the assignment for ${who}: ${err.message}`); }
69
+ }
70
+ if (r.shared && !warnedFull) {
71
+ warnedFull = true;
72
+ console.warn(`[worca] agent pool: all ${pool.users.length} agent users are assigned; more people now share one. Their agents can read each other's processes.`);
73
+ }
74
+ // dedicated: this person's user is theirs alone (not the shared fallback, not hash-shared)
75
+ return { user: r.user, home: join(pool.homes, r.user), gid: base.gid, dedicated: !r.shared };
76
+ }