@worca/ui 0.35.0 → 0.37.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 +1658 -1414
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +114 -1
- package/package.json +1 -1
- package/server/app.js +227 -0
- package/server/code-review-graph-status.js +206 -0
- package/server/integrations/index.js +163 -0
- package/server/project-routes.js +63 -13
- package/server/run-dir-resolver.js +37 -1
- package/server/settings-validator.js +22 -0
package/app/styles.css
CHANGED
|
@@ -2202,7 +2202,6 @@ sl-details.dispatch-section-details::part(content) {
|
|
|
2202
2202
|
|
|
2203
2203
|
.pricing-model-name {
|
|
2204
2204
|
font-weight: 500;
|
|
2205
|
-
text-transform: uppercase;
|
|
2206
2205
|
}
|
|
2207
2206
|
|
|
2208
2207
|
.pricing-table sl-input {
|
|
@@ -2226,6 +2225,28 @@ sl-input.pricing-input::part(input) {
|
|
|
2226
2225
|
margin-bottom: 8px;
|
|
2227
2226
|
}
|
|
2228
2227
|
|
|
2228
|
+
/* Inline explainer that distinguishes alt-endpoint overrides from default
|
|
2229
|
+
Anthropic runs (where Claude CLI's total_cost_usd remains authoritative). */
|
|
2230
|
+
.pricing-source-note {
|
|
2231
|
+
display: block;
|
|
2232
|
+
margin: 12px 0;
|
|
2233
|
+
font-size: 13px;
|
|
2234
|
+
line-height: 1.5;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
.pricing-source-note strong {
|
|
2238
|
+
display: block;
|
|
2239
|
+
margin-bottom: 4px;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
.pricing-source-note code {
|
|
2243
|
+
font-family: var(--sl-font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
2244
|
+
font-size: 0.92em;
|
|
2245
|
+
padding: 1px 4px;
|
|
2246
|
+
background: var(--sl-color-neutral-100, rgba(127, 127, 127, 0.12));
|
|
2247
|
+
border-radius: 3px;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2229
2250
|
/* Settings toast overlay */
|
|
2230
2251
|
.settings-toast {
|
|
2231
2252
|
position: fixed;
|
|
@@ -2295,6 +2316,12 @@ sl-input.pricing-input::part(input) {
|
|
|
2295
2316
|
font-size: 13px;
|
|
2296
2317
|
}
|
|
2297
2318
|
|
|
2319
|
+
/* CRG invocation badge tooltip lists one tool per line (one <div> each, via
|
|
2320
|
+
the tooltip's HTML content slot) — just left-align them. */
|
|
2321
|
+
sl-tooltip.crg-tool-tooltip::part(body) {
|
|
2322
|
+
text-align: left;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2298
2325
|
.iteration-tags-sep {
|
|
2299
2326
|
color: var(--fg);
|
|
2300
2327
|
opacity: 0.7;
|
|
@@ -5302,6 +5329,10 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
5302
5329
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
5303
5330
|
}
|
|
5304
5331
|
|
|
5332
|
+
.model-card .settings-card-title {
|
|
5333
|
+
text-transform: none;
|
|
5334
|
+
}
|
|
5335
|
+
|
|
5305
5336
|
.model-card.is-dirty {
|
|
5306
5337
|
border-color: var(--status-running, #3b82f6);
|
|
5307
5338
|
box-shadow: 0 0 0 1px var(--status-running, #3b82f6);
|
|
@@ -6211,6 +6242,17 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
6211
6242
|
color: var(--fg-muted);
|
|
6212
6243
|
}
|
|
6213
6244
|
|
|
6245
|
+
/* W-061: plan revision selector inside the plan dialog */
|
|
6246
|
+
.plan-iter-selector {
|
|
6247
|
+
display: flex;
|
|
6248
|
+
flex-wrap: wrap;
|
|
6249
|
+
align-items: center;
|
|
6250
|
+
gap: 6px;
|
|
6251
|
+
margin: 0 0 12px;
|
|
6252
|
+
padding-bottom: 12px;
|
|
6253
|
+
border-bottom: 1px solid var(--border, var(--bg-secondary));
|
|
6254
|
+
}
|
|
6255
|
+
|
|
6214
6256
|
/* --- sl-dialog: wider markdown-body dialogs (plan, guide) --- */
|
|
6215
6257
|
|
|
6216
6258
|
sl-dialog.markdown-dialog::part(panel) {
|
|
@@ -6616,3 +6658,74 @@ sl-dialog.markdown-dialog::part(body) {
|
|
|
6616
6658
|
.graphify-not-installed .settings-tab-description {
|
|
6617
6659
|
color: var(--status-paused);
|
|
6618
6660
|
}
|
|
6661
|
+
|
|
6662
|
+
/* Code Review Graph (CRG) — mirrors graphify-* classes above */
|
|
6663
|
+
.crg-codebox {
|
|
6664
|
+
display: block;
|
|
6665
|
+
margin: 0.25rem 0 0.75rem;
|
|
6666
|
+
padding: 0.4rem 0.6rem;
|
|
6667
|
+
background: var(--bg-secondary);
|
|
6668
|
+
border: 1px solid var(--border-subtle);
|
|
6669
|
+
border-radius: var(--radius);
|
|
6670
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
6671
|
+
font-size: 0.85rem;
|
|
6672
|
+
color: var(--fg);
|
|
6673
|
+
word-break: break-all;
|
|
6674
|
+
user-select: all;
|
|
6675
|
+
}
|
|
6676
|
+
|
|
6677
|
+
.crg-copy-row {
|
|
6678
|
+
display: flex;
|
|
6679
|
+
align-items: center;
|
|
6680
|
+
gap: 0.4rem;
|
|
6681
|
+
margin: 0.25rem 0 0.75rem;
|
|
6682
|
+
}
|
|
6683
|
+
|
|
6684
|
+
.crg-copy-row .crg-codebox {
|
|
6685
|
+
flex: 1;
|
|
6686
|
+
min-width: 0;
|
|
6687
|
+
margin: 0;
|
|
6688
|
+
}
|
|
6689
|
+
|
|
6690
|
+
.crg-not-installed {
|
|
6691
|
+
margin: 0 0 0.75rem;
|
|
6692
|
+
}
|
|
6693
|
+
|
|
6694
|
+
.crg-not-installed .settings-tab-description {
|
|
6695
|
+
color: var(--status-paused);
|
|
6696
|
+
}
|
|
6697
|
+
|
|
6698
|
+
.crg-coming-soon {
|
|
6699
|
+
color: var(--muted);
|
|
6700
|
+
font-style: italic;
|
|
6701
|
+
}
|
|
6702
|
+
|
|
6703
|
+
.crg-stage-tools {
|
|
6704
|
+
margin: 1rem 0;
|
|
6705
|
+
}
|
|
6706
|
+
|
|
6707
|
+
.crg-stage-tools-grid {
|
|
6708
|
+
display: grid;
|
|
6709
|
+
gap: 0.4rem;
|
|
6710
|
+
margin-top: 0.5rem;
|
|
6711
|
+
}
|
|
6712
|
+
|
|
6713
|
+
.crg-stage-tools-entry {
|
|
6714
|
+
display: flex;
|
|
6715
|
+
gap: 0.75rem;
|
|
6716
|
+
align-items: baseline;
|
|
6717
|
+
font-size: 0.85rem;
|
|
6718
|
+
}
|
|
6719
|
+
|
|
6720
|
+
.crg-stage-role {
|
|
6721
|
+
font-weight: 600;
|
|
6722
|
+
min-width: 6rem;
|
|
6723
|
+
color: var(--fg);
|
|
6724
|
+
}
|
|
6725
|
+
|
|
6726
|
+
.crg-stage-tool-list {
|
|
6727
|
+
color: var(--muted);
|
|
6728
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
6729
|
+
font-size: 0.8rem;
|
|
6730
|
+
word-break: break-all;
|
|
6731
|
+
}
|
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,
|
|
@@ -98,6 +102,23 @@ export function buildWorkspaceArgs(workspace_root, workspace_id, manifest) {
|
|
|
98
102
|
return args;
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Returns true when `host` resolves to a loopback bind — undefined/null are
|
|
107
|
+
* treated as loopback because the production default in
|
|
108
|
+
* `worca-ui/server/index.js` is `127.0.0.1`. Used by the outbound-send route
|
|
109
|
+
* to refuse exposing user-addressable chat from a non-loopback bind.
|
|
110
|
+
*
|
|
111
|
+
* @param {string|undefined|null} host
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function isLoopbackHost(host) {
|
|
115
|
+
if (host === undefined || host === null || host === '') return true;
|
|
116
|
+
if (host === 'localhost' || host === '::1') return true;
|
|
117
|
+
if (host === '::ffff:127.0.0.1') return true;
|
|
118
|
+
if (host.startsWith('127.')) return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
101
122
|
export function createApp(options = {}) {
|
|
102
123
|
const app = express();
|
|
103
124
|
const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
|
|
@@ -861,6 +882,68 @@ export function createApp(options = {}) {
|
|
|
861
882
|
res.json(integrations.status());
|
|
862
883
|
});
|
|
863
884
|
|
|
885
|
+
// POST /api/integrations/send — fan out a NormalizedMessage to one or
|
|
886
|
+
// more chat platforms through the same allowlist + rate-limiter pipeline
|
|
887
|
+
// the worca event fan-out uses. Drives the worca-notify skill.
|
|
888
|
+
//
|
|
889
|
+
// Body shape:
|
|
890
|
+
// {
|
|
891
|
+
// platforms?: string[], // omit → all enabled chat adapters
|
|
892
|
+
// message: NormalizedMessage, // { title, body: MessageSegment[], severity }
|
|
893
|
+
// chat_id?: string // override the configured chat_id
|
|
894
|
+
// }
|
|
895
|
+
//
|
|
896
|
+
// Returns 200 with `{ results: [{ platform, ok, error? }] }` for both
|
|
897
|
+
// total-success and partial-success cases; 4xx only for malformed input.
|
|
898
|
+
//
|
|
899
|
+
// Auth model: the endpoint is INTENTIONALLY restricted to loopback binds
|
|
900
|
+
// (127.0.0.0/8, ::1, localhost). The CSRF middleware above lets through
|
|
901
|
+
// requests with no Origin header (so webhooks work); without this guard,
|
|
902
|
+
// a UI server started with HOST=0.0.0.0 or --host <public-ip> would expose
|
|
903
|
+
// unauthenticated send-to-user's-chat to anything that can reach the port.
|
|
904
|
+
// 503 (subsystem disabled) and 403 (non-loopback bind) are terminal —
|
|
905
|
+
// retrying won't help.
|
|
906
|
+
app.post('/api/integrations/send', async (req, res) => {
|
|
907
|
+
if (!isLoopbackHost(serverHost)) {
|
|
908
|
+
return res.status(403).json({
|
|
909
|
+
error:
|
|
910
|
+
'send endpoint is restricted to loopback binds; ' +
|
|
911
|
+
'restart the UI server on 127.0.0.1 / ::1 / localhost ' +
|
|
912
|
+
'to send notifications',
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
const integrations = app.locals.integrations;
|
|
916
|
+
if (!integrations) {
|
|
917
|
+
return res
|
|
918
|
+
.status(503)
|
|
919
|
+
.json({ error: 'integrations subsystem not initialized' });
|
|
920
|
+
}
|
|
921
|
+
if (integrations.status?.().enabled === false) {
|
|
922
|
+
return res
|
|
923
|
+
.status(503)
|
|
924
|
+
.json({ error: 'integrations subsystem disabled in config' });
|
|
925
|
+
}
|
|
926
|
+
const { platforms, message, chat_id: chatIdOverride } = req.body ?? {};
|
|
927
|
+
if (!message || typeof message !== 'object') {
|
|
928
|
+
return res
|
|
929
|
+
.status(400)
|
|
930
|
+
.json({ error: 'message is required and must be an object' });
|
|
931
|
+
}
|
|
932
|
+
if (platforms !== undefined && !Array.isArray(platforms)) {
|
|
933
|
+
return res.status(400).json({ error: 'platforms must be an array' });
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const out = await integrations.sendOutbound({
|
|
937
|
+
platforms,
|
|
938
|
+
message,
|
|
939
|
+
chatIdOverride,
|
|
940
|
+
});
|
|
941
|
+
res.json(out);
|
|
942
|
+
} catch (err) {
|
|
943
|
+
res.status(400).json({ error: String(err?.message ?? err) });
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
|
|
864
947
|
// GET /api/integrations/config — return saved config (secrets redacted)
|
|
865
948
|
app.get('/api/integrations/config', (_req, res) => {
|
|
866
949
|
const configPath = join(prefsDir, 'integrations', 'config.json');
|
|
@@ -1165,6 +1248,150 @@ export function createApp(options = {}) {
|
|
|
1165
1248
|
res.sendFile(htmlPath);
|
|
1166
1249
|
});
|
|
1167
1250
|
|
|
1251
|
+
// ─── CRG (code-review-graph) endpoints ─────────────────────────────────
|
|
1252
|
+
if (!app.locals.crgStatus) {
|
|
1253
|
+
app.locals.crgStatus = createCrgStatus({});
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function readCrgSettings(projectId) {
|
|
1257
|
+
const readJson = (p) => {
|
|
1258
|
+
if (!p) return {};
|
|
1259
|
+
try {
|
|
1260
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
1261
|
+
} catch {
|
|
1262
|
+
return {};
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
const globalSettingsPath = prefsDir
|
|
1266
|
+
? join(prefsDir, 'settings.json')
|
|
1267
|
+
: settingsPath;
|
|
1268
|
+
|
|
1269
|
+
let projectSettingsPath = settingsPath;
|
|
1270
|
+
let root = projectRoot || process.cwd();
|
|
1271
|
+
if (projectId && prefsDir) {
|
|
1272
|
+
const proj = readProjects(prefsDir).find((p) => p.name === projectId);
|
|
1273
|
+
if (proj) {
|
|
1274
|
+
projectSettingsPath =
|
|
1275
|
+
proj.settingsPath || join(proj.path, '.claude', 'settings.json');
|
|
1276
|
+
root = proj.path;
|
|
1277
|
+
}
|
|
1278
|
+
} else if (!projectSettingsPath && projectRoot) {
|
|
1279
|
+
projectSettingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return {
|
|
1283
|
+
globalSettings: readJson(globalSettingsPath),
|
|
1284
|
+
projectSettings: readJson(projectSettingsPath),
|
|
1285
|
+
root,
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
async function crgStatusPayload(projectId) {
|
|
1290
|
+
const { globalSettings, projectSettings, root } =
|
|
1291
|
+
readCrgSettings(projectId);
|
|
1292
|
+
const result = await app.locals.crgStatus.getStatus({
|
|
1293
|
+
globalSettings,
|
|
1294
|
+
projectSettings,
|
|
1295
|
+
projectRoot: root,
|
|
1296
|
+
});
|
|
1297
|
+
return { ...result, building: Boolean(app.locals.crgBuilding) };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
app.get('/api/crg/status', async (req, res) => {
|
|
1301
|
+
try {
|
|
1302
|
+
res.json(await crgStatusPayload(req.query.project));
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
app.post('/api/crg/recheck', async (req, res) => {
|
|
1309
|
+
try {
|
|
1310
|
+
app.locals.crgStatus.invalidate();
|
|
1311
|
+
res.json(await crgStatusPayload(req.query.project));
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
app.post('/api/crg/build', async (req, res) => {
|
|
1318
|
+
try {
|
|
1319
|
+
const { globalSettings, projectSettings, root } = readCrgSettings(
|
|
1320
|
+
req.query.project,
|
|
1321
|
+
);
|
|
1322
|
+
const effective = _effectiveCrgConfig(globalSettings, projectSettings);
|
|
1323
|
+
if (!effective.enabled) {
|
|
1324
|
+
return res
|
|
1325
|
+
.status(400)
|
|
1326
|
+
.json({ ok: false, error: 'Code-review-graph is not enabled' });
|
|
1327
|
+
}
|
|
1328
|
+
const detection = await app.locals.crgStatus.detect();
|
|
1329
|
+
if (
|
|
1330
|
+
!detection.installed ||
|
|
1331
|
+
!detection.compatible ||
|
|
1332
|
+
!detection.fastmcp_ok
|
|
1333
|
+
) {
|
|
1334
|
+
return res.status(400).json({
|
|
1335
|
+
ok: false,
|
|
1336
|
+
error:
|
|
1337
|
+
detection.error ||
|
|
1338
|
+
'Code-review-graph is not installed or not compatible',
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
if (app.locals.crgBuilding) {
|
|
1342
|
+
return res.json({ ok: true, status: 'building' });
|
|
1343
|
+
}
|
|
1344
|
+
const snap = snapshotDir(root);
|
|
1345
|
+
if (snap) {
|
|
1346
|
+
try {
|
|
1347
|
+
rmSync(snap, { recursive: true, force: true });
|
|
1348
|
+
} catch {}
|
|
1349
|
+
}
|
|
1350
|
+
app.locals.crgBuilding = true;
|
|
1351
|
+
const child = spawn(
|
|
1352
|
+
'python3',
|
|
1353
|
+
[
|
|
1354
|
+
'-c',
|
|
1355
|
+
'from worca.scripts.crg_preflight import run_crg_preflight as r; r()',
|
|
1356
|
+
],
|
|
1357
|
+
{ cwd: root, stdio: 'ignore' },
|
|
1358
|
+
);
|
|
1359
|
+
const done = () => {
|
|
1360
|
+
app.locals.crgBuilding = false;
|
|
1361
|
+
app.locals.crgStatus.invalidate();
|
|
1362
|
+
};
|
|
1363
|
+
child.on('exit', done);
|
|
1364
|
+
child.on('error', done);
|
|
1365
|
+
res.json({ ok: true, status: 'building' });
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
app.locals.crgBuilding = false;
|
|
1368
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
app.post('/api/crg/clear', async (req, res) => {
|
|
1373
|
+
try {
|
|
1374
|
+
const { root } = readCrgSettings(req.query.project);
|
|
1375
|
+
const cleared = clearRepoCache(root);
|
|
1376
|
+
app.locals.crgStatus.invalidate();
|
|
1377
|
+
res.json({ ok: true, cleared });
|
|
1378
|
+
} catch (err) {
|
|
1379
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
app.get('/api/crg/graph.html', (req, res) => {
|
|
1384
|
+
const { root } = readCrgSettings(req.query.project);
|
|
1385
|
+
const snap = snapshotDir(root);
|
|
1386
|
+
const htmlPath = snap
|
|
1387
|
+
? join(snap, 'code-review-graph', 'graph.html')
|
|
1388
|
+
: null;
|
|
1389
|
+
if (!htmlPath || !existsSync(htmlPath)) {
|
|
1390
|
+
return res.status(404).json({ ok: false, error: 'graph.html not found' });
|
|
1391
|
+
}
|
|
1392
|
+
res.sendFile(htmlPath);
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1168
1395
|
// ─── Dynamic favicon ──────────────────────────────────────────────────
|
|
1169
1396
|
// Serve mode-specific favicon before express.static so it takes precedence.
|
|
1170
1397
|
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
|
+
}
|