@worca/ui 0.29.0 → 0.31.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 +1446 -1227
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +51 -3
- package/package.json +1 -1
- package/server/app.js +162 -2
- package/server/beads-reader.js +16 -9
- package/server/graphify-status.js +234 -0
- package/server/index.js +5 -6
- package/server/integrations/commands/global.js +5 -0
- package/server/integrations/commands/project.js +3 -0
- package/server/integrations/renderers.js +15 -0
- package/server/project-routes.js +20 -1
- package/server/ws-beads-watcher.js +86 -3
- package/server/ws-message-router.js +5 -12
- package/server/ws-modular.js +6 -0
package/app/styles.css
CHANGED
|
@@ -766,6 +766,13 @@ h1, h2, h3, h4, h5, h6 {
|
|
|
766
766
|
to { transform: rotate(360deg); }
|
|
767
767
|
}
|
|
768
768
|
|
|
769
|
+
.effort-zap-icon {
|
|
770
|
+
width: 12px;
|
|
771
|
+
height: 12px;
|
|
772
|
+
vertical-align: -1px;
|
|
773
|
+
margin-right: 2px;
|
|
774
|
+
}
|
|
775
|
+
|
|
769
776
|
/* --- 13. Stage Connector --- */
|
|
770
777
|
.stage-connector {
|
|
771
778
|
width: 36px;
|
|
@@ -4779,9 +4786,9 @@ sl-details.learnings-panel::part(content) {
|
|
|
4779
4786
|
|
|
4780
4787
|
.bead-tooltip-header {
|
|
4781
4788
|
display: flex;
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
gap:
|
|
4789
|
+
flex-direction: column;
|
|
4790
|
+
align-items: flex-start;
|
|
4791
|
+
gap: 4px;
|
|
4785
4792
|
}
|
|
4786
4793
|
|
|
4787
4794
|
sl-tooltip.bead-tooltip::part(body) {
|
|
@@ -4790,6 +4797,7 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4790
4797
|
|
|
4791
4798
|
.bead-tooltip-badges {
|
|
4792
4799
|
display: flex;
|
|
4800
|
+
flex-wrap: wrap;
|
|
4793
4801
|
align-items: center;
|
|
4794
4802
|
gap: 4px;
|
|
4795
4803
|
}
|
|
@@ -6473,3 +6481,43 @@ sl-dialog.markdown-dialog::part(body) {
|
|
|
6473
6481
|
.conflict-icon {
|
|
6474
6482
|
color: var(--status-blocked);
|
|
6475
6483
|
}
|
|
6484
|
+
|
|
6485
|
+
/* Graphify cache location — selectable monospace path (W-053 cache relocation) */
|
|
6486
|
+
.graphify-codebox {
|
|
6487
|
+
display: block;
|
|
6488
|
+
margin: 0.25rem 0 0.75rem;
|
|
6489
|
+
padding: 0.4rem 0.6rem;
|
|
6490
|
+
background: var(--bg-secondary);
|
|
6491
|
+
border: 1px solid var(--border-subtle);
|
|
6492
|
+
border-radius: var(--radius);
|
|
6493
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
6494
|
+
font-size: 0.85rem;
|
|
6495
|
+
color: var(--fg);
|
|
6496
|
+
word-break: break-all;
|
|
6497
|
+
user-select: all;
|
|
6498
|
+
}
|
|
6499
|
+
|
|
6500
|
+
/* A monospace value/command box with a copy button pinned to the right. */
|
|
6501
|
+
.graphify-copy-row {
|
|
6502
|
+
display: flex;
|
|
6503
|
+
align-items: center;
|
|
6504
|
+
gap: 0.4rem;
|
|
6505
|
+
margin: 0.25rem 0 0.75rem;
|
|
6506
|
+
}
|
|
6507
|
+
|
|
6508
|
+
.graphify-copy-row .graphify-codebox {
|
|
6509
|
+
flex: 1;
|
|
6510
|
+
min-width: 0;
|
|
6511
|
+
margin: 0;
|
|
6512
|
+
}
|
|
6513
|
+
|
|
6514
|
+
/* Shown when the graphify CLI is missing/incompatible. */
|
|
6515
|
+
.graphify-not-installed {
|
|
6516
|
+
margin: 0 0 0.75rem;
|
|
6517
|
+
}
|
|
6518
|
+
|
|
6519
|
+
/* Caution color (action needed: install) on the message text only — not the
|
|
6520
|
+
install-command code box, which keeps normal monospace styling. */
|
|
6521
|
+
.graphify-not-installed .settings-tab-description {
|
|
6522
|
+
color: var(--status-paused);
|
|
6523
|
+
}
|
package/package.json
CHANGED
package/server/app.js
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
4
4
|
import { createHmac, randomUUID } from 'node:crypto';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
6
12
|
import { homedir } from 'node:os';
|
|
7
13
|
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
8
14
|
import { fileURLToPath } from 'node:url';
|
|
@@ -10,13 +16,19 @@ import express from 'express';
|
|
|
10
16
|
|
|
11
17
|
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
12
18
|
import { createFleetRouter } from './fleet-routes.js';
|
|
19
|
+
import {
|
|
20
|
+
_effectiveConfig,
|
|
21
|
+
clearRepoCache,
|
|
22
|
+
createGraphifyStatus,
|
|
23
|
+
snapshotDir,
|
|
24
|
+
} from './graphify-status.js';
|
|
13
25
|
import { RAW_BODY } from './integrations/index.js';
|
|
14
26
|
import { verify } from './integrations/verify.js';
|
|
15
27
|
import { LaunchLock } from './launch-lock.js';
|
|
16
28
|
import { fleetRunsDir, workspaceRunsDir, workspacesDir } from './paths.js';
|
|
17
29
|
import { createPreferencesRouter } from './preferences-routes.js';
|
|
18
30
|
import { ProcessManager } from './process-manager.js';
|
|
19
|
-
import { scanDirectory } from './project-registry.js';
|
|
31
|
+
import { readProjects, scanDirectory } from './project-registry.js';
|
|
20
32
|
import {
|
|
21
33
|
createProjectRoutes,
|
|
22
34
|
createProjectScopedRoutes,
|
|
@@ -994,6 +1006,154 @@ export function createApp(options = {}) {
|
|
|
994
1006
|
res.json({ ok: true, path: configPath });
|
|
995
1007
|
});
|
|
996
1008
|
|
|
1009
|
+
// ─── Graphify endpoints ──────────────────────────────────────────────
|
|
1010
|
+
if (!app.locals.graphifyStatus) {
|
|
1011
|
+
app.locals.graphifyStatus = createGraphifyStatus({});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Resolve the graphify settings for a request. In single-project mode the
|
|
1015
|
+
// server's own projectRoot/settingsPath are used. In global mode there is no
|
|
1016
|
+
// fixed project, so the selected project is passed as ?project=<id> and we
|
|
1017
|
+
// resolve its root + settings.json from the registry — otherwise the endpoint
|
|
1018
|
+
// would be blind to every project and only ever see global settings.
|
|
1019
|
+
function readGraphifySettings(projectId) {
|
|
1020
|
+
const readJson = (p) => {
|
|
1021
|
+
if (!p) return {};
|
|
1022
|
+
try {
|
|
1023
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
1024
|
+
} catch {
|
|
1025
|
+
return {};
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
const globalSettingsPath = prefsDir
|
|
1029
|
+
? join(prefsDir, 'settings.json')
|
|
1030
|
+
: settingsPath;
|
|
1031
|
+
|
|
1032
|
+
let projectSettingsPath = settingsPath;
|
|
1033
|
+
let root = projectRoot || process.cwd();
|
|
1034
|
+
if (projectId && prefsDir) {
|
|
1035
|
+
const proj = readProjects(prefsDir).find((p) => p.name === projectId);
|
|
1036
|
+
if (proj) {
|
|
1037
|
+
projectSettingsPath =
|
|
1038
|
+
proj.settingsPath || join(proj.path, '.claude', 'settings.json');
|
|
1039
|
+
root = proj.path;
|
|
1040
|
+
}
|
|
1041
|
+
} else if (!projectSettingsPath && projectRoot) {
|
|
1042
|
+
projectSettingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return {
|
|
1046
|
+
globalSettings: readJson(globalSettingsPath),
|
|
1047
|
+
projectSettings: readJson(projectSettingsPath),
|
|
1048
|
+
root,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async function graphifyStatusPayload(projectId) {
|
|
1053
|
+
const { globalSettings, projectSettings, root } =
|
|
1054
|
+
readGraphifySettings(projectId);
|
|
1055
|
+
const result = await app.locals.graphifyStatus.getStatus({
|
|
1056
|
+
globalSettings,
|
|
1057
|
+
projectSettings,
|
|
1058
|
+
projectRoot: root,
|
|
1059
|
+
});
|
|
1060
|
+
return { ...result, building: Boolean(app.locals.graphifyBuilding) };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
app.get('/api/graphify/status', async (req, res) => {
|
|
1064
|
+
try {
|
|
1065
|
+
res.json(await graphifyStatusPayload(req.query.project));
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
app.post('/api/graphify/recheck', async (req, res) => {
|
|
1072
|
+
try {
|
|
1073
|
+
app.locals.graphifyStatus.invalidate();
|
|
1074
|
+
res.json(await graphifyStatusPayload(req.query.project));
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// Build/refresh the current HEAD's cache snapshot (async, long-running).
|
|
1081
|
+
// The lock + .complete publish discipline lives in run_graphify_preflight,
|
|
1082
|
+
// so we drive it via a detached Python process and track a `building` flag
|
|
1083
|
+
// the UI polls through /api/graphify/status.
|
|
1084
|
+
app.post('/api/graphify/build', async (req, res) => {
|
|
1085
|
+
try {
|
|
1086
|
+
const { globalSettings, projectSettings, root } = readGraphifySettings(
|
|
1087
|
+
req.query.project,
|
|
1088
|
+
);
|
|
1089
|
+
const effective = _effectiveConfig(globalSettings, projectSettings);
|
|
1090
|
+
if (!effective.enabled) {
|
|
1091
|
+
return res
|
|
1092
|
+
.status(400)
|
|
1093
|
+
.json({ ok: false, error: 'Graphify is not enabled' });
|
|
1094
|
+
}
|
|
1095
|
+
const detection = await app.locals.graphifyStatus.detect();
|
|
1096
|
+
if (!detection.installed || !detection.compatible) {
|
|
1097
|
+
return res.status(400).json({
|
|
1098
|
+
ok: false,
|
|
1099
|
+
error:
|
|
1100
|
+
detection.error || 'Graphify is not installed or not compatible',
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
if (app.locals.graphifyBuilding) {
|
|
1104
|
+
return res.json({ ok: true, status: 'building' });
|
|
1105
|
+
}
|
|
1106
|
+
// Fresh build for the current HEAD: clear its snapshot first.
|
|
1107
|
+
const snap = snapshotDir(root);
|
|
1108
|
+
if (snap) {
|
|
1109
|
+
try {
|
|
1110
|
+
rmSync(snap, { recursive: true, force: true });
|
|
1111
|
+
} catch {}
|
|
1112
|
+
}
|
|
1113
|
+
app.locals.graphifyBuilding = true;
|
|
1114
|
+
const child = spawn(
|
|
1115
|
+
'python3',
|
|
1116
|
+
[
|
|
1117
|
+
'-c',
|
|
1118
|
+
'from worca.scripts.graphify_preflight import run_graphify_preflight as r; r()',
|
|
1119
|
+
],
|
|
1120
|
+
{ cwd: root, stdio: 'ignore' },
|
|
1121
|
+
);
|
|
1122
|
+
const done = () => {
|
|
1123
|
+
app.locals.graphifyBuilding = false;
|
|
1124
|
+
app.locals.graphifyStatus.invalidate();
|
|
1125
|
+
};
|
|
1126
|
+
child.on('exit', done);
|
|
1127
|
+
child.on('error', done);
|
|
1128
|
+
res.json({ ok: true, status: 'building' });
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
app.locals.graphifyBuilding = false;
|
|
1131
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// Clear ALL cached snapshots for this project's repo.
|
|
1136
|
+
app.post('/api/graphify/clear', async (req, res) => {
|
|
1137
|
+
try {
|
|
1138
|
+
const { root } = readGraphifySettings(req.query.project);
|
|
1139
|
+
const cleared = clearRepoCache(root);
|
|
1140
|
+
app.locals.graphifyStatus.invalidate();
|
|
1141
|
+
res.json({ ok: true, cleared });
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
app.get('/api/graphify/graph.html', (req, res) => {
|
|
1148
|
+
const { root } = readGraphifySettings(req.query.project);
|
|
1149
|
+
const snap = snapshotDir(root);
|
|
1150
|
+
const htmlPath = snap ? join(snap, 'graphify', 'graph.html') : null;
|
|
1151
|
+
if (!htmlPath || !existsSync(htmlPath)) {
|
|
1152
|
+
return res.status(404).json({ ok: false, error: 'graph.html not found' });
|
|
1153
|
+
}
|
|
1154
|
+
res.sendFile(htmlPath);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
997
1157
|
// ─── Dynamic favicon ──────────────────────────────────────────────────
|
|
998
1158
|
// Serve mode-specific favicon before express.static so it takes precedence.
|
|
999
1159
|
app.get('/favicon.svg', (_req, res) => {
|
package/server/beads-reader.js
CHANGED
|
@@ -14,6 +14,13 @@ async function runBd(args, dbPath) {
|
|
|
14
14
|
return JSON.parse(stdout);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export function extractEffortFromLabels(labels) {
|
|
18
|
+
if (!labels || labels.length === 0) return null;
|
|
19
|
+
const match = labels.find((l) => l.startsWith('worca-effort:'));
|
|
20
|
+
if (!match) return null;
|
|
21
|
+
return match.slice('worca-effort:'.length);
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
function transformIssue(issue, deps) {
|
|
18
25
|
const depends_on = (deps || []).map((d) => d.id);
|
|
19
26
|
const blocked_by = (deps || [])
|
|
@@ -29,17 +36,18 @@ function transformIssue(issue, deps) {
|
|
|
29
36
|
external_ref: issue.external_ref || null,
|
|
30
37
|
depends_on,
|
|
31
38
|
blocked_by,
|
|
39
|
+
effort: extractEffortFromLabels(issue.labels),
|
|
32
40
|
};
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
async function enrichWithDeps(issues, dbPath) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
if (issues.length === 0) return [];
|
|
45
|
+
const detailed = await runBd(['show', ...issues.map((i) => i.id)], dbPath);
|
|
46
|
+
const detailMap = new Map(detailed.map((d) => [d.id, d]));
|
|
47
|
+
return issues.map((i) => {
|
|
48
|
+
const d = detailMap.get(i.id);
|
|
49
|
+
return transformIssue(d || i, d?.dependencies || []);
|
|
50
|
+
});
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
export function dbExists(beadsDb) {
|
|
@@ -89,10 +97,9 @@ export async function listUnlinkedIssues(beadsDb) {
|
|
|
89
97
|
const labels = d?.labels || [];
|
|
90
98
|
return !labels.some((l) => l.startsWith('run:'));
|
|
91
99
|
});
|
|
92
|
-
// detailed already has dependencies, use them directly
|
|
93
100
|
return unlinked.map((i) => {
|
|
94
101
|
const d = detailMap.get(i.id);
|
|
95
|
-
return transformIssue(i, d?.dependencies || []);
|
|
102
|
+
return transformIssue(d || i, d?.dependencies || []);
|
|
96
103
|
});
|
|
97
104
|
} catch {
|
|
98
105
|
return [];
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { isAbsolute, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
// Mirror of _GRAPHIFY_DEFAULTS in src/worca/utils/graphify.py — keep in sync.
|
|
8
|
+
const GRAPHIFY_DEFAULTS = {
|
|
9
|
+
enabled: false,
|
|
10
|
+
mode: 'structural',
|
|
11
|
+
backend: null,
|
|
12
|
+
model_profile: null,
|
|
13
|
+
out_dir: 'graphify-out',
|
|
14
|
+
update_on: { preflight: true, guardian_post_commit: true },
|
|
15
|
+
min_repo_files: 100,
|
|
16
|
+
version_range: '>=0.8.16,<1',
|
|
17
|
+
preflight_timeout_seconds: 300,
|
|
18
|
+
freshness: 'clean_only',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Mirror of effective_graphify_config() in src/worca/utils/graphify.py.
|
|
22
|
+
// Enablement is project-level: the project opts in via graphify.enabled. Global
|
|
23
|
+
// graphify.enabled is purely a kill-switch — an EXPLICIT global `false` disables
|
|
24
|
+
// everywhere; `true`/unset defer to the project. These rules MUST match the
|
|
25
|
+
// Python implementation; the parity is guarded by graphify-status.test.js
|
|
26
|
+
// ("effective-config parity with Python"). Update both together.
|
|
27
|
+
export function _effectiveConfig(globalSettings, projectSettings) {
|
|
28
|
+
const gGraphify = globalSettings?.worca?.graphify ?? {};
|
|
29
|
+
const pGraphify = projectSettings?.worca?.graphify ?? {};
|
|
30
|
+
|
|
31
|
+
// Only an explicit global `enabled: false` disables; `true`/unset defer.
|
|
32
|
+
if (gGraphify.enabled === false) {
|
|
33
|
+
return { ...GRAPHIFY_DEFAULTS, enabled: false, reason: 'global-off' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const projectEnabled = pGraphify.enabled ?? false;
|
|
37
|
+
if (!projectEnabled) {
|
|
38
|
+
return { ...GRAPHIFY_DEFAULTS, enabled: false, reason: 'project-off' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const merged = { ...GRAPHIFY_DEFAULTS };
|
|
42
|
+
for (const [k, v] of Object.entries(gGraphify)) {
|
|
43
|
+
if (v != null || k === 'enabled') merged[k] = v;
|
|
44
|
+
}
|
|
45
|
+
for (const [k, v] of Object.entries(pGraphify)) {
|
|
46
|
+
if (v != null || k === 'enabled') merged[k] = v;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
enabled: true,
|
|
51
|
+
mode: merged.mode,
|
|
52
|
+
backend: merged.backend,
|
|
53
|
+
model_profile: merged.model_profile,
|
|
54
|
+
out_dir: merged.out_dir,
|
|
55
|
+
update_on: merged.update_on,
|
|
56
|
+
min_repo_files: merged.min_repo_files,
|
|
57
|
+
version_range: merged.version_range,
|
|
58
|
+
preflight_timeout_seconds: merged.preflight_timeout_seconds,
|
|
59
|
+
freshness: merged.freshness,
|
|
60
|
+
reason: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Per-commit cache resolution (mirrors utils/paths.py + utils/git.py) ────
|
|
65
|
+
|
|
66
|
+
export function cacheDir() {
|
|
67
|
+
if (process.env.WORCA_CACHE) return process.env.WORCA_CACHE;
|
|
68
|
+
const home = process.env.WORCA_HOME || join(homedir(), '.worca');
|
|
69
|
+
return join(home, 'cache');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function repoId(projectRoot) {
|
|
73
|
+
try {
|
|
74
|
+
const common = execFileSync(
|
|
75
|
+
'git',
|
|
76
|
+
['-C', projectRoot, 'rev-parse', '--git-common-dir'],
|
|
77
|
+
{ encoding: 'utf-8' },
|
|
78
|
+
).trim();
|
|
79
|
+
if (!common) return null;
|
|
80
|
+
const abs = isAbsolute(common) ? common : join(projectRoot, common);
|
|
81
|
+
const real = realpathSync(abs);
|
|
82
|
+
return createHash('sha256').update(real).digest('hex').slice(0, 12);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function headSha(projectRoot) {
|
|
89
|
+
try {
|
|
90
|
+
return execFileSync('git', ['-C', projectRoot, 'rev-parse', 'HEAD'], {
|
|
91
|
+
encoding: 'utf-8',
|
|
92
|
+
}).trim();
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Absolute snapshot dir for the project's current HEAD, or null. */
|
|
99
|
+
export function snapshotDir(projectRoot) {
|
|
100
|
+
const rid = repoId(projectRoot);
|
|
101
|
+
const sha = headSha(projectRoot);
|
|
102
|
+
if (!rid || !sha) return null;
|
|
103
|
+
return join(cacheDir(), 'ast', rid, sha);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The per-project cache dir (<cache>/ast/<repo-id>/), or null if not a repo. */
|
|
107
|
+
export function repoCacheDir(projectRoot) {
|
|
108
|
+
const rid = repoId(projectRoot);
|
|
109
|
+
if (!rid) return null;
|
|
110
|
+
return join(cacheDir(), 'ast', rid);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Remove all cached snapshots for the project's repo. Returns the path or null. */
|
|
114
|
+
export function clearRepoCache(projectRoot) {
|
|
115
|
+
const repoCache = repoCacheDir(projectRoot);
|
|
116
|
+
if (!repoCache) return null;
|
|
117
|
+
rmSync(repoCache, { recursive: true, force: true });
|
|
118
|
+
return repoCache;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Stats for a per-commit snapshot dir, or null if not complete/present. */
|
|
122
|
+
export function _graphStats(snapDir) {
|
|
123
|
+
if (!snapDir || !existsSync(join(snapDir, '.complete'))) return null;
|
|
124
|
+
const reportPath = join(snapDir, 'graphify', 'GRAPH_REPORT.md');
|
|
125
|
+
if (!existsSync(reportPath)) return null;
|
|
126
|
+
|
|
127
|
+
const stat = statSync(reportPath);
|
|
128
|
+
const ageSeconds = Math.max(0, (Date.now() - stat.mtimeMs) / 1000);
|
|
129
|
+
const htmlPath = join(snapDir, 'graphify', 'graph.html');
|
|
130
|
+
const graphJsonPath = join(snapDir, 'graphify', 'graph.json');
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
report_path: reportPath,
|
|
134
|
+
// The queryable dataset for humans: `graphify query … --graph <path>`.
|
|
135
|
+
// null when the snapshot lacks graph.json (older/partial builds).
|
|
136
|
+
graph_json_path: existsSync(graphJsonPath) ? graphJsonPath : null,
|
|
137
|
+
snapshot_dir: snapDir,
|
|
138
|
+
age_seconds: ageSeconds,
|
|
139
|
+
size_bytes: stat.size,
|
|
140
|
+
has_html: existsSync(htmlPath),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function defaultDetect() {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
const child = spawn(
|
|
147
|
+
'python3',
|
|
148
|
+
[
|
|
149
|
+
'-c',
|
|
150
|
+
'import json; from worca.utils.graphify import detect_graphify; d = detect_graphify(); print(json.dumps({"installed": d.installed, "version": d.version, "compatible": d.compatible, "backend_env_present": d.backend_env_present, "error": d.error}))',
|
|
151
|
+
],
|
|
152
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
let stdout = '';
|
|
156
|
+
let stderr = '';
|
|
157
|
+
child.stdout.on('data', (chunk) => {
|
|
158
|
+
stdout += chunk.toString();
|
|
159
|
+
});
|
|
160
|
+
child.stderr.on('data', (chunk) => {
|
|
161
|
+
stderr += chunk.toString();
|
|
162
|
+
});
|
|
163
|
+
child.on('error', () => {
|
|
164
|
+
resolve({
|
|
165
|
+
installed: false,
|
|
166
|
+
version: null,
|
|
167
|
+
compatible: false,
|
|
168
|
+
backend_env_present: [],
|
|
169
|
+
error: 'python3 not available',
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
child.on('exit', (code) => {
|
|
173
|
+
if (code === 0 && stdout.trim()) {
|
|
174
|
+
try {
|
|
175
|
+
resolve(JSON.parse(stdout.trim()));
|
|
176
|
+
return;
|
|
177
|
+
} catch {
|
|
178
|
+
// fall through
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
resolve({
|
|
182
|
+
installed: false,
|
|
183
|
+
version: null,
|
|
184
|
+
compatible: false,
|
|
185
|
+
backend_env_present: [],
|
|
186
|
+
error: stderr.trim() || `detect exited ${code}`,
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
193
|
+
|
|
194
|
+
export function createGraphifyStatus(opts = {}) {
|
|
195
|
+
const detectFn = opts.detectFn || defaultDetect;
|
|
196
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
197
|
+
|
|
198
|
+
let cached = null;
|
|
199
|
+
let cachedAt = 0;
|
|
200
|
+
|
|
201
|
+
async function detect() {
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
if (cached && now - cachedAt < ttlMs) return cached;
|
|
204
|
+
cached = await detectFn();
|
|
205
|
+
cachedAt = Date.now();
|
|
206
|
+
return cached;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function invalidate() {
|
|
210
|
+
cached = null;
|
|
211
|
+
cachedAt = 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function getStatus({ globalSettings, projectSettings, projectRoot }) {
|
|
215
|
+
const effective = _effectiveConfig(globalSettings, projectSettings);
|
|
216
|
+
const detection = await detect();
|
|
217
|
+
const graphStats = effective.enabled
|
|
218
|
+
? _graphStats(snapshotDir(projectRoot))
|
|
219
|
+
: null;
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
effective,
|
|
223
|
+
detection,
|
|
224
|
+
graph_stats: graphStats,
|
|
225
|
+
// The cache path is a pure function of the repo location (it's null only
|
|
226
|
+
// when projectRoot isn't a git repo), so resolve it regardless of whether
|
|
227
|
+
// graphify is enabled. This lets the UI show the path immediately when
|
|
228
|
+
// the user toggles graphify on in-memory, before the setting is saved.
|
|
229
|
+
cache_path: repoCacheDir(projectRoot),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { detect, invalidate, getStatus };
|
|
234
|
+
}
|
package/server/index.js
CHANGED
|
@@ -90,22 +90,21 @@ server.on('error', (err) => {
|
|
|
90
90
|
process.exit(1);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
const { broadcast, scheduleRefresh, resolveRunProject } =
|
|
94
|
-
server,
|
|
95
|
-
{
|
|
93
|
+
const { broadcast, scheduleRefresh, resolveRunProject, getBeadsCounts } =
|
|
94
|
+
attachWsServer(server, {
|
|
96
95
|
worcaDir,
|
|
97
96
|
settingsPath,
|
|
98
97
|
prefsPath: preferencesPath(),
|
|
99
98
|
prefsDir,
|
|
100
99
|
webhookInbox,
|
|
101
100
|
projectRoot,
|
|
102
|
-
}
|
|
103
|
-
);
|
|
101
|
+
});
|
|
104
102
|
|
|
105
|
-
// Expose broadcast, scheduleRefresh, and
|
|
103
|
+
// Expose broadcast, scheduleRefresh, resolveRunProject, and getBeadsCounts to REST route handlers
|
|
106
104
|
app.locals.broadcast = broadcast;
|
|
107
105
|
app.locals.scheduleRefresh = scheduleRefresh;
|
|
108
106
|
app.locals.resolveRunProject = resolveRunProject;
|
|
107
|
+
app.locals.getBeadsCounts = getBeadsCounts;
|
|
109
108
|
|
|
110
109
|
// Boot chat integrations only in global mode — project-scoped instances skip
|
|
111
110
|
// integrations to avoid duplicate Telegram long-poll connections on the same bot.
|
|
@@ -211,6 +211,11 @@ export function createGlobalHandlers({ chatContext, prefsDir, restClient }) {
|
|
|
211
211
|
} else if (elapsed) {
|
|
212
212
|
parts.push(` **Duration:** ${elapsed}`);
|
|
213
213
|
}
|
|
214
|
+
if (run.beads_total > 0) {
|
|
215
|
+
parts.push(
|
|
216
|
+
` **Beads:** ${run.beads_done ?? 0}/${run.beads_total}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
214
219
|
lines.push(parts.join('\n'));
|
|
215
220
|
}
|
|
216
221
|
}
|
|
@@ -181,6 +181,9 @@ function fmtStatusBlock(run) {
|
|
|
181
181
|
const iterPart = iteration ? ` (iteration ${iteration})` : '';
|
|
182
182
|
parts.push(` **Stage:** ${stage}${iterPart}`);
|
|
183
183
|
}
|
|
184
|
+
if (run.beads_total > 0) {
|
|
185
|
+
parts.push(` **Beads:** ${run.beads_done ?? 0}/${run.beads_total}`);
|
|
186
|
+
}
|
|
184
187
|
if (elapsed) parts.push(` **Duration:** ${elapsed}`);
|
|
185
188
|
if (cost) parts.push(` **Cost:** ${cost}`);
|
|
186
189
|
if (ps === 'completed' && run.pr_url)
|
|
@@ -114,6 +114,9 @@ function renderStageCompleted(envelope) {
|
|
|
114
114
|
parts.push(` **Stage:** ${p.stage ?? 'unknown'} completed`);
|
|
115
115
|
const dur = fmtMs(p.duration_ms);
|
|
116
116
|
if (dur) parts.push(` **Duration:** ${dur}`);
|
|
117
|
+
if (p.beads_total > 0) {
|
|
118
|
+
parts.push(` **Beads:** ${p.beads_done ?? 0}/${p.beads_total}`);
|
|
119
|
+
}
|
|
117
120
|
return mdMsg(parts.join('\n'), 'success');
|
|
118
121
|
}
|
|
119
122
|
|
|
@@ -504,6 +507,17 @@ function renderWorkspaceGuideConflict(envelope) {
|
|
|
504
507
|
return mdMsg(parts.join('\n'), 'warning');
|
|
505
508
|
}
|
|
506
509
|
|
|
510
|
+
function renderBeadNext(envelope) {
|
|
511
|
+
const p = envelope.payload;
|
|
512
|
+
const parts = [`⚙ **Run:** \`${runId(envelope)}\``];
|
|
513
|
+
const label =
|
|
514
|
+
p.max_beads != null
|
|
515
|
+
? `${p.bead_iteration}/${p.max_beads}`
|
|
516
|
+
: `${p.bead_iteration}`;
|
|
517
|
+
parts.push(` **Bead:** ${label}`);
|
|
518
|
+
return mdMsg(parts.join('\n'), 'info');
|
|
519
|
+
}
|
|
520
|
+
|
|
507
521
|
// ---------------------------------------------------------------------------
|
|
508
522
|
// Registry
|
|
509
523
|
// ---------------------------------------------------------------------------
|
|
@@ -550,6 +564,7 @@ const EVENT_RENDERERS = {
|
|
|
550
564
|
// than Tier-1 defaults. Callers that want them can pull from this export and
|
|
551
565
|
// register them in their own pipeline.
|
|
552
566
|
export const OPT_IN_RENDERERS = {
|
|
567
|
+
'pipeline.bead.next': renderBeadNext,
|
|
553
568
|
'fleet.launched': renderFleetLaunched,
|
|
554
569
|
'workspace.launched': renderWorkspaceLaunched,
|
|
555
570
|
'workspace.plan.started': renderWorkspacePlanStarted,
|
package/server/project-routes.js
CHANGED
|
@@ -360,10 +360,29 @@ export function createProjectScopedRoutes({
|
|
|
360
360
|
});
|
|
361
361
|
|
|
362
362
|
// GET /api/projects/:projectId/runs — list runs for this project
|
|
363
|
-
router.get('/runs', requireWorcaDir, (req, res) => {
|
|
363
|
+
router.get('/runs', requireWorcaDir, async (req, res) => {
|
|
364
364
|
try {
|
|
365
365
|
const runs = discoverRuns(req.project.worcaDir);
|
|
366
366
|
const default_branch = getDefaultBranch(req.project.projectRoot);
|
|
367
|
+
|
|
368
|
+
const { getBeadsCounts } = req.app.locals;
|
|
369
|
+
if (getBeadsCounts) {
|
|
370
|
+
try {
|
|
371
|
+
const counts = await getBeadsCounts(req.project.name);
|
|
372
|
+
if (counts) {
|
|
373
|
+
for (const run of runs) {
|
|
374
|
+
const c = counts[run.id];
|
|
375
|
+
if (c) {
|
|
376
|
+
run.beads_done = c.done;
|
|
377
|
+
run.beads_total = c.total;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
/* non-fatal — runs returned without bead counts */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
367
386
|
const response = { ok: true, runs, default_branch };
|
|
368
387
|
// Include settings so multi-project clients can use loop limits, etc.
|
|
369
388
|
const { settingsPath } = req.project;
|