@worca/ui 0.8.1 → 0.9.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 +1245 -684
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +194 -19
- package/package.json +1 -1
- package/server/app.js +58 -2
- package/server/dispatch-events-aggregator.js +161 -0
- package/server/project-registry.js +37 -0
- package/server/project-routes.js +132 -5
- package/server/settings-validator.js +29 -2
- package/server/subagents-discovery.js +116 -0
- package/server/version-check.js +35 -0
- package/server/watcher.js +37 -10
- package/server/worca-setup.js +15 -1
- package/server/ws-modular.js +6 -2
package/server/project-routes.js
CHANGED
|
@@ -23,6 +23,7 @@ import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
|
23
23
|
import { readPreferences } from './preferences.js';
|
|
24
24
|
import { ProcessManager } from './process-manager.js';
|
|
25
25
|
import {
|
|
26
|
+
getMaxProjects,
|
|
26
27
|
readProjects,
|
|
27
28
|
removeProject,
|
|
28
29
|
SLUG_RE,
|
|
@@ -36,6 +37,8 @@ import {
|
|
|
36
37
|
readMergedSettings,
|
|
37
38
|
} from './settings-merge.js';
|
|
38
39
|
import { validateSettingsPayload } from './settings-validator.js';
|
|
40
|
+
import { isVersionBehind } from './version-check.js';
|
|
41
|
+
import { getVersionInfo } from './versions.js';
|
|
39
42
|
import { discoverRuns } from './watcher.js';
|
|
40
43
|
import {
|
|
41
44
|
checkWorcaInstalled,
|
|
@@ -182,15 +185,114 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
|
|
|
182
185
|
res.json({ ok: true, removed: id });
|
|
183
186
|
});
|
|
184
187
|
|
|
188
|
+
// POST /api/projects/batch — register multiple projects atomically
|
|
189
|
+
router.post('/batch', (req, res) => {
|
|
190
|
+
const { projects: batch } = req.body || {};
|
|
191
|
+
if (!Array.isArray(batch) || batch.length === 0) {
|
|
192
|
+
return res
|
|
193
|
+
.status(400)
|
|
194
|
+
.json({ ok: false, error: 'projects must be a non-empty array' });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Validate all entries first (all-or-nothing)
|
|
198
|
+
const failed = [];
|
|
199
|
+
for (const entry of batch) {
|
|
200
|
+
const validation = validateProjectEntry(entry);
|
|
201
|
+
if (!validation.valid) {
|
|
202
|
+
failed.push({ name: entry?.name ?? '', error: validation.error });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!existsSync(entry.path)) {
|
|
206
|
+
failed.push({
|
|
207
|
+
name: entry.name,
|
|
208
|
+
error: `directory does not exist: ${entry.path}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (failed.length > 0) {
|
|
213
|
+
return res.status(400).json({
|
|
214
|
+
ok: false,
|
|
215
|
+
error: `${failed.length} project${failed.length > 1 ? 's' : ''} failed validation`,
|
|
216
|
+
failed,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check for intra-batch duplicate names
|
|
221
|
+
const batchNames = batch.map((e) => e?.name).filter(Boolean);
|
|
222
|
+
if (new Set(batchNames).size < batchNames.length) {
|
|
223
|
+
return res
|
|
224
|
+
.status(400)
|
|
225
|
+
.json({ ok: false, error: 'Duplicate names within batch' });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check for intra-batch duplicate paths
|
|
229
|
+
const batchPaths = batch.map((e) => e?.path).filter(Boolean);
|
|
230
|
+
if (new Set(batchPaths).size < batchPaths.length) {
|
|
231
|
+
return res
|
|
232
|
+
.status(400)
|
|
233
|
+
.json({ ok: false, error: 'Duplicate paths within batch' });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check for duplicate paths against existing projects
|
|
237
|
+
const existing = readProjects(prefsDir);
|
|
238
|
+
const existingPaths = new Set(
|
|
239
|
+
existing.map((p) => p.path.replace(/\/+$/, '')),
|
|
240
|
+
);
|
|
241
|
+
const duplicates = batch.filter((entry) =>
|
|
242
|
+
existingPaths.has(entry.path.replace(/\/+$/, '')),
|
|
243
|
+
);
|
|
244
|
+
if (duplicates.length > 0) {
|
|
245
|
+
return res.status(400).json({
|
|
246
|
+
ok: false,
|
|
247
|
+
error: `${duplicates.length} project${duplicates.length > 1 ? 's' : ''} already registered`,
|
|
248
|
+
failed: duplicates.map((entry) => ({
|
|
249
|
+
name: entry.name,
|
|
250
|
+
error: `path already registered: ${entry.path}`,
|
|
251
|
+
})),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Check max projects limit
|
|
256
|
+
const max = getMaxProjects(prefsDir);
|
|
257
|
+
if (existing.length + batch.length > max) {
|
|
258
|
+
return res.status(400).json({
|
|
259
|
+
ok: false,
|
|
260
|
+
error: `adding ${batch.length} project${batch.length > 1 ? 's' : ''} would exceed the limit of ${max}`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Write all projects — roll back on partial failure
|
|
265
|
+
const written = [];
|
|
266
|
+
try {
|
|
267
|
+
for (const entry of batch) {
|
|
268
|
+
writeProject(prefsDir, entry);
|
|
269
|
+
written.push(entry.name);
|
|
270
|
+
}
|
|
271
|
+
res.status(201).json({ ok: true, projects: batch });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
for (const name of written) {
|
|
274
|
+
try {
|
|
275
|
+
removeProject(prefsDir, name);
|
|
276
|
+
} catch {
|
|
277
|
+
// ignore rollback errors
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
res.status(400).json({ ok: false, error: err.message });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
185
284
|
return router;
|
|
186
285
|
}
|
|
187
286
|
|
|
188
287
|
/**
|
|
189
288
|
* Router for project-scoped sub-routes.
|
|
190
289
|
* The projectResolver middleware must run before this to set req.project.
|
|
290
|
+
* @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
|
|
291
|
+
* worca-cc version lookup for /worca-status' `outdated` flag.
|
|
191
292
|
*/
|
|
192
|
-
export function createProjectScopedRoutes() {
|
|
293
|
+
export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
193
294
|
const router = Router({ mergeParams: true });
|
|
295
|
+
const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
|
|
194
296
|
|
|
195
297
|
// Guard: run-related, cost, and pipeline routes require worcaDir
|
|
196
298
|
function requireWorcaDir(req, res, next) {
|
|
@@ -1213,10 +1315,35 @@ export function createProjectScopedRoutes() {
|
|
|
1213
1315
|
res.json({ ok: true, templates });
|
|
1214
1316
|
});
|
|
1215
1317
|
|
|
1216
|
-
// GET /api/projects/:projectId/worca-status — check worca installation state
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1318
|
+
// GET /api/projects/:projectId/worca-status — check worca installation state.
|
|
1319
|
+
// `outdated` is true when the project's installed worca-cc version is
|
|
1320
|
+
// strictly behind the active (dev-path or globally-installed) worca-cc.
|
|
1321
|
+
router.get('/worca-status', async (req, res) => {
|
|
1322
|
+
const { projectRoot } = req.project;
|
|
1323
|
+
const installed = checkWorcaInstalled(projectRoot);
|
|
1324
|
+
if (!installed) {
|
|
1325
|
+
return res.json({
|
|
1326
|
+
ok: true,
|
|
1327
|
+
installed: false,
|
|
1328
|
+
version: null,
|
|
1329
|
+
outdated: false,
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
const version = readProjectWorcaVersion(projectRoot);
|
|
1333
|
+
let outdated = false;
|
|
1334
|
+
if (version != null) {
|
|
1335
|
+
try {
|
|
1336
|
+
const versionInfo = await getVersionInfo({
|
|
1337
|
+
prefsPath,
|
|
1338
|
+
worcaVersion: req.app.locals.worcaVersion || null,
|
|
1339
|
+
});
|
|
1340
|
+
outdated = isVersionBehind(version, versionInfo.activeWorcaCc);
|
|
1341
|
+
} catch {
|
|
1342
|
+
// Best-effort — if version lookup fails, treat as not outdated
|
|
1343
|
+
outdated = false;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
res.json({ ok: true, installed: true, version, outdated });
|
|
1220
1347
|
});
|
|
1221
1348
|
|
|
1222
1349
|
// POST /api/projects/:projectId/worca-setup — install or update worca
|
|
@@ -323,8 +323,35 @@ export function validateSettingsPayload(body) {
|
|
|
323
323
|
continue;
|
|
324
324
|
}
|
|
325
325
|
for (const v of val) {
|
|
326
|
-
if (
|
|
327
|
-
details.push(`
|
|
326
|
+
if (typeof v !== 'string') {
|
|
327
|
+
details.push(`Dispatch entry for "${key}" must be a string`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (g.subagent_dispatch !== undefined) {
|
|
334
|
+
if (
|
|
335
|
+
typeof g.subagent_dispatch !== 'object' ||
|
|
336
|
+
g.subagent_dispatch === null ||
|
|
337
|
+
Array.isArray(g.subagent_dispatch)
|
|
338
|
+
) {
|
|
339
|
+
details.push('governance.subagent_dispatch must be an object');
|
|
340
|
+
} else {
|
|
341
|
+
for (const [key, val] of Object.entries(g.subagent_dispatch)) {
|
|
342
|
+
if (!VALID_AGENTS.includes(key)) {
|
|
343
|
+
details.push(`Unknown subagent_dispatch agent: "${key}"`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (!Array.isArray(val)) {
|
|
347
|
+
details.push(`subagent_dispatch for "${key}" must be an array`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
for (const v of val) {
|
|
351
|
+
if (typeof v !== 'string') {
|
|
352
|
+
details.push(
|
|
353
|
+
`subagent_dispatch entry for "${key}" must be a string`,
|
|
354
|
+
);
|
|
328
355
|
}
|
|
329
356
|
}
|
|
330
357
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent discovery for the settings dispatch-rule editor.
|
|
3
|
+
*
|
|
4
|
+
* Walks three sources (built-ins, user-global, plugin cache) and returns a
|
|
5
|
+
* deduplicated list matching the shape used by `worca-ui/app/views/
|
|
6
|
+
* dispatch-tag-state.js` (`{name, label, group}`).
|
|
7
|
+
*
|
|
8
|
+
* The three sources:
|
|
9
|
+
* 1. Built-ins — hardcoded Claude Code types that are not on disk.
|
|
10
|
+
* 2. User — `<userDir>/*.md`, one file per subagent.
|
|
11
|
+
* 3. Plugins — `<pluginCacheDir>/<marketplace>/<plugin>/<version>/agents/*.md`.
|
|
12
|
+
* Deduped by the qualified name `<plugin>:<agent>` — first file wins
|
|
13
|
+
* across versions (the set of agents within a plugin is stable in
|
|
14
|
+
* practice; when two versions disagree we prefer filesystem order for
|
|
15
|
+
* determinism rather than trying to parse semver from directory names).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { basename, join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
// Built-in Claude Code subagents — shipped with a factory CC install, no
|
|
22
|
+
// plugins required. Mirror this list in worca-ui/app/views/
|
|
23
|
+
// dispatch-tag-state.js (KNOWN_TYPES) so the UI falls back to the same set
|
|
24
|
+
// when the /api/subagents fetch fails.
|
|
25
|
+
export const BUILTINS = [
|
|
26
|
+
{ name: 'Explore', label: '(built-in)', group: 'Built-in' },
|
|
27
|
+
{ name: 'general-purpose', label: '(built-in)', group: 'Built-in' },
|
|
28
|
+
{ name: 'Plan', label: '(built-in)', group: 'Built-in' },
|
|
29
|
+
{ name: 'statusline-setup', label: '(built-in)', group: 'Built-in' },
|
|
30
|
+
{ name: 'claude-code-guide', label: '(built-in)', group: 'Built-in' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function listMarkdownBasenames(dir) {
|
|
34
|
+
if (!dir || !existsSync(dir)) return [];
|
|
35
|
+
try {
|
|
36
|
+
return readdirSync(dir)
|
|
37
|
+
.filter((n) => n.endsWith('.md'))
|
|
38
|
+
.map((n) => basename(n, '.md'));
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listSubdirs(dir) {
|
|
45
|
+
if (!existsSync(dir)) return [];
|
|
46
|
+
try {
|
|
47
|
+
return readdirSync(dir).filter((n) => {
|
|
48
|
+
try {
|
|
49
|
+
return statSync(join(dir, n)).isDirectory();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Discover all subagent types reachable from the given directories.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} options
|
|
63
|
+
* @param {Array<{name:string,label:string,group:string}>} [options.builtins]
|
|
64
|
+
* @param {string} [options.userDir] e.g. ~/.claude/agents
|
|
65
|
+
* @param {string} [options.pluginCacheDir] e.g. ~/.claude/plugins/cache
|
|
66
|
+
* @param {string} [options.projectAgentsDir] e.g. <project>/.claude/agents
|
|
67
|
+
* @returns {Array<{name:string,label:string,group:string}>}
|
|
68
|
+
*/
|
|
69
|
+
export function discoverSubagents({
|
|
70
|
+
builtins = BUILTINS,
|
|
71
|
+
userDir,
|
|
72
|
+
pluginCacheDir,
|
|
73
|
+
projectAgentsDir,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const result = [...builtins];
|
|
76
|
+
const seen = new Set(result.map((t) => t.name));
|
|
77
|
+
|
|
78
|
+
for (const name of listMarkdownBasenames(userDir)) {
|
|
79
|
+
if (!seen.has(name)) {
|
|
80
|
+
seen.add(name);
|
|
81
|
+
result.push({ name, label: '(user)', group: 'User' });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (pluginCacheDir && existsSync(pluginCacheDir)) {
|
|
86
|
+
for (const marketplace of listSubdirs(pluginCacheDir)) {
|
|
87
|
+
const marketplaceDir = join(pluginCacheDir, marketplace);
|
|
88
|
+
for (const plugin of listSubdirs(marketplaceDir)) {
|
|
89
|
+
const pluginDir = join(marketplaceDir, plugin);
|
|
90
|
+
for (const version of listSubdirs(pluginDir)) {
|
|
91
|
+
const agentsDir = join(pluginDir, version, 'agents');
|
|
92
|
+
for (const agent of listMarkdownBasenames(agentsDir)) {
|
|
93
|
+
const qualified = `${plugin}:${agent}`;
|
|
94
|
+
if (!seen.has(qualified)) {
|
|
95
|
+
seen.add(qualified);
|
|
96
|
+
result.push({
|
|
97
|
+
name: qualified,
|
|
98
|
+
label: '(plugin)',
|
|
99
|
+
group: 'Plugin',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const name of listMarkdownBasenames(projectAgentsDir)) {
|
|
109
|
+
if (!seen.has(name)) {
|
|
110
|
+
seen.add(name);
|
|
111
|
+
result.push({ name, label: '(project)', group: 'Project' });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
}
|
package/server/version-check.js
CHANGED
|
@@ -40,6 +40,41 @@ export function meetsMinimum(installed, minimum) {
|
|
|
40
40
|
return true; // equal
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Parse a version string into comparable parts, tracking RC suffixes.
|
|
45
|
+
* "0.6.0rc7" → { parts: [0, 6, 0], rc: 7 }
|
|
46
|
+
* "0.6.0" → { parts: [0, 6, 0], rc: Infinity } (stable > any rc)
|
|
47
|
+
* "0.1.0-rc.5" → { parts: [0, 1, 0], rc: 5 }
|
|
48
|
+
*/
|
|
49
|
+
export function parseVersion(v) {
|
|
50
|
+
if (!v) return { parts: [], rc: Infinity };
|
|
51
|
+
const rcMatch = v.match(/^(.+?)[-.]?rc\.?(\d+)$/);
|
|
52
|
+
const base = rcMatch ? rcMatch[1] : v;
|
|
53
|
+
const rc = rcMatch ? parseInt(rcMatch[2], 10) : Infinity;
|
|
54
|
+
const parts = base.split('.').map((s) => parseInt(s, 10) || 0);
|
|
55
|
+
return { parts, rc };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns true if `project` version is strictly behind `active`.
|
|
60
|
+
* RC-aware: "0.6.0rc3" is behind "0.6.0". Returns false if either arg is falsy.
|
|
61
|
+
*/
|
|
62
|
+
export function isVersionBehind(project, active) {
|
|
63
|
+
if (!project || !active) return false;
|
|
64
|
+
const p = parseVersion(project);
|
|
65
|
+
const a = parseVersion(active);
|
|
66
|
+
const len = Math.max(p.parts.length, a.parts.length);
|
|
67
|
+
for (let i = 0; i < len; i++) {
|
|
68
|
+
const pv = p.parts[i] || 0;
|
|
69
|
+
const av = a.parts[i] || 0;
|
|
70
|
+
if (pv < av) return true;
|
|
71
|
+
if (pv > av) return false;
|
|
72
|
+
}
|
|
73
|
+
// Same base version — compare RC numbers
|
|
74
|
+
if (p.rc < a.rc) return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
/**
|
|
44
79
|
* Run `worca --version` and check compatibility.
|
|
45
80
|
* @returns {Promise<{ok: boolean, installed: string|null, minimum: string, message: string}>}
|
package/server/watcher.js
CHANGED
|
@@ -2,6 +2,25 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, readdirSync, readFileSync, watch } from 'node:fs';
|
|
3
3
|
import { readdir, readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
assignEventsToIterations,
|
|
7
|
+
readDispatchEventsFromJsonl,
|
|
8
|
+
} from './dispatch-events-aggregator.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Enrich a status object with dispatch events read from events.jsonl in the
|
|
12
|
+
* same run directory. Mutates `status.stages` by adding `dispatch_events` to
|
|
13
|
+
* matching iterations. No-op when events.jsonl is missing (e.g. a run that
|
|
14
|
+
* started before the emit was wired, or a run with no dispatches).
|
|
15
|
+
*/
|
|
16
|
+
function enrichWithDispatchEvents(status, runDir) {
|
|
17
|
+
if (!status?.stages) return status;
|
|
18
|
+
const eventsPath = join(runDir, 'events.jsonl');
|
|
19
|
+
const events = readDispatchEventsFromJsonl(eventsPath);
|
|
20
|
+
if (events.length === 0) return status;
|
|
21
|
+
status.stages = assignEventsToIterations(events, status.stages);
|
|
22
|
+
return status;
|
|
23
|
+
}
|
|
5
24
|
|
|
6
25
|
export function createRunId(status) {
|
|
7
26
|
// Prefer run_id from status (new per-run format)
|
|
@@ -35,9 +54,11 @@ export function discoverRuns(worcaDir) {
|
|
|
35
54
|
if (existsSync(activeRunPath)) {
|
|
36
55
|
try {
|
|
37
56
|
const activeId = readFileSync(activeRunPath, 'utf8').trim();
|
|
38
|
-
const
|
|
57
|
+
const runDir = join(worcaDir, 'runs', activeId);
|
|
58
|
+
const candidate = join(runDir, 'status.json');
|
|
39
59
|
if (existsSync(candidate)) {
|
|
40
|
-
|
|
60
|
+
let status = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
61
|
+
status = enrichWithDispatchEvents(status, runDir);
|
|
41
62
|
const active =
|
|
42
63
|
!isTerminal(status) && status.pipeline_status === 'running';
|
|
43
64
|
const id = createRunId(status);
|
|
@@ -53,10 +74,12 @@ export function discoverRuns(worcaDir) {
|
|
|
53
74
|
const runsDir = join(worcaDir, 'runs');
|
|
54
75
|
if (existsSync(runsDir)) {
|
|
55
76
|
for (const entry of readdirSync(runsDir)) {
|
|
56
|
-
const
|
|
77
|
+
const runDir = join(runsDir, entry);
|
|
78
|
+
const statusPath = join(runDir, 'status.json');
|
|
57
79
|
if (!existsSync(statusPath)) continue;
|
|
58
80
|
try {
|
|
59
|
-
|
|
81
|
+
let status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
82
|
+
status = enrichWithDispatchEvents(status, runDir);
|
|
60
83
|
const id = createRunId(status);
|
|
61
84
|
if (seenIds.has(id)) continue;
|
|
62
85
|
seenIds.add(id);
|
|
@@ -140,8 +163,10 @@ export async function discoverRunsAsync(worcaDir) {
|
|
|
140
163
|
const activeRunPath = join(worcaDir, 'active_run');
|
|
141
164
|
try {
|
|
142
165
|
const activeId = (await readFile(activeRunPath, 'utf8')).trim();
|
|
143
|
-
const
|
|
144
|
-
const
|
|
166
|
+
const runDir = join(worcaDir, 'runs', activeId);
|
|
167
|
+
const candidate = join(runDir, 'status.json');
|
|
168
|
+
let status = JSON.parse(await readFile(candidate, 'utf8'));
|
|
169
|
+
status = enrichWithDispatchEvents(status, runDir);
|
|
145
170
|
const active = !isTerminal(status) && status.pipeline_status === 'running';
|
|
146
171
|
const id = createRunId(status);
|
|
147
172
|
runs.push({ id, active, ...status });
|
|
@@ -156,15 +181,17 @@ export async function discoverRunsAsync(worcaDir) {
|
|
|
156
181
|
const entries = await readdir(runsDir);
|
|
157
182
|
const readPromises = entries.map(async (entry) => {
|
|
158
183
|
try {
|
|
159
|
-
const
|
|
184
|
+
const runDir = join(runsDir, entry);
|
|
185
|
+
const statusPath = join(runDir, 'status.json');
|
|
160
186
|
const status = JSON.parse(await readFile(statusPath, 'utf8'));
|
|
161
|
-
return status;
|
|
187
|
+
return { status, runDir };
|
|
162
188
|
} catch {
|
|
163
189
|
return null;
|
|
164
190
|
}
|
|
165
191
|
});
|
|
166
|
-
for (const
|
|
167
|
-
if (!
|
|
192
|
+
for (const result of await Promise.all(readPromises)) {
|
|
193
|
+
if (!result) continue;
|
|
194
|
+
const status = enrichWithDispatchEvents(result.status, result.runDir);
|
|
168
195
|
const id = createRunId(status);
|
|
169
196
|
if (seenIds.has(id)) continue;
|
|
170
197
|
seenIds.add(id);
|
package/server/worca-setup.js
CHANGED
|
@@ -20,10 +20,24 @@ export function checkWorcaInstalled(projectPath) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Read the worca-cc version from a project's
|
|
23
|
+
* Read the worca-cc version from a project's worca installation.
|
|
24
|
+
* Tries .claude/worca/version.json first, then falls back to __init__.py.
|
|
24
25
|
* Returns the version string or null if not found.
|
|
25
26
|
*/
|
|
26
27
|
export function readProjectWorcaVersion(projectPath) {
|
|
28
|
+
// Try version.json first (preferred format)
|
|
29
|
+
try {
|
|
30
|
+
const versionJson = JSON.parse(
|
|
31
|
+
readFileSync(
|
|
32
|
+
join(projectPath, '.claude', 'worca', 'version.json'),
|
|
33
|
+
'utf8',
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
if (versionJson.version) return versionJson.version;
|
|
37
|
+
} catch {
|
|
38
|
+
// fall through to __init__.py
|
|
39
|
+
}
|
|
40
|
+
// Fall back to __init__.py
|
|
27
41
|
try {
|
|
28
42
|
const initPy = readFileSync(
|
|
29
43
|
join(projectPath, '.claude', 'worca', '__init__.py'),
|
package/server/ws-modular.js
CHANGED
|
@@ -11,6 +11,7 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { WebSocketServer } from 'ws';
|
|
12
12
|
import { readProjects, synthesizeDefaultProject } from './project-registry.js';
|
|
13
13
|
import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
|
|
14
|
+
import { readProjectWorcaVersion } from './worca-setup.js';
|
|
14
15
|
import { createBroadcaster } from './ws-broadcaster.js';
|
|
15
16
|
import { createClientManager } from './ws-client-manager.js';
|
|
16
17
|
import { createMessageRouter } from './ws-message-router.js';
|
|
@@ -157,9 +158,12 @@ export function attachWsServer(httpServer, config) {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
// Broadcast projects-updated to all clients
|
|
161
|
+
// Shape must match GET /api/projects so frontend state stays consistent
|
|
162
|
+
// (include worcaVersion — without it, clients would show "unknown" after
|
|
163
|
+
// the WS event clobbers the enriched REST response on add/remove)
|
|
160
164
|
const projectList = freshProjects.map((p) => ({
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
...p,
|
|
166
|
+
worcaVersion: readProjectWorcaVersion(p.path),
|
|
163
167
|
}));
|
|
164
168
|
broadcaster.broadcast('projects-updated', { projects: projectList });
|
|
165
169
|
}
|