@worca/ui 0.40.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.
@@ -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,
@@ -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
- const runs = discoverRuns(req.project.worcaDir);
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
- const runs = discoverRuns(worcaDir);
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) {
@@ -33,6 +33,11 @@
33
33
  "milestones": {
34
34
  "plan_approval": true,
35
35
  "pr_approval": false
36
+ },
37
+ "telemetry": {
38
+ "file_access": {
39
+ "enabled": true
40
+ }
36
41
  }
37
42
  }
38
43
  }
@@ -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
+ }