@loicngr/kobo 2.1.15 → 2.1.16
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/AGENTS.md +9 -0
- package/CHANGELOG.md +4 -0
- package/CONFIGURATION.md +13 -1
- package/README.md +3 -1
- package/dist/mcp-server/kobo-tasks-server.js +19 -104
- package/dist/mcp-server/workspace-dialogue-client.js +15 -3
- package/dist/server/db/migrations.js +2 -0
- package/dist/server/db/schema.js +2 -0
- package/dist/server/db/workspace-message-batch-schema.js +9 -0
- package/dist/server/index.js +6 -0
- package/dist/server/routes/mcp.js +69 -3
- package/dist/server/routes/workspace-messages.js +22 -0
- package/dist/server/routes/workspaces.js +58 -3
- package/dist/server/services/agent/orchestrator.js +1 -2
- package/dist/server/services/agent/session-controller.js +4 -1
- package/dist/server/services/pr-checkout-rollback-service.js +51 -0
- package/dist/server/services/pr-checkout-service.js +21 -1
- package/dist/server/services/workspace-creation-mcp-service.js +198 -0
- package/dist/server/services/workspace-group-message-mcp-service.js +58 -0
- package/dist/server/services/workspace-group-message-service.js +182 -0
- package/dist/server/services/workspace-lifecycle-mcp-service.js +72 -0
- package/dist/server/services/workspace-message-service.js +5 -0
- package/dist/shared/pr-checkout-steps.js +86 -0
- package/dist/shared/workspace-creation-tools.js +182 -0
- package/dist/shared/workspace-group-message-tools.js +134 -0
- package/dist/shared/workspace-group-messages.js +27 -0
- package/dist/shared/workspace-lifecycle-tools.js +112 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-tJmJhmtl.js +2 -0
- package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-lKjfaC6V.js → AutoLoopFinalReviewSettings-Bbrq02Xt.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-BplM7qEB.js → ChangelogPage-BJfkAVYp.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-DxK5v9w_.js → ComparisonPanel-BfP8APKY.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-BbtNfo0x.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-BsP-MATS.js → DashboardPage-DZYsAh5y.js} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-4Q-GJgyA.js +8 -0
- package/src/client/dist/pwa/assets/{DocumentsPanel-CGrf8Q3H.js → DocumentsPanel-BfPlfiwU.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-BANeLSsR.js → DrawerToggleButton-DG1Qjqk1.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-ECnbb_rx.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-BVTNx-92.js → HealthPage-_VXG1Zeo.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-BH6grTuW.js +2 -0
- package/src/client/dist/pwa/assets/{MainLayout-C6nvIQjU.css → MainLayout-DnmUxNk7.css} +1 -1
- package/src/client/dist/pwa/assets/{MemoryPanel-C71rTpv9.js → MemoryPanel-CVlvGZXZ.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-CoqhUGrv.js → MemoryProposalList-SJsnzUAM.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-U9Ey5W8h.js → QBtnDropdown-DBgGsN-D.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-Be_A1MT1.js → QExpansionItem-DKgaMJ_l.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-D4lYzAPQ.js → QScrollArea-D3OCsq3g.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-Cb_s_nox.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-kStOgY0X.js → QTooltip-X5ps8Opw.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-C7nTGdWE.js → SchedulePanel-Ww_FVlW5.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-C6J72pkQ.js → SearchPage-B87WEHf3.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-BAZLGUyg.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-Bqn4gdVw.js → SplitWorkspacePage-B99AJUbG.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-zLeDCReI.js → TerminalPanel-DnA1MXwu.js} +10 -10
- package/src/client/dist/pwa/assets/{ToolCallItem-B8b_Sagj.js → ToolCallItem-2vFggaur.js} +3 -3
- package/src/client/dist/pwa/assets/{TouchPan-Bl8VzIDK.js → TouchPan-BSU3z0aO.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-pL0HCboU.js → TourReplayButton-DQwS2xdD.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-9Eomx0rQ.js → WorkspaceAttentionLabels-CM1tGDje.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-CQN6EKIn.js +4 -0
- package/src/client/dist/pwa/assets/{browser-C-PJ4alw.js → browser-DPsupI9Q.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-SW9rj7B_.js → build-path-tree-CbIdt-p9.js} +1 -1
- package/src/client/dist/pwa/assets/{custom-sounds-toz-4drX.js → custom-sounds-DR68eyF8.js} +1 -1
- package/src/client/dist/pwa/assets/{de-Ddxe8cc6.js → de-D3Ycvf_F.js} +2 -2
- package/src/client/dist/pwa/assets/{editor.api-BJTPhnh8.js → editor.api-DvgAtnOj.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-BO9xGn9q.js → efforts-nmxfTVQD.js} +1 -1
- package/src/client/dist/pwa/assets/es-TgeS5898.js +17 -0
- package/src/client/dist/pwa/assets/{expand-template-BOZMtWau.js → expand-template-Brn5O4a1.js} +1 -1
- package/src/client/dist/pwa/assets/{format-hEnpRyit.js → format-DfJ3Fv1i.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-DD_eFc4z.js → formatters-B30bNFhq.js} +1 -1
- package/src/client/dist/pwa/assets/fr-CJafryNb.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-B6nUofvT.js → freemarker2-DnZe74tc.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-4P3gtM8p.js → handlebars-CmuMfxzO.js} +1 -1
- package/src/client/dist/pwa/assets/{html-C9ZzWo-y.js → html-BD7OJFGX.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BY4JeBjk.js +1 -0
- package/src/client/dist/pwa/assets/index-C5fAw9ll.js +20 -0
- package/src/client/dist/pwa/assets/it-C5mN8Y61.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-BGEC5ggd.js → javascript-Y-OrgcmW.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-DNtF630O.js → liquid-BqOkKePH.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-CICwyOd0.js → mdx-D-58x2h0.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-BkuwvgrT.js → monaco-contributions-BW4ZMKO6.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-GX8XA-PE.js → monaco.contribution-DJWqzNG-.js} +2 -2
- package/src/client/dist/pwa/assets/network-auth-BGPRhUa6.js +1 -0
- package/src/client/dist/pwa/assets/notify-theme-DZDzfxph.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-B1SMFjSe.js → project-color-gB5yeZPN.js} +1 -1
- package/src/client/dist/pwa/assets/{python-n5uPdTVS.js → python-BkBj1wDC.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-AOj0nsUt.js → razor-DOOJ_0b8.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-Uhkrqihh.js → typescript-Bheo43G5.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-CW0BdpBZ.js → use-search-index-status-CBpnyV-R.js} +1 -1
- package/src/client/dist/pwa/assets/use-tours-BHNasCvg.js +2 -0
- package/src/client/dist/pwa/assets/{use-worktree-restore-BZxibZS5.js → use-worktree-restore-CgABsWno.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-W557e1-m.js → workspace-templates-BnFkigYg.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-B-hnMgEs.js → xml-vHt2BD26.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-DqXkTvgm.js → yaml-BvhLPzP9.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +130 -12
- package/src/mcp-server/kobo-tasks-server.ts +27 -105
- package/src/mcp-server/workspace-dialogue-client.ts +18 -3
- package/src/client/dist/pwa/assets/ActivityFeed-D7jouiuP.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-D5ilP5kt.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-Cd15vFWj.js +0 -8
- package/src/client/dist/pwa/assets/GitPanel-CEPV5Qw0.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-DBKtwilW.js +0 -2
- package/src/client/dist/pwa/assets/QSelect-fS0DLOd1.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-SuH-aIzv.js +0 -9
- package/src/client/dist/pwa/assets/WorkspacePage-B6clgURl.js +0 -4
- package/src/client/dist/pwa/assets/es-DBJIQeWW.js +0 -17
- package/src/client/dist/pwa/assets/fr-yBzIIPaQ.js +0 -17
- package/src/client/dist/pwa/assets/i18n-2-ULabk2.js +0 -1
- package/src/client/dist/pwa/assets/index-Vk99jtxD.js +0 -20
- package/src/client/dist/pwa/assets/it-D44U-PT1.js +0 -17
- package/src/client/dist/pwa/assets/network-auth-KnWzjYIc.js +0 -1
- package/src/client/dist/pwa/assets/notify-theme-CWuZTz4Z.js +0 -1
- package/src/client/dist/pwa/assets/use-tours-CndFlyL-.js +0 -2
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parseGroupMessageInput, } from '../../shared/workspace-group-messages.js';
|
|
3
|
+
import { getDb } from '../db/index.js';
|
|
4
|
+
import { isShuttingDown } from './agent/orchestrator.js';
|
|
5
|
+
import { broadcastPersistedEvent, persistWorkspaceEvent } from './websocket-service.js';
|
|
6
|
+
import { deliverWorkspaceMessage } from './workspace-message-service.js';
|
|
7
|
+
import { getWorkspace } from './workspace-service.js';
|
|
8
|
+
export class GroupMessageError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, status) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const jobs = [];
|
|
16
|
+
const active = new Set();
|
|
17
|
+
let stopping = false;
|
|
18
|
+
export function getGroupMessageBatch(id) {
|
|
19
|
+
const row = getDb().prepare('SELECT receipt_json FROM workspace_message_batches WHERE id=?').get(id);
|
|
20
|
+
return row ? JSON.parse(row.receipt_json) : undefined;
|
|
21
|
+
}
|
|
22
|
+
function updateRecipient(id, workspaceId, state, error) {
|
|
23
|
+
const batch = getGroupMessageBatch(id);
|
|
24
|
+
if (!batch)
|
|
25
|
+
throw new Error('Group message receipt is unavailable');
|
|
26
|
+
const recipient = batch.recipients.find((item) => item.workspaceId === workspaceId);
|
|
27
|
+
recipient.state = state;
|
|
28
|
+
if (error)
|
|
29
|
+
recipient.error = error.slice(0, 1000);
|
|
30
|
+
else
|
|
31
|
+
delete recipient.error;
|
|
32
|
+
batch.complete = batch.recipients.every((item) => item.state !== 'pending' && item.state !== 'sending');
|
|
33
|
+
getDb().prepare('UPDATE workspace_message_batches SET receipt_json=? WHERE id=?').run(JSON.stringify(batch), id);
|
|
34
|
+
}
|
|
35
|
+
/** Persist before scheduling. Repeating an ID only observes its existing immutable request. */
|
|
36
|
+
export function startGroupMessageBatch(value, source) {
|
|
37
|
+
let input;
|
|
38
|
+
try {
|
|
39
|
+
input = parseGroupMessageInput(value);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new GroupMessageError(error instanceof Error ? error.message : String(error), 400);
|
|
43
|
+
}
|
|
44
|
+
const fingerprint = createHash('sha256')
|
|
45
|
+
.update(JSON.stringify([input.content, [...input.workspaceIds].sort(), source ?? null]))
|
|
46
|
+
.digest('hex');
|
|
47
|
+
const db = getDb();
|
|
48
|
+
const result = db
|
|
49
|
+
.transaction(() => {
|
|
50
|
+
const row = db
|
|
51
|
+
.prepare('SELECT fingerprint,receipt_json FROM workspace_message_batches WHERE id=?')
|
|
52
|
+
.get(input.requestId);
|
|
53
|
+
if (row) {
|
|
54
|
+
if (row.fingerprint !== fingerprint)
|
|
55
|
+
throw new GroupMessageError('This requestId was already used for different content, recipients or source', 409);
|
|
56
|
+
return { fresh: false, batch: JSON.parse(row.receipt_json) };
|
|
57
|
+
}
|
|
58
|
+
if (stopping || isShuttingDown())
|
|
59
|
+
throw new GroupMessageError('Kōbō is shutting down', 503);
|
|
60
|
+
const batch = {
|
|
61
|
+
id: input.requestId,
|
|
62
|
+
createdAt: new Date().toISOString(),
|
|
63
|
+
complete: false,
|
|
64
|
+
recipients: input.workspaceIds.map((workspaceId) => {
|
|
65
|
+
const workspace = getWorkspace(workspaceId);
|
|
66
|
+
const error = !workspace
|
|
67
|
+
? 'Workspace not found'
|
|
68
|
+
: workspace.archivedAt || workspace.worktreePurgedAt
|
|
69
|
+
? 'Restore the workspace before sending a message'
|
|
70
|
+
: undefined;
|
|
71
|
+
return {
|
|
72
|
+
workspaceId,
|
|
73
|
+
name: workspace?.name ?? workspaceId,
|
|
74
|
+
delivery: workspace?.autoLoop ? 'next_iteration' : 'immediate',
|
|
75
|
+
state: error ? 'rejected' : 'pending',
|
|
76
|
+
...(error ? { error } : {}),
|
|
77
|
+
};
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
batch.complete = batch.recipients.every((item) => item.state === 'rejected');
|
|
81
|
+
db.prepare('INSERT INTO workspace_message_batches(id,fingerprint,receipt_json,created_at) VALUES (?,?,?,?)').run(batch.id, fingerprint, JSON.stringify(batch), batch.createdAt);
|
|
82
|
+
return { fresh: true, batch };
|
|
83
|
+
})
|
|
84
|
+
.immediate();
|
|
85
|
+
if (result.fresh) {
|
|
86
|
+
for (const recipient of result.batch.recipients)
|
|
87
|
+
if (recipient.state === 'pending')
|
|
88
|
+
jobs.push({ batchId: result.batch.id, recipient, content: input.content, source });
|
|
89
|
+
queueMicrotask(pump);
|
|
90
|
+
}
|
|
91
|
+
return result.batch;
|
|
92
|
+
}
|
|
93
|
+
async function deliver(job) {
|
|
94
|
+
const { batchId, recipient } = job;
|
|
95
|
+
let dispatched = false;
|
|
96
|
+
let accepted = false;
|
|
97
|
+
try {
|
|
98
|
+
updateRecipient(batchId, recipient.workspaceId, 'sending');
|
|
99
|
+
await deliverWorkspaceMessage(recipient.workspaceId, {
|
|
100
|
+
content: job.content,
|
|
101
|
+
source: job.source,
|
|
102
|
+
delivery: recipient.delivery,
|
|
103
|
+
clientMessageId: `group-${createHash('sha256')
|
|
104
|
+
.update(JSON.stringify([batchId, recipient.workspaceId]))
|
|
105
|
+
.digest('hex')}`,
|
|
106
|
+
}, {
|
|
107
|
+
beforeDispatch() {
|
|
108
|
+
const workspace = getWorkspace(recipient.workspaceId);
|
|
109
|
+
if (!workspace || !!workspace.autoLoop !== (recipient.delivery === 'next_iteration'))
|
|
110
|
+
throw new Error('Auto-loop mode changed since submission; review the recipient before sending again');
|
|
111
|
+
dispatched = true;
|
|
112
|
+
},
|
|
113
|
+
persist(payload, sessionId) {
|
|
114
|
+
const event = getDb().transaction(() => {
|
|
115
|
+
const persisted = persistWorkspaceEvent(recipient.workspaceId, 'user:message', payload, sessionId);
|
|
116
|
+
updateRecipient(batchId, recipient.workspaceId, recipient.delivery === 'next_iteration' ? 'queued' : 'sent');
|
|
117
|
+
return persisted;
|
|
118
|
+
})();
|
|
119
|
+
accepted = true;
|
|
120
|
+
broadcastPersistedEvent(event);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
if (!accepted)
|
|
124
|
+
throw new Error('Delivery completed without a durable receipt');
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (accepted)
|
|
128
|
+
return;
|
|
129
|
+
updateRecipient(batchId, recipient.workspaceId, dispatched ? 'unknown' : 'rejected', error instanceof Error ? error.message : String(error));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function pump() {
|
|
133
|
+
if (stopping)
|
|
134
|
+
return;
|
|
135
|
+
while (active.size < 3 && jobs.length) {
|
|
136
|
+
const job = jobs.shift();
|
|
137
|
+
const task = deliver(job)
|
|
138
|
+
.catch((error) => console.error('[group-message] Receipt persistence failed:', error))
|
|
139
|
+
.finally(() => {
|
|
140
|
+
active.delete(task);
|
|
141
|
+
pump();
|
|
142
|
+
});
|
|
143
|
+
active.add(task);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Startup only: persisted uncertain work is never automatically dispatched again. */
|
|
147
|
+
export function reconcileGroupMessageBatches(db) {
|
|
148
|
+
stopping = false;
|
|
149
|
+
db.transaction(() => {
|
|
150
|
+
const rows = db.prepare('SELECT id,receipt_json FROM workspace_message_batches').all();
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
const batch = JSON.parse(row.receipt_json);
|
|
153
|
+
if (batch.complete)
|
|
154
|
+
continue;
|
|
155
|
+
for (const recipient of batch.recipients) {
|
|
156
|
+
if (recipient.state === 'pending') {
|
|
157
|
+
recipient.state = 'not_sent';
|
|
158
|
+
recipient.error = 'Server stopped before delivery';
|
|
159
|
+
}
|
|
160
|
+
else if (recipient.state === 'sending') {
|
|
161
|
+
recipient.state = 'unknown';
|
|
162
|
+
recipient.error = 'Server stopped during delivery; inspect workspace history before sending again';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
batch.complete = true;
|
|
166
|
+
db.prepare('UPDATE workspace_message_batches SET receipt_json=? WHERE id=?').run(JSON.stringify(batch), row.id);
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
}
|
|
170
|
+
export async function stopGroupMessageBatches() {
|
|
171
|
+
stopping = true;
|
|
172
|
+
for (const job of jobs.splice(0)) {
|
|
173
|
+
try {
|
|
174
|
+
updateRecipient(job.batchId, job.recipient.workspaceId, 'not_sent', 'Server stopped before delivery');
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
// Startup will reconcile the durable pending row; still drain active jobs before closing SQLite.
|
|
178
|
+
console.error('[group-message] Could not persist shutdown receipt:', error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await Promise.allSettled([...active]);
|
|
182
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { validateWorkspaceLifecycleArguments } from '../../shared/workspace-lifecycle-tools.js';
|
|
2
|
+
export class WorkspaceLifecycleMcpError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
stage;
|
|
5
|
+
details;
|
|
6
|
+
constructor(message, status, stage, details = null) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.stage = stage;
|
|
10
|
+
this.details = details;
|
|
11
|
+
this.name = 'WorkspaceLifecycleMcpError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async function request(dispatch, path, stage, method, body) {
|
|
15
|
+
const response = await dispatch(path, {
|
|
16
|
+
method,
|
|
17
|
+
...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
|
18
|
+
});
|
|
19
|
+
const payload = response.status === 204 ? null : await response.json().catch(() => null);
|
|
20
|
+
if (!response.ok)
|
|
21
|
+
throw new WorkspaceLifecycleMcpError(payload?.error ?? `Workspace lifecycle request failed (${response.status})`, response.status, stage, payload);
|
|
22
|
+
return payload;
|
|
23
|
+
}
|
|
24
|
+
/** The HTTP lifecycle remains the sole owner of stopping, locks and cleanup. */
|
|
25
|
+
export async function executeWorkspaceLifecycleTool(name, raw, dispatch) {
|
|
26
|
+
let input;
|
|
27
|
+
try {
|
|
28
|
+
input = validateWorkspaceLifecycleArguments(name, raw);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw new WorkspaceLifecycleMcpError(error instanceof Error ? error.message : 'Invalid arguments', 400, 'validate');
|
|
32
|
+
}
|
|
33
|
+
const workspaceId = input.workspace_id;
|
|
34
|
+
const path = `/api/workspaces/${encodeURIComponent(workspaceId)}`;
|
|
35
|
+
if (name === 'archive_workspace')
|
|
36
|
+
return request(dispatch, `${path}/archive`, 'archive', 'POST');
|
|
37
|
+
if (name === 'purge_workspace_worktree')
|
|
38
|
+
return request(dispatch, `${path}/purge-worktree`, 'purge', 'POST');
|
|
39
|
+
if (name === 'unarchive_workspace')
|
|
40
|
+
return request(dispatch, `${path}/unarchive`, 'unarchive', 'POST');
|
|
41
|
+
if (name === 'restore_workspace') {
|
|
42
|
+
let workspace = (await request(dispatch, path, 'lookup', 'GET'));
|
|
43
|
+
if (!workspace.archivedAt && !workspace.worktreePurgedAt)
|
|
44
|
+
return { workspace, outcome: 'unchanged' };
|
|
45
|
+
let restoration = {
|
|
46
|
+
workspace,
|
|
47
|
+
outcome: 'unarchived',
|
|
48
|
+
};
|
|
49
|
+
if (workspace.worktreePurgedAt || workspace.worktreeOwned !== false) {
|
|
50
|
+
// Even without a purge flag, check an owned archived checkout on disk.
|
|
51
|
+
// The existing restore endpoint rejects missing manual deletions rather
|
|
52
|
+
// than silently announcing successful restoration of an absent folder.
|
|
53
|
+
restoration = (await request(dispatch, `${path}/restore-worktree`, 'restore', 'POST'));
|
|
54
|
+
workspace = restoration.workspace;
|
|
55
|
+
}
|
|
56
|
+
if (workspace.archivedAt)
|
|
57
|
+
workspace = (await request(dispatch, `${path}/unarchive`, 'unarchive', 'POST'));
|
|
58
|
+
return { ...restoration, workspace };
|
|
59
|
+
}
|
|
60
|
+
const workspace = (await request(dispatch, path, 'lookup', 'GET'));
|
|
61
|
+
const confirmationBranch = input.confirmation_branch.trim();
|
|
62
|
+
if (workspace?.workingBranch !== confirmationBranch)
|
|
63
|
+
throw new WorkspaceLifecycleMcpError('confirmation_branch must match the current workspace working branch', 409, 'confirm-delete');
|
|
64
|
+
const result = await request(dispatch, path, 'delete', 'DELETE', {
|
|
65
|
+
deleteLocalBranch: input.delete_local_branch ?? false,
|
|
66
|
+
deleteRemoteBranch: input.delete_remote_branch ?? false,
|
|
67
|
+
// Rechecked by the existing route under its lifecycle guard, so a changed
|
|
68
|
+
// branch between GET and DELETE cannot authorize a different deletion.
|
|
69
|
+
confirmationBranch,
|
|
70
|
+
});
|
|
71
|
+
return result ?? { ok: true, workspaceId, warnings: [] };
|
|
72
|
+
}
|
|
@@ -24,6 +24,11 @@ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
|
|
|
24
24
|
if (!message.clientMessageId)
|
|
25
25
|
throw new Error('clientMessageId is required for queued delivery');
|
|
26
26
|
const acceptedBefore = hasLoopMessage(workspaceId, message.clientMessageId);
|
|
27
|
+
if (!acceptedBefore) {
|
|
28
|
+
if (!workspace.autoLoop)
|
|
29
|
+
throw new Error('Auto-loop is not enabled');
|
|
30
|
+
hooks?.beforeDispatch?.();
|
|
31
|
+
}
|
|
27
32
|
if (!autoLoop.queueInstruction(workspaceId, message.content, message.clientMessageId, message.source))
|
|
28
33
|
throw new Error('Auto-loop is not enabled');
|
|
29
34
|
if (!acceptedBefore)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Blockers the user CAN resolve, by picking another worktree path. Everything
|
|
2
|
+
* else (missing CLI, fork PR, deleted remote branch, index.lock, unrelated
|
|
3
|
+
* history) leaves nothing to choose and ends the flow. */
|
|
4
|
+
const RESOLVABLE_BLOCKERS = new Set(['path-occupied', 'worktree-other-branch']);
|
|
5
|
+
/**
|
|
6
|
+
* Derive the ordered steps for a report. A blocker short-circuits everything:
|
|
7
|
+
* there is no point asking about a stash when the forge CLI is missing.
|
|
8
|
+
* `operation` precedes `changes` because aborting a rebase changes what is dirty.
|
|
9
|
+
*/
|
|
10
|
+
export function deriveSteps(report) {
|
|
11
|
+
const fatal = report.blockers.filter((b) => !RESOLVABLE_BLOCKERS.has(b.kind));
|
|
12
|
+
if (fatal.length > 0) {
|
|
13
|
+
return [{ id: 'blocked', blocking: true, titleKey: 'prCheckout.step.blocked' }];
|
|
14
|
+
}
|
|
15
|
+
const steps = [];
|
|
16
|
+
if (report.blockers.some((b) => RESOLVABLE_BLOCKERS.has(b.kind))) {
|
|
17
|
+
steps.push({ id: 'path', blocking: false, titleKey: 'prCheckout.step.path' });
|
|
18
|
+
}
|
|
19
|
+
if (report.workspace.state !== 'none') {
|
|
20
|
+
steps.push({ id: 'workspace', blocking: false, titleKey: 'prCheckout.step.workspace' });
|
|
21
|
+
}
|
|
22
|
+
// A purged workspace's worktree can reappear as an 'orphan' on disk during the
|
|
23
|
+
// pr-watcher's restore window (see AGENTS.md § Worktree purge) — restoring the
|
|
24
|
+
// workspace (via the 'workspace' step above) already handles reattaching it, so
|
|
25
|
+
// this must not also surface as a separate worktree step.
|
|
26
|
+
if (report.workspace.state !== 'purged' &&
|
|
27
|
+
(report.worktree.state === 'orphan' || report.worktree.state === 'attached')) {
|
|
28
|
+
steps.push({ id: 'worktree', blocking: false, titleKey: 'prCheckout.step.worktree' });
|
|
29
|
+
}
|
|
30
|
+
if (report.ongoingOperation) {
|
|
31
|
+
steps.push({ id: 'operation', blocking: false, titleKey: 'prCheckout.step.operation' });
|
|
32
|
+
}
|
|
33
|
+
if (report.localChanges.present) {
|
|
34
|
+
steps.push({ id: 'changes', blocking: false, titleKey: 'prCheckout.step.changes' });
|
|
35
|
+
}
|
|
36
|
+
if (report.branch.state === 'behind' || report.branch.state === 'ahead' || report.branch.state === 'diverged') {
|
|
37
|
+
steps.push({ id: 'divergence', blocking: false, titleKey: 'prCheckout.step.divergence' });
|
|
38
|
+
}
|
|
39
|
+
return steps;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The pre-selected choice for each step. The rule that matters: wherever local
|
|
43
|
+
* commits or local edits exist, the default is the one that loses nothing.
|
|
44
|
+
*/
|
|
45
|
+
export function defaultDecisions(report) {
|
|
46
|
+
const decisions = {};
|
|
47
|
+
if (report.workspace.state === 'active')
|
|
48
|
+
decisions.existingWorkspace = 'open';
|
|
49
|
+
if (report.workspace.state === 'archived')
|
|
50
|
+
decisions.archivedWorkspace = 'unarchive';
|
|
51
|
+
if (report.workspace.state === 'purged')
|
|
52
|
+
decisions.purgedWorktree = 'restore';
|
|
53
|
+
// A purged workspace's worktree can reappear as an 'orphan' on disk during the
|
|
54
|
+
// pr-watcher's restore window (see AGENTS.md § Worktree purge) — restoring the
|
|
55
|
+
// workspace already handles reattaching it, so this must not also surface as a
|
|
56
|
+
// separate 'attach' decision.
|
|
57
|
+
if (report.workspace.state !== 'purged' && report.worktree.state === 'orphan')
|
|
58
|
+
decisions.orphanWorktree = 'attach';
|
|
59
|
+
// 'cancel' — not 'abort' — is the loses-nothing default: aborting a rebase
|
|
60
|
+
// or merge discards any conflict-resolution work already staged, exactly
|
|
61
|
+
// the kind of destructive default this function otherwise avoids.
|
|
62
|
+
if (report.ongoingOperation)
|
|
63
|
+
decisions.ongoingOperation = 'cancel';
|
|
64
|
+
if (report.localChanges.present)
|
|
65
|
+
decisions.localChanges = 'keep';
|
|
66
|
+
if (report.branch.state === 'behind')
|
|
67
|
+
decisions.divergence = 'fast-forward';
|
|
68
|
+
else if (report.branch.state === 'ahead' || report.branch.state === 'diverged')
|
|
69
|
+
decisions.divergence = 'keep';
|
|
70
|
+
return decisions;
|
|
71
|
+
}
|
|
72
|
+
/** Return an i18n key for choices that cannot preserve the requested work. */
|
|
73
|
+
export function checkoutDecisionError(report, decisions) {
|
|
74
|
+
if (decisions.divergence === 'fast-forward' &&
|
|
75
|
+
(report.branch.state === 'ahead' || report.branch.state === 'diverged'))
|
|
76
|
+
return 'prCheckout.divergence.notFastForward';
|
|
77
|
+
if ((report.worktree.state === 'orphan' || report.worktree.state === 'attached') &&
|
|
78
|
+
decisions.orphanWorktree === 'create-elsewhere')
|
|
79
|
+
return 'prCheckout.worktree.branchInUse';
|
|
80
|
+
if (report.localChanges.present &&
|
|
81
|
+
(decisions.localChanges ?? 'keep') === 'keep' &&
|
|
82
|
+
decisions.divergence === 'reset-hard') {
|
|
83
|
+
return 'prCheckout.changes.keepResetConflict';
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_BYTES, validateAttachments } from './attachments.js';
|
|
2
|
+
/** Base64 expansion of the shared upload limit, plus bounded JSON metadata. */
|
|
3
|
+
export const MAX_MCP_CREATION_REQUEST_BYTES = Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4 + 1024 * 1024;
|
|
4
|
+
const string = (maxLength = 200, minLength = 1) => ({ type: 'string', minLength, maxLength });
|
|
5
|
+
const choice = (...values) => ({ type: 'string', enum: values });
|
|
6
|
+
const object = (properties, required = []) => ({
|
|
7
|
+
type: 'object',
|
|
8
|
+
properties,
|
|
9
|
+
required,
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
});
|
|
12
|
+
const effort = choice('auto', 'none', 'low', 'medium', 'high', 'xhigh', 'max');
|
|
13
|
+
const engine = choice('claude-code', 'codex');
|
|
14
|
+
const path = string(4096);
|
|
15
|
+
const url = string(4096);
|
|
16
|
+
const titles = { type: 'array', items: string(20_000), maxItems: 500 };
|
|
17
|
+
const properties = {
|
|
18
|
+
name: string(1000),
|
|
19
|
+
project_path: path,
|
|
20
|
+
source_branch: string(1024),
|
|
21
|
+
working_branch: string(1024),
|
|
22
|
+
engine,
|
|
23
|
+
model: string(),
|
|
24
|
+
reasoning_effort: effort,
|
|
25
|
+
brainstorm_model: string(),
|
|
26
|
+
brainstorm_reasoning_effort: effort,
|
|
27
|
+
description: string(200_000, 0),
|
|
28
|
+
tags: { type: 'array', items: string(200), maxItems: 100 },
|
|
29
|
+
tasks: titles,
|
|
30
|
+
acceptance_criteria: titles,
|
|
31
|
+
agent_permission_mode: choice('plan', 'bypass', 'strict', 'interactive'),
|
|
32
|
+
auto_loop: { type: 'boolean' },
|
|
33
|
+
auto_loop_session_mode: choice('per_task', 'continuous'),
|
|
34
|
+
skip_setup_script: { type: 'boolean' },
|
|
35
|
+
auto_loop_final_review: {
|
|
36
|
+
...object({ engine, model: string(), reasoning_effort: effort, additional_instructions: string(20_000, 0) }, [
|
|
37
|
+
'engine',
|
|
38
|
+
'model',
|
|
39
|
+
'reasoning_effort',
|
|
40
|
+
]),
|
|
41
|
+
type: ['object', 'null'],
|
|
42
|
+
},
|
|
43
|
+
workflow_policy: object({
|
|
44
|
+
commit: choice('manual', 'automatic'),
|
|
45
|
+
push: choice('manual', 'automatic'),
|
|
46
|
+
publish: choice('manual', 'automatic'),
|
|
47
|
+
}),
|
|
48
|
+
notion_url: {
|
|
49
|
+
...url,
|
|
50
|
+
description: 'Import this Notion ticket before launching the agent. Requires an enabled, configured Notion integration.',
|
|
51
|
+
},
|
|
52
|
+
notion_page_id: {
|
|
53
|
+
...string(),
|
|
54
|
+
description: 'Optional stored Notion page identifier. Does not import content on its own; provide notion_url to import the ticket.',
|
|
55
|
+
},
|
|
56
|
+
sentry_url: url,
|
|
57
|
+
pr_url: url,
|
|
58
|
+
worktree_path: path,
|
|
59
|
+
comparison_id: string(),
|
|
60
|
+
creation_id: string(),
|
|
61
|
+
attachments: {
|
|
62
|
+
type: 'array',
|
|
63
|
+
maxItems: MAX_ATTACHMENTS,
|
|
64
|
+
items: object({ name: string(255), mime_type: string(200, 0), data_base64: string(Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4) }, ['name', 'mime_type', 'data_base64']),
|
|
65
|
+
},
|
|
66
|
+
pr_checkout: object({
|
|
67
|
+
fingerprint: string(),
|
|
68
|
+
decisions: object({
|
|
69
|
+
existingWorkspace: choice('open', 'continue'),
|
|
70
|
+
archivedWorkspace: choice('unarchive', 'continue'),
|
|
71
|
+
purgedWorktree: choice('restore'),
|
|
72
|
+
orphanWorktree: choice('attach', 'create-elsewhere'),
|
|
73
|
+
pathCollision: object({ worktreePath: path }, ['worktreePath']),
|
|
74
|
+
localChanges: choice('stash', 'commit', 'discard', 'keep'),
|
|
75
|
+
ongoingOperation: choice('abort', 'cancel'),
|
|
76
|
+
divergence: choice('fast-forward', 'rebase', 'reset-hard', 'keep'),
|
|
77
|
+
}),
|
|
78
|
+
}, ['fingerprint', 'decisions']),
|
|
79
|
+
};
|
|
80
|
+
const schemas = {
|
|
81
|
+
create_workspace: object(properties, ['name', 'project_path']),
|
|
82
|
+
diagnose_workspace_pr: object({ project_path: path, pr_url: url }, ['project_path', 'pr_url']),
|
|
83
|
+
};
|
|
84
|
+
export const WORKSPACE_CREATION_TOOLS = [
|
|
85
|
+
{
|
|
86
|
+
name: 'create_workspace',
|
|
87
|
+
description: 'Create a Kōbō workspace with the same configuration as the create form, including final review, workflow policy, imports and inline base64 attachments (10 files, 50 MiB total). Plain creation requires source_branch and working_branch; worktree_path requires source_branch. A pr_url derives canonical branches and diagnoses checkout first. Unsafe or ambiguous checkout returns requiresAction and a fingerprint: repeat with explicit pr_checkout decisions. Never silently discards local work. Existing workspaces can be opened or explicitly unarchived; no duplicate is created. skip_setup_script defaults to true for PR checkout. Project must be configured in Kōbō.',
|
|
88
|
+
inputSchema: schemas.create_workspace,
|
|
89
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'diagnose_workspace_pr',
|
|
93
|
+
description: 'Read the canonical PR and local checkout state for a configured project. Returns report, pr and fingerprint for explicit create_workspace pr_checkout decisions; creates no workspace and applies no checkout changes.',
|
|
94
|
+
inputSchema: schemas.diagnose_workspace_pr,
|
|
95
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
export function isWorkspaceCreationTool(name) {
|
|
99
|
+
return Object.hasOwn(schemas, name);
|
|
100
|
+
}
|
|
101
|
+
function validate(schema, value, label) {
|
|
102
|
+
if (value === null && Array.isArray(schema.type))
|
|
103
|
+
return;
|
|
104
|
+
if (schema.type === 'object' || Array.isArray(schema.type)) {
|
|
105
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
106
|
+
throw new Error(`${label} must be an object`);
|
|
107
|
+
const input = value;
|
|
108
|
+
for (const key of Object.keys(input)) {
|
|
109
|
+
if (!Object.hasOwn(schema.properties, key))
|
|
110
|
+
throw new Error(`Unknown field ${label}.${key}`);
|
|
111
|
+
validate(schema.properties[key], input[key], `${label}.${key}`);
|
|
112
|
+
}
|
|
113
|
+
for (const key of schema.required ?? [])
|
|
114
|
+
if (!Object.hasOwn(input, key))
|
|
115
|
+
throw new Error(`${label}.${key} is required`);
|
|
116
|
+
}
|
|
117
|
+
else if (schema.type === 'array') {
|
|
118
|
+
if (!Array.isArray(value) || value.length > schema.maxItems)
|
|
119
|
+
throw new Error(`${label} must be an array with at most ${schema.maxItems} entries`);
|
|
120
|
+
for (const item of value)
|
|
121
|
+
validate(schema.items, item, label);
|
|
122
|
+
}
|
|
123
|
+
else if (schema.type === 'boolean') {
|
|
124
|
+
if (typeof value !== 'boolean')
|
|
125
|
+
throw new Error(`${label} must be a boolean`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
if (typeof value !== 'string' ||
|
|
129
|
+
value.includes('\0') ||
|
|
130
|
+
value.length < (schema.minLength ?? 0) ||
|
|
131
|
+
value.length > (schema.maxLength ?? Infinity) ||
|
|
132
|
+
(schema.minLength && !value.trim()) ||
|
|
133
|
+
(schema.enum && !schema.enum.includes(value)))
|
|
134
|
+
throw new Error(`Invalid ${label}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Validates before dispatch, including uploads before a PR checkout can mutate Git. */
|
|
138
|
+
export function validateWorkspaceCreationArguments(name, input) {
|
|
139
|
+
if (!isWorkspaceCreationTool(name))
|
|
140
|
+
throw new Error(`Unknown workspace creation tool: ${name}`);
|
|
141
|
+
validate(schemas[name], input, name);
|
|
142
|
+
const value = input;
|
|
143
|
+
for (const key of ['notion_url', 'sentry_url', 'pr_url']) {
|
|
144
|
+
if (value[key] !== undefined) {
|
|
145
|
+
const parsed = new URL(value[key]);
|
|
146
|
+
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
147
|
+
throw new Error(`Invalid ${key}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (name === 'create_workspace') {
|
|
151
|
+
const { attachments: _attachments, ...metadataFields } = value;
|
|
152
|
+
// Keep room for multipart file headers within the downstream 1 MiB allowance.
|
|
153
|
+
if (new TextEncoder().encode(JSON.stringify(metadataFields)).byteLength > 960 * 1024)
|
|
154
|
+
throw new Error('Workspace creation metadata exceeds 960 KiB');
|
|
155
|
+
if (!value.pr_url && (!value.source_branch || (!value.worktree_path && !value.working_branch)))
|
|
156
|
+
throw new Error('source_branch and working_branch are required unless using a PR or existing worktree');
|
|
157
|
+
if (value.pr_checkout && !value.pr_url)
|
|
158
|
+
throw new Error('pr_checkout requires pr_url');
|
|
159
|
+
const files = (value.attachments ?? []);
|
|
160
|
+
const metadata = files.map((file) => {
|
|
161
|
+
if (/[/\\]/.test(file.name) || file.name === '.' || file.name === '..')
|
|
162
|
+
throw new Error('Attachment name must be a filename, not a path');
|
|
163
|
+
const data = file.data_base64;
|
|
164
|
+
if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data))
|
|
165
|
+
throw new Error('Attachment data_base64 must be canonical base64');
|
|
166
|
+
// Reject non-zero pad bits too, before the server allocates decoded buffers.
|
|
167
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
168
|
+
if ((data.endsWith('==') && (alphabet.indexOf(data.at(-3)) & 15) !== 0) ||
|
|
169
|
+
(!data.endsWith('==') && data.endsWith('=') && (alphabet.indexOf(data.at(-2)) & 3) !== 0))
|
|
170
|
+
throw new Error('Attachment data_base64 must be canonical base64');
|
|
171
|
+
return {
|
|
172
|
+
name: file.name,
|
|
173
|
+
type: file.mime_type,
|
|
174
|
+
size: (data.length / 4) * 3 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0),
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
const error = validateAttachments(metadata);
|
|
178
|
+
if (error)
|
|
179
|
+
throw new Error(`Invalid attachments: ${error}`);
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { MAX_GROUP_MESSAGE_LENGTH, MAX_GROUP_MESSAGE_RECIPIENTS, parseGroupMessageInput, } from './workspace-group-messages.js';
|
|
2
|
+
const identifier = { type: 'string', pattern: '^[A-Za-z0-9_-]{1,200}$', minLength: 1, maxLength: 200 };
|
|
3
|
+
const statuses = [
|
|
4
|
+
'created',
|
|
5
|
+
'extracting',
|
|
6
|
+
'brainstorming',
|
|
7
|
+
'executing',
|
|
8
|
+
'compacting',
|
|
9
|
+
'awaiting-user',
|
|
10
|
+
'completed',
|
|
11
|
+
'idle',
|
|
12
|
+
'error',
|
|
13
|
+
'quota',
|
|
14
|
+
];
|
|
15
|
+
export const WORKSPACE_GROUP_MESSAGE_TOOLS = [
|
|
16
|
+
{
|
|
17
|
+
name: 'preview_workspace_group_message',
|
|
18
|
+
description: 'Preview eligible non-archived, non-purged workspaces before sending a group message. Tags match ANY selected tag, statuses match ANY selected status, and all enabled filters intersect. Set dev_server_running to true to include only workspaces with a running development server. Empty filters select all eligible workspaces. Paginate with offset and limit (maximum 200); results sort by workspace ID. Delivery is immediate for manual workspaces and next_iteration for auto-loop. This is a live preview, not a reservation; explicitly select recipient IDs for send_workspace_group_message.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
tags: {
|
|
23
|
+
type: 'array',
|
|
24
|
+
maxItems: 200,
|
|
25
|
+
uniqueItems: true,
|
|
26
|
+
items: { type: 'string', minLength: 1, maxLength: 200 },
|
|
27
|
+
},
|
|
28
|
+
statuses: {
|
|
29
|
+
type: 'array',
|
|
30
|
+
uniqueItems: true,
|
|
31
|
+
maxItems: statuses.length,
|
|
32
|
+
items: { type: 'string', enum: statuses },
|
|
33
|
+
},
|
|
34
|
+
dev_server_running: {
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
default: false,
|
|
37
|
+
description: 'Only include workspaces whose development server status is running. Intersects other filters.',
|
|
38
|
+
},
|
|
39
|
+
limit: {
|
|
40
|
+
type: 'integer',
|
|
41
|
+
minimum: 1,
|
|
42
|
+
maximum: MAX_GROUP_MESSAGE_RECIPIENTS,
|
|
43
|
+
default: MAX_GROUP_MESSAGE_RECIPIENTS,
|
|
44
|
+
},
|
|
45
|
+
offset: { type: 'integer', minimum: 0, default: 0 },
|
|
46
|
+
},
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'send_workspace_group_message',
|
|
53
|
+
description: 'Send one message to an explicit selection of 1–200 unique workspace IDs after previewing recipients. A workspace-bound agent cannot target its own workspace. Supply a unique request_id; retrying the same ID and identical payload observes the existing batch without resending. Different payloads with that ID fail. Returns a durable asynchronous batch receipt: use get_workspace_group_message to inspect per-recipient outcomes. Manual workspaces receive immediately; auto-loop workspaces queue for their next iteration. Ineligible targets are rejected individually. After interruption, not_sent and unknown outcomes are never automatically retried; inspect history before submitting a new request.',
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
request_id: identifier,
|
|
58
|
+
workspace_ids: {
|
|
59
|
+
type: 'array',
|
|
60
|
+
minItems: 1,
|
|
61
|
+
maxItems: MAX_GROUP_MESSAGE_RECIPIENTS,
|
|
62
|
+
uniqueItems: true,
|
|
63
|
+
items: identifier,
|
|
64
|
+
},
|
|
65
|
+
content: { type: 'string', minLength: 1, maxLength: MAX_GROUP_MESSAGE_LENGTH },
|
|
66
|
+
},
|
|
67
|
+
required: ['request_id', 'workspace_ids', 'content'],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
},
|
|
70
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'get_workspace_group_message',
|
|
74
|
+
description: 'Read a persisted group-message receipt by request_id, including complete and each recipient state: pending, sending, sent, queued, rejected, unknown, or not_sent. This read never sends or retries messages. Queued means accepted for a future auto-loop iteration, not executed.',
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: { request_id: identifier },
|
|
78
|
+
required: ['request_id'],
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
export function isWorkspaceGroupMessageTool(name) {
|
|
85
|
+
return WORKSPACE_GROUP_MESSAGE_TOOLS.some((tool) => tool.name === name);
|
|
86
|
+
}
|
|
87
|
+
export function validateWorkspaceGroupMessageArguments(name, raw) {
|
|
88
|
+
const tool = WORKSPACE_GROUP_MESSAGE_TOOLS.find((item) => item.name === name);
|
|
89
|
+
if (!tool)
|
|
90
|
+
throw new Error(`Unknown group message tool: ${name}`);
|
|
91
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
92
|
+
throw new Error('Arguments must be an object');
|
|
93
|
+
const input = raw;
|
|
94
|
+
for (const key of Object.keys(input))
|
|
95
|
+
if (!Object.hasOwn(tool.inputSchema.properties, key))
|
|
96
|
+
throw new Error(`Unknown field: ${key}`);
|
|
97
|
+
for (const key of tool.inputSchema.required ?? [])
|
|
98
|
+
if (!Object.hasOwn(input, key))
|
|
99
|
+
throw new Error(`${key} is required`);
|
|
100
|
+
if (name === 'send_workspace_group_message') {
|
|
101
|
+
parseGroupMessageInput({ requestId: input.request_id, workspaceIds: input.workspace_ids, content: input.content });
|
|
102
|
+
}
|
|
103
|
+
else if (name === 'get_workspace_group_message') {
|
|
104
|
+
if (typeof input.request_id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(input.request_id))
|
|
105
|
+
throw new Error('Invalid request_id');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
if (input.dev_server_running !== undefined && typeof input.dev_server_running !== 'boolean')
|
|
109
|
+
throw new Error('Invalid dev_server_running');
|
|
110
|
+
for (const key of ['tags', 'statuses']) {
|
|
111
|
+
const value = input[key];
|
|
112
|
+
if (value === undefined)
|
|
113
|
+
continue;
|
|
114
|
+
if (!Array.isArray(value) ||
|
|
115
|
+
value.length > (key === 'tags' ? 200 : statuses.length) ||
|
|
116
|
+
new Set(value).size !== value.length ||
|
|
117
|
+
value.some((item) => typeof item !== 'string' ||
|
|
118
|
+
!item.trim() ||
|
|
119
|
+
item.length > 200 ||
|
|
120
|
+
(key === 'statuses' && !statuses.includes(item))))
|
|
121
|
+
throw new Error(`Invalid ${key}`);
|
|
122
|
+
}
|
|
123
|
+
if (input.limit !== undefined &&
|
|
124
|
+
(typeof input.limit !== 'number' ||
|
|
125
|
+
!Number.isSafeInteger(input.limit) ||
|
|
126
|
+
input.limit < 1 ||
|
|
127
|
+
input.limit > MAX_GROUP_MESSAGE_RECIPIENTS))
|
|
128
|
+
throw new Error('Invalid limit');
|
|
129
|
+
if (input.offset !== undefined &&
|
|
130
|
+
(typeof input.offset !== 'number' || !Number.isSafeInteger(input.offset) || input.offset < 0))
|
|
131
|
+
throw new Error('Invalid offset');
|
|
132
|
+
}
|
|
133
|
+
return input;
|
|
134
|
+
}
|