@worca/ui 0.41.0 → 0.42.0
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/app/main.bundle.js +2667 -2024
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +2 -0
- package/app/styles.css +1320 -6
- package/package.json +2 -2
- package/server/app.js +90 -0
- package/server/dispatch-defaults.js +5 -5
- package/server/dispatch-events-aggregator.js +27 -13
- package/server/dispatch-migration.js +35 -1
- package/server/events-jsonl-reader.js +93 -0
- package/server/file-access-aggregator.js +481 -0
- package/server/graph-query-aggregator.js +165 -0
- package/server/integrations/renderers.js +11 -0
- package/server/process-manager.js +86 -0
- package/server/project-routes.js +16 -3
- package/server/schemas/keys.json +5 -0
- package/server/template-prompts.js +136 -0
- package/server/templates-routes.js +287 -49
- package/server/watcher.js +122 -40
- package/server/ws-broadcaster.js +5 -4
- package/server/ws-message-router.js +23 -2
- package/server/ws-status-watcher.js +5 -1
|
@@ -541,6 +541,9 @@ export class ProcessManager {
|
|
|
541
541
|
if (opts.mloops && opts.mloops > 1) {
|
|
542
542
|
args.push('--mloops', String(opts.mloops));
|
|
543
543
|
}
|
|
544
|
+
if (opts.maxBeads != null) {
|
|
545
|
+
args.push('--max-beads', String(opts.maxBeads));
|
|
546
|
+
}
|
|
544
547
|
if (opts.planFile) {
|
|
545
548
|
args.push('--plan', opts.planFile);
|
|
546
549
|
}
|
|
@@ -554,6 +557,89 @@ export class ProcessManager {
|
|
|
554
557
|
const env = { ...process.env };
|
|
555
558
|
delete env.CLAUDECODE;
|
|
556
559
|
|
|
560
|
+
// run_worktree.py is a *launcher*: it performs all setup (PR-metadata
|
|
561
|
+
// fetch, worktree checkout, registry write) and only exits 0 after the
|
|
562
|
+
// real pipeline wrote its status.json (_await_pipeline_startup), printing
|
|
563
|
+
// diagnostics to stderr and exiting non-zero on any failure. Its detached
|
|
564
|
+
// grandchild redirects its own stdio to a log file, so it never holds our
|
|
565
|
+
// pipes open. That makes the launcher's exit code an authoritative
|
|
566
|
+
// success/failure signal — wait for it instead of guessing with a fixed
|
|
567
|
+
// timer. The old 2s timer resolved "started" before slow failures (e.g. a
|
|
568
|
+
// PR fetch followed by a worktree collision) surfaced, so the UI reported
|
|
569
|
+
// success while nothing ran. run_pipeline.py (in-place / resume) is NOT a
|
|
570
|
+
// launcher — it *is* the long-lived pipeline — so it keeps the timer path.
|
|
571
|
+
const isFireAndForget = scriptRel === worktreeScriptRel;
|
|
572
|
+
|
|
573
|
+
if (isFireAndForget) {
|
|
574
|
+
return new Promise((resolve, reject) => {
|
|
575
|
+
const child = spawn('python3', args, {
|
|
576
|
+
detached: true,
|
|
577
|
+
// Capture stderr to surface the launcher's error; ignore stdin/stdout
|
|
578
|
+
// (stdout carries only run_id+path and is not needed).
|
|
579
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
580
|
+
cwd,
|
|
581
|
+
env,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
let settled = false;
|
|
585
|
+
let stderr = '';
|
|
586
|
+
const STDERR_CAP = 8192;
|
|
587
|
+
// Generous safety net: the launcher normally exits within seconds, but
|
|
588
|
+
// a hung gh/network call shouldn't block the launch request forever.
|
|
589
|
+
const hardCap = setTimeout(() => {
|
|
590
|
+
if (settled) return;
|
|
591
|
+
settled = true;
|
|
592
|
+
child.removeAllListeners('error');
|
|
593
|
+
child.removeAllListeners('exit');
|
|
594
|
+
cleanupPromptFile(promptFilePath);
|
|
595
|
+
const err = new Error(
|
|
596
|
+
'Pipeline launcher did not finish within 180s — aborting launch',
|
|
597
|
+
);
|
|
598
|
+
err.code = 'spawn_timeout';
|
|
599
|
+
reject(err);
|
|
600
|
+
}, 180000);
|
|
601
|
+
hardCap.unref?.();
|
|
602
|
+
|
|
603
|
+
if (child.stderr) {
|
|
604
|
+
child.stderr.on('data', (d) => {
|
|
605
|
+
if (stderr.length < STDERR_CAP) stderr += d.toString();
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
child.on('error', (spawnErr) => {
|
|
610
|
+
if (settled) return;
|
|
611
|
+
settled = true;
|
|
612
|
+
clearTimeout(hardCap);
|
|
613
|
+
cleanupPromptFile(promptFilePath);
|
|
614
|
+
const err = new Error(
|
|
615
|
+
`Failed to start pipeline: ${spawnErr.message}`,
|
|
616
|
+
);
|
|
617
|
+
err.code = 'spawn_error';
|
|
618
|
+
reject(err);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
child.on('exit', (code, signal) => {
|
|
622
|
+
if (settled) return;
|
|
623
|
+
settled = true;
|
|
624
|
+
clearTimeout(hardCap);
|
|
625
|
+
cleanupPromptFile(promptFilePath);
|
|
626
|
+
if (code === 0) {
|
|
627
|
+
child.unref();
|
|
628
|
+
resolve({ pid: child.pid });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const detail = stderr.trim().split('\n').slice(-6).join('\n').trim();
|
|
632
|
+
const reason =
|
|
633
|
+
code !== null ? `exit code ${code}` : `signal ${signal}`;
|
|
634
|
+
const err = new Error(
|
|
635
|
+
`Pipeline failed to start (${reason})${detail ? `:\n${detail}` : ''}`,
|
|
636
|
+
);
|
|
637
|
+
err.code = 'spawn_error';
|
|
638
|
+
reject(err);
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
557
643
|
return new Promise((resolve, reject) => {
|
|
558
644
|
const child = spawn('python3', args, {
|
|
559
645
|
detached: true,
|
package/server/project-routes.js
CHANGED
|
@@ -53,7 +53,7 @@ import { validateSettingsPayload } from './settings-validator.js';
|
|
|
53
53
|
import { createTemplatesRoutes } from './templates-routes.js';
|
|
54
54
|
import { isVersionBehind } from './version-check.js';
|
|
55
55
|
import { getVersionInfo } from './versions.js';
|
|
56
|
-
import { discoverRuns } from './watcher.js';
|
|
56
|
+
import { discoverRuns, discoverRunsAsync } from './watcher.js';
|
|
57
57
|
import {
|
|
58
58
|
checkWorcaInstalled,
|
|
59
59
|
readProjectWorcaVersion,
|
|
@@ -365,7 +365,12 @@ export function createProjectScopedRoutes({
|
|
|
365
365
|
// GET /api/projects/:projectId/runs — list runs for this project
|
|
366
366
|
router.get('/runs', requireWorcaDir, async (req, res) => {
|
|
367
367
|
try {
|
|
368
|
-
|
|
368
|
+
// List/sidebar path: scan off the event loop (async) and skip
|
|
369
|
+
// events.jsonl enrichment entirely — neither the run list nor the sidebar
|
|
370
|
+
// render dispatch_events / graph-query counts (issue #296).
|
|
371
|
+
const runs = await discoverRunsAsync(req.project.worcaDir, {
|
|
372
|
+
enrich: false,
|
|
373
|
+
});
|
|
369
374
|
const default_branch = getDefaultBranch(req.project.projectRoot);
|
|
370
375
|
|
|
371
376
|
const { getBeadsCounts } = req.app.locals;
|
|
@@ -924,6 +929,7 @@ export function createProjectScopedRoutes({
|
|
|
924
929
|
planFile,
|
|
925
930
|
msize,
|
|
926
931
|
mloops,
|
|
932
|
+
maxBeads,
|
|
927
933
|
branch,
|
|
928
934
|
template,
|
|
929
935
|
} = body;
|
|
@@ -1013,6 +1019,10 @@ export function createProjectScopedRoutes({
|
|
|
1013
1019
|
mloops != null
|
|
1014
1020
|
? Math.max(1, Math.min(10, Math.round(Number(mloops))))
|
|
1015
1021
|
: 1;
|
|
1022
|
+
const maxBeadsVal =
|
|
1023
|
+
maxBeads != null
|
|
1024
|
+
? Math.max(0, Math.min(50, Math.round(Number(maxBeads))))
|
|
1025
|
+
: undefined;
|
|
1016
1026
|
|
|
1017
1027
|
// Atomically check global cap and start pipeline under lock
|
|
1018
1028
|
await launchLock.withLock(async () => {
|
|
@@ -1040,6 +1050,7 @@ export function createProjectScopedRoutes({
|
|
|
1040
1050
|
prompt: hasPrompt ? prompt : undefined,
|
|
1041
1051
|
msize: msizeVal,
|
|
1042
1052
|
mloops: mloopsVal,
|
|
1053
|
+
maxBeads: maxBeadsVal,
|
|
1043
1054
|
planFile: hasPlan ? planFile.trim() : undefined,
|
|
1044
1055
|
branch: branch || undefined,
|
|
1045
1056
|
template: template || undefined,
|
|
@@ -1709,7 +1720,9 @@ export function createProjectScopedRoutes({
|
|
|
1709
1720
|
// Reads per-iteration token_usage from each run's status.json.
|
|
1710
1721
|
router.get('/costs', requireWorcaDir, (req, res) => {
|
|
1711
1722
|
const { worcaDir } = req.project;
|
|
1712
|
-
|
|
1723
|
+
// Costs read only per-iteration token_usage from status.json — no
|
|
1724
|
+
// events.jsonl enrichment needed (issue #296).
|
|
1725
|
+
const runs = discoverRuns(worcaDir, { enrich: false });
|
|
1713
1726
|
const tokenData = {};
|
|
1714
1727
|
|
|
1715
1728
|
for (const run of runs) {
|
package/server/schemas/keys.json
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective prompt model for the Pipelines editor "Prompts" tab.
|
|
3
|
+
*
|
|
4
|
+
* For each stage prompt file (agent `*.md` and user-prompt `*.block.md`) this
|
|
5
|
+
* resolves what the pipeline actually runs, classifying each file as one of:
|
|
6
|
+
*
|
|
7
|
+
* - 'builtin' — the template has no overlay; the built-in core prompt is used
|
|
8
|
+
* unchanged (a fallback).
|
|
9
|
+
* - 'pipeline' — the template overlay replaces the built-in prompt entirely
|
|
10
|
+
* (default mode, or an explicit `<!-- replace -->`).
|
|
11
|
+
* - 'extends' — the overlay is `<!-- append -->`; it merges into the built-in
|
|
12
|
+
* via `## Override: <Section>` blocks (each appending, or
|
|
13
|
+
* overwriting when the block opens with `<!-- replace -->`), or
|
|
14
|
+
* a raw trailing append when there are no override blocks.
|
|
15
|
+
*
|
|
16
|
+
* Mode/override parsing mirrors src/worca/orchestrator/overlay.py so the editor
|
|
17
|
+
* preview matches what the runtime actually assembles.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const OVERLAY_NAME_RE = /^[a-z0-9._-]{1,64}\.(md|block\.md)$/;
|
|
24
|
+
const APPEND_TAG = '<!-- append -->';
|
|
25
|
+
const REPLACE_TAG = '<!-- replace -->';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Split an `<!-- append -->` overlay body into `## Override: <Section>` blocks.
|
|
29
|
+
* Mirrors overlay.py:_parse_overrides. A block whose first non-blank line is
|
|
30
|
+
* `<!-- replace -->` overwrites the matching built-in section; otherwise it
|
|
31
|
+
* appends. Returns `[{ section, mode: 'append'|'overwrite', body }]`.
|
|
32
|
+
*/
|
|
33
|
+
export function parseOverrides(content) {
|
|
34
|
+
const parts = content.split(/^(## Override:\s*.+)$/m);
|
|
35
|
+
const overrides = [];
|
|
36
|
+
for (let i = 1; i < parts.length - 1; i += 2) {
|
|
37
|
+
const headingLine = parts[i];
|
|
38
|
+
const section = headingLine.replace(/^##\s*Override:\s*/, '').trim();
|
|
39
|
+
const lines = parts[i + 1].split('\n');
|
|
40
|
+
const kept = [];
|
|
41
|
+
let foundReplace = false;
|
|
42
|
+
let replace = false;
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (!foundReplace && line.trim() === REPLACE_TAG) {
|
|
45
|
+
replace = true;
|
|
46
|
+
foundReplace = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
kept.push(line);
|
|
50
|
+
}
|
|
51
|
+
overrides.push({
|
|
52
|
+
section,
|
|
53
|
+
mode: replace ? 'overwrite' : 'append',
|
|
54
|
+
body: kept.join('\n').trim(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return overrides;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Classify a single file given its built-in (core) and overlay contents.
|
|
62
|
+
* Either may be null. Returns the per-file model the editor renders.
|
|
63
|
+
*/
|
|
64
|
+
export function classifyPromptFile(name, coreContent, overlayContent) {
|
|
65
|
+
const role = name.endsWith('.block.md') ? 'block' : 'agent';
|
|
66
|
+
const base = { name, role };
|
|
67
|
+
|
|
68
|
+
if (overlayContent == null) {
|
|
69
|
+
return { ...base, source: 'builtin', content: coreContent ?? '' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const stripped = overlayContent.replace(/^\s+/, '');
|
|
73
|
+
|
|
74
|
+
if (stripped.startsWith(APPEND_TAG)) {
|
|
75
|
+
const body = stripped.slice(APPEND_TAG.length);
|
|
76
|
+
const overrides = parseOverrides(body);
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
source: 'extends',
|
|
80
|
+
builtin: coreContent ?? '',
|
|
81
|
+
contributions: overrides,
|
|
82
|
+
rawAppend: overrides.length === 0 ? body.trim() : null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const content = stripped.startsWith(REPLACE_TAG)
|
|
87
|
+
? stripped.slice(REPLACE_TAG.length).trim()
|
|
88
|
+
: overlayContent.trim();
|
|
89
|
+
return { ...base, source: 'pipeline', content };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read every prompt file under a directory (filtered to overlay-name shape),
|
|
94
|
+
* returning `{ filename: content }`. Missing dir → empty object.
|
|
95
|
+
*/
|
|
96
|
+
function readPromptDir(dir) {
|
|
97
|
+
const out = {};
|
|
98
|
+
if (!dir || !existsSync(dir)) return out;
|
|
99
|
+
let names;
|
|
100
|
+
try {
|
|
101
|
+
names = readdirSync(dir);
|
|
102
|
+
} catch {
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
for (const f of names) {
|
|
106
|
+
if (!OVERLAY_NAME_RE.test(f)) continue;
|
|
107
|
+
try {
|
|
108
|
+
out[f] = readFileSync(join(dir, f), 'utf8');
|
|
109
|
+
} catch {
|
|
110
|
+
/* skip unreadable files */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the prompts model for a template.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} coreDir - built-in core prompts dir (.../agents/core)
|
|
120
|
+
* @param {string} overlayDir - template overlay dir (.../<template>/agents); may not exist
|
|
121
|
+
* @returns {object} `{ filename: model }` over the union of core+overlay files
|
|
122
|
+
*/
|
|
123
|
+
export function buildPromptsModel(coreDir, overlayDir) {
|
|
124
|
+
const core = readPromptDir(coreDir);
|
|
125
|
+
const overlay = readPromptDir(overlayDir);
|
|
126
|
+
const names = new Set([...Object.keys(core), ...Object.keys(overlay)]);
|
|
127
|
+
const model = {};
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
model[name] = classifyPromptFile(
|
|
130
|
+
name,
|
|
131
|
+
Object.hasOwn(core, name) ? core[name] : null,
|
|
132
|
+
Object.hasOwn(overlay, name) ? overlay[name] : null,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return model;
|
|
136
|
+
}
|