@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.
@@ -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
- router.get('/worca-status', (req, res) => {
1218
- const installed = checkWorcaInstalled(req.project.projectRoot);
1219
- res.json({ ok: true, installed });
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 (!VALID_AGENTS.includes(v)) {
327
- details.push(`Unknown agent "${v}" in dispatch for "${key}"`);
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
+ }
@@ -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 candidate = join(worcaDir, 'runs', activeId, 'status.json');
57
+ const runDir = join(worcaDir, 'runs', activeId);
58
+ const candidate = join(runDir, 'status.json');
39
59
  if (existsSync(candidate)) {
40
- const status = JSON.parse(readFileSync(candidate, 'utf8'));
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 statusPath = join(runsDir, entry, 'status.json');
77
+ const runDir = join(runsDir, entry);
78
+ const statusPath = join(runDir, 'status.json');
57
79
  if (!existsSync(statusPath)) continue;
58
80
  try {
59
- const status = JSON.parse(readFileSync(statusPath, 'utf8'));
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 candidate = join(worcaDir, 'runs', activeId, 'status.json');
144
- const status = JSON.parse(await readFile(candidate, 'utf8'));
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 statusPath = join(runsDir, entry, 'status.json');
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 status of await Promise.all(readPromises)) {
167
- if (!status) continue;
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);
@@ -20,10 +20,24 @@ export function checkWorcaInstalled(projectPath) {
20
20
  }
21
21
 
22
22
  /**
23
- * Read the worca-cc version from a project's .claude/worca/__init__.py.
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'),
@@ -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
- name: p.name,
162
- path: p.path,
165
+ ...p,
166
+ worcaVersion: readProjectWorcaVersion(p.path),
163
167
  }));
164
168
  broadcaster.broadcast('projects-updated', { projects: projectList });
165
169
  }