@worca/ui 0.41.0 → 0.43.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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Graph-query aggregator — reads pipeline.hook.graph_query events from a run's
3
+ * events.jsonl and turns them into live per-iteration query counts
4
+ * (graphify_invocations / crg_invocations / crg_tool_counts), assigned to the
5
+ * matching iteration in status.json by timestamp range.
6
+ *
7
+ * This is the live counterpart to the runner's completion-time tally: the
8
+ * runner writes the authoritative graphify_invocations / crg_invocations onto
9
+ * each iteration when the stage *completes*. For a still-running iteration that
10
+ * count is absent, so the graphify/CRG badges would otherwise stay blank until
11
+ * completion. This aggregator fills the gap by counting the live hook events —
12
+ * exactly like dispatch-events-aggregator does for skills/subagents — and only
13
+ * for iterations that don't already carry the runner's number (so the
14
+ * authoritative completion count is never clobbered).
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+
19
+ const GRAPH_QUERY_EVENT_TYPE = 'pipeline.hook.graph_query';
20
+
21
+ /**
22
+ * Classify a single parsed events.jsonl line. Returns the normalised graph-query
23
+ * entry if the line is a graph-query event, or null otherwise. Extracted so a
24
+ * combined single-pass reader (events-jsonl-reader.js) can share the exact same
25
+ * normalisation as the standalone reader below.
26
+ *
27
+ * @param {object} e — a parsed events.jsonl object
28
+ * @returns {{engine, op, timestamp}|null}
29
+ */
30
+ export function parseGraphQueryEventLine(e) {
31
+ if (!e || e.event_type !== GRAPH_QUERY_EVENT_TYPE) return null;
32
+ const payload = e.payload || {};
33
+ const engine = payload.engine;
34
+ if (engine !== 'graphify' && engine !== 'crg') return null;
35
+ return { engine, op: payload.op || '', timestamp: e.timestamp };
36
+ }
37
+
38
+ /**
39
+ * Parse events.jsonl and return only the graph-query events.
40
+ * Malformed lines are skipped so a corrupt event doesn't break the run view.
41
+ *
42
+ * @param {string} eventsPath — absolute path to events.jsonl
43
+ * @returns {Array<{engine, op, timestamp}>}
44
+ */
45
+ export function readGraphQueryEventsFromJsonl(eventsPath) {
46
+ if (!eventsPath || !existsSync(eventsPath)) return [];
47
+ let content;
48
+ try {
49
+ content = readFileSync(eventsPath, 'utf8');
50
+ } catch {
51
+ return [];
52
+ }
53
+ const out = [];
54
+ for (const line of content.split('\n')) {
55
+ if (!line.trim()) continue;
56
+ let e;
57
+ try {
58
+ e = JSON.parse(line);
59
+ } catch {
60
+ continue;
61
+ }
62
+ const entry = parseGraphQueryEventLine(e);
63
+ if (entry) out.push(entry);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Given a list of graph-query events and a stages map from status.json, return
70
+ * a new stages map where each iteration that overlaps an event's timestamp gets
71
+ * live query counts — but ONLY when the iteration does not already carry the
72
+ * runner's authoritative count (i.e. the still-running iteration). Completed
73
+ * iterations are left exactly as the runner wrote them.
74
+ *
75
+ * Counts added per matching running iteration:
76
+ * graphify_invocations: <number> (graphify-engine events)
77
+ * crg_invocations: <number> (crg-engine events)
78
+ * crg_tool_counts: { <op>: <number> } (crg-engine, by tool — tooltip)
79
+ *
80
+ * Non-destructive: input stages object is shallow-copied; touched iterations
81
+ * get new objects. Existing iteration fields are preserved.
82
+ *
83
+ * @param {Array<{engine, op, timestamp}>} events
84
+ * @param {object} stages — status.stages
85
+ * @returns {object} enriched stages
86
+ */
87
+ export function assignGraphQueryCountsToIterations(events, stages) {
88
+ if (!stages || typeof stages !== 'object') return stages;
89
+ if (!events || events.length === 0) return stages;
90
+
91
+ // Bucket events into iterations by timestamp. Bucket key: `${stageKey}|${num}`.
92
+ const buckets = new Map();
93
+ for (const ev of events) {
94
+ if (!ev.timestamp) continue;
95
+ const eventTime = Date.parse(ev.timestamp);
96
+ if (Number.isNaN(eventTime)) continue;
97
+
98
+ let matched = false;
99
+ for (const [stageKey, stage] of Object.entries(stages)) {
100
+ const iterations = stage?.iterations;
101
+ if (!Array.isArray(iterations)) continue;
102
+ for (const iter of iterations) {
103
+ // Only fill counts for iterations the runner hasn't tallied yet —
104
+ // a present number means the stage completed and is authoritative.
105
+ if (typeof iter.graphify_invocations === 'number') continue;
106
+ if (typeof iter.crg_invocations === 'number') continue;
107
+ const start = iter.started_at ? Date.parse(iter.started_at) : NaN;
108
+ if (Number.isNaN(start)) continue;
109
+ const end = iter.completed_at
110
+ ? Date.parse(iter.completed_at)
111
+ : Number.POSITIVE_INFINITY;
112
+ if (eventTime >= start && eventTime <= end) {
113
+ const key = `${stageKey}|${iter.number}`;
114
+ if (!buckets.has(key)) buckets.set(key, []);
115
+ buckets.get(key).push(ev);
116
+ matched = true;
117
+ break;
118
+ }
119
+ }
120
+ if (matched) break;
121
+ }
122
+ }
123
+
124
+ if (buckets.size === 0) return stages;
125
+
126
+ const enrichedStages = { ...stages };
127
+ for (const [key, bucketEvents] of buckets) {
128
+ const [stageKey, iterNumStr] = key.split('|');
129
+ const iterNum = Number(iterNumStr);
130
+ const stage = enrichedStages[stageKey];
131
+ if (!stage) continue;
132
+ const counts = tally(bucketEvents);
133
+ const newIterations = stage.iterations.map((iter) =>
134
+ iter.number === iterNum ? { ...iter, ...counts } : iter,
135
+ );
136
+ enrichedStages[stageKey] = { ...stage, iterations: newIterations };
137
+ }
138
+ return enrichedStages;
139
+ }
140
+
141
+ /**
142
+ * Tally graph-query events into the count shape the badges read.
143
+ *
144
+ * @param {Array<{engine, op}>} events
145
+ * @returns {{graphify_invocations, crg_invocations, crg_tool_counts}}
146
+ */
147
+ function tally(events) {
148
+ let graphify = 0;
149
+ let crg = 0;
150
+ const crgToolCounts = {};
151
+ for (const ev of events) {
152
+ if (ev.engine === 'graphify') {
153
+ graphify += 1;
154
+ } else if (ev.engine === 'crg') {
155
+ crg += 1;
156
+ const op = ev.op || 'unknown';
157
+ crgToolCounts[op] = (crgToolCounts[op] || 0) + 1;
158
+ }
159
+ }
160
+ return {
161
+ graphify_invocations: graphify,
162
+ crg_invocations: crg,
163
+ crg_tool_counts: crgToolCounts,
164
+ };
165
+ }
@@ -134,6 +134,16 @@ function renderGitPrCreated(envelope) {
134
134
  return mdMsg(parts.join('\n'), 'info');
135
135
  }
136
136
 
137
+ function renderGitPrDeferred(envelope) {
138
+ const p = envelope.payload;
139
+ const parts = [`\u{1F7E1} **Run:** \`${runId(envelope)}\``];
140
+ parts.push(
141
+ ` **PR deferred:** branch \`${p.head_branch}\` pushed — open the run and click **Create PR** to publish.`,
142
+ );
143
+ if (p.pr_title) parts.push(` **Title:** ${p.pr_title}`);
144
+ return mdMsg(parts.join('\n'), 'warning');
145
+ }
146
+
137
147
  function renderGitPrMerged(envelope) {
138
148
  const p = envelope.payload;
139
149
  const parts = [`\u2705 **PR merged:** [#${p.pr_number}](${p.pr_url})`];
@@ -560,6 +570,7 @@ const EVENT_RENDERERS = {
560
570
  'pipeline.stage.completed': renderStageCompleted,
561
571
  'pipeline.stage.interrupted': renderStageInterrupted,
562
572
  'pipeline.git.pr_created': renderGitPrCreated,
573
+ 'pipeline.git.pr_deferred': renderGitPrDeferred,
563
574
  'pipeline.git.pr_merged': renderGitPrMerged,
564
575
  'pipeline.circuit_breaker.tripped': renderCbTripped,
565
576
  'pipeline.cost.budget_warning': renderCostBudgetWarning,
@@ -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
+ }