@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/app/main.bundle.js +2341 -1205
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +4 -1
- package/app/styles.css +446 -8
- package/app/utils/state-actions.js +21 -3
- package/app/utils/status-constants.js +11 -0
- package/bin/worca-ui.js +2 -2
- package/package.json +2 -1
- package/scripts/build-frontend.js +48 -1
- package/server/app.js +92 -1
- package/server/fleet-routes.js +5 -3
- package/server/index.js +4 -3
- package/server/integrations/commands/fleet.js +1 -1
- package/server/integrations/commands/global.js +9 -0
- package/server/integrations/commands/workspace.js +295 -0
- package/server/integrations/index.js +6 -0
- package/server/integrations/renderers.js +291 -3
- package/server/paths.js +78 -0
- package/server/project-routes.js +68 -5
- package/server/workspace-routes.js +1554 -0
- package/server/worktree-ops.js +12 -1
- package/server/ws-fleet-manifest-watcher.js +4 -3
- package/server/ws-modular.js +10 -2
- package/server/ws-workspace-manifest-watcher.js +136 -0
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:
|
|
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.
|
package/server/fleet-routes.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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 =
|
|
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:
|
|
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
|
|
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
|