@worca/app 1.4.0 → 1.5.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 +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- package/ui/server.mjs +454 -104
package/src/cli/schedule.mjs
CHANGED
|
@@ -242,6 +242,16 @@ async function serverIsUp() {
|
|
|
242
242
|
* `worca … --at/--every/--cron`: write the ticket or the schedule. Returns
|
|
243
243
|
* { ticket, schedule, spec } — the caller decides whether to --wait.
|
|
244
244
|
*/
|
|
245
|
+
// Schedule attribution (identity.mjs): the CLI is the machine itself, so it records 'local'.
|
|
246
|
+
const CLI_ACTOR = 'local';
|
|
247
|
+
/** A stored actor worth naming: a person, never 'local' or empty. */
|
|
248
|
+
const person = (v) => (typeof v === 'string' && v && v !== 'local' ? v : null);
|
|
249
|
+
/** The list's "by" column: only for rows a person created or changed. */
|
|
250
|
+
function byCol(x) {
|
|
251
|
+
const who = person(x.updatedBy) || person(x.createdBy);
|
|
252
|
+
return who ? ` · by ${who}` : '';
|
|
253
|
+
}
|
|
254
|
+
|
|
245
255
|
export async function createFromFlags(flags, { projectDir, extras, promptText, spec, out, c }) {
|
|
246
256
|
const title = fallbackTitle(flags, promptText);
|
|
247
257
|
let ticket, schedule = null;
|
|
@@ -250,7 +260,7 @@ export async function createFromFlags(flags, { projectDir, extras, promptText, s
|
|
|
250
260
|
const request = await requestFromFlags(flags, { projectDir, extras, promptText, stageId: id });
|
|
251
261
|
({ schedule, ticket } = createSchedule({
|
|
252
262
|
id, title, projectDir, request, rule: spec.rule, overlap: spec.overlap, maxFailures: spec.maxFailures,
|
|
253
|
-
ifMissed: spec.ifMissed, graceMin: spec.graceMin,
|
|
263
|
+
ifMissed: spec.ifMissed, graceMin: spec.graceMin, createdBy: CLI_ACTOR,
|
|
254
264
|
}));
|
|
255
265
|
out(`${c('green', 'Scheduled')} ${c('bold', schedule.id)} — ${describeRule(schedule.rule)} (${spec.tz})`);
|
|
256
266
|
if (ticket) out(` Next run: ${formatInstant(Date.parse(ticket.runAt), spec.tz, { withYear: true })} (in ${formatCountdown(Date.parse(ticket.runAt) - Date.now())})`);
|
|
@@ -258,11 +268,11 @@ export async function createFromFlags(flags, { projectDir, extras, promptText, s
|
|
|
258
268
|
const id = randomUUID();
|
|
259
269
|
const request = await requestFromFlags(flags, { projectDir, extras, promptText, stageId: id });
|
|
260
270
|
if (spec.after) {
|
|
261
|
-
ticket = createTicket({ id, title, projectDir, request, after: { kind: spec.after.kind, id: spec.after.id }, afterPolicy: spec.afterPolicy, sourceFromPrevious: spec.sourceFromPrevious, ifMissed: spec.ifMissed, graceMin: spec.graceMin });
|
|
271
|
+
ticket = createTicket({ id, title, projectDir, request, after: { kind: spec.after.kind, id: spec.after.id }, afterPolicy: spec.afterPolicy, sourceFromPrevious: spec.sourceFromPrevious, ifMissed: spec.ifMissed, graceMin: spec.graceMin, createdBy: CLI_ACTOR });
|
|
262
272
|
out(`${c('green', 'Scheduled')} ${c('bold', ticket.id.slice(0, 8))} after ‘${spec.after.title || spec.after.id.slice(0, 8)}’ (${spec.after.status})`);
|
|
263
273
|
} else {
|
|
264
274
|
ticket = createTicket({
|
|
265
|
-
id, title, projectDir, runAtMs: spec.runAtMs, request, ifMissed: spec.ifMissed, graceMin: spec.graceMin,
|
|
275
|
+
id, title, projectDir, runAtMs: spec.runAtMs, request, ifMissed: spec.ifMissed, graceMin: spec.graceMin, createdBy: CLI_ACTOR,
|
|
266
276
|
...(flags.wait ? { ownerPid: process.pid, ownerHost: hostname() } : {}),
|
|
267
277
|
});
|
|
268
278
|
out(`${c('green', 'Scheduled')} ${c('bold', ticket.id.slice(0, 8))} for ${formatInstant(spec.runAtMs, spec.tz, { withYear: true })} (in ${formatCountdown(spec.runAtMs - Date.now())})`);
|
|
@@ -381,7 +391,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
|
|
|
381
391
|
out(c('bold', 'Repeating'));
|
|
382
392
|
for (const s of series) {
|
|
383
393
|
const state = s.status === 'active' ? `next ${when(s.nextRunAt)}` : s.status === 'paused' ? c('yellow', s.pauseReason === 'failure_streak' ? `paused after ${s.failureStreak} failures` : 'paused') : 'ended';
|
|
384
|
-
out(` ${s.id} ${s.sentence} · ${state} · ${where(s)} · ${s.title || ''}`);
|
|
394
|
+
out(` ${s.id} ${s.sentence} · ${state} · ${where(s)} · ${s.title || ''}${byCol(s)}`);
|
|
385
395
|
}
|
|
386
396
|
}
|
|
387
397
|
if (tickets.length) {
|
|
@@ -389,7 +399,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
|
|
|
389
399
|
for (const t of tickets) {
|
|
390
400
|
const st = t.after && t.status === 'scheduled' ? 'waiting' : t.status === 'scheduled' ? `in ${formatCountdown(Date.parse(t.runAt) - Date.now()) || 'a moment'}` : t.status === 'missed' ? c('yellow', 'missed') : t.status;
|
|
391
401
|
const held = t.ownerPid != null && t.status === 'scheduled' ? ' · held by a waiting terminal' : '';
|
|
392
|
-
out(` ${t.id.slice(0, 8)} ${t.after ? `after ${afterLabel(t, { withStatus: false })}` : when(t.runAt)} · ${st} · ${where(t)} · ${t.title || ''}${held}`);
|
|
402
|
+
out(` ${t.id.slice(0, 8)} ${t.after ? `after ${afterLabel(t, { withStatus: false })}` : when(t.runAt)} · ${st} · ${where(t)} · ${t.title || ''}${byCol(t)}${held}`);
|
|
393
403
|
}
|
|
394
404
|
}
|
|
395
405
|
const unread = unreadCount('schedule');
|
|
@@ -421,6 +431,8 @@ export async function cmdSchedule(argv, { out, c, fail }) {
|
|
|
421
431
|
out(c('bold', item.title || label));
|
|
422
432
|
out(` id ${item.id}`);
|
|
423
433
|
out(` target ${where(item)}`);
|
|
434
|
+
if (person(item.createdBy)) out(` created by ${person(item.createdBy)}`);
|
|
435
|
+
if (person(item.updatedBy) && item.updatedBy !== item.createdBy) out(` changed by ${person(item.updatedBy)}`);
|
|
424
436
|
if (kind === 'recurring') {
|
|
425
437
|
out(` repeats ${item.sentence} (${item.tz})`);
|
|
426
438
|
out(` status ${item.status}${item.pauseReason ? ` (${item.pauseReason.replace(/_/g, ' ')})` : ''}`);
|
|
@@ -446,7 +458,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
|
|
|
446
458
|
const p = predecessorState(item.after, { policy: item.after.policy });
|
|
447
459
|
if (!p.pipelineId || !previousBranchesOf(p.pipelineId)) fail(`Start ‘${p.title || 'the run before it'}’ first, or change its source branch`);
|
|
448
460
|
}
|
|
449
|
-
const t = kind === 'recurring' ? runScheduleNow(item.id) : requestRunNow(item.id);
|
|
461
|
+
const t = kind === 'recurring' ? runScheduleNow(item.id, { by: CLI_ACTOR }) : requestRunNow(item.id, { by: CLI_ACTOR });
|
|
450
462
|
if (!t) fail(`${label} is ${item.status} and cannot be started`);
|
|
451
463
|
out(`Asked ${c('bold', label)} to start now.`);
|
|
452
464
|
if (!(await serverIsUp()) && t.ownerPid == null) out(c('yellow', 'Note: no Worca server is up — it starts as soon as `worca ui` runs.'));
|
|
@@ -455,7 +467,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
|
|
|
455
467
|
if (verb === 'cancel') {
|
|
456
468
|
if (kind === 'recurring') { deleteSchedule(item.id); out(`Deleted the repeating schedule ${c('bold', label)}.`); return 0; }
|
|
457
469
|
if (item.scheduleId) fail(`${label} is the next run of ${item.scheduleId} — use: worca schedule skip ${item.scheduleId}`);
|
|
458
|
-
if (!cancelTicket(item.id)) fail(`${label} is ${item.status} and can no longer be canceled`);
|
|
470
|
+
if (!cancelTicket(item.id, { by: CLI_ACTOR })) fail(`${label} is ${item.status} and can no longer be canceled`);
|
|
459
471
|
out(`Canceled ${c('bold', label)}.`);
|
|
460
472
|
return 0;
|
|
461
473
|
}
|
|
@@ -474,7 +486,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
|
|
|
474
486
|
const policy = rest.includes('--after-any') ? 'any' : (item.after ? item.after.policy : 'done');
|
|
475
487
|
const r = resolveAfterRef({ kind: ref.kind, id: ref.id }, { projectDir: item.projectDir, workspaceId: item.workspaceId, policy, selfId: item.id });
|
|
476
488
|
if (!r.ok) fail(r.error);
|
|
477
|
-
if (!updateTicket(item.id, { after: { kind: r.after.kind, id: r.after.id }, afterPolicy: policy, ...(rest.includes('--source-from-previous') ? { sourceFromPrevious: true } : {}) })) fail(`${label} is ${item.status} and can no longer be moved`);
|
|
489
|
+
if (!updateTicket(item.id, { after: { kind: r.after.kind, id: r.after.id }, afterPolicy: policy, ...(rest.includes('--source-from-previous') ? { sourceFromPrevious: true } : {}) }, { by: CLI_ACTOR })) fail(`${label} is ${item.status} and can no longer be moved`);
|
|
478
490
|
out(`Moved ${c('bold', label)} after ‘${r.after.title || r.after.id.slice(0, 8)}’.`);
|
|
479
491
|
return 0;
|
|
480
492
|
}
|
|
@@ -482,12 +494,13 @@ export async function cmdSchedule(argv, { out, c, fail }) {
|
|
|
482
494
|
const at = parseAt(value, { nowMs: Date.now(), tz });
|
|
483
495
|
if (!at.ok) fail(at.error);
|
|
484
496
|
if (at.ms <= Date.now()) fail('--at: that time is in the past');
|
|
485
|
-
if (!updateTicket(item.id, { runAtMs: at.ms })) fail(`${label} is ${item.status} and can no longer be moved`);
|
|
497
|
+
if (!updateTicket(item.id, { runAtMs: at.ms }, { by: CLI_ACTOR })) fail(`${label} is ${item.status} and can no longer be moved`);
|
|
486
498
|
out(`Moved ${c('bold', label)} to ${formatInstant(at.ms, tz, { withYear: true })} (in ${formatCountdown(at.ms - Date.now())}).`);
|
|
487
499
|
return 0;
|
|
488
500
|
}
|
|
489
501
|
if (kind !== 'recurring') fail(`${verb} applies to a repeating schedule; ${label} runs once`);
|
|
490
|
-
const
|
|
502
|
+
const opt = { by: CLI_ACTOR };
|
|
503
|
+
const s = verb === 'skip' ? skipNext(item.id, opt) : verb === 'pause' ? pauseSchedule(item.id, opt) : resumeSchedule(item.id, opt);
|
|
491
504
|
if (!s) fail(`${label} is ${item.status}`);
|
|
492
505
|
out(verb === 'skip' ? `Skipped. Next run: ${when(s.nextRunAt)}.` : verb === 'pause' ? `Paused ${c('bold', label)}.` : `Resumed ${c('bold', label)}. Next run: ${when(s.nextRunAt)}.`);
|
|
493
506
|
return 0;
|
package/src/cli/worca-cc.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { pauseExitCode, describePauseReason, promptOptions, REASON } from '../co
|
|
|
39
39
|
import { effectiveDebugSpawn } from '../core/settings.mjs';
|
|
40
40
|
import { SCHEDULE_VALUE_FLAGS, wantsSchedule, readScheduleFlags, createFromFlags, waitAndRun, cmdSchedule } from './schedule.mjs';
|
|
41
41
|
import { cmdModels } from './models.mjs';
|
|
42
|
+
import { cmdContainer } from './container.mjs';
|
|
42
43
|
import {
|
|
43
44
|
DEFAULT_UI_HOST, DEFAULT_UI_PORT, probeUi, stopUi, readUiInstance, uiUrl, waitForUiState,
|
|
44
45
|
} from '../core/ui-instance.mjs';
|
|
@@ -251,6 +252,8 @@ Usage:
|
|
|
251
252
|
|
|
252
253
|
Subcommands:
|
|
253
254
|
add [name] [--path <dir>] Register a project. Defaults: name = basename(path), path = cwd.
|
|
255
|
+
add --clone <https-url> [--branch <b>] [--name <folder>]
|
|
256
|
+
Clone a repository into the projects folder and register it.
|
|
254
257
|
list List registered projects (tab-separated; missing dirs are flagged).
|
|
255
258
|
remove <name> Remove a registered project by name (case-insensitive).
|
|
256
259
|
resume <pipelineId> Continue a paused pipeline (re-attaches Claude sessions).
|
|
@@ -266,11 +269,14 @@ Subcommands:
|
|
|
266
269
|
Run the web UI (default http://localhost:4317). See: worca ui help
|
|
267
270
|
workflow <cmd> [...] Export a workflow (Claude Code skill, JSON, or plugin) / import JSON: list|export|import. See: worca workflow help
|
|
268
271
|
metrics push [--project <path>] Push pending team-metrics run records (headless flush)
|
|
272
|
+
metrics pr-workflow [--project <path>] Add the GitHub Action that records PR merges for the Timeline
|
|
269
273
|
policy <cmd> [...] Team policy from the worca-policy branch: show|pull|init|setup. See: worca policy help
|
|
270
274
|
schedule <cmd> [...] Manage scheduled runs: list|show|run-now|move|cancel|skip|pause|resume|log.
|
|
271
275
|
See: worca schedule help
|
|
272
276
|
models <cmd> [...] Model catalog + providers: list|providers|login|logout|import|test|set.
|
|
273
277
|
See: worca models help
|
|
278
|
+
container <cmd> [...] Run Worca in a container: init|up|down|status|logs|pull|login|shell|run|where.
|
|
279
|
+
See: worca container help (docs/docker.md)
|
|
274
280
|
help Print this help (same as --help).
|
|
275
281
|
version Print the version (same as --version).
|
|
276
282
|
|
|
@@ -1049,10 +1055,12 @@ function runInstall(targetDir, passthrough) {
|
|
|
1049
1055
|
|
|
1050
1056
|
// ── project registry subcommands ──────────────────────────────────────────────
|
|
1051
1057
|
|
|
1052
|
-
/** Parse a tiny argv slice for the `add` subcommand. Supports --path/--path=<dir
|
|
1058
|
+
/** Parse a tiny argv slice for the `add` subcommand. Supports --path/--path=<dir> and
|
|
1059
|
+
* --clone <url> [--branch <b>] [--name <folder>]. */
|
|
1053
1060
|
function parseAddArgs(argv) {
|
|
1054
1061
|
const positionals = [];
|
|
1055
1062
|
let pathArg = null;
|
|
1063
|
+
const clone = { url: null, branch: null, name: null };
|
|
1056
1064
|
for (let i = 0; i < argv.length; i++) {
|
|
1057
1065
|
let a = argv[i];
|
|
1058
1066
|
let inline;
|
|
@@ -1065,17 +1073,44 @@ function parseAddArgs(argv) {
|
|
|
1065
1073
|
const v = inline !== undefined ? inline : argv[++i];
|
|
1066
1074
|
if (v === undefined) fail('Flag --path requires a value.');
|
|
1067
1075
|
pathArg = v;
|
|
1076
|
+
} else if (a === '--clone' || a === '--branch' || a === '--name') {
|
|
1077
|
+
const v = inline !== undefined ? inline : argv[++i];
|
|
1078
|
+
if (v === undefined) fail(`Flag ${a} requires a value.`);
|
|
1079
|
+
clone[a === '--clone' ? 'url' : a.slice(2)] = v;
|
|
1068
1080
|
} else if (a.startsWith('-')) {
|
|
1069
1081
|
fail(`Unknown flag: ${a}`);
|
|
1070
1082
|
} else {
|
|
1071
1083
|
positionals.push(a);
|
|
1072
1084
|
}
|
|
1073
1085
|
}
|
|
1074
|
-
|
|
1086
|
+
if ((clone.branch || clone.name) && !clone.url) fail('--branch and --name go with --clone.');
|
|
1087
|
+
if (clone.url && pathArg) fail('--clone and --path cannot be combined: the clone goes into the projects folder.');
|
|
1088
|
+
return { name: positionals[0], path: pathArg, clone: clone.url ? clone : null };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** `worca add --clone`: the same path as the UI's Clone from URL (src/core/clone-project.mjs). */
|
|
1092
|
+
async function cmdAddClone(clone, positionalName) {
|
|
1093
|
+
const { cloneProject } = await import('../core/clone-project.mjs');
|
|
1094
|
+
const { getProjectsRoot } = await import('../core/settings.mjs');
|
|
1095
|
+
try {
|
|
1096
|
+
out(`Cloning ${clone.url} …`);
|
|
1097
|
+
const { project } = await cloneProject({ url: clone.url, branch: clone.branch, name: clone.name || positionalName || null },
|
|
1098
|
+
{ projectsRoot: getProjectsRoot(), listProjects, addProject });
|
|
1099
|
+
out(`Added project "${project.name}" -> ${project.path}`);
|
|
1100
|
+
try {
|
|
1101
|
+
const m = await import('../core/metrics/sync.mjs');
|
|
1102
|
+
await m.discoverProject(project.path, { force: true });
|
|
1103
|
+
} catch { /* metrics never block `worca add` */ }
|
|
1104
|
+
return 0;
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
process.stderr.write(`worca: ${err?.message || err}${err?.code ? ` (${err.code})` : ''}\n`);
|
|
1107
|
+
return 1;
|
|
1108
|
+
}
|
|
1075
1109
|
}
|
|
1076
1110
|
|
|
1077
1111
|
async function cmdAdd(argv) {
|
|
1078
|
-
const { name: rawName, path: rawPath } = parseAddArgs(argv);
|
|
1112
|
+
const { name: rawName, path: rawPath, clone } = parseAddArgs(argv);
|
|
1113
|
+
if (clone) return cmdAddClone(clone, rawName);
|
|
1079
1114
|
// Always route through normalizeProjectPath so display, storage, and
|
|
1080
1115
|
// basename() all see exactly the same string addProject will persist.
|
|
1081
1116
|
const target = normalizeProjectPath(rawPath) || resolve(process.cwd());
|
|
@@ -2879,6 +2914,12 @@ const METRICS_HELP = `worca metrics — team metrics (git-backed, team-wide run
|
|
|
2879
2914
|
Usage:
|
|
2880
2915
|
worca metrics push [--project <path>] Flush pending run records to their worca-metrics branch.
|
|
2881
2916
|
Without --project, every outbox on this machine is flushed.
|
|
2917
|
+
worca metrics pr-workflow [--project <path>] [--force] [--print]
|
|
2918
|
+
Add the GitHub Action that records pull-request events
|
|
2919
|
+
(opened, merged, closed) on worca-metrics, so the Team
|
|
2920
|
+
metrics Timeline knows when work shipped without gh.
|
|
2921
|
+
Commit and push the file afterwards. --print writes it
|
|
2922
|
+
to stdout instead.
|
|
2882
2923
|
worca metrics help
|
|
2883
2924
|
|
|
2884
2925
|
Exit codes: 0 all pushed (or nothing pending) · 1 at least one outbox could not be pushed.
|
|
@@ -2912,6 +2953,22 @@ async function cmdMetrics(argv) {
|
|
|
2912
2953
|
}
|
|
2913
2954
|
return failed ? 1 : 0;
|
|
2914
2955
|
}
|
|
2956
|
+
case 'pr-workflow': {
|
|
2957
|
+
const a = pluginArgs(rest, ['--project'], ['--force', '--print']);
|
|
2958
|
+
if (a._.length) fail(`unexpected argument "${a._[0]}" — see: worca metrics help`);
|
|
2959
|
+
const prs = await import('../core/metrics/prs.mjs');
|
|
2960
|
+
if (a.print) { process.stdout.write(await prs.prWorkflowText()); return 0; }
|
|
2961
|
+
const dir = resolve(a.project || process.cwd());
|
|
2962
|
+
const r = await prs.installPrWorkflow(dir, { force: !!a.force });
|
|
2963
|
+
const rel = prs.PR_WORKFLOW_PATH;
|
|
2964
|
+
if (r.status === 'differs') {
|
|
2965
|
+
process.stderr.write(`worca metrics pr-workflow: ${rel} already exists and differs — re-run with --force to replace it\n`);
|
|
2966
|
+
return 1;
|
|
2967
|
+
}
|
|
2968
|
+
out(r.status === 'unchanged' ? `${c('green', '✓')} ${rel} is up to date` : `${c('green', '✓')} ${r.status} ${rel}`);
|
|
2969
|
+
if (r.status !== 'unchanged') out(` Commit and push it to the default branch. Run it once from the Actions tab ("Run workflow") to backfill recent pull requests.`);
|
|
2970
|
+
return 0;
|
|
2971
|
+
}
|
|
2915
2972
|
default:
|
|
2916
2973
|
fail(`unknown metrics verb "${verb}" — see: worca metrics help`);
|
|
2917
2974
|
}
|
|
@@ -3047,7 +3104,7 @@ async function drainMetricsFlushes() {
|
|
|
3047
3104
|
|
|
3048
3105
|
// ── main ──────────────────────────────────────────────────────────────────────────
|
|
3049
3106
|
|
|
3050
|
-
const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow', 'metrics', 'script', 'policy', 'schedule', 'models']);
|
|
3107
|
+
const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow', 'metrics', 'script', 'policy', 'schedule', 'models', 'container']);
|
|
3051
3108
|
|
|
3052
3109
|
/** Levenshtein distance, two-row. Only ever called on short argv tokens. */
|
|
3053
3110
|
function editDistance(a, b) {
|
|
@@ -3112,6 +3169,7 @@ async function main() {
|
|
|
3112
3169
|
if (sub === 'policy') return cmdPolicy(rest);
|
|
3113
3170
|
if (sub === 'schedule') return cmdSchedule(rest, { out, c, fail });
|
|
3114
3171
|
if (sub === 'models') return cmdModels(rest, { out, c, fail });
|
|
3172
|
+
if (sub === 'container') return cmdContainer(rest, { out, c, fail });
|
|
3115
3173
|
}
|
|
3116
3174
|
// `worca --ui [...]` is the historical spelling of `worca ui start [...]`; hand the
|
|
3117
3175
|
// remaining tokens to the ui parser so --port/--open/--mock work with either.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Agents under their own uid (docker/entrypoint.sh, single-volume hosts).
|
|
2
|
+
//
|
|
3
|
+
// Worca never hands an agent a credential (github-credentials.mjs), but an agent
|
|
4
|
+
// running as the server's own user could still read the server's environment
|
|
5
|
+
// through /proc/<pid>/environ, or worca's settings and database. So on a hosted
|
|
6
|
+
// container the entrypoint creates the boundary and tells the server about it:
|
|
7
|
+
//
|
|
8
|
+
// WORCA_AGENT_USER the user agents run as (worca-agent)
|
|
9
|
+
// WORCA_AGENT_HOME that user's HOME (its own ~/.claude, npm and pip caches)
|
|
10
|
+
// WORCA_AGENT_GID the group server and agent share (projects, runs, store)
|
|
11
|
+
//
|
|
12
|
+
// Each agent spawn becomes `sudo -n -E -u <user> -- <bin> …` (a sudoers rule
|
|
13
|
+
// lets the server switch to that one user and nothing else). Unset = agents run
|
|
14
|
+
// as the server, as on every local install.
|
|
15
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
16
|
+
import { chmodSync, chownSync, readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
20
|
+
|
|
21
|
+
/** { user, home, gid } when agent isolation is on, else null. Pure: reads only `env`. */
|
|
22
|
+
export function agentIdentity(env = process.env) {
|
|
23
|
+
const user = String(env.WORCA_AGENT_USER || '').trim();
|
|
24
|
+
const home = String(env.WORCA_AGENT_HOME || '').trim();
|
|
25
|
+
if (!USER_RE.test(user) || !isAbsolute(home)) return null;
|
|
26
|
+
const gid = Number.parseInt(env.WORCA_AGENT_GID, 10);
|
|
27
|
+
return { user, home, gid: Number.isInteger(gid) && gid > 0 ? gid : null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An absolute path for `bin` from PATH (sudo's secure_path must not change which binary runs). */
|
|
31
|
+
export function resolveOnPath(bin, pathVar = process.env.PATH || '', isFile = defaultIsFile) {
|
|
32
|
+
if (isAbsolute(bin)) return bin;
|
|
33
|
+
if (bin.includes('/')) return null;
|
|
34
|
+
for (const dir of String(pathVar).split(delimiter)) {
|
|
35
|
+
if (dir && isAbsolute(dir) && isFile(join(dir, bin))) return join(dir, bin);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultIsFile(p) {
|
|
41
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The spawn for one agent command under `id`: `sudo -n -E -u <user> -- <abs bin> …args`,
|
|
46
|
+
* with the agent's HOME, USER and LOGNAME in its env. Throws when `bin` cannot be resolved,
|
|
47
|
+
* rather than running something else.
|
|
48
|
+
*/
|
|
49
|
+
export function agentSpawn(bin, args, env, id, { isFile = defaultIsFile } = {}) {
|
|
50
|
+
const abs = resolveOnPath(bin, env.PATH || process.env.PATH || '', isFile);
|
|
51
|
+
if (!abs) throw new Error(`cannot run ${bin} as ${id.user}: not found on PATH`);
|
|
52
|
+
return {
|
|
53
|
+
file: 'sudo',
|
|
54
|
+
args: ['-n', '-E', '-u', id.user, '--', abs, ...args],
|
|
55
|
+
env: { ...env, HOME: id.home, USER: id.user, LOGNAME: id.user },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** SIGKILL every process of the agent's group `pgid` (the server cannot signal another uid;
|
|
60
|
+
* sudo relays SIGTERM, but a SIGKILLed sudo would orphan its command). Best effort. */
|
|
61
|
+
export function killAgentGroup(pgid, id, spawnImpl = spawn) {
|
|
62
|
+
if (!id || !Number.isInteger(pgid) || pgid <= 1) return;
|
|
63
|
+
try {
|
|
64
|
+
const c = spawnImpl('sudo', ['-n', '-u', id.user, '--', 'kill', '-KILL', '--', `-${pgid}`], { stdio: 'ignore' });
|
|
65
|
+
c.on?.('error', () => {});
|
|
66
|
+
c.unref?.();
|
|
67
|
+
} catch { /* nothing left to kill */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** killAgentGroup, synchronously: for a process 'exit' listener, which cannot wait. */
|
|
71
|
+
export function killAgentGroupSync(pgid, id, spawnSyncImpl = spawnSync) {
|
|
72
|
+
if (!id || !Number.isInteger(pgid) || pgid <= 1) return;
|
|
73
|
+
try {
|
|
74
|
+
spawnSyncImpl('sudo', ['-n', '-u', id.user, '--', 'kill', '-KILL', '--', `-${pgid}`], { stdio: 'ignore', timeout: 5000 });
|
|
75
|
+
} catch { /* nothing left to kill */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Let the agent read a directory the server made for one spawn (staged prompt files):
|
|
79
|
+
* the shared group, group read/traverse. Best effort; a failure surfaces as the agent's
|
|
80
|
+
* own read error. */
|
|
81
|
+
export function shareWithAgent(dir, id) {
|
|
82
|
+
if (!id || id.gid == null) return;
|
|
83
|
+
try {
|
|
84
|
+
chownSync(dir, -1, id.gid);
|
|
85
|
+
chmodSync(dir, 0o750);
|
|
86
|
+
for (const name of readdirSync(dir)) {
|
|
87
|
+
const p = join(dir, name);
|
|
88
|
+
chownSync(p, -1, id.gid);
|
|
89
|
+
chmodSync(p, 0o640);
|
|
90
|
+
}
|
|
91
|
+
} catch { /* the agent reports what it cannot read */ }
|
|
92
|
+
}
|
package/src/core/artifacts.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { RUN_LOG_FILE } from './run-log.mjs';
|
|
|
21
21
|
import { readRunLedger } from './metrics/ledger.mjs';
|
|
22
22
|
import { readPolicyState } from './policy/state.mjs';
|
|
23
23
|
import { memoryTotals } from './memory-sync.mjs';
|
|
24
|
+
import { actorLabel } from './identity.mjs';
|
|
24
25
|
|
|
25
26
|
// ── DB row <-> state object mapping (Phase 3) ──────────────────────────────────
|
|
26
27
|
// JSON columns are TEXT; (de)serialize at THIS boundary only. Reads are fail-safe:
|
|
@@ -269,6 +270,12 @@ function formAskOf(qWrap, aWrap) {
|
|
|
269
270
|
|
|
270
271
|
/** The two form fields a reader row carries — `{}` for a legacy row, so no legacy
|
|
271
272
|
* payload gains a key (test/step-questions-db.test.mjs pins the exact row shape). */
|
|
273
|
+
/** `{answeredBy}` when the answer row names who answered (identity.mjs actor), else {} —
|
|
274
|
+
* additive, so a row answered before attribution keeps its exact wire shape. */
|
|
275
|
+
function answeredByOf(aWrap) {
|
|
276
|
+
return aWrap && typeof aWrap.answeredBy === 'string' && aWrap.answeredBy ? { answeredBy: aWrap.answeredBy } : {};
|
|
277
|
+
}
|
|
278
|
+
|
|
272
279
|
function formFieldsOf(qWrap, aWrap) {
|
|
273
280
|
const ask = formAskOf(qWrap, aWrap);
|
|
274
281
|
return ask ? { ask, formAnswer: formAnswerOf(aWrap) } : {};
|
|
@@ -305,6 +312,7 @@ export function readStepQuestions(pipelineId) {
|
|
|
305
312
|
// gains no key at all, so its wire shape (History, get_run_progress) stays
|
|
306
313
|
// byte-identical; consumers test `row.ask`, never `'ask' in row`.
|
|
307
314
|
...formFieldsOf(qWrap, aWrap),
|
|
315
|
+
...answeredByOf(aWrap),
|
|
308
316
|
};
|
|
309
317
|
});
|
|
310
318
|
}
|
|
@@ -384,6 +392,7 @@ export function readPipelineExtras(pipelineId) {
|
|
|
384
392
|
questions: Array.isArray(qWrap?.questions) ? qWrap.questions : [],
|
|
385
393
|
answers: Array.isArray(aWrap?.answers) ? aWrap.answers : [],
|
|
386
394
|
...formFieldsOf(qWrap, aWrap), // spec §9: `ask` + `formAnswer` on a form row only
|
|
395
|
+
...answeredByOf(aWrap),
|
|
387
396
|
};
|
|
388
397
|
const reviews = getDb().prepare(
|
|
389
398
|
'SELECT kind, cycle, verdict FROM reviews WHERE pipeline_id = ? ORDER BY kind, cycle'
|
|
@@ -933,7 +942,7 @@ export async function createPipeline(projectDir, opts = {}) {
|
|
|
933
942
|
const {
|
|
934
943
|
prompt, promptFile, extras = [], title,
|
|
935
944
|
promptText: precomputedPromptText = null, sourceType = null, sourceMeta = null,
|
|
936
|
-
guardrailsId = null,
|
|
945
|
+
guardrailsId = null, startedBy = null,
|
|
937
946
|
workspaceKey = null, workspaceId = null, workspaceName = null,
|
|
938
947
|
workspaceDescription = '', projects = null,
|
|
939
948
|
} = opts;
|
|
@@ -1030,6 +1039,8 @@ export async function createPipeline(projectDir, opts = {}) {
|
|
|
1030
1039
|
// unguarded runs). Creation-immutable, like sourceType: written on INSERT,
|
|
1031
1040
|
// never touched by updates. NULL = legacy/pre-entity or non-orchestrator row.
|
|
1032
1041
|
guardrailsId: guardrailsId || null,
|
|
1042
|
+
// Who started the run (identity.mjs): creation-immutable like guardrailsId. NULL = unknown.
|
|
1043
|
+
startedBy: startedBy || null,
|
|
1033
1044
|
};
|
|
1034
1045
|
|
|
1035
1046
|
// Workspace runs carry the §5.2 superset, discriminated by target:'workspace'.
|
|
@@ -1095,15 +1106,30 @@ function firstMeaningfulLine(text) {
|
|
|
1095
1106
|
* @param {string} markdownLine
|
|
1096
1107
|
* @returns {Promise<void>}
|
|
1097
1108
|
*/
|
|
1098
|
-
export async function appendAudit(pipelineDir, markdownLine) {
|
|
1099
|
-
|
|
1109
|
+
export async function appendAudit(pipelineDir, markdownLine, { actor = null } = {}) {
|
|
1110
|
+
appendAuditById(resolvePipelineId(pipelineDir), markdownLine, { actor });
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* appendAudit by pipeline id (the server's human actions: PRs, archive, cap overrides).
|
|
1115
|
+
* `actor` = who did it (identity.mjs; 'local' allowed), stored in its own column always;
|
|
1116
|
+
* the line's text names the person only when they are not 'local' (byActor), so the
|
|
1117
|
+
* History view and the markdown export read "Paused by ada@example.com." Best-effort.
|
|
1118
|
+
*/
|
|
1119
|
+
export function appendAuditById(id, markdownLine, { actor = null } = {}) {
|
|
1100
1120
|
if (!id) return;
|
|
1101
1121
|
const ts = new Date().toISOString();
|
|
1102
1122
|
const text = String(markdownLine ?? '').trim();
|
|
1123
|
+
const who = typeof actor === 'string' && actor ? actor.slice(0, 200) : null;
|
|
1103
1124
|
try {
|
|
1104
1125
|
tx(() => {
|
|
1105
|
-
|
|
1106
|
-
.
|
|
1126
|
+
if (who) {
|
|
1127
|
+
getDb().prepare('INSERT INTO pipeline_events (pipeline_id, ts, text, actor) VALUES (?, ?, ?, ?)')
|
|
1128
|
+
.run(id, ts, text, who);
|
|
1129
|
+
} else {
|
|
1130
|
+
getDb().prepare('INSERT INTO pipeline_events (pipeline_id, ts, text) VALUES (?, ?, ?)')
|
|
1131
|
+
.run(id, ts, text);
|
|
1132
|
+
}
|
|
1107
1133
|
});
|
|
1108
1134
|
} catch { /* audit is best-effort; never break a run on a logging failure */ }
|
|
1109
1135
|
}
|
|
@@ -1164,11 +1190,11 @@ export async function writeState(pipelineDir, stateObj) {
|
|
|
1164
1190
|
INSERT INTO pipelines (id, project_key, workspace_key, target, title, base_name,
|
|
1165
1191
|
date_prefix, status, phase, cycle, started_at, updated_at, total_cost_usd,
|
|
1166
1192
|
total_active_ms, prompt, branch, workspace_meta, stepper, tools, resume_point,
|
|
1167
|
-
source_type, source_ref, guardrails_id, outcome, human_hours)
|
|
1193
|
+
source_type, source_ref, guardrails_id, outcome, human_hours, started_by)
|
|
1168
1194
|
VALUES (@id,@project_key,@workspace_key,@target,@title,@base_name,@date_prefix,
|
|
1169
1195
|
@status,@phase,@cycle,@started_at,@updated_at,@total_cost_usd,@total_active_ms,
|
|
1170
1196
|
@prompt,@branch,@workspace_meta,@stepper,@tools,@resume_point,
|
|
1171
|
-
@source_type,@source_ref,@guardrails_id,@outcome,@human_hours)
|
|
1197
|
+
@source_type,@source_ref,@guardrails_id,@outcome,@human_hours,@started_by)
|
|
1172
1198
|
ON CONFLICT(id) DO UPDATE SET
|
|
1173
1199
|
status=excluded.status, phase=excluded.phase, cycle=excluded.cycle,
|
|
1174
1200
|
updated_at=excluded.updated_at, total_cost_usd=excluded.total_cost_usd,
|
|
@@ -1552,6 +1578,7 @@ function toPipelineRow(o) {
|
|
|
1552
1578
|
source_type: o.sourceType ?? 'prompt',
|
|
1553
1579
|
source_ref: s(o.sourceMeta),
|
|
1554
1580
|
guardrails_id: o.guardrailsId ?? null,
|
|
1581
|
+
started_by: o.startedBy ?? null,
|
|
1555
1582
|
// §5.9 outcome: the derived run-level v2 facts, so a rehydrated state matches
|
|
1556
1583
|
// a live one. NULL for a v1 run (nothing to say), so v1 rows are unchanged.
|
|
1557
1584
|
outcome: (o.engine === 2 || o.endReached !== undefined)
|
|
@@ -1668,6 +1695,7 @@ async function rowToHistoryEntry(row, repoDir = null, opts = {}) {
|
|
|
1668
1695
|
branch: feature,
|
|
1669
1696
|
sourceBranch: source,
|
|
1670
1697
|
guardrailsId: row.guardrails_id ?? null,
|
|
1698
|
+
startedBy: row.started_by ?? null,
|
|
1671
1699
|
pauseReason: row.pause_reason ?? null,
|
|
1672
1700
|
pauseDetail: row.pause_detail ?? null,
|
|
1673
1701
|
retainedWork: retainedWorkFor(row),
|
|
@@ -1727,7 +1755,7 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
|
|
|
1727
1755
|
const dirById = await runDirIndex(pipelinesDir);
|
|
1728
1756
|
const rows = getDb().prepare(`
|
|
1729
1757
|
SELECT id, project_key, target, title, status, started_at, updated_at, total_cost_usd, total_active_ms,
|
|
1730
|
-
branch, workspace_meta, guardrails_id, pr_url,
|
|
1758
|
+
branch, workspace_meta, guardrails_id, started_by, pr_url,
|
|
1731
1759
|
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
|
|
1732
1760
|
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
|
|
1733
1761
|
FROM pipelines
|
|
@@ -1756,7 +1784,7 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
|
|
|
1756
1784
|
export async function listAllPipelines(opts = {}, { batchSize = 16 } = {}) {
|
|
1757
1785
|
const rows = getDb().prepare(`
|
|
1758
1786
|
SELECT id, project_key, workspace_key, target, title, status, started_at, updated_at,
|
|
1759
|
-
total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id, pr_url,
|
|
1787
|
+
total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id, started_by, pr_url,
|
|
1760
1788
|
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
|
|
1761
1789
|
json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
|
|
1762
1790
|
FROM pipelines
|
|
@@ -1938,6 +1966,7 @@ function rowToState(row) {
|
|
|
1938
1966
|
stepper: j(row.stepper, null),
|
|
1939
1967
|
tools: j(row.tools, null),
|
|
1940
1968
|
guardrailsId: row.guardrails_id ?? null,
|
|
1969
|
+
startedBy: row.started_by ?? null,
|
|
1941
1970
|
// v31 provenance: set when a schedule started this run (NULL = started by hand).
|
|
1942
1971
|
scheduledFor: row.scheduled_for ?? null,
|
|
1943
1972
|
scheduleId: row.schedule_id ?? null,
|
|
@@ -1957,6 +1986,8 @@ function rowToState(row) {
|
|
|
1957
1986
|
// too, so a deep-linked History detail no longer waits for the LIST row.
|
|
1958
1987
|
const rp = j(row.resume_point, null);
|
|
1959
1988
|
state.pauseReason = typeof rp?.pauseReason === 'string' ? rp.pauseReason : null;
|
|
1989
|
+
// Who paused it (run-harness _recordAction): { kind, by, at } or null.
|
|
1990
|
+
state.lastAction = rp && rp.lastAction && typeof rp.lastAction.by === 'string' ? { ...rp.lastAction } : null;
|
|
1960
1991
|
state.pauseDetail = typeof rp?.pauseDetail === 'string' ? rp.pauseDetail : null;
|
|
1961
1992
|
const outcome = j(row.outcome, null);
|
|
1962
1993
|
if (outcome) {
|
|
@@ -2008,6 +2039,7 @@ function buildAuditMarkdown(row) {
|
|
|
2008
2039
|
`- **id**: ${row.id}\n` +
|
|
2009
2040
|
`- **project**: ${(readStoreMeta(row.project_key)?.path) ?? ''}\n` +
|
|
2010
2041
|
`- **started**: ${row.started_at ?? ''}\n` +
|
|
2042
|
+
(actorLabel(row.started_by) ? `- **started by**: ${actorLabel(row.started_by)}\n` : '') +
|
|
2011
2043
|
`- **prompt file**: prompt.md\n\n` +
|
|
2012
2044
|
`## Prompt\n\n` +
|
|
2013
2045
|
(row.prompt && row.prompt.trim() ? row.prompt.trim() + '\n' : '_(empty prompt)_\n') +
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/core/ask/clone-deps.mjs
|
|
2
|
+
// Binds clone-proposal.mjs to the real projects folder and registry. The MCP child gets
|
|
3
|
+
// `clones.validateChange` (validate only, for the model's self-correction; it never sees a
|
|
4
|
+
// GitHub credential, so its card has no GitHub line); the parent turn re-validates with
|
|
5
|
+
// `validateCloneProposal`, whose card names how the clone authenticates.
|
|
6
|
+
import { getProjectsRoot } from '../settings.mjs';
|
|
7
|
+
import { listProjects } from '../projects.mjs';
|
|
8
|
+
import { githubMode } from '../deployment.mjs';
|
|
9
|
+
import { createCloneValidator, githubLabel } from './clone-proposal.mjs';
|
|
10
|
+
|
|
11
|
+
/** The parent's validator: the card carries the GitHub line (deployment.mjs, never a secret). */
|
|
12
|
+
export const validateCloneProposal = createCloneValidator({
|
|
13
|
+
projectsRoot: () => getProjectsRoot(),
|
|
14
|
+
listProjects: () => listProjects(),
|
|
15
|
+
github: (host) => githubLabel(githubMode(process.env), { host, appId: (process.env.WORCA_GH_APP_ID || '').trim() || null }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** The MCP child's bundle. */
|
|
19
|
+
export function defaultCloneDeps() {
|
|
20
|
+
return {
|
|
21
|
+
clones: {
|
|
22
|
+
validateChange: createCloneValidator({ projectsRoot: () => getProjectsRoot(), listProjects: () => listProjects() }),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/core/ask/clone-proposal.mjs
|
|
2
|
+
// The ONE validator behind mcp__worca__propose_clone_project, and the event/notice text of the
|
|
3
|
+
// clone card (docs/deploy-railway.md "First project"). Pure: the projects root, the registered
|
|
4
|
+
// projects and the GitHub description are injected (clone-deps.mjs binds the real ones), so the
|
|
5
|
+
// MCP child validates for the model's self-correction and the parent turn re-validates
|
|
6
|
+
// authoritatively and mints the card — the model-proposal.mjs split. Nothing here clones:
|
|
7
|
+
// applying the card starts ui/server.mjs's clone job, behind the user's click.
|
|
8
|
+
import { planClone, CloneError } from '../clone-project.mjs';
|
|
9
|
+
|
|
10
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
11
|
+
// eslint-disable-next-line no-control-regex
|
|
12
|
+
const BREAKS_RE = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/g;
|
|
13
|
+
const clip = (v, n) => String(v ?? '').replace(BREAKS_RE, ' ').slice(0, n);
|
|
14
|
+
|
|
15
|
+
/** How the clone authenticates, in words for the card. `mode` = githubMode() (deployment.mjs). */
|
|
16
|
+
export function githubLabel(mode, { host = 'github.com', appId = null } = {}) {
|
|
17
|
+
if (host !== 'github.com') return `none (${host} is not GitHub): public repositories only`;
|
|
18
|
+
if (mode === 'app') return `GitHub App${appId ? ` ${appId}` : ''} (a read-only token for this clone)`;
|
|
19
|
+
if (mode === 'split') return 'the read token (WORCA_GH_READ_TOKEN)';
|
|
20
|
+
if (mode === 'single') return 'the deployment token (GH_TOKEN)';
|
|
21
|
+
return 'none: public repositories only';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} r
|
|
26
|
+
* @param {() => string} r.projectsRoot the effective projects folder
|
|
27
|
+
* @param {() => Promise<Array>} r.listProjects registered projects ({name, path})
|
|
28
|
+
* @param {object} [r.env] for WORCA_CLONE_ALLOW
|
|
29
|
+
* @param {(host:string) => string|null} [r.github] the card's GitHub line (null in the MCP child)
|
|
30
|
+
* @param {(p:string) => boolean} [r.exists]
|
|
31
|
+
*/
|
|
32
|
+
export function createCloneValidator(r) {
|
|
33
|
+
/** @returns {Promise<{ok:true, card:object}|{ok:false, errors:string[]}>} */
|
|
34
|
+
return async function validateCloneProposal(input) {
|
|
35
|
+
const inp = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
36
|
+
let plan;
|
|
37
|
+
try {
|
|
38
|
+
plan = planClone({ url: inp.url, branch: str(inp.branch) || null, name: str(inp.name) || null },
|
|
39
|
+
{ projectsRoot: r.projectsRoot(), env: r.env || process.env, ...(r.exists ? { exists: r.exists } : {}) });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { ok: false, errors: [err instanceof CloneError ? err.message : String(err && err.message ? err.message : err)] };
|
|
42
|
+
}
|
|
43
|
+
const projects = await r.listProjects();
|
|
44
|
+
if ((projects || []).some((p) => p && typeof p.name === 'string' && p.name.toLowerCase() === plan.name.toLowerCase())) {
|
|
45
|
+
return { ok: false, errors: [`a project named "${plan.name}" already exists — pass another name`] };
|
|
46
|
+
}
|
|
47
|
+
const note = clip(str(inp.note), 200);
|
|
48
|
+
const github = typeof r.github === 'function' ? r.github(plan.host) : null;
|
|
49
|
+
return { ok: true, card: {
|
|
50
|
+
type: 'clone', kind: 'clone',
|
|
51
|
+
summary: `Clone ${plan.owner}/${plan.repo} as project ${plan.name}`,
|
|
52
|
+
url: plan.url, branch: plan.branch, name: plan.name, dir: plan.dir,
|
|
53
|
+
...(github ? { github } : {}),
|
|
54
|
+
...(note ? { note } : {}),
|
|
55
|
+
change: { url: plan.url, branch: plan.branch, name: plan.name },
|
|
56
|
+
} };
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const eventText = (s, n) => clip(s, n).replace(/"/g, "'").replace(/\[(\/?)worca context\]/gi, '($1worca context)');
|
|
61
|
+
|
|
62
|
+
/** `[worca event] …` — the synthetic turn's prompt after the user acted on a clone card, or its clone ended. */
|
|
63
|
+
export function cloneEventPrompt({ cardId, state, card = {}, result = null }) {
|
|
64
|
+
const summary = eventText(card.summary, 200);
|
|
65
|
+
if (state === 'declined') return `[worca event] clone card ${cardId} declined; "${summary}"`;
|
|
66
|
+
if (state === 'failed') {
|
|
67
|
+
return `[worca event] clone card ${cardId} failed${result?.code ? ` (${eventText(result.code, 40)})` : ''}: ${eventText(result?.error || 'unknown error', 300)}; "${summary}"`;
|
|
68
|
+
}
|
|
69
|
+
const where = result?.project ? `; project ${eventText(result.project.name, 100)} at ${eventText(result.project.path, 300)}` : '';
|
|
70
|
+
return `[worca event] clone card ${cardId} applied: cloned and registered${where}; "${summary}"`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The user-row notice above the event turn. */
|
|
74
|
+
export function cloneNoticeText({ state, card = {}, result = null }) {
|
|
75
|
+
const s = clip(card.summary, 160);
|
|
76
|
+
if (state === 'declined') return `Declined — ${s}`;
|
|
77
|
+
if (state === 'failed') return `Could not clone — ${s}: ${clip(result?.error || 'unknown error', 200)}`;
|
|
78
|
+
return `Cloned — ${s}${result?.project?.path ? ` · ${clip(result.project.path, 200)}` : ''}`;
|
|
79
|
+
}
|
package/src/core/ask/events.mjs
CHANGED
|
@@ -126,6 +126,7 @@ export function labelForTool(name, input = {}, attachmentNames = {}) {
|
|
|
126
126
|
const id = typeof input?.id === 'string' ? input.id : '';
|
|
127
127
|
switch (n) {
|
|
128
128
|
case 'list_runs': return 'Finding runs';
|
|
129
|
+
case 'list_people': return 'Looking up who ran what';
|
|
129
130
|
case 'get_run':
|
|
130
131
|
case 'get_run_diff': return id ? `Reading run ${id.slice(0, 12)}` : 'Reading run';
|
|
131
132
|
case 'list_workflows': return 'Looking at workflows';
|
|
@@ -170,6 +171,7 @@ export function labelForTool(name, input = {}, attachmentNames = {}) {
|
|
|
170
171
|
case 'test_provider': return input?.provider ? `Testing ${String(input.provider).slice(0, 20)}` : 'Testing a provider';
|
|
171
172
|
case 'list_copilot_models': return 'Listing Copilot models';
|
|
172
173
|
case 'propose_model_change': return 'Proposing a model change';
|
|
174
|
+
case 'propose_clone_project': return 'Proposing a project clone';
|
|
173
175
|
default: return `Using ${n}`;
|
|
174
176
|
}
|
|
175
177
|
}
|
|
@@ -246,6 +248,7 @@ export function createTurnReducer({
|
|
|
246
248
|
onPolicyProposal = null, // propose_policy_change RESULT (team policy card; same split)
|
|
247
249
|
onScheduleProposal = null, // propose_schedule_change RESULT (schedule card; the parent re-validates the input)
|
|
248
250
|
onModelProposal = null, // propose_model_change RESULT (model card; same split)
|
|
251
|
+
onCloneProposal = null, // propose_clone_project RESULT (clone card; same split)
|
|
249
252
|
onScheduleMutation = null, // a direct schedule write succeeded in the MCP child
|
|
250
253
|
onTrackRun = null,
|
|
251
254
|
onCommentMutation = null,
|
|
@@ -606,6 +609,13 @@ export function createTurnReducer({
|
|
|
606
609
|
if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
|
|
607
610
|
} catch { reducerErrors += 1; }
|
|
608
611
|
}
|
|
612
|
+
if (b.name === 'mcp__worca__propose_clone_project' && typeof onCloneProposal === 'function') {
|
|
613
|
+
// Same split as the model card: the parent re-validates the INPUT and adds how GitHub is reached (clone-proposal.mjs).
|
|
614
|
+
try {
|
|
615
|
+
const ret = onCloneProposal({ toolUseId: b.id, input: fullInputs.get(b.id) ?? {}, text, isError: !!c.is_error });
|
|
616
|
+
if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
|
|
617
|
+
} catch { reducerErrors += 1; }
|
|
618
|
+
}
|
|
609
619
|
if (b.name === 'mcp__worca__track_run' && typeof onTrackRun === 'function') {
|
|
610
620
|
// The parent owns the runs Map, the link rows and the followers: it re-resolves the id itself (D4).
|
|
611
621
|
try {
|