@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/app/main.bundle.js +2625 -1971
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +2 -0
- package/app/styles.css +1356 -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 +553 -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 +303 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/ui",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
+
}
|