@worca/ui 0.23.0 → 0.25.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/server/app.js CHANGED
@@ -13,6 +13,7 @@ import { createFleetRouter } from './fleet-routes.js';
13
13
  import { RAW_BODY } from './integrations/index.js';
14
14
  import { verify } from './integrations/verify.js';
15
15
  import { LaunchLock } from './launch-lock.js';
16
+ import { fleetRunsDir, workspaceRunsDir, workspacesDir } from './paths.js';
16
17
  import { createPreferencesRouter } from './preferences-routes.js';
17
18
  import { ProcessManager } from './process-manager.js';
18
19
  import { scanDirectory } from './project-registry.js';
@@ -27,6 +28,30 @@ import { discoverSubagents } from './subagents-discovery.js';
27
28
  import { checkWorcaVersion } from './version-check.js';
28
29
  import { getVersionInfo } from './versions.js';
29
30
  import { createInbox } from './webhook-inbox.js';
31
+ import { createWorkspaceRouter } from './workspace-routes.js';
32
+
33
+ // Invokes `worca cleanup --<flag> <id>` as a subprocess and resolves once
34
+ // the cleanup completes. Wired into the fleet/workspace router DELETE
35
+ // ?cleanup=1 path so the UI Cleanup button actually removes the worktrees
36
+ // + manifest dir (without this, the route falls back to a no-op default).
37
+ function runWorcaCleanupSubprocess(flag, id) {
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn(
40
+ 'python3',
41
+ ['-m', 'worca.cli.main', 'cleanup', flag, id],
42
+ { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } },
43
+ );
44
+ let stderr = '';
45
+ child.stderr.on('data', (chunk) => {
46
+ stderr += chunk.toString();
47
+ });
48
+ child.on('error', reject);
49
+ child.on('exit', (code) => {
50
+ if (code === 0) resolve({});
51
+ else reject(new Error(`worca cleanup exited ${code}: ${stderr.trim()}`));
52
+ });
53
+ });
54
+ }
30
55
 
31
56
  export function createApp(options = {}) {
32
57
  const app = express();
@@ -554,8 +579,9 @@ export function createApp(options = {}) {
554
579
  app.use(
555
580
  '/api/fleet-runs',
556
581
  createFleetRouter({
557
- fleetRunsDir: join(homedir(), '.worca', 'fleet-runs'),
582
+ fleetRunsDir: fleetRunsDir(),
558
583
  prefsDir,
584
+ runCleanup: (id) => runWorcaCleanupSubprocess('--fleet-id', id),
559
585
  // Spawn run_fleet.py in a detached subprocess so the route can return
560
586
  // immediately. We pass the pre-generated fleet_id so the in-flight
561
587
  // manifest path matches what the route just wrote.
@@ -617,6 +643,71 @@ export function createApp(options = {}) {
617
643
  },
618
644
  }),
619
645
  );
646
+
647
+ // Workspace routers — both definitions (/api/workspaces) and runs
648
+ // (/api/workspace-runs). The router factory exposes them as a pair.
649
+ const workspaceRouters = createWorkspaceRouter({
650
+ workspaceRunsDir: workspaceRunsDir(),
651
+ workspacesDir: workspacesDir(),
652
+ runCleanup: (id) => runWorcaCleanupSubprocess('--workspace-id', id),
653
+ // Spawn run_workspace.py in a detached subprocess, mirroring the fleet
654
+ // dispatcher. We pass --workspace-id so the script reuses the manifest
655
+ // the route just wrote instead of generating a fresh ID (which would
656
+ // orphan the manifest the UI navigated to).
657
+ dispatchWorkspace: async ({
658
+ workspace_id,
659
+ workspace_root,
660
+ manifest,
661
+ resume,
662
+ }) => {
663
+ if (resume) {
664
+ const child = spawn(
665
+ 'python3',
666
+ [
667
+ '-m',
668
+ 'worca.scripts.run_workspace',
669
+ workspace_root,
670
+ '--resume',
671
+ workspace_id,
672
+ ],
673
+ { detached: true, stdio: 'ignore', env: { ...process.env } },
674
+ );
675
+ child.unref();
676
+ return;
677
+ }
678
+ const args = [
679
+ '-m',
680
+ 'worca.scripts.run_workspace',
681
+ workspace_root,
682
+ '--workspace-id',
683
+ workspace_id,
684
+ ];
685
+ if (manifest.work_request?.source) {
686
+ args.push('--source', manifest.work_request.source);
687
+ } else {
688
+ args.push('--prompt', manifest.work_request?.description ?? '');
689
+ }
690
+ if (manifest.branch_template) {
691
+ args.push('--branch', manifest.branch_template);
692
+ }
693
+ if (manifest.skip_integration) args.push('--skip-integration');
694
+ if (manifest.skip_planning) args.push('--skip-planning');
695
+ if (manifest.max_parallel) {
696
+ args.push('--max-parallel', String(manifest.max_parallel));
697
+ }
698
+ for (const p of manifest.guide?.paths || []) {
699
+ args.push('--guide', p);
700
+ }
701
+ const child = spawn('python3', args, {
702
+ detached: true,
703
+ stdio: 'ignore',
704
+ env: { ...process.env },
705
+ });
706
+ child.unref();
707
+ },
708
+ });
709
+ app.use('/api/workspaces', workspaceRouters.workspaces);
710
+ app.use('/api/workspace-runs', workspaceRouters.workspaceRuns);
620
711
  }
621
712
 
622
713
  // POST /api/integrations/telegram/detect — find chat IDs from recent messages.
@@ -15,11 +15,10 @@ import {
15
15
  unlinkSync,
16
16
  writeFileSync,
17
17
  } from 'node:fs';
18
- import { homedir } from 'node:os';
19
18
  import { basename, join } from 'node:path';
20
19
  import { Router } from 'express';
20
+ import { fleetRunsDir as resolveFleetRunsDir } from './paths.js';
21
21
 
22
- const DEFAULT_FLEET_RUNS_DIR = join(homedir(), '.worca', 'fleet-runs');
23
22
  const GUIDE_CAP_BYTES_DEFAULT = 64 * 1024; // 64 KB
24
23
 
25
24
  // Fleet IDs have the form f_<12 digits>_<hex> — enforces no path traversal.
@@ -513,7 +512,7 @@ function defaultStopFleet(fleetId) {
513
512
  * entries that reference the fleet_id and includes them in the response.
514
513
  */
515
514
  export function createFleetRouter({
516
- fleetRunsDir = DEFAULT_FLEET_RUNS_DIR,
515
+ fleetRunsDir: fleetRunsDirArg,
517
516
  prefsDir = null,
518
517
  dispatchFleet = null,
519
518
  runCleanup = defaultRunCleanup,
@@ -522,6 +521,9 @@ export function createFleetRouter({
522
521
  validateBaseBranch = defaultValidateBaseBranch,
523
522
  guideCapBytes = GUIDE_CAP_BYTES_DEFAULT,
524
523
  } = {}) {
524
+ // Lazy resolution honors $WORCA_HOME at router-construction time, falling
525
+ // back to ~/.worca/fleet-runs. Issue #162.
526
+ const fleetRunsDir = resolveFleetRunsDir(fleetRunsDirArg);
525
527
  const router = Router();
526
528
 
527
529
  // ── GET /api/fleet-runs ─────────────────────────────────────────────────
package/server/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // server/index.js
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { createServer } from 'node:http';
4
- import { homedir, platform } from 'node:os';
4
+ import { platform } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { createApp } from './app.js';
7
7
  import { parseServerArgv } from './argv-parser.js';
8
8
  import { createIntegrations } from './integrations/index.js';
9
+ import { preferencesPath, prefsDir as resolvePrefsDir } from './paths.js';
9
10
  import { attachWsServer } from './ws.js';
10
11
 
11
12
  // Parse argv (env vars provide defaults; argv flags take precedence)
@@ -49,7 +50,7 @@ if (isGlobal) {
49
50
  settingsPath = join(projectRoot, '.claude', 'settings.json');
50
51
  }
51
52
 
52
- const prefsDir = join(homedir(), '.worca');
53
+ const prefsDir = resolvePrefsDir();
53
54
  const webhookInbox = createInbox();
54
55
  const app = createApp({
55
56
  settingsPath,
@@ -94,7 +95,7 @@ const { broadcast, scheduleRefresh, resolveRunProject } = attachWsServer(
94
95
  {
95
96
  worcaDir,
96
97
  settingsPath,
97
- prefsPath: join(homedir(), '.worca', 'preferences.json'),
98
+ prefsPath: preferencesPath(),
98
99
  prefsDir,
99
100
  webhookInbox,
100
101
  projectRoot,
@@ -161,7 +161,7 @@ export function createFleetHandlers({ chatContext, restClient }) {
161
161
  const resolved = await resolveFleet(restClient, args[0], 'fleet-children');
162
162
  if (resolved.disambig) return resolved.disambig;
163
163
  const f = resolved.fleet;
164
- return `Children of \`${f.fleet_id}\`:\n\n` + fmtChildrenTable(f);
164
+ return `Children of \`${f.fleet_id}\`:\n\n${fmtChildrenTable(f)}`;
165
165
  }
166
166
 
167
167
  async function fleetHalt(_chatKey, args) {
@@ -124,6 +124,15 @@ Fleet commands (cross-project):
124
124
  /fleet-pause <id> \u2014 pause every in-flight child
125
125
  /fleet-resume <id> \u2014 resume paused/interrupted, re-dispatch failed
126
126
 
127
+ Workspace commands (DAG-ordered cross-project):
128
+ /workspaces \u2014 list active workspaces
129
+ /workspace [id|last] \u2014 workspace status
130
+ /workspace-projects <id|last> \u2014 per-project status
131
+ /workspace-tiers <id|last> \u2014 tier-by-tier DAG status
132
+ /workspace-halt <id> \u2014 graceful halt (in-flight finish naturally)
133
+ /workspace-resume <id> \u2014 re-dispatch failed/halted projects
134
+ /workspace-prs <id> \u2014 list child PRs + umbrella issue
135
+
127
136
  Commands with [run_id] auto-resolve to the active run if omitted.
128
137
  Use \`*suffix\` to match by ending, e.g. /status \`*2db5\`
129
138
  Project commands require /use first.`;
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Workspace-scoped chat commands.
3
+ *
4
+ * /workspaces list active workspaces
5
+ * /workspace [id|last] show one workspace's summary
6
+ * /workspace-projects <id|last> per-project status grid (DAG)
7
+ * /workspace-tiers <id|last> tier-by-tier DAG status
8
+ * /workspace-halt <id> graceful halt (in-flight finish naturally)
9
+ * /workspace-resume <id> re-dispatch failed/halted children
10
+ * /workspace-prs <id> list child PRs + umbrella issue
11
+ *
12
+ * Mirrors the fleet command surface, but uses workspace terminology — the
13
+ * unit of work is a named project entry in workspace.json, not an arbitrary
14
+ * project path. /workspace-pause and /workspace-stop are intentionally
15
+ * absent: workspaces don't yet have pause/stop infrastructure (only halt
16
+ * via DELETE). When the lifecycle gains those, add commands here.
17
+ *
18
+ * Authz: every handler runs *after* the inbound allowlist gate in
19
+ * index.js — same gate that already guards /pause, /resume, /stop.
20
+ *
21
+ * @module commands/workspace
22
+ */
23
+
24
+ import { statusEmoji } from './global.js';
25
+
26
+ function pickLatest(workspaces) {
27
+ if (!workspaces || workspaces.length === 0) return null;
28
+ return [...workspaces].sort((a, b) => {
29
+ const at = a.created_at || '';
30
+ const bt = b.created_at || '';
31
+ return bt.localeCompare(at);
32
+ })[0];
33
+ }
34
+
35
+ async function fetchWorkspaces(restClient) {
36
+ const resp = await restClient.get('/api/workspace-runs');
37
+ const data = resp.data;
38
+ if (!data || data.ok === false) return [];
39
+ return Array.isArray(data.workspace_runs) ? data.workspace_runs : [];
40
+ }
41
+
42
+ async function fetchWorkspaceById(restClient, id) {
43
+ const resp = await restClient.get(
44
+ `/api/workspace-runs/${encodeURIComponent(id)}`,
45
+ );
46
+ const data = resp.data;
47
+ if (!data || data.ok === false) return null;
48
+ // GET /:id returns { ok, manifest, cost_usd } — flatten to a single object
49
+ // with the shape /workspace-runs list emits, so downstream formatters can
50
+ // be uniform.
51
+ if (data.manifest) {
52
+ return { ...data.manifest, cost_usd: data.cost_usd };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Resolve "last" / short-suffix / full-id into a workspace manifest object.
59
+ * Returns { workspace, disambig } — exactly one of them populated.
60
+ */
61
+ async function resolveWorkspace(restClient, idArg, command) {
62
+ if (!idArg || idArg === 'last') {
63
+ const all = await fetchWorkspaces(restClient);
64
+ const latest = pickLatest(all);
65
+ if (!latest) return { disambig: 'No workspaces found.' };
66
+ return { workspace: latest };
67
+ }
68
+ if (idArg.startsWith('ws_')) {
69
+ const workspace = await fetchWorkspaceById(restClient, idArg);
70
+ if (!workspace) return { disambig: `Workspace \`${idArg}\` not found.` };
71
+ return { workspace };
72
+ }
73
+ // Short suffix match — `4318dbf9` matches `ws_..._4318dbf9`.
74
+ const all = await fetchWorkspaces(restClient);
75
+ const matches = all.filter((w) => w.workspace_id?.endsWith(idArg));
76
+ if (matches.length === 0) {
77
+ return { disambig: `No workspace matches \`${idArg}\`.` };
78
+ }
79
+ if (matches.length > 1) {
80
+ const lines = matches.map(
81
+ (w) => ` • \`${w.workspace_id}\` — ${w.status}`,
82
+ );
83
+ return {
84
+ disambig:
85
+ `Multiple workspaces match \`${idArg}\`:\n${lines.join('\n')}\n\n` +
86
+ `Usage: /${command} <workspace_id>`,
87
+ };
88
+ }
89
+ return { workspace: matches[0] };
90
+ }
91
+
92
+ function fmtWorkspaceSummary(ws) {
93
+ const id = ws.workspace_id;
94
+ const name = ws.workspace_name ? ` (${ws.workspace_name})` : '';
95
+ const status = ws.status || 'unknown';
96
+ const reason = ws.halt_reason ? ` (${ws.halt_reason})` : '';
97
+ const children = ws.children || [];
98
+ const childCount = ws.children_count ?? children.length;
99
+ const completed = children.filter((c) => c.status === 'completed').length;
100
+ const failed = children.filter(
101
+ (c) =>
102
+ c.status === 'failed' ||
103
+ c.status === 'blocked' ||
104
+ c.status === 'setup_failed',
105
+ ).length;
106
+ const parts = [`${statusEmoji(status)} **Workspace:** \`${id}\`${name}`];
107
+ parts.push(` **Status:** ${status}${reason}`);
108
+ parts.push(
109
+ ` **Projects:** ${completed}/${childCount} completed${failed ? `, ${failed} failed` : ''}`,
110
+ );
111
+ const tiers = ws.dag?.tiers || [];
112
+ if (tiers.length > 0) {
113
+ parts.push(` **Tiers:** ${tiers.length}`);
114
+ }
115
+ if (ws.cost_usd != null) {
116
+ parts.push(` **Cost:** $${Number(ws.cost_usd).toFixed(2)}`);
117
+ }
118
+ return parts.join('\n');
119
+ }
120
+
121
+ function fmtProjectsTable(ws) {
122
+ const children = ws.children || [];
123
+ if (children.length === 0) return ' (no projects dispatched yet)';
124
+ return children
125
+ .map((c) => {
126
+ const project = c.project || '(?)';
127
+ const st = c.status || 'unknown';
128
+ const rid = c.run_id ? ` \`${c.run_id}\`` : '';
129
+ const tier = c.tier != null ? ` [tier ${c.tier}]` : '';
130
+ return ` ${statusEmoji(st)} **${project}**${tier} — ${st}${rid}`;
131
+ })
132
+ .join('\n');
133
+ }
134
+
135
+ function fmtTiersTable(ws) {
136
+ const tiers = ws.dag?.tiers || [];
137
+ if (tiers.length === 0) return ' (no tiers defined)';
138
+ const childByProject = new Map();
139
+ for (const c of ws.children || []) {
140
+ childByProject.set(c.project, c);
141
+ }
142
+ return tiers
143
+ .map((t) => {
144
+ const projects = t.projects || [];
145
+ const lines = projects.map((p) => {
146
+ const child = childByProject.get(p);
147
+ const st = child?.status || 'pending';
148
+ return ` ${statusEmoji(st)} ${p} — ${st}`;
149
+ });
150
+ const tierStatus = t.status || 'pending';
151
+ return [` **Tier ${t.tier} (${tierStatus}):**`, ...lines].join('\n');
152
+ })
153
+ .join('\n');
154
+ }
155
+
156
+ function fmtPrsTable(ws) {
157
+ const children = ws.children || [];
158
+ const withPrs = children.filter((c) => c.pr_number || c.pr_url);
159
+ if (withPrs.length === 0 && !ws.umbrella_issue) {
160
+ return ' (no PRs created yet)';
161
+ }
162
+ const lines = [];
163
+ for (const c of withPrs) {
164
+ const ref = c.nwo && c.pr_number ? `${c.nwo}#${c.pr_number}` : c.pr_url;
165
+ lines.push(` • **${c.project}** — ${ref}`);
166
+ }
167
+ if (ws.umbrella_issue?.url) {
168
+ lines.push('');
169
+ lines.push(` \u{1F517} **Umbrella issue:** ${ws.umbrella_issue.url}`);
170
+ }
171
+ return lines.join('\n');
172
+ }
173
+
174
+ /**
175
+ * Creates handlers for the /workspace… commands.
176
+ *
177
+ * The chatContext is used to remember an `active_workspace` per chat,
178
+ * symmetrical to the existing `active_project` mechanism — so a user can
179
+ * `/workspace ws_…_abc` once, then `/workspace-halt` (no arg) on the same
180
+ * one. Implementation left for a follow-up if/when the UX warrants it.
181
+ *
182
+ * @param {{ chatContext, restClient }} deps
183
+ */
184
+ export function createWorkspaceHandlers({
185
+ chatContext: _chatContext,
186
+ restClient,
187
+ }) {
188
+ async function workspaces() {
189
+ const all = await fetchWorkspaces(restClient);
190
+ const active = all.filter((w) => {
191
+ const s = w.status;
192
+ return (
193
+ s === 'running' ||
194
+ s === 'planning' ||
195
+ s === 'integration_testing' ||
196
+ s === 'resuming' ||
197
+ s === 'paused'
198
+ );
199
+ });
200
+ if (active.length === 0) return 'No active workspaces.';
201
+ const lines = active.map((w) => {
202
+ const childCount = w.children_count ?? w.children?.length ?? 0;
203
+ const name = w.workspace_name ? ` (${w.workspace_name})` : '';
204
+ const reason = w.halt_reason ? ` (${w.halt_reason})` : '';
205
+ return [
206
+ `${statusEmoji(w.status)} **Workspace:** \`${w.workspace_id}\`${name}`,
207
+ ` **Status:** ${w.status}${reason} | **Projects:** ${childCount}`,
208
+ ].join('\n');
209
+ });
210
+ return `Active workspaces:\n\n${lines.join('\n')}`;
211
+ }
212
+
213
+ async function workspace(_chatKey, args) {
214
+ const resolved = await resolveWorkspace(restClient, args[0], 'workspace');
215
+ if (resolved.disambig) return resolved.disambig;
216
+ return fmtWorkspaceSummary(resolved.workspace);
217
+ }
218
+
219
+ async function workspaceProjects(_chatKey, args) {
220
+ const resolved = await resolveWorkspace(
221
+ restClient,
222
+ args[0],
223
+ 'workspace-projects',
224
+ );
225
+ if (resolved.disambig) return resolved.disambig;
226
+ const w = resolved.workspace;
227
+ return `Projects in \`${w.workspace_id}\`:\n\n${fmtProjectsTable(w)}`;
228
+ }
229
+
230
+ async function workspaceTiers(_chatKey, args) {
231
+ const resolved = await resolveWorkspace(
232
+ restClient,
233
+ args[0],
234
+ 'workspace-tiers',
235
+ );
236
+ if (resolved.disambig) return resolved.disambig;
237
+ const w = resolved.workspace;
238
+ return `DAG tiers for \`${w.workspace_id}\`:\n\n${fmtTiersTable(w)}`;
239
+ }
240
+
241
+ async function workspaceHalt(_chatKey, args) {
242
+ const resolved = await resolveWorkspace(
243
+ restClient,
244
+ args[0],
245
+ 'workspace-halt',
246
+ );
247
+ if (resolved.disambig) return resolved.disambig;
248
+ const id = resolved.workspace.workspace_id;
249
+ const resp = await restClient.delete(
250
+ `/api/workspace-runs/${encodeURIComponent(id)}`,
251
+ );
252
+ if (!resp.data || resp.data.ok === false) {
253
+ return `Failed to halt workspace \`${id}\` (${resp.status}).`;
254
+ }
255
+ return `\u{1F7E1} Halted workspace \`${id}\`.\nIn-flight tier children will finish naturally.`;
256
+ }
257
+
258
+ async function workspaceResume(_chatKey, args) {
259
+ const resolved = await resolveWorkspace(
260
+ restClient,
261
+ args[0],
262
+ 'workspace-resume',
263
+ );
264
+ if (resolved.disambig) return resolved.disambig;
265
+ const id = resolved.workspace.workspace_id;
266
+ const resp = await restClient.post(
267
+ `/api/workspace-runs/${encodeURIComponent(id)}/resume`,
268
+ );
269
+ if (!resp.data || resp.data.ok === false) {
270
+ return `Failed to resume workspace \`${id}\` (${resp.status}).`;
271
+ }
272
+ return `\u{1F7E2} Resumed workspace \`${id}\`. Re-dispatching pending project(s).`;
273
+ }
274
+
275
+ async function workspacePrs(_chatKey, args) {
276
+ const resolved = await resolveWorkspace(
277
+ restClient,
278
+ args[0],
279
+ 'workspace-prs',
280
+ );
281
+ if (resolved.disambig) return resolved.disambig;
282
+ const w = resolved.workspace;
283
+ return `PRs for \`${w.workspace_id}\`:\n\n${fmtPrsTable(w)}`;
284
+ }
285
+
286
+ return {
287
+ workspaces,
288
+ workspace,
289
+ 'workspace-projects': workspaceProjects,
290
+ 'workspace-tiers': workspaceTiers,
291
+ 'workspace-halt': workspaceHalt,
292
+ 'workspace-resume': workspaceResume,
293
+ 'workspace-prs': workspacePrs,
294
+ };
295
+ }
@@ -16,6 +16,7 @@ import { createFleetHandlers } from './commands/fleet.js';
16
16
  import { createGlobalHandlers } from './commands/global.js';
17
17
  import { parseCommand } from './commands/parser.js';
18
18
  import { createProjectHandlers } from './commands/project.js';
19
+ import { createWorkspaceHandlers } from './commands/workspace.js';
19
20
  import { loadIntegrationsConfig } from './config-loader.js';
20
21
  import { createRateLimiter } from './rate_limiter.js';
21
22
  import { renderEvent } from './renderers.js';
@@ -67,11 +68,16 @@ export function createIntegrations({
67
68
  const projectHandlers = createProjectHandlers({ chatContext, restClient });
68
69
  const controlHandlers = createControlHandlers({ chatContext, restClient });
69
70
  const fleetHandlers = createFleetHandlers({ chatContext, restClient });
71
+ const workspaceHandlers = createWorkspaceHandlers({
72
+ chatContext,
73
+ restClient,
74
+ });
70
75
  const allHandlers = {
71
76
  ...globalHandlers,
72
77
  ...projectHandlers,
73
78
  ...controlHandlers,
74
79
  ...fleetHandlers,
80
+ ...workspaceHandlers,
75
81
  };
76
82
 
77
83
  // Mutable adapter registry — keyed by adapter name