@worca/ui 0.22.0 → 0.24.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 +7298 -4688
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +5 -1
- package/app/styles.css +1315 -23
- package/app/utils/state-actions.js +33 -4
- 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 +159 -1
- package/server/fleet-routes.js +1149 -0
- package/server/index.js +4 -3
- package/server/integrations/commands/fleet.js +266 -0
- package/server/integrations/commands/global.js +18 -0
- package/server/integrations/commands/parser.js +4 -1
- package/server/integrations/commands/workspace.js +295 -0
- package/server/integrations/index.js +9 -0
- package/server/integrations/renderers.js +386 -0
- package/server/integrations/rest_client.js +7 -0
- 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/worktrees-routes.js +34 -0
- package/server/ws-fleet-manifest-watcher.js +131 -0
- package/server/ws-message-router.js +20 -0
- package/server/ws-modular.js +18 -1
- package/server/ws-workspace-manifest-watcher.js +136 -0
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,
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet-scoped chat commands.
|
|
3
|
+
*
|
|
4
|
+
* /fleets list active fleets (running + paused)
|
|
5
|
+
* /fleet [id|last] show one fleet's status
|
|
6
|
+
* /fleet-children <id|last> per-child status table
|
|
7
|
+
* /fleet-halt <id> graceful halt (in-flight finish naturally)
|
|
8
|
+
* /fleet-stop <id> [--force] hard stop (SIGTERM + control file), with confirmation
|
|
9
|
+
* /fleet-pause <id> pause every in-flight child
|
|
10
|
+
* /fleet-resume <id> resume paused/interrupted children, re-dispatch failed
|
|
11
|
+
*
|
|
12
|
+
* Authz: every handler runs *after* the inbound allowlist gate in
|
|
13
|
+
* index.js — same gate that already guards /pause, /resume, /stop.
|
|
14
|
+
*
|
|
15
|
+
* Destructive actions (/fleet-stop) require a confirmation token written
|
|
16
|
+
* into chat_context with a 60s expiry. The token mechanism is local to
|
|
17
|
+
* this module so we don't bloat chat_context.js's public API for what is
|
|
18
|
+
* really a per-command UX concern.
|
|
19
|
+
*
|
|
20
|
+
* @module commands/fleet
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { statusEmoji } from './global.js';
|
|
24
|
+
|
|
25
|
+
const CONFIRM_TTL_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
/** Pick the most recent fleet from a list (created_at desc). */
|
|
28
|
+
function pickLatest(fleets) {
|
|
29
|
+
if (!fleets || fleets.length === 0) return null;
|
|
30
|
+
return [...fleets].sort((a, b) => {
|
|
31
|
+
const at = a.created_at || '';
|
|
32
|
+
const bt = b.created_at || '';
|
|
33
|
+
return bt.localeCompare(at);
|
|
34
|
+
})[0];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchFleets(restClient) {
|
|
38
|
+
const resp = await restClient.get('/api/fleet-runs');
|
|
39
|
+
const data = resp.data;
|
|
40
|
+
if (!data || data.ok === false) return [];
|
|
41
|
+
return Array.isArray(data.fleets) ? data.fleets : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchFleetById(restClient, id) {
|
|
45
|
+
const resp = await restClient.get(
|
|
46
|
+
`/api/fleet-runs/${encodeURIComponent(id)}`,
|
|
47
|
+
);
|
|
48
|
+
const data = resp.data;
|
|
49
|
+
if (!data || data.ok === false) return null;
|
|
50
|
+
return data.fleet ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve "last" / short-suffix / full-id into a fleet manifest object.
|
|
55
|
+
* Returns { fleet, disambig } — exactly one of them populated.
|
|
56
|
+
*/
|
|
57
|
+
async function resolveFleet(restClient, idArg, command) {
|
|
58
|
+
if (!idArg || idArg === 'last') {
|
|
59
|
+
const all = await fetchFleets(restClient);
|
|
60
|
+
const latest = pickLatest(all);
|
|
61
|
+
if (!latest) return { disambig: 'No fleets found.' };
|
|
62
|
+
return { fleet: latest };
|
|
63
|
+
}
|
|
64
|
+
if (idArg.startsWith('f_')) {
|
|
65
|
+
const fleet = await fetchFleetById(restClient, idArg);
|
|
66
|
+
if (!fleet) return { disambig: `Fleet \`${idArg}\` not found.` };
|
|
67
|
+
return { fleet };
|
|
68
|
+
}
|
|
69
|
+
// Short suffix match — `4318dbf9` matches `f_..._4318dbf9`.
|
|
70
|
+
const all = await fetchFleets(restClient);
|
|
71
|
+
const matches = all.filter((f) => f.fleet_id?.endsWith(idArg));
|
|
72
|
+
if (matches.length === 0) {
|
|
73
|
+
return { disambig: `No fleet matches \`${idArg}\`.` };
|
|
74
|
+
}
|
|
75
|
+
if (matches.length > 1) {
|
|
76
|
+
const lines = matches.map((f) => ` • \`${f.fleet_id}\` — ${f.status}`);
|
|
77
|
+
return {
|
|
78
|
+
disambig:
|
|
79
|
+
`Multiple fleets match \`${idArg}\`:\n${lines.join('\n')}\n\n` +
|
|
80
|
+
`Usage: /${command} <fleet_id>`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { fleet: matches[0] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fmtFleetSummary(fleet) {
|
|
87
|
+
const id = fleet.fleet_id;
|
|
88
|
+
const title = fleet.work_request?.title || '(no title)';
|
|
89
|
+
const status = fleet.status || 'unknown';
|
|
90
|
+
const reason = fleet.halt_reason ? ` (${fleet.halt_reason})` : '';
|
|
91
|
+
const childCount = fleet.children_count ?? fleet.children?.length ?? 0;
|
|
92
|
+
const completed = (fleet.children || []).filter(
|
|
93
|
+
(c) => c.status === 'completed',
|
|
94
|
+
).length;
|
|
95
|
+
const failed = (fleet.children || []).filter(
|
|
96
|
+
(c) => c.status === 'failed' || c.status === 'setup_failed',
|
|
97
|
+
).length;
|
|
98
|
+
const parts = [`${statusEmoji(status)} **Fleet:** \`${id}\``];
|
|
99
|
+
parts.push(` **Title:** ${title}`);
|
|
100
|
+
parts.push(` **Status:** ${status}${reason}`);
|
|
101
|
+
parts.push(
|
|
102
|
+
` **Children:** ${completed}/${childCount} completed${failed ? `, ${failed} failed` : ''}`,
|
|
103
|
+
);
|
|
104
|
+
if (fleet.cost_usd != null) {
|
|
105
|
+
parts.push(` **Cost:** $${Number(fleet.cost_usd).toFixed(2)}`);
|
|
106
|
+
}
|
|
107
|
+
return parts.join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fmtChildrenTable(fleet) {
|
|
111
|
+
const children = fleet.children || [];
|
|
112
|
+
if (children.length === 0) return ' (no children dispatched yet)';
|
|
113
|
+
return children
|
|
114
|
+
.map((c) => {
|
|
115
|
+
const project =
|
|
116
|
+
(c.project_path || '').split('/').filter(Boolean).pop() || '(?)';
|
|
117
|
+
const st = c.status || 'unknown';
|
|
118
|
+
const rid = c.run_id ? ` \`${c.run_id}\`` : '';
|
|
119
|
+
return ` ${statusEmoji(st)} **${project}** — ${st}${rid}`;
|
|
120
|
+
})
|
|
121
|
+
.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Creates handlers for the /fleet… commands.
|
|
126
|
+
*
|
|
127
|
+
* The chatContext is used for /fleet-stop confirmation tokens — we store
|
|
128
|
+
* a `pending_fleet_stop = { fleet_id, expires_at }` shape on the chat key
|
|
129
|
+
* and clear it after the confirming message comes in.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ chatContext, restClient }} deps
|
|
132
|
+
*/
|
|
133
|
+
export function createFleetHandlers({ chatContext, restClient }) {
|
|
134
|
+
async function fleets() {
|
|
135
|
+
const all = await fetchFleets(restClient);
|
|
136
|
+
const active = all.filter((f) => {
|
|
137
|
+
const s = f.status;
|
|
138
|
+
return s === 'running' || s === 'paused' || s === 'resuming';
|
|
139
|
+
});
|
|
140
|
+
if (active.length === 0) return 'No active fleets.';
|
|
141
|
+
const lines = active.map((f) => {
|
|
142
|
+
const childCount = f.children_count ?? f.children?.length ?? 0;
|
|
143
|
+
const title = f.work_request?.title || '(no title)';
|
|
144
|
+
const reason = f.halt_reason ? ` (${f.halt_reason})` : '';
|
|
145
|
+
return [
|
|
146
|
+
`${statusEmoji(f.status)} **Fleet:** \`${f.fleet_id}\``,
|
|
147
|
+
` **Title:** ${title}`,
|
|
148
|
+
` **Status:** ${f.status}${reason} | **Children:** ${childCount}`,
|
|
149
|
+
].join('\n');
|
|
150
|
+
});
|
|
151
|
+
return `Active fleets:\n\n${lines.join('\n')}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function fleet(_chatKey, args) {
|
|
155
|
+
const resolved = await resolveFleet(restClient, args[0], 'fleet');
|
|
156
|
+
if (resolved.disambig) return resolved.disambig;
|
|
157
|
+
return fmtFleetSummary(resolved.fleet);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function fleetChildren(_chatKey, args) {
|
|
161
|
+
const resolved = await resolveFleet(restClient, args[0], 'fleet-children');
|
|
162
|
+
if (resolved.disambig) return resolved.disambig;
|
|
163
|
+
const f = resolved.fleet;
|
|
164
|
+
return `Children of \`${f.fleet_id}\`:\n\n${fmtChildrenTable(f)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function fleetHalt(_chatKey, args) {
|
|
168
|
+
const resolved = await resolveFleet(restClient, args[0], 'fleet-halt');
|
|
169
|
+
if (resolved.disambig) return resolved.disambig;
|
|
170
|
+
const id = resolved.fleet.fleet_id;
|
|
171
|
+
const resp = await restClient.delete(
|
|
172
|
+
`/api/fleet-runs/${encodeURIComponent(id)}`,
|
|
173
|
+
);
|
|
174
|
+
if (!resp.data || resp.data.ok === false) {
|
|
175
|
+
return `Failed to halt fleet \`${id}\` (${resp.status}).`;
|
|
176
|
+
}
|
|
177
|
+
return `\u{1F7E1} Halted fleet \`${id}\`.\nIn-flight children will finish naturally.`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function fleetStop(chatKey, args) {
|
|
181
|
+
const isForce = args.includes('--force') || args.includes('YES');
|
|
182
|
+
const cleanArgs = args.filter((a) => a !== '--force' && a !== 'YES');
|
|
183
|
+
const resolved = await resolveFleet(restClient, cleanArgs[0], 'fleet-stop');
|
|
184
|
+
if (resolved.disambig) return resolved.disambig;
|
|
185
|
+
const id = resolved.fleet.fleet_id;
|
|
186
|
+
|
|
187
|
+
const ctx = chatContext.get(chatKey) || {};
|
|
188
|
+
const pending = ctx.pending_fleet_stop;
|
|
189
|
+
|
|
190
|
+
if (!isForce) {
|
|
191
|
+
// Issue / refresh a confirmation token.
|
|
192
|
+
chatContext.set(chatKey, {
|
|
193
|
+
pending_fleet_stop: {
|
|
194
|
+
fleet_id: id,
|
|
195
|
+
expires_at: new Date(Date.now() + CONFIRM_TTL_MS).toISOString(),
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
const child_count =
|
|
199
|
+
resolved.fleet.children_count ?? (resolved.fleet.children || []).length;
|
|
200
|
+
return (
|
|
201
|
+
`⚠ \`/fleet-stop\` will SIGTERM every in-flight child of fleet \`${id}\` (${child_count} children).\n` +
|
|
202
|
+
`Confirm with \`/fleet-stop ${id} YES\` within 60s, or pass \`--force\`.`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --force or YES path. Either matches a fresh confirmation token, or
|
|
207
|
+
// the caller is bypassing the gate entirely with --force.
|
|
208
|
+
if (
|
|
209
|
+
args.includes('YES') &&
|
|
210
|
+
(!pending ||
|
|
211
|
+
pending.fleet_id !== id ||
|
|
212
|
+
new Date(pending.expires_at).getTime() < Date.now())
|
|
213
|
+
) {
|
|
214
|
+
return `Confirmation expired or never issued for fleet \`${id}\`. Re-run \`/fleet-stop ${id}\` to get a fresh token.`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
chatContext.set(chatKey, { pending_fleet_stop: null });
|
|
218
|
+
const resp = await restClient.post(
|
|
219
|
+
`/api/fleet-runs/${encodeURIComponent(id)}/stop`,
|
|
220
|
+
);
|
|
221
|
+
if (!resp.data || resp.data.ok === false) {
|
|
222
|
+
return `Failed to stop fleet \`${id}\` (${resp.status}).`;
|
|
223
|
+
}
|
|
224
|
+
const count = resp.data.stopped_count ?? '?';
|
|
225
|
+
return `\u{1F534} Stopped fleet \`${id}\`. SIGTERM sent to ${count} child(ren).`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function fleetPause(_chatKey, args) {
|
|
229
|
+
const resolved = await resolveFleet(restClient, args[0], 'fleet-pause');
|
|
230
|
+
if (resolved.disambig) return resolved.disambig;
|
|
231
|
+
const id = resolved.fleet.fleet_id;
|
|
232
|
+
const resp = await restClient.post(
|
|
233
|
+
`/api/fleet-runs/${encodeURIComponent(id)}/pause`,
|
|
234
|
+
);
|
|
235
|
+
if (!resp.data || resp.data.ok === false) {
|
|
236
|
+
return `Failed to pause fleet \`${id}\` (${resp.status}).`;
|
|
237
|
+
}
|
|
238
|
+
const count = resp.data.paused_count ?? '?';
|
|
239
|
+
return `\u{1F7E1} Paused fleet \`${id}\`. ${count} child(ren) will pause at their next iteration boundary.`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function fleetResume(_chatKey, args) {
|
|
243
|
+
const resolved = await resolveFleet(restClient, args[0], 'fleet-resume');
|
|
244
|
+
if (resolved.disambig) return resolved.disambig;
|
|
245
|
+
const id = resolved.fleet.fleet_id;
|
|
246
|
+
const resp = await restClient.post(
|
|
247
|
+
`/api/fleet-runs/${encodeURIComponent(id)}/resume`,
|
|
248
|
+
);
|
|
249
|
+
if (!resp.data || resp.data.ok === false) {
|
|
250
|
+
return `Failed to resume fleet \`${id}\` (${resp.status}).`;
|
|
251
|
+
}
|
|
252
|
+
const continued = resp.data.continued_count ?? 0;
|
|
253
|
+
const redispatched = resp.data.redispatched_count ?? 0;
|
|
254
|
+
return `\u{1F7E2} Resumed fleet \`${id}\`. ${continued} continued in-place, ${redispatched} re-dispatched.`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
fleets,
|
|
259
|
+
fleet,
|
|
260
|
+
'fleet-children': fleetChildren,
|
|
261
|
+
'fleet-halt': fleetHalt,
|
|
262
|
+
'fleet-stop': fleetStop,
|
|
263
|
+
'fleet-pause': fleetPause,
|
|
264
|
+
'fleet-resume': fleetResume,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -115,6 +115,24 @@ const HELP_TEXT = `/start \u2014 show your chat ID
|
|
|
115
115
|
/resume [run_id] \u2014 resume paused run
|
|
116
116
|
/stop [run_id] \u2014 stop run
|
|
117
117
|
|
|
118
|
+
Fleet commands (cross-project):
|
|
119
|
+
/fleets \u2014 list active fleets
|
|
120
|
+
/fleet [id|last] \u2014 fleet status
|
|
121
|
+
/fleet-children <id|last> \u2014 per-child status
|
|
122
|
+
/fleet-halt <id> \u2014 graceful halt (in-flight finish naturally)
|
|
123
|
+
/fleet-stop <id> [--force] \u2014 SIGTERM every in-flight child (confirms first)
|
|
124
|
+
/fleet-pause <id> \u2014 pause every in-flight child
|
|
125
|
+
/fleet-resume <id> \u2014 resume paused/interrupted, re-dispatch failed
|
|
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
|
+
|
|
118
136
|
Commands with [run_id] auto-resolve to the active run if omitted.
|
|
119
137
|
Use \`*suffix\` to match by ending, e.g. /status \`*2db5\`
|
|
120
138
|
Project commands require /use first.`;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
const MENTION_RE = /^@\S+$/i;
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Allow `-` so namespaced commands like /fleet-halt and /fleet-resume parse.
|
|
4
|
+
// Hyphens must appear inside the name, not lead or trail. Backwards-compatible
|
|
5
|
+
// — every existing underscore-only command still matches.
|
|
6
|
+
const COMMAND_RE = /^\/([a-z_][a-z0-9_-]*)(?:@\S+)?$/i;
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Parses a chat message into a command name and argument list.
|
|
@@ -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
|
+
}
|
|
@@ -12,9 +12,11 @@ import { createWebhookOutAdapter } from './adapters/webhook_out.js';
|
|
|
12
12
|
import { createAllowlistGuard } from './allowlist.js';
|
|
13
13
|
import { createChatContext } from './chat_context.js';
|
|
14
14
|
import { createControlHandlers } from './commands/control.js';
|
|
15
|
+
import { createFleetHandlers } from './commands/fleet.js';
|
|
15
16
|
import { createGlobalHandlers } from './commands/global.js';
|
|
16
17
|
import { parseCommand } from './commands/parser.js';
|
|
17
18
|
import { createProjectHandlers } from './commands/project.js';
|
|
19
|
+
import { createWorkspaceHandlers } from './commands/workspace.js';
|
|
18
20
|
import { loadIntegrationsConfig } from './config-loader.js';
|
|
19
21
|
import { createRateLimiter } from './rate_limiter.js';
|
|
20
22
|
import { renderEvent } from './renderers.js';
|
|
@@ -65,10 +67,17 @@ export function createIntegrations({
|
|
|
65
67
|
});
|
|
66
68
|
const projectHandlers = createProjectHandlers({ chatContext, restClient });
|
|
67
69
|
const controlHandlers = createControlHandlers({ chatContext, restClient });
|
|
70
|
+
const fleetHandlers = createFleetHandlers({ chatContext, restClient });
|
|
71
|
+
const workspaceHandlers = createWorkspaceHandlers({
|
|
72
|
+
chatContext,
|
|
73
|
+
restClient,
|
|
74
|
+
});
|
|
68
75
|
const allHandlers = {
|
|
69
76
|
...globalHandlers,
|
|
70
77
|
...projectHandlers,
|
|
71
78
|
...controlHandlers,
|
|
79
|
+
...fleetHandlers,
|
|
80
|
+
...workspaceHandlers,
|
|
72
81
|
};
|
|
73
82
|
|
|
74
83
|
// Mutable adapter registry — keyed by adapter name
|