@worca/ui 0.34.0 → 0.36.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 +1550 -1402
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +77 -0
- package/package.json +1 -1
- package/server/app.js +148 -0
- package/server/code-review-graph-status.js +206 -0
- package/server/watcher.js +131 -0
- package/server/ws-message-router.js +3 -5
package/app/styles.css
CHANGED
|
@@ -2295,6 +2295,12 @@ sl-input.pricing-input::part(input) {
|
|
|
2295
2295
|
font-size: 13px;
|
|
2296
2296
|
}
|
|
2297
2297
|
|
|
2298
|
+
/* CRG invocation badge tooltip lists one tool per line (one <div> each, via
|
|
2299
|
+
the tooltip's HTML content slot) — just left-align them. */
|
|
2300
|
+
sl-tooltip.crg-tool-tooltip::part(body) {
|
|
2301
|
+
text-align: left;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2298
2304
|
.iteration-tags-sep {
|
|
2299
2305
|
color: var(--fg);
|
|
2300
2306
|
opacity: 0.7;
|
|
@@ -6616,3 +6622,74 @@ sl-dialog.markdown-dialog::part(body) {
|
|
|
6616
6622
|
.graphify-not-installed .settings-tab-description {
|
|
6617
6623
|
color: var(--status-paused);
|
|
6618
6624
|
}
|
|
6625
|
+
|
|
6626
|
+
/* Code Review Graph (CRG) — mirrors graphify-* classes above */
|
|
6627
|
+
.crg-codebox {
|
|
6628
|
+
display: block;
|
|
6629
|
+
margin: 0.25rem 0 0.75rem;
|
|
6630
|
+
padding: 0.4rem 0.6rem;
|
|
6631
|
+
background: var(--bg-secondary);
|
|
6632
|
+
border: 1px solid var(--border-subtle);
|
|
6633
|
+
border-radius: var(--radius);
|
|
6634
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
6635
|
+
font-size: 0.85rem;
|
|
6636
|
+
color: var(--fg);
|
|
6637
|
+
word-break: break-all;
|
|
6638
|
+
user-select: all;
|
|
6639
|
+
}
|
|
6640
|
+
|
|
6641
|
+
.crg-copy-row {
|
|
6642
|
+
display: flex;
|
|
6643
|
+
align-items: center;
|
|
6644
|
+
gap: 0.4rem;
|
|
6645
|
+
margin: 0.25rem 0 0.75rem;
|
|
6646
|
+
}
|
|
6647
|
+
|
|
6648
|
+
.crg-copy-row .crg-codebox {
|
|
6649
|
+
flex: 1;
|
|
6650
|
+
min-width: 0;
|
|
6651
|
+
margin: 0;
|
|
6652
|
+
}
|
|
6653
|
+
|
|
6654
|
+
.crg-not-installed {
|
|
6655
|
+
margin: 0 0 0.75rem;
|
|
6656
|
+
}
|
|
6657
|
+
|
|
6658
|
+
.crg-not-installed .settings-tab-description {
|
|
6659
|
+
color: var(--status-paused);
|
|
6660
|
+
}
|
|
6661
|
+
|
|
6662
|
+
.crg-coming-soon {
|
|
6663
|
+
color: var(--muted);
|
|
6664
|
+
font-style: italic;
|
|
6665
|
+
}
|
|
6666
|
+
|
|
6667
|
+
.crg-stage-tools {
|
|
6668
|
+
margin: 1rem 0;
|
|
6669
|
+
}
|
|
6670
|
+
|
|
6671
|
+
.crg-stage-tools-grid {
|
|
6672
|
+
display: grid;
|
|
6673
|
+
gap: 0.4rem;
|
|
6674
|
+
margin-top: 0.5rem;
|
|
6675
|
+
}
|
|
6676
|
+
|
|
6677
|
+
.crg-stage-tools-entry {
|
|
6678
|
+
display: flex;
|
|
6679
|
+
gap: 0.75rem;
|
|
6680
|
+
align-items: baseline;
|
|
6681
|
+
font-size: 0.85rem;
|
|
6682
|
+
}
|
|
6683
|
+
|
|
6684
|
+
.crg-stage-role {
|
|
6685
|
+
font-weight: 600;
|
|
6686
|
+
min-width: 6rem;
|
|
6687
|
+
color: var(--fg);
|
|
6688
|
+
}
|
|
6689
|
+
|
|
6690
|
+
.crg-stage-tool-list {
|
|
6691
|
+
color: var(--muted);
|
|
6692
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
6693
|
+
font-size: 0.8rem;
|
|
6694
|
+
word-break: break-all;
|
|
6695
|
+
}
|
package/package.json
CHANGED
package/server/app.js
CHANGED
|
@@ -15,6 +15,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import express from 'express';
|
|
16
16
|
|
|
17
17
|
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
18
|
+
import {
|
|
19
|
+
_effectiveCrgConfig,
|
|
20
|
+
createCrgStatus,
|
|
21
|
+
} from './code-review-graph-status.js';
|
|
18
22
|
import { createFleetRouter } from './fleet-routes.js';
|
|
19
23
|
import {
|
|
20
24
|
_effectiveConfig,
|
|
@@ -1165,6 +1169,150 @@ export function createApp(options = {}) {
|
|
|
1165
1169
|
res.sendFile(htmlPath);
|
|
1166
1170
|
});
|
|
1167
1171
|
|
|
1172
|
+
// ─── CRG (code-review-graph) endpoints ─────────────────────────────────
|
|
1173
|
+
if (!app.locals.crgStatus) {
|
|
1174
|
+
app.locals.crgStatus = createCrgStatus({});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function readCrgSettings(projectId) {
|
|
1178
|
+
const readJson = (p) => {
|
|
1179
|
+
if (!p) return {};
|
|
1180
|
+
try {
|
|
1181
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
1182
|
+
} catch {
|
|
1183
|
+
return {};
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
const globalSettingsPath = prefsDir
|
|
1187
|
+
? join(prefsDir, 'settings.json')
|
|
1188
|
+
: settingsPath;
|
|
1189
|
+
|
|
1190
|
+
let projectSettingsPath = settingsPath;
|
|
1191
|
+
let root = projectRoot || process.cwd();
|
|
1192
|
+
if (projectId && prefsDir) {
|
|
1193
|
+
const proj = readProjects(prefsDir).find((p) => p.name === projectId);
|
|
1194
|
+
if (proj) {
|
|
1195
|
+
projectSettingsPath =
|
|
1196
|
+
proj.settingsPath || join(proj.path, '.claude', 'settings.json');
|
|
1197
|
+
root = proj.path;
|
|
1198
|
+
}
|
|
1199
|
+
} else if (!projectSettingsPath && projectRoot) {
|
|
1200
|
+
projectSettingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
return {
|
|
1204
|
+
globalSettings: readJson(globalSettingsPath),
|
|
1205
|
+
projectSettings: readJson(projectSettingsPath),
|
|
1206
|
+
root,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function crgStatusPayload(projectId) {
|
|
1211
|
+
const { globalSettings, projectSettings, root } =
|
|
1212
|
+
readCrgSettings(projectId);
|
|
1213
|
+
const result = await app.locals.crgStatus.getStatus({
|
|
1214
|
+
globalSettings,
|
|
1215
|
+
projectSettings,
|
|
1216
|
+
projectRoot: root,
|
|
1217
|
+
});
|
|
1218
|
+
return { ...result, building: Boolean(app.locals.crgBuilding) };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
app.get('/api/crg/status', async (req, res) => {
|
|
1222
|
+
try {
|
|
1223
|
+
res.json(await crgStatusPayload(req.query.project));
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
app.post('/api/crg/recheck', async (req, res) => {
|
|
1230
|
+
try {
|
|
1231
|
+
app.locals.crgStatus.invalidate();
|
|
1232
|
+
res.json(await crgStatusPayload(req.query.project));
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
app.post('/api/crg/build', async (req, res) => {
|
|
1239
|
+
try {
|
|
1240
|
+
const { globalSettings, projectSettings, root } = readCrgSettings(
|
|
1241
|
+
req.query.project,
|
|
1242
|
+
);
|
|
1243
|
+
const effective = _effectiveCrgConfig(globalSettings, projectSettings);
|
|
1244
|
+
if (!effective.enabled) {
|
|
1245
|
+
return res
|
|
1246
|
+
.status(400)
|
|
1247
|
+
.json({ ok: false, error: 'Code-review-graph is not enabled' });
|
|
1248
|
+
}
|
|
1249
|
+
const detection = await app.locals.crgStatus.detect();
|
|
1250
|
+
if (
|
|
1251
|
+
!detection.installed ||
|
|
1252
|
+
!detection.compatible ||
|
|
1253
|
+
!detection.fastmcp_ok
|
|
1254
|
+
) {
|
|
1255
|
+
return res.status(400).json({
|
|
1256
|
+
ok: false,
|
|
1257
|
+
error:
|
|
1258
|
+
detection.error ||
|
|
1259
|
+
'Code-review-graph is not installed or not compatible',
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
if (app.locals.crgBuilding) {
|
|
1263
|
+
return res.json({ ok: true, status: 'building' });
|
|
1264
|
+
}
|
|
1265
|
+
const snap = snapshotDir(root);
|
|
1266
|
+
if (snap) {
|
|
1267
|
+
try {
|
|
1268
|
+
rmSync(snap, { recursive: true, force: true });
|
|
1269
|
+
} catch {}
|
|
1270
|
+
}
|
|
1271
|
+
app.locals.crgBuilding = true;
|
|
1272
|
+
const child = spawn(
|
|
1273
|
+
'python3',
|
|
1274
|
+
[
|
|
1275
|
+
'-c',
|
|
1276
|
+
'from worca.scripts.crg_preflight import run_crg_preflight as r; r()',
|
|
1277
|
+
],
|
|
1278
|
+
{ cwd: root, stdio: 'ignore' },
|
|
1279
|
+
);
|
|
1280
|
+
const done = () => {
|
|
1281
|
+
app.locals.crgBuilding = false;
|
|
1282
|
+
app.locals.crgStatus.invalidate();
|
|
1283
|
+
};
|
|
1284
|
+
child.on('exit', done);
|
|
1285
|
+
child.on('error', done);
|
|
1286
|
+
res.json({ ok: true, status: 'building' });
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
app.locals.crgBuilding = false;
|
|
1289
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
app.post('/api/crg/clear', async (req, res) => {
|
|
1294
|
+
try {
|
|
1295
|
+
const { root } = readCrgSettings(req.query.project);
|
|
1296
|
+
const cleared = clearRepoCache(root);
|
|
1297
|
+
app.locals.crgStatus.invalidate();
|
|
1298
|
+
res.json({ ok: true, cleared });
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
app.get('/api/crg/graph.html', (req, res) => {
|
|
1305
|
+
const { root } = readCrgSettings(req.query.project);
|
|
1306
|
+
const snap = snapshotDir(root);
|
|
1307
|
+
const htmlPath = snap
|
|
1308
|
+
? join(snap, 'code-review-graph', 'graph.html')
|
|
1309
|
+
: null;
|
|
1310
|
+
if (!htmlPath || !existsSync(htmlPath)) {
|
|
1311
|
+
return res.status(404).json({ ok: false, error: 'graph.html not found' });
|
|
1312
|
+
}
|
|
1313
|
+
res.sendFile(htmlPath);
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1168
1316
|
// ─── Dynamic favicon ──────────────────────────────────────────────────
|
|
1169
1317
|
// Serve mode-specific favicon before express.static so it takes precedence.
|
|
1170
1318
|
app.get('/favicon.svg', (_req, res) => {
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { repoCacheDir, snapshotDir } from './graphify-status.js';
|
|
6
|
+
|
|
7
|
+
// Mirror of _CRG_DEFAULTS in src/worca/utils/code_review_graph.py — keep in sync.
|
|
8
|
+
const CRG_DEFAULTS = {
|
|
9
|
+
enabled: false,
|
|
10
|
+
embeddings: false,
|
|
11
|
+
update_on: {
|
|
12
|
+
preflight: true,
|
|
13
|
+
post_implement: true,
|
|
14
|
+
guardian_post_commit: true,
|
|
15
|
+
},
|
|
16
|
+
freshness: 'clean_only',
|
|
17
|
+
min_repo_files: 100,
|
|
18
|
+
version_range: '>=2,<3',
|
|
19
|
+
fastmcp_min: '3.2.4',
|
|
20
|
+
preflight_timeout_seconds: 300,
|
|
21
|
+
stage_tools: null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Mirror of effective_crg_config() in src/worca/utils/code_review_graph.py.
|
|
25
|
+
// Enablement is project-level: the project opts in via code_review_graph.enabled.
|
|
26
|
+
// Global code_review_graph.enabled is purely a kill-switch — an EXPLICIT global
|
|
27
|
+
// `false` disables everywhere; `true`/unset defer to the project. These rules
|
|
28
|
+
// MUST match the Python implementation; the parity is guarded by
|
|
29
|
+
// code-review-graph-status.test.js ("_effectiveCrgConfig parity with Python").
|
|
30
|
+
// Update both together.
|
|
31
|
+
export function _effectiveCrgConfig(globalSettings, projectSettings) {
|
|
32
|
+
const gCrg = globalSettings?.worca?.code_review_graph ?? {};
|
|
33
|
+
const pCrg = projectSettings?.worca?.code_review_graph ?? {};
|
|
34
|
+
|
|
35
|
+
if (gCrg.enabled === false) {
|
|
36
|
+
return _disabledConfig('global-off');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const projectEnabled = pCrg.enabled ?? false;
|
|
40
|
+
if (!projectEnabled) {
|
|
41
|
+
return _disabledConfig('project-off');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const merged = { ...CRG_DEFAULTS };
|
|
45
|
+
for (const [k, v] of Object.entries(gCrg)) {
|
|
46
|
+
if (v != null || k === 'enabled') merged[k] = v;
|
|
47
|
+
}
|
|
48
|
+
for (const [k, v] of Object.entries(pCrg)) {
|
|
49
|
+
if (v != null || k === 'enabled') merged[k] = v;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const defaultsUpdateOn = { ...CRG_DEFAULTS.update_on };
|
|
53
|
+
if (gCrg.update_on && typeof gCrg.update_on === 'object') {
|
|
54
|
+
Object.assign(defaultsUpdateOn, gCrg.update_on);
|
|
55
|
+
}
|
|
56
|
+
if (pCrg.update_on && typeof pCrg.update_on === 'object') {
|
|
57
|
+
Object.assign(defaultsUpdateOn, pCrg.update_on);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
enabled: true,
|
|
62
|
+
embeddings: merged.embeddings,
|
|
63
|
+
update_on_preflight: defaultsUpdateOn.preflight ?? true,
|
|
64
|
+
update_on_post_implement: defaultsUpdateOn.post_implement ?? true,
|
|
65
|
+
update_on_guardian_post_commit:
|
|
66
|
+
defaultsUpdateOn.guardian_post_commit ?? true,
|
|
67
|
+
min_repo_files: merged.min_repo_files,
|
|
68
|
+
version_range: merged.version_range,
|
|
69
|
+
fastmcp_min: merged.fastmcp_min,
|
|
70
|
+
preflight_timeout_seconds: merged.preflight_timeout_seconds,
|
|
71
|
+
freshness: merged.freshness,
|
|
72
|
+
stage_tools: merged.stage_tools,
|
|
73
|
+
reason: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function _disabledConfig(reason) {
|
|
78
|
+
const d = CRG_DEFAULTS;
|
|
79
|
+
const u = d.update_on;
|
|
80
|
+
return {
|
|
81
|
+
enabled: false,
|
|
82
|
+
embeddings: d.embeddings,
|
|
83
|
+
update_on_preflight: u.preflight,
|
|
84
|
+
update_on_post_implement: u.post_implement,
|
|
85
|
+
update_on_guardian_post_commit: u.guardian_post_commit,
|
|
86
|
+
min_repo_files: d.min_repo_files,
|
|
87
|
+
version_range: d.version_range,
|
|
88
|
+
fastmcp_min: d.fastmcp_min,
|
|
89
|
+
preflight_timeout_seconds: d.preflight_timeout_seconds,
|
|
90
|
+
freshness: d.freshness,
|
|
91
|
+
stage_tools: d.stage_tools,
|
|
92
|
+
reason,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Per-commit CRG graph stats ─────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const _CRG_SUBDIR = 'code-review-graph';
|
|
99
|
+
|
|
100
|
+
export function _crgGraphStats(snapDir) {
|
|
101
|
+
if (!snapDir) return null;
|
|
102
|
+
const dbPath = join(snapDir, _CRG_SUBDIR, 'graph.db');
|
|
103
|
+
if (!existsSync(dbPath)) return null;
|
|
104
|
+
|
|
105
|
+
const stat = statSync(dbPath);
|
|
106
|
+
const ageSeconds = Math.max(0, (Date.now() - stat.mtimeMs) / 1000);
|
|
107
|
+
const htmlPath = join(snapDir, _CRG_SUBDIR, 'graph.html');
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
db_path: dbPath,
|
|
111
|
+
snapshot_dir: snapDir,
|
|
112
|
+
age_seconds: ageSeconds,
|
|
113
|
+
size_bytes: stat.size,
|
|
114
|
+
has_html: existsSync(htmlPath),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Detection (delegates to Python detect_code_review_graph) ───────────
|
|
119
|
+
|
|
120
|
+
function defaultDetect() {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
const child = spawn(
|
|
123
|
+
'python3',
|
|
124
|
+
[
|
|
125
|
+
'-c',
|
|
126
|
+
'import json; from worca.utils.code_review_graph import detect_code_review_graph; d = detect_code_review_graph(); print(json.dumps({"installed": d.installed, "version": d.version, "compatible": d.compatible, "fastmcp_ok": d.fastmcp_ok, "error": d.error}))',
|
|
127
|
+
],
|
|
128
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
let stdout = '';
|
|
132
|
+
let stderr = '';
|
|
133
|
+
child.stdout.on('data', (chunk) => {
|
|
134
|
+
stdout += chunk.toString();
|
|
135
|
+
});
|
|
136
|
+
child.stderr.on('data', (chunk) => {
|
|
137
|
+
stderr += chunk.toString();
|
|
138
|
+
});
|
|
139
|
+
child.on('error', () => {
|
|
140
|
+
resolve({
|
|
141
|
+
installed: false,
|
|
142
|
+
version: null,
|
|
143
|
+
compatible: false,
|
|
144
|
+
fastmcp_ok: false,
|
|
145
|
+
error: 'python3 not available',
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
child.on('exit', (code) => {
|
|
149
|
+
if (code === 0 && stdout.trim()) {
|
|
150
|
+
try {
|
|
151
|
+
resolve(JSON.parse(stdout.trim()));
|
|
152
|
+
return;
|
|
153
|
+
} catch {
|
|
154
|
+
// fall through
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
resolve({
|
|
158
|
+
installed: false,
|
|
159
|
+
version: null,
|
|
160
|
+
compatible: false,
|
|
161
|
+
fastmcp_ok: false,
|
|
162
|
+
error: stderr.trim() || `detect exited ${code}`,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
169
|
+
|
|
170
|
+
export function createCrgStatus(opts = {}) {
|
|
171
|
+
const detectFn = opts.detectFn || defaultDetect;
|
|
172
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
173
|
+
|
|
174
|
+
let cached = null;
|
|
175
|
+
let cachedAt = 0;
|
|
176
|
+
|
|
177
|
+
async function detect() {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
if (cached && now - cachedAt < ttlMs) return cached;
|
|
180
|
+
cached = await detectFn();
|
|
181
|
+
cachedAt = Date.now();
|
|
182
|
+
return cached;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function invalidate() {
|
|
186
|
+
cached = null;
|
|
187
|
+
cachedAt = 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function getStatus({ globalSettings, projectSettings, projectRoot }) {
|
|
191
|
+
const effective = _effectiveCrgConfig(globalSettings, projectSettings);
|
|
192
|
+
const detection = await detect();
|
|
193
|
+
const graphStats = effective.enabled
|
|
194
|
+
? _crgGraphStats(snapshotDir(projectRoot))
|
|
195
|
+
: null;
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
effective,
|
|
199
|
+
detection,
|
|
200
|
+
graph_stats: graphStats,
|
|
201
|
+
cache_path: repoCacheDir(projectRoot),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { detect, invalidate, getStatus };
|
|
206
|
+
}
|
package/server/watcher.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
assignEventsToIterations,
|
|
7
7
|
readDispatchEventsFromJsonl,
|
|
8
8
|
} from './dispatch-events-aggregator.js';
|
|
9
|
+
import { readPipelineOverlay } from './run-dir-resolver.js';
|
|
9
10
|
import { safeWatch } from './safe-watch.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -46,7 +47,137 @@ function isTerminal(status) {
|
|
|
46
47
|
);
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
const _discoverRunsCache = new Map(); // worcaDir → { ts, runs }
|
|
51
|
+
// TTL defaults to 0 under vitest (NODE_ENV=test) so the cache is a no-op in
|
|
52
|
+
// tests — they build fixture dirs from Date.now() and a shared path could
|
|
53
|
+
// otherwise serve a stale cached scan across tests. Production uses 1500ms;
|
|
54
|
+
// _setDiscoverRunsTtlForTest lets the dedicated cache test exercise real TTL.
|
|
55
|
+
let _discoverRunsTtlMs = process.env.NODE_ENV === 'test' ? 0 : 1500;
|
|
56
|
+
|
|
57
|
+
/** Test hook: override the discoverRuns cache TTL in ms. */
|
|
58
|
+
export function _setDiscoverRunsTtlForTest(ms) {
|
|
59
|
+
_discoverRunsTtlMs = ms;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cached wrapper around the run-discovery scan. The scan reads + JSON-parses
|
|
64
|
+
* every run's status.json across runs/, results/, and pipelines.d/ worktree
|
|
65
|
+
* overlays — hundreds of ms on a large project. Whole-list callers (list-runs,
|
|
66
|
+
* REST /runs) hit this; a short TTL collapses repeated calls in a burst into a
|
|
67
|
+
* single scan. Per-run handlers use findRun() instead. Live status changes
|
|
68
|
+
* still reach clients via the statusWatcher broadcast, so TTL-window staleness
|
|
69
|
+
* here is invisible in the UI.
|
|
70
|
+
*/
|
|
49
71
|
export function discoverRuns(worcaDir) {
|
|
72
|
+
const cached = _discoverRunsCache.get(worcaDir);
|
|
73
|
+
if (cached && Date.now() - cached.ts < _discoverRunsTtlMs) {
|
|
74
|
+
return cached.runs;
|
|
75
|
+
}
|
|
76
|
+
const runs = _discoverRunsUncached(worcaDir);
|
|
77
|
+
_discoverRunsCache.set(worcaDir, { ts: Date.now(), runs });
|
|
78
|
+
return runs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Clear the discoverRuns TTL cache (tests, or explicit invalidation). */
|
|
82
|
+
export function clearDiscoverRunsCache() {
|
|
83
|
+
_discoverRunsCache.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a SINGLE run by id without scanning every run on disk — the O(1)
|
|
88
|
+
* counterpart to discoverRuns().find(r => r.id === runId), for hot WS handlers
|
|
89
|
+
* (subscribe-run, get-agent-prompt) that need exactly one run. Mirrors
|
|
90
|
+
* discoverRuns' per-source shaping: dispatch-event enrichment for runs/ and
|
|
91
|
+
* worktree sources (not results/), plus the worktree registry fields. The
|
|
92
|
+
* findRun-vs-discoverRuns parity test keeps the two aligned.
|
|
93
|
+
*
|
|
94
|
+
* Falls back to a (TTL-cached) discoverRuns scan for legacy layouts where the
|
|
95
|
+
* on-disk name doesn't equal the computed id (flat `.worca/status.json`, hashed
|
|
96
|
+
* legacy ids), so it never resolves fewer runs than discoverRuns().find().
|
|
97
|
+
*
|
|
98
|
+
* @returns {object|null} a run record shaped like a discoverRuns entry, or null
|
|
99
|
+
*/
|
|
100
|
+
export function findRun(worcaDir, runId) {
|
|
101
|
+
if (!worcaDir || !runId) return null;
|
|
102
|
+
|
|
103
|
+
// 1. Local active: runs/<id>/status.json (enriched)
|
|
104
|
+
const localRunDir = join(worcaDir, 'runs', runId);
|
|
105
|
+
if (existsSync(join(localRunDir, 'status.json'))) {
|
|
106
|
+
return _shapeRunFromFile(join(localRunDir, 'status.json'), {
|
|
107
|
+
enrich: true,
|
|
108
|
+
runDir: localRunDir,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. Local archived dir: results/<id>/status.json (not enriched)
|
|
113
|
+
const resultsDirStatus = join(worcaDir, 'results', runId, 'status.json');
|
|
114
|
+
if (existsSync(resultsDirStatus)) {
|
|
115
|
+
return _shapeRunFromFile(resultsDirStatus, { enrich: false });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 2b. Legacy archived file: results/<id>.json (not enriched)
|
|
119
|
+
const legacyFile = join(worcaDir, 'results', `${runId}.json`);
|
|
120
|
+
if (existsSync(legacyFile)) {
|
|
121
|
+
return _shapeRunFromFile(legacyFile, {
|
|
122
|
+
enrich: false,
|
|
123
|
+
requireStartedAt: true,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. Worktree overlay: pipelines.d/<id>.json → <worktree>/.worca/runs/<id>
|
|
128
|
+
const reg = readPipelineOverlay(worcaDir, runId);
|
|
129
|
+
if (reg?.worktree_path) {
|
|
130
|
+
const wtRunDir = join(reg.worktree_path, '.worca', 'runs', runId);
|
|
131
|
+
if (existsSync(join(wtRunDir, 'status.json'))) {
|
|
132
|
+
return _shapeRunFromFile(join(wtRunDir, 'status.json'), {
|
|
133
|
+
enrich: true,
|
|
134
|
+
runDir: wtRunDir,
|
|
135
|
+
worktreeReg: reg,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fallback for legacy layouts where the on-disk name != the computed id
|
|
141
|
+
// (flat .worca/status.json, hashed legacy ids). Rare — pay one (TTL-cached)
|
|
142
|
+
// full scan rather than regress correctness vs discoverRuns().find().
|
|
143
|
+
return discoverRuns(worcaDir).find((r) => r.id === runId) || null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _shapeRunFromFile(
|
|
147
|
+
statusPath,
|
|
148
|
+
{
|
|
149
|
+
enrich = false,
|
|
150
|
+
runDir = null,
|
|
151
|
+
worktreeReg = null,
|
|
152
|
+
requireStartedAt = false,
|
|
153
|
+
} = {},
|
|
154
|
+
) {
|
|
155
|
+
try {
|
|
156
|
+
let status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
157
|
+
if (requireStartedAt && !status.started_at) return null;
|
|
158
|
+
if (enrich && runDir) status = enrichWithDispatchEvents(status, runDir);
|
|
159
|
+
const id = createRunId(status);
|
|
160
|
+
const active = !isTerminal(status) && status.pipeline_status === 'running';
|
|
161
|
+
const base = { id, active, ...status };
|
|
162
|
+
if (worktreeReg) {
|
|
163
|
+
return {
|
|
164
|
+
...base,
|
|
165
|
+
worktree_worca_dir: join(worktreeReg.worktree_path, '.worca'),
|
|
166
|
+
is_worktree_run: true,
|
|
167
|
+
head_branch: worktreeReg.branch || null,
|
|
168
|
+
fleet_id: worktreeReg.fleet_id || null,
|
|
169
|
+
workspace_id: worktreeReg.workspace_id || null,
|
|
170
|
+
group_type: worktreeReg.group_type || null,
|
|
171
|
+
target_branch: worktreeReg.target_branch || null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return base;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _discoverRunsUncached(worcaDir) {
|
|
50
181
|
const runs = [];
|
|
51
182
|
const seenIds = new Set();
|
|
52
183
|
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from './process-manager.js';
|
|
34
34
|
import { resolveRunDir } from './run-dir-resolver.js';
|
|
35
35
|
import { readSettings } from './settings-reader.js';
|
|
36
|
-
import { discoverRuns } from './watcher.js';
|
|
36
|
+
import { discoverRuns, findRun } from './watcher.js';
|
|
37
37
|
import { resolveBeadsCounts } from './ws-beads-watcher.js';
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -165,8 +165,7 @@ export function createMessageRouter({
|
|
|
165
165
|
}
|
|
166
166
|
_adoptProjectFromPayload(ws, req.payload);
|
|
167
167
|
const proj = resolveProject(ws, req.payload);
|
|
168
|
-
const
|
|
169
|
-
const run = runs.find((r) => r.id === runId);
|
|
168
|
+
const run = findRun(proj.worcaDir, runId);
|
|
170
169
|
if (!run) {
|
|
171
170
|
ws.send(
|
|
172
171
|
JSON.stringify(makeError(req, 'NOT_FOUND', `Run ${runId} not found`)),
|
|
@@ -321,8 +320,7 @@ export function createMessageRouter({
|
|
|
321
320
|
}
|
|
322
321
|
const s = clientManager.ensureSubs(ws);
|
|
323
322
|
s.runId = runId;
|
|
324
|
-
const
|
|
325
|
-
const run = runs.find((r) => r.id === runId);
|
|
323
|
+
const run = findRun(proj.worcaDir, runId);
|
|
326
324
|
if (run) {
|
|
327
325
|
if (
|
|
328
326
|
run.pipeline_status !== undefined &&
|