@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -75,7 +75,7 @@
75
75
  },
76
76
  "devDependencies": {
77
77
  "@biomejs/biome": "^2.2.4",
78
- "@playwright/test": "^1.58.2",
78
+ "@playwright/test": "^1.60.0",
79
79
  "@vitest/coverage-v8": "^4.0.15",
80
80
  "esbuild": "^0.27.1",
81
81
  "jsdom": "^29.0.1",
package/server/app.js CHANGED
@@ -191,6 +191,50 @@ export function createApp(options = {}) {
191
191
  // /api/projects/:id/runs could both pass the cap check and start.
192
192
  const launchLock = new LaunchLock();
193
193
 
194
+ // In-process mutex for deferred PR creation — keyed by `${project}/${runId}`.
195
+ // Handles double-click races: a second POST while the first is in-flight gets 409.
196
+ const inFlightPrCreation = new Map();
197
+
198
+ // Spawner for `worca pr create` — overridable via options._spawnPrCreate for tests.
199
+ const prSpawn =
200
+ options._spawnPrCreate ||
201
+ ((runId, projectPath) =>
202
+ new Promise((resolve, reject) => {
203
+ const child = spawn(
204
+ 'python3',
205
+ [
206
+ '-m',
207
+ 'worca.cli.main',
208
+ 'pr',
209
+ 'create',
210
+ '--project',
211
+ projectPath,
212
+ '--',
213
+ runId,
214
+ ],
215
+ { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } },
216
+ );
217
+ let stdout = '';
218
+ let stderr = '';
219
+ child.stdout.on('data', (chunk) => {
220
+ stdout += chunk.toString();
221
+ });
222
+ child.stderr.on('data', (chunk) => {
223
+ stderr += chunk.toString();
224
+ });
225
+ child.on('error', reject);
226
+ child.on('exit', (code) => {
227
+ if (code === 0) resolve({ stdout, stderr });
228
+ else
229
+ reject(
230
+ Object.assign(
231
+ new Error(`worca pr create failed: ${stderr.trim()}`),
232
+ { stderr },
233
+ ),
234
+ );
235
+ });
236
+ }));
237
+
194
238
  // ─── Legacy single-project API ─────────────────────────────────────────
195
239
  // Mounts the shared project-scoped routes at /api with a middleware that
196
240
  // injects req.project from the closure options, so /api/runs, /api/settings,
@@ -817,6 +861,52 @@ export function createApp(options = {}) {
817
861
  app.use('/api/workspace-runs', workspaceRouters.workspaceRuns);
818
862
  }
819
863
 
864
+ // POST /api/projects/:project/runs/:runId/pr — trigger deferred PR creation.
865
+ // The in-process mutex (inFlightPrCreation) prevents double-click races: a
866
+ // second request while the first is still in-flight returns 409 immediately.
867
+ // The CLI's own status.json lock covers concurrent-process races.
868
+ app.post('/api/projects/:project/runs/:runId/pr', async (req, res) => {
869
+ const { project, runId } = req.params;
870
+
871
+ if (!/^[A-Za-z0-9_.-]+$/.test(runId) || runId.startsWith('-')) {
872
+ return res.status(400).json({ error: 'invalid_run_id' });
873
+ }
874
+
875
+ const key = `${project}/${runId}`;
876
+
877
+ if (inFlightPrCreation.has(key)) {
878
+ return res.status(409).json({ error: 'pr_creation_in_progress' });
879
+ }
880
+
881
+ // Resolve the project path: use req.project if set by projectResolver
882
+ // (global mode), otherwise fall back to projectRoot or cwd.
883
+ const projectPath = req.project?.path ?? projectRoot ?? process.cwd();
884
+
885
+ const work = (async () => {
886
+ const { stdout } = await prSpawn(runId, projectPath);
887
+ // Match a PR/MR URL across hosts the CLI may emit: GitHub (/pull/N),
888
+ // GitLab (/-/merge_requests/N), Bitbucket (/pull-requests/N), and
889
+ // Azure DevOps (/pullrequest/N). Keep it provider-agnostic so the
890
+ // returned pr_url isn't silently dropped on non-GitHub hosts.
891
+ const prUrlMatch = stdout.match(
892
+ /https?:\/\/\S+?\/(?:pull|pull-requests|pullrequest|merge_requests)\/\d+/,
893
+ );
894
+ if (prUrlMatch) return { pr_url: prUrlMatch[0] };
895
+ return {};
896
+ })();
897
+
898
+ inFlightPrCreation.set(key, work);
899
+
900
+ try {
901
+ const result = await work;
902
+ res.json(result);
903
+ } catch (err) {
904
+ res.status(500).json({ error: err.message });
905
+ } finally {
906
+ inFlightPrCreation.delete(key);
907
+ }
908
+ });
909
+
820
910
  // POST /api/integrations/telegram/detect — find chat IDs from recent messages.
821
911
  // If the Telegram adapter is running, temporarily pauses its poll loop so
822
912
  // getUpdates returns results instead of being consumed by the long-poller.
@@ -64,12 +64,12 @@ export const DISPATCH_DEFAULTS = {
64
64
  },
65
65
  },
66
66
  subagents: {
67
+ // No subagents are denied by default. general-purpose (a full-tool Claude
68
+ // session) is now allowed under the '*' wildcard like any other subagent;
69
+ // a project can still deny specific subagents via always_disallowed /
70
+ // default_denied. Mirror of _DISPATCH_DEFAULTS in tracking.py.
67
71
  always_disallowed: [],
68
- // general-purpose spawns an unconstrained full-tool Claude session, so it
69
- // stays denied under the '*' wildcard — but as default_denied (not
70
- // always_disallowed) a project can re-allow it per agent by naming it in
71
- // per_agent_allow.
72
- default_denied: ['general-purpose'],
72
+ default_denied: [],
73
73
  per_agent_allow: { _defaults: ['*'] },
74
74
  },
75
75
  };
@@ -24,6 +24,31 @@ const DISPATCH_EVENT_TYPES = new Set([
24
24
  'pipeline.hook.dispatch_blocked',
25
25
  ]);
26
26
 
27
+ /**
28
+ * Classify a single parsed events.jsonl line. Returns the normalised dispatch
29
+ * entry if the line is a dispatch event, or null otherwise. Extracted so a
30
+ * combined single-pass reader (events-jsonl-reader.js) can share the exact same
31
+ * normalisation as the standalone reader below.
32
+ *
33
+ * @param {object} e — a parsed events.jsonl object
34
+ * @returns {{type, section, candidate, via?, reason?, timestamp}|null}
35
+ */
36
+ export function parseDispatchEventLine(e) {
37
+ if (!e || !DISPATCH_EVENT_TYPES.has(e.event_type)) return null;
38
+ const payload = e.payload || {};
39
+ const candidate = payload.candidate;
40
+ if (!candidate) return null;
41
+ const entry = {
42
+ type: e.event_type,
43
+ section: payload.section || 'subagents',
44
+ candidate,
45
+ timestamp: e.timestamp,
46
+ };
47
+ if (payload.reason) entry.reason = payload.reason;
48
+ if (payload.via) entry.via = payload.via;
49
+ return entry;
50
+ }
51
+
27
52
  /**
28
53
  * Parse events.jsonl and return only the dispatch events, with normalised shape.
29
54
  * Malformed lines are silently skipped so a corrupt event doesn't break the run view.
@@ -48,19 +73,8 @@ export function readDispatchEventsFromJsonl(eventsPath) {
48
73
  } catch {
49
74
  continue;
50
75
  }
51
- if (!DISPATCH_EVENT_TYPES.has(e.event_type)) continue;
52
- const payload = e.payload || {};
53
- const candidate = payload.candidate;
54
- if (!candidate) continue;
55
- const entry = {
56
- type: e.event_type,
57
- section: payload.section || 'subagents',
58
- candidate,
59
- timestamp: e.timestamp,
60
- };
61
- if (payload.reason) entry.reason = payload.reason;
62
- if (payload.via) entry.via = payload.via;
63
- out.push(entry);
76
+ const entry = parseDispatchEventLine(e);
77
+ if (entry) out.push(entry);
64
78
  }
65
79
  return out;
66
80
  }
@@ -63,7 +63,9 @@ function _absorbFlatDispatchKeys(dispatch) {
63
63
  // v1: collapse stale Explore-only subagent default; narrow worca-* skills glob.
64
64
  // v2: move general-purpose from subagents.always_disallowed to default_denied
65
65
  // (still denied by default, but allowable per-agent).
66
- export const DISPATCH_MIGRATION_VERSION = 2;
66
+ // v3: release general-purpose from default_denied entirely (now allowed under
67
+ // the "*" wildcard, matching the shipped default after the policy change).
68
+ export const DISPATCH_MIGRATION_VERSION = 3;
67
69
 
68
70
  // Pre-W-054 (W-038-era) shipped subagent default: every pipeline agent capped
69
71
  // to Explore-only. coordinator:[] / empty lists fall through to _defaults and
@@ -184,6 +186,30 @@ export function adoptGeneralPurposeAllowable(subagentsCfg) {
184
186
  return true;
185
187
  }
186
188
 
189
+ /**
190
+ * Remove general-purpose from default_denied to match the shipped default,
191
+ * which now allows it under the '*' wildcard. Reverses the untouched v2
192
+ * artifact (default_denied exactly `['general-purpose']`); a customized
193
+ * denylist (extra entries) is left alone. Returns true if changed. Mirror of
194
+ * release_general_purpose_default_deny() in tracking.py.
195
+ *
196
+ * @param {object} subagentsCfg
197
+ * @returns {boolean}
198
+ */
199
+ export function releaseGeneralPurposeDefaultDeny(subagentsCfg) {
200
+ if (!subagentsCfg || typeof subagentsCfg !== 'object') return false;
201
+ const denied = subagentsCfg.default_denied;
202
+ if (
203
+ !Array.isArray(denied) ||
204
+ denied.length !== 1 ||
205
+ denied[0] !== 'general-purpose'
206
+ ) {
207
+ return false;
208
+ }
209
+ subagentsCfg.default_denied = [];
210
+ return true;
211
+ }
212
+
187
213
  /**
188
214
  * Apply one-time dispatch-default normalizations, gated by a version stamp.
189
215
  * Brings an *untouched* config up to current shipped defaults for the two
@@ -215,6 +241,14 @@ export function normalizeDispatchDefaults(governanceCfg) {
215
241
  'governance.dispatch.subagents: moved general-purpose from always_disallowed to default_denied (now allowable per-agent)',
216
242
  );
217
243
  }
244
+ // Runs AFTER adoptGeneralPurposeAllowable so a v1 config that just had
245
+ // general-purpose moved into default_denied gets it released in the same
246
+ // pass — net result: general-purpose allowed under "*", matching the default.
247
+ if (releaseGeneralPurposeDefaultDeny(dispatch.subagents)) {
248
+ changes.push(
249
+ 'governance.dispatch.subagents: released general-purpose from default_denied (now allowed under "*", matching the shipped default)',
250
+ );
251
+ }
218
252
  governanceCfg.dispatch_migration_version = DISPATCH_MIGRATION_VERSION;
219
253
  return changes;
220
254
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Combined single-pass reader for a run's events.jsonl.
3
+ *
4
+ * Previously enrichment read the same file TWICE — once for dispatch events
5
+ * (dispatch-events-aggregator) and once for graph-query events
6
+ * (graph-query-aggregator) — each its own readFileSync + split + per-line
7
+ * JSON.parse. This module reads + parses the file ONCE and dispatches each line
8
+ * to both classifiers, halving the cost wherever enrichment still runs
9
+ * (findRun, the detailed-view live-update path). See issue #296.
10
+ *
11
+ * Results are cached by file mtime+size so an unchanged events.jsonl is parsed
12
+ * at most once across repeated enrichment (multiple subscribe-run calls, the
13
+ * status watcher's debounced refresh). A live run appends to the file, changing
14
+ * mtime+size on every flush, so it always re-reads — exactly what we want for
15
+ * fresh per-iteration counts. The cache is bounded to avoid unbounded growth on
16
+ * a long-lived global-mode server.
17
+ */
18
+
19
+ import { existsSync, readFileSync, statSync } from 'node:fs';
20
+ import { parseDispatchEventLine } from './dispatch-events-aggregator.js';
21
+ import { parseGraphQueryEventLine } from './graph-query-aggregator.js';
22
+
23
+ const _parsedEventsCache = new Map(); // cacheKey → { dispatchEvents, graphEvents }
24
+ const _MAX_CACHE_ENTRIES = 256;
25
+
26
+ const EMPTY = Object.freeze({
27
+ dispatchEvents: Object.freeze([]),
28
+ graphEvents: Object.freeze([]),
29
+ });
30
+
31
+ /** Clear the events.jsonl parse cache (tests, or explicit invalidation). */
32
+ export function _clearEventsJsonlCache() {
33
+ _parsedEventsCache.clear();
34
+ }
35
+
36
+ /**
37
+ * Read a run's events.jsonl ONCE and return both dispatch and graph-query
38
+ * events. No-op (empty arrays) when the file is missing or unreadable.
39
+ *
40
+ * @param {string} eventsPath — absolute path to events.jsonl
41
+ * @returns {{dispatchEvents: Array, graphEvents: Array}}
42
+ */
43
+ export function readEventsForEnrichment(eventsPath) {
44
+ if (!eventsPath || !existsSync(eventsPath)) return EMPTY;
45
+
46
+ let stat;
47
+ try {
48
+ stat = statSync(eventsPath);
49
+ } catch {
50
+ return EMPTY;
51
+ }
52
+
53
+ const cacheKey = `${eventsPath}:${stat.mtimeMs}:${stat.size}`;
54
+ const cached = _parsedEventsCache.get(cacheKey);
55
+ if (cached) return cached;
56
+
57
+ let content;
58
+ try {
59
+ content = readFileSync(eventsPath, 'utf8');
60
+ } catch {
61
+ return EMPTY;
62
+ }
63
+
64
+ const dispatchEvents = [];
65
+ const graphEvents = [];
66
+ for (const line of content.split('\n')) {
67
+ if (!line.trim()) continue;
68
+ let e;
69
+ try {
70
+ e = JSON.parse(line);
71
+ } catch {
72
+ continue;
73
+ }
74
+ // A line is at most one of these (distinct event_types) — classify once.
75
+ const d = parseDispatchEventLine(e);
76
+ if (d) {
77
+ dispatchEvents.push(d);
78
+ continue;
79
+ }
80
+ const g = parseGraphQueryEventLine(e);
81
+ if (g) graphEvents.push(g);
82
+ }
83
+
84
+ const result = { dispatchEvents, graphEvents };
85
+
86
+ // Bound cache growth — evict the oldest entry (insertion order) when full.
87
+ if (_parsedEventsCache.size >= _MAX_CACHE_ENTRIES) {
88
+ const oldest = _parsedEventsCache.keys().next().value;
89
+ _parsedEventsCache.delete(oldest);
90
+ }
91
+ _parsedEventsCache.set(cacheKey, result);
92
+ return result;
93
+ }