@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
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-access aggregator — reads pipeline.iteration.access events from a
|
|
3
|
+
* run's events.jsonl and folds payloads into the row/column model used by
|
|
4
|
+
* the Access Map view.
|
|
5
|
+
*
|
|
6
|
+
* Output shape:
|
|
7
|
+
* { enabled: false } — no access events (pre-W-064 run)
|
|
8
|
+
* { enabled: true, columns, tree, searches, summary }
|
|
9
|
+
*
|
|
10
|
+
* Columns: stage-ordered (STAGE_ORDER), then ascending iteration, then
|
|
11
|
+
* bead_id (nulls first, then lexicographic).
|
|
12
|
+
*
|
|
13
|
+
* Tree: union of reads∪writes paths, hierarchical dir/file nodes. Dir rows
|
|
14
|
+
* carry server-side rollups so the browser never recomputes aggregates.
|
|
15
|
+
*
|
|
16
|
+
* Searches: flat list of per-event search records with broad/zero_hit flags.
|
|
17
|
+
*
|
|
18
|
+
* GraphQueries: flat list of per-event knowledge-graph queries (graphify / CRG)
|
|
19
|
+
* with engine, op, target, and zero_hit flags. Empty unless the run used a
|
|
20
|
+
* graph engine.
|
|
21
|
+
*
|
|
22
|
+
* Summary: global aggregates. oracle:"degraded" if ANY event was degraded.
|
|
23
|
+
*
|
|
24
|
+
* Pattern: mirrors dispatch-events-aggregator.js.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { join, resolve } from 'node:path';
|
|
29
|
+
import { STAGE_ORDER } from '../app/utils/stage-order.js';
|
|
30
|
+
|
|
31
|
+
const ACCESS_EVENT_TYPE = 'pipeline.iteration.access';
|
|
32
|
+
|
|
33
|
+
// Access fragment filename: `<stage>-<iter>.jsonl` or `<stage>-<iter>-<bead>.jsonl`.
|
|
34
|
+
// Stage keys never contain a hyphen (plan, coordinate, implement, plan_review…);
|
|
35
|
+
// iteration is digits; bead ids may contain hyphens, so they soak up the rest.
|
|
36
|
+
const FRAGMENT_NAME_RE = /^([a-z_]+)-(\d+)(?:-(.+))?\.jsonl$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the Access Map model from a run's events.jsonl.
|
|
40
|
+
*
|
|
41
|
+
* The completed iterations come from `pipeline.iteration.access` events (the
|
|
42
|
+
* runner's authoritative completion-time aggregation). When `runDir` is given,
|
|
43
|
+
* the still-running iteration is folded in LIVE by reading its on-disk access
|
|
44
|
+
* fragment directly — so the map, searches and graph-queries populate during
|
|
45
|
+
* the stage instead of only at completion. A live column is never double-counted
|
|
46
|
+
* once its completion event lands (the completion payload wins by colKey), and
|
|
47
|
+
* capture-integrity (leakage/oracle) stays pending for live columns since it's
|
|
48
|
+
* only computable from the finished iteration.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} eventsPath — absolute path to events.jsonl
|
|
51
|
+
* @param {string|null} runDir — run directory (enables live fragment folding)
|
|
52
|
+
* @returns {{ enabled: false } | { enabled: true, columns, tree, searches, summary }}
|
|
53
|
+
*/
|
|
54
|
+
export function buildFileAccessModel(eventsPath, runDir = null) {
|
|
55
|
+
// Parse completion-time access events (authoritative for finished iterations).
|
|
56
|
+
const accessPayloads = [];
|
|
57
|
+
if (eventsPath && existsSync(eventsPath)) {
|
|
58
|
+
let content = '';
|
|
59
|
+
try {
|
|
60
|
+
content = readFileSync(eventsPath, 'utf8');
|
|
61
|
+
} catch {
|
|
62
|
+
content = '';
|
|
63
|
+
}
|
|
64
|
+
for (const line of content.split('\n')) {
|
|
65
|
+
if (!line.trim()) continue;
|
|
66
|
+
let e;
|
|
67
|
+
try {
|
|
68
|
+
e = JSON.parse(line);
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (e.event_type !== ACCESS_EVENT_TYPE) continue;
|
|
73
|
+
if (!e.payload) continue;
|
|
74
|
+
accessPayloads.push(e.payload);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fold the still-running iteration's fragment(s) in live — skipping any
|
|
79
|
+
// column that already has an authoritative completion event.
|
|
80
|
+
const completedCols = new Set(
|
|
81
|
+
accessPayloads.map((p) => colKey(p.stage, p.iteration, p.bead_id)),
|
|
82
|
+
);
|
|
83
|
+
const livePayloads = runDir
|
|
84
|
+
? readLiveFragmentPayloads(runDir, completedCols)
|
|
85
|
+
: [];
|
|
86
|
+
accessPayloads.push(...livePayloads);
|
|
87
|
+
|
|
88
|
+
if (accessPayloads.length === 0) return { enabled: false };
|
|
89
|
+
|
|
90
|
+
// ------------------------------------------------------------------
|
|
91
|
+
// 1. Build columns (deduplicated, sorted)
|
|
92
|
+
// ------------------------------------------------------------------
|
|
93
|
+
const colMap = new Map();
|
|
94
|
+
for (const p of accessPayloads) {
|
|
95
|
+
const key = colKey(p.stage, p.iteration, p.bead_id);
|
|
96
|
+
if (!colMap.has(key)) {
|
|
97
|
+
colMap.set(key, {
|
|
98
|
+
key,
|
|
99
|
+
stage: p.stage,
|
|
100
|
+
iteration: p.iteration,
|
|
101
|
+
bead_id: p.bead_id ?? null,
|
|
102
|
+
agent: p.agent,
|
|
103
|
+
live: !!p._live,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const columns = [...colMap.values()].sort(compareColumns);
|
|
109
|
+
|
|
110
|
+
// ------------------------------------------------------------------
|
|
111
|
+
// 2. Fold payloads into per-file data and searches
|
|
112
|
+
// ------------------------------------------------------------------
|
|
113
|
+
// fileData: path → { cells: { colKey: { read?, write? } }, tracked }
|
|
114
|
+
const fileData = new Map();
|
|
115
|
+
|
|
116
|
+
const searches = [];
|
|
117
|
+
const graphQueries = [];
|
|
118
|
+
let oracleDegraded = false;
|
|
119
|
+
|
|
120
|
+
const summary = {
|
|
121
|
+
files_touched: 0,
|
|
122
|
+
distinct_read: 0,
|
|
123
|
+
total_read: 0,
|
|
124
|
+
distinct_write: 0,
|
|
125
|
+
total_write: 0,
|
|
126
|
+
searches: 0,
|
|
127
|
+
grep: 0,
|
|
128
|
+
glob: 0,
|
|
129
|
+
zero_result: 0,
|
|
130
|
+
root_scoped: 0,
|
|
131
|
+
graph_queries: 0,
|
|
132
|
+
graphify: 0,
|
|
133
|
+
crg: 0,
|
|
134
|
+
leakage_pct_max: 0,
|
|
135
|
+
oracle: 'ok',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
for (const p of accessPayloads) {
|
|
139
|
+
const ck = colKey(p.stage, p.iteration, p.bead_id);
|
|
140
|
+
const fa = p.file_access || {};
|
|
141
|
+
|
|
142
|
+
for (const [path, count] of Object.entries(fa.reads || {})) {
|
|
143
|
+
const fd = ensureFile(fileData, path);
|
|
144
|
+
if (!fd.cells[ck]) fd.cells[ck] = {};
|
|
145
|
+
fd.cells[ck].read = (fd.cells[ck].read || 0) + count;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const [path, count] of Object.entries(fa.writes || {})) {
|
|
149
|
+
const fd = ensureFile(fileData, path);
|
|
150
|
+
if (!fd.cells[ck]) fd.cells[ck] = {};
|
|
151
|
+
fd.cells[ck].write = (fd.cells[ck].write || 0) + count;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const s of fa.searches || []) {
|
|
155
|
+
const isBroad = s.scope === '.' || s.scope === '';
|
|
156
|
+
searches.push({
|
|
157
|
+
colKey: ck,
|
|
158
|
+
stage: p.stage,
|
|
159
|
+
iteration: p.iteration,
|
|
160
|
+
tool: s.tool,
|
|
161
|
+
pattern: s.pattern,
|
|
162
|
+
scope: s.scope,
|
|
163
|
+
result_count: s.result_count,
|
|
164
|
+
broad: isBroad,
|
|
165
|
+
zero_hit: s.result_count === 0,
|
|
166
|
+
filter: s.filter ?? null,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const cap = fa.capture || {};
|
|
171
|
+
if (cap.oracle === 'degraded') oracleDegraded = true;
|
|
172
|
+
if (cap.leakage_pct != null && cap.leakage_pct > summary.leakage_pct_max) {
|
|
173
|
+
summary.leakage_pct_max = cap.leakage_pct;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
summary.searches += (fa.searches || []).length;
|
|
177
|
+
for (const s of fa.searches || []) {
|
|
178
|
+
if (s.tool === 'Grep') summary.grep++;
|
|
179
|
+
if (s.tool === 'Glob') summary.glob++;
|
|
180
|
+
if (s.result_count === 0) summary.zero_result++;
|
|
181
|
+
if (s.scope === '.' || s.scope === '') summary.root_scoped++;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Knowledge-graph queries (graphify / CRG) — structural/semantic lookups
|
|
185
|
+
// recorded alongside the lexical searches above. We only surface fields we
|
|
186
|
+
// can reliably capture from both engines: the engine, the op (graphify
|
|
187
|
+
// subcommand / CRG MCP tool name), and the verbatim query/args. Result
|
|
188
|
+
// counts and a separate "target" are op-dependent and not reliably
|
|
189
|
+
// available, so they are intentionally not collected.
|
|
190
|
+
for (const g of fa.graph_queries || []) {
|
|
191
|
+
graphQueries.push({
|
|
192
|
+
colKey: ck,
|
|
193
|
+
stage: p.stage,
|
|
194
|
+
iteration: p.iteration,
|
|
195
|
+
engine: g.engine,
|
|
196
|
+
op: g.op,
|
|
197
|
+
query: g.query,
|
|
198
|
+
});
|
|
199
|
+
summary.graph_queries++;
|
|
200
|
+
if (g.engine === 'graphify') summary.graphify++;
|
|
201
|
+
if (g.engine === 'crg') summary.crg++;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Compute global file-level aggregates from the folded fileData.
|
|
206
|
+
for (const fd of fileData.values()) {
|
|
207
|
+
let fileRead = 0;
|
|
208
|
+
let fileWrite = 0;
|
|
209
|
+
for (const cell of Object.values(fd.cells)) {
|
|
210
|
+
fileRead += cell.read || 0;
|
|
211
|
+
fileWrite += cell.write || 0;
|
|
212
|
+
}
|
|
213
|
+
if (fileRead > 0) {
|
|
214
|
+
summary.distinct_read++;
|
|
215
|
+
summary.total_read += fileRead;
|
|
216
|
+
}
|
|
217
|
+
if (fileWrite > 0) {
|
|
218
|
+
summary.distinct_write++;
|
|
219
|
+
summary.total_write += fileWrite;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
summary.files_touched = fileData.size;
|
|
224
|
+
if (oracleDegraded) summary.oracle = 'degraded';
|
|
225
|
+
|
|
226
|
+
// ------------------------------------------------------------------
|
|
227
|
+
// 3. Build hierarchical tree with server-side dir rollups
|
|
228
|
+
// ------------------------------------------------------------------
|
|
229
|
+
const tree = buildTree(fileData);
|
|
230
|
+
|
|
231
|
+
return { enabled: true, columns, tree, searches, graphQueries, summary };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Helpers
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
function colKey(stage, iteration, beadId) {
|
|
239
|
+
return beadId ? `${stage}:${iteration}:${beadId}` : `${stage}:${iteration}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Read access fragments under runDir/access/ and synthesise per-iteration
|
|
244
|
+
* payloads (same shape as a pipeline.iteration.access event's payload) for the
|
|
245
|
+
* still-running iterations — i.e. any fragment whose column has no completion
|
|
246
|
+
* event yet. Mirrors the Python aggregation in file_access_aggregation.py,
|
|
247
|
+
* minus the GitPathOracle respelling (paths are repo-root-relativised here as a
|
|
248
|
+
* live approximation; the completion event respells authoritatively).
|
|
249
|
+
*
|
|
250
|
+
* @param {string} runDir
|
|
251
|
+
* @param {Set<string>} completedCols — colKeys that already have a completion event
|
|
252
|
+
* @returns {Array<object>} synthetic access payloads with `_live: true`
|
|
253
|
+
*/
|
|
254
|
+
function readLiveFragmentPayloads(runDir, completedCols) {
|
|
255
|
+
const accessDir = join(runDir, 'access');
|
|
256
|
+
if (!existsSync(accessDir)) return [];
|
|
257
|
+
// runDir is `<repoRoot>/.worca/runs/<id>` → repoRoot is three levels up.
|
|
258
|
+
const repoRoot = resolve(runDir, '..', '..', '..');
|
|
259
|
+
|
|
260
|
+
let files;
|
|
261
|
+
try {
|
|
262
|
+
files = readdirSync(accessDir);
|
|
263
|
+
} catch {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const payloads = [];
|
|
268
|
+
for (const fname of files) {
|
|
269
|
+
const m = FRAGMENT_NAME_RE.exec(fname);
|
|
270
|
+
if (!m) continue;
|
|
271
|
+
const stage = m[1];
|
|
272
|
+
const iteration = Number(m[2]);
|
|
273
|
+
const bead_id = m[3] || null;
|
|
274
|
+
if (completedCols.has(colKey(stage, iteration, bead_id))) continue;
|
|
275
|
+
|
|
276
|
+
let records;
|
|
277
|
+
try {
|
|
278
|
+
records = readFileSync(join(accessDir, fname), 'utf8')
|
|
279
|
+
.split('\n')
|
|
280
|
+
.filter((l) => l.trim())
|
|
281
|
+
.map((l) => {
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(l);
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
.filter(Boolean);
|
|
289
|
+
} catch {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (records.length === 0) continue;
|
|
293
|
+
|
|
294
|
+
payloads.push({
|
|
295
|
+
stage,
|
|
296
|
+
iteration,
|
|
297
|
+
bead_id,
|
|
298
|
+
agent: null,
|
|
299
|
+
_live: true,
|
|
300
|
+
file_access: fragmentRecordsToFileAccess(records, repoRoot),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return payloads;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Fold raw access-fragment records into the `file_access` shape the model
|
|
308
|
+
* builder consumes. Capture is left empty — leakage/oracle are only computable
|
|
309
|
+
* from the finished iteration, so they stay pending for a live column.
|
|
310
|
+
*/
|
|
311
|
+
function fragmentRecordsToFileAccess(records, repoRoot) {
|
|
312
|
+
const reads = {};
|
|
313
|
+
const writes = {};
|
|
314
|
+
const searches = [];
|
|
315
|
+
const graph_queries = [];
|
|
316
|
+
|
|
317
|
+
for (const r of records) {
|
|
318
|
+
switch (r.op) {
|
|
319
|
+
case 'read': {
|
|
320
|
+
const p = canonicalizePath(r.path, repoRoot);
|
|
321
|
+
if (p) reads[p] = (reads[p] || 0) + 1;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case 'write': {
|
|
325
|
+
const p = canonicalizePath(r.path, repoRoot);
|
|
326
|
+
if (p) writes[p] = (writes[p] || 0) + 1;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case 'search': {
|
|
330
|
+
let scope = r.scope || '';
|
|
331
|
+
if (!scope || scope === '.') scope = '.';
|
|
332
|
+
const entry = {
|
|
333
|
+
tool: r.tool,
|
|
334
|
+
pattern: (r.pattern || '').slice(0, 200),
|
|
335
|
+
scope,
|
|
336
|
+
result_count: r.result_count ?? 0,
|
|
337
|
+
};
|
|
338
|
+
if ('filter' in r) entry.filter = r.filter;
|
|
339
|
+
searches.push(entry);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
case 'graph_query': {
|
|
343
|
+
if (r.engine === 'graphify' || r.engine === 'crg') {
|
|
344
|
+
graph_queries.push({
|
|
345
|
+
engine: r.engine,
|
|
346
|
+
op: r.graph_op || '',
|
|
347
|
+
query: (r.query || '').slice(0, 200),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
default:
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { reads, writes, searches, graph_queries, capture: {} };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Relativise an absolute fragment path against the repo root (a live stand-in
|
|
362
|
+
* for the Python GitPathOracle respelling). Paths already relative, or outside
|
|
363
|
+
* the repo, are returned unchanged; the repo root itself maps to null.
|
|
364
|
+
*/
|
|
365
|
+
function canonicalizePath(rawPath, repoRoot) {
|
|
366
|
+
if (!rawPath) return null;
|
|
367
|
+
if (repoRoot && rawPath.startsWith(`${repoRoot}/`)) {
|
|
368
|
+
return rawPath.slice(repoRoot.length + 1);
|
|
369
|
+
}
|
|
370
|
+
if (rawPath === repoRoot) return null;
|
|
371
|
+
return rawPath;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function ensureFile(fileData, path) {
|
|
375
|
+
if (!fileData.has(path)) {
|
|
376
|
+
fileData.set(path, { cells: {}, tracked: true });
|
|
377
|
+
}
|
|
378
|
+
return fileData.get(path);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function compareColumns(a, b) {
|
|
382
|
+
const ai = STAGE_ORDER.indexOf(a.stage);
|
|
383
|
+
const bi = STAGE_ORDER.indexOf(b.stage);
|
|
384
|
+
const stageA = ai === -1 ? 999 : ai;
|
|
385
|
+
const stageB = bi === -1 ? 999 : bi;
|
|
386
|
+
if (stageA !== stageB) return stageA - stageB;
|
|
387
|
+
if (a.iteration !== b.iteration) return a.iteration - b.iteration;
|
|
388
|
+
// nulls first
|
|
389
|
+
if (a.bead_id === null && b.bead_id !== null) return -1;
|
|
390
|
+
if (a.bead_id !== null && b.bead_id === null) return 1;
|
|
391
|
+
if (a.bead_id === b.bead_id) return 0;
|
|
392
|
+
return a.bead_id < b.bead_id ? -1 : 1;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Build a hierarchical dir/file tree from the flat fileData map.
|
|
397
|
+
* Dir nodes carry rolled-up totals and cells aggregated from children.
|
|
398
|
+
*
|
|
399
|
+
* @param {Map<string, {cells, tracked}>} fileData
|
|
400
|
+
* @returns {Array<TreeNode>}
|
|
401
|
+
*/
|
|
402
|
+
function buildTree(fileData) {
|
|
403
|
+
// Root sentinel — not emitted, just holds top-level children.
|
|
404
|
+
const root = {
|
|
405
|
+
children: new Map(),
|
|
406
|
+
cells: {},
|
|
407
|
+
totals: { read: 0, write: 0 },
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
for (const [path, fd] of fileData) {
|
|
411
|
+
const parts = path.split('/');
|
|
412
|
+
let node = root;
|
|
413
|
+
|
|
414
|
+
// Walk/create intermediate dir nodes.
|
|
415
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
416
|
+
const name = parts[i];
|
|
417
|
+
if (!node.children.has(name)) {
|
|
418
|
+
const dirPath = parts.slice(0, i + 1).join('/');
|
|
419
|
+
node.children.set(name, {
|
|
420
|
+
type: 'dir',
|
|
421
|
+
path: dirPath,
|
|
422
|
+
name,
|
|
423
|
+
children: new Map(),
|
|
424
|
+
cells: {},
|
|
425
|
+
totals: { read: 0, write: 0 },
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
node = node.children.get(name);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Place the file leaf.
|
|
432
|
+
const fileName = parts[parts.length - 1];
|
|
433
|
+
const fileTotals = { read: 0, write: 0 };
|
|
434
|
+
for (const cell of Object.values(fd.cells)) {
|
|
435
|
+
fileTotals.read += cell.read || 0;
|
|
436
|
+
fileTotals.write += cell.write || 0;
|
|
437
|
+
}
|
|
438
|
+
const category =
|
|
439
|
+
fileTotals.write > 0 ? (fd.tracked ? 'write' : 'leaked') : 'read';
|
|
440
|
+
|
|
441
|
+
node.children.set(fileName, {
|
|
442
|
+
type: 'file',
|
|
443
|
+
path,
|
|
444
|
+
name: fileName,
|
|
445
|
+
tracked: fd.tracked,
|
|
446
|
+
category,
|
|
447
|
+
cells: fd.cells,
|
|
448
|
+
totals: fileTotals,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Rollup dir totals and cells bottom-up, then serialise to arrays.
|
|
453
|
+
rollupDir(root);
|
|
454
|
+
return [...root.children.values()].map(serializeNode);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function rollupDir(node) {
|
|
458
|
+
if (node.type === 'file') return;
|
|
459
|
+
for (const child of node.children.values()) {
|
|
460
|
+
rollupDir(child);
|
|
461
|
+
node.totals.read += child.totals.read;
|
|
462
|
+
node.totals.write += child.totals.write;
|
|
463
|
+
for (const [ck, cell] of Object.entries(child.cells)) {
|
|
464
|
+
if (!node.cells[ck]) node.cells[ck] = {};
|
|
465
|
+
node.cells[ck].read = (node.cells[ck].read || 0) + (cell.read || 0);
|
|
466
|
+
node.cells[ck].write = (node.cells[ck].write || 0) + (cell.write || 0);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function serializeNode(node) {
|
|
472
|
+
if (node.type === 'file') return node;
|
|
473
|
+
return {
|
|
474
|
+
type: 'dir',
|
|
475
|
+
path: node.path,
|
|
476
|
+
name: node.name,
|
|
477
|
+
children: [...node.children.values()].map(serializeNode),
|
|
478
|
+
cells: node.cells,
|
|
479
|
+
totals: node.totals,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
@@ -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,
|