@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.
- package/README.md +7 -1
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +52 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +165 -9
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +244 -43
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +175 -6
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +33 -6
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +99 -50
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
package/src/cli/models.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
186
|
-
|
|
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:
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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);
|
package/src/cli/render.mjs
CHANGED
|
@@ -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.
|
package/src/cli/runs.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/cli/worca-cc.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|