@loicngr/kobo 2.1.13 → 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 +38 -6
- package/CHANGELOG.md +12 -0
- package/CONFIGURATION.md +41 -5
- package/README.md +4 -2
- package/dist/mcp-server/kobo-tasks-server.js +62 -105
- package/dist/mcp-server/workspace-dialogue-client.js +15 -3
- package/dist/server/db/auto-loop-final-review-schema.js +18 -0
- package/dist/server/db/migrations.js +12 -1
- package/dist/server/db/review-return-schema.js +18 -1
- package/dist/server/db/schema.js +4 -0
- package/dist/server/db/workspace-message-batch-schema.js +9 -0
- package/dist/server/index.js +8 -1
- package/dist/server/routes/mcp.js +69 -3
- package/dist/server/routes/workspace-messages.js +22 -0
- package/dist/server/routes/workspaces.js +119 -4
- package/dist/server/services/agent/engines/claude-code/engine.js +37 -1
- package/dist/server/services/agent/orchestrator.js +159 -40
- package/dist/server/services/agent/session-controller.js +4 -1
- package/dist/server/services/auto-loop-final-review-service.js +329 -0
- package/dist/server/services/auto-loop-service.js +53 -10
- 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/review-launch-runtime.js +17 -0
- package/dist/server/services/review-return-service.js +58 -10
- package/dist/server/services/review-service.js +59 -11
- 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/server/services/workspace-service.js +11 -0
- package/dist/server/services/workspace-template-service.js +10 -0
- package/dist/shared/auto-loop-review.js +1 -0
- package/dist/shared/pr-checkout-steps.js +86 -0
- package/dist/shared/review-return.js +1 -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-Bbrq02Xt.js +1 -0
- package/src/client/dist/pwa/assets/AutoLoopFinalReviewSettings-Q-OlSQ1c.css +1 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-DD90Nl6c.js → ChangelogPage-BJfkAVYp.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-D_BQFWiT.js → ComparisonPanel-BfP8APKY.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-BbtNfo0x.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-DxOes_cF.css +1 -0
- package/src/client/dist/pwa/assets/{DashboardPage-DyvTdmBg.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-bm639sIu.js → DocumentsPanel-BfPlfiwU.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-KfwSzjeY.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-37IHOIW-.js → HealthPage-_VXG1Zeo.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-BH6grTuW.js +2 -0
- package/src/client/dist/pwa/assets/MainLayout-DnmUxNk7.css +1 -0
- package/src/client/dist/pwa/assets/{MemoryPanel-CucZMhjy.js → MemoryPanel-CVlvGZXZ.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-CUQybzVR.js → MemoryProposalList-SJsnzUAM.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-x6qPyl_7.js → QBtnDropdown-DBgGsN-D.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-D33aoRoG.js → QExpansionItem-DKgaMJ_l.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-Di-D6t2u.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-BhwG7lp2.js → QTooltip-X5ps8Opw.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-qFi9Wxzh.js → SchedulePanel-Ww_FVlW5.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-DA9nRmsA.js → SearchPage-B87WEHf3.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-BAZLGUyg.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-Brw2XsYJ.js → SplitWorkspacePage-B99AJUbG.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DVP18-nk.js → TerminalPanel-DnA1MXwu.js} +9 -9
- package/src/client/dist/pwa/assets/{ToolCallItem-cSHh3p_F.js → ToolCallItem-2vFggaur.js} +3 -3
- package/src/client/dist/pwa/assets/{TouchPan-C6LZZ3rw.js → TouchPan-BSU3z0aO.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-Czlqo18H.js → TourReplayButton-DQwS2xdD.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-BDYGq0pC.js → WorkspaceAttentionLabels-CM1tGDje.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-B248nKIU.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-CQN6EKIn.js +4 -0
- package/src/client/dist/pwa/assets/{browser-ifCwUGpE.js → browser-DPsupI9Q.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-BBkGq3lN.js → build-path-tree-CbIdt-p9.js} +1 -1
- package/src/client/dist/pwa/assets/custom-sounds-DR68eyF8.js +1 -0
- package/src/client/dist/pwa/assets/de-D3Ycvf_F.js +17 -0
- package/src/client/dist/pwa/assets/{editor.api-CV_seGJR.js → editor.api-DvgAtnOj.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-ttw0bL63.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-C8biMRXo.js → expand-template-Brn5O4a1.js} +1 -1
- package/src/client/dist/pwa/assets/{format-Cu95dbxt.js → format-DfJ3Fv1i.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-C25D1RSd.js → formatters-B30bNFhq.js} +1 -1
- package/src/client/dist/pwa/assets/fr-CJafryNb.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-DJEFr74q.js → freemarker2-DnZe74tc.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-C_lzsMjX.js → handlebars-CmuMfxzO.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CWmLFAmY.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-tsuiEUe-.js → javascript-Y-OrgcmW.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-BBr9BvUk.js → liquid-BqOkKePH.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-gjBH1cXG.js → mdx-D-58x2h0.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-CvGcBIsb.js → monaco-contributions-BW4ZMKO6.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-Uw-Lfb8Z.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-PMSxqP6t.js → notify-theme-DZDzfxph.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-BDQa2TgU.js → project-color-gB5yeZPN.js} +1 -1
- package/src/client/dist/pwa/assets/{python-DlIgD1n6.js → python-BkBj1wDC.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-AraFPlpM.js → razor-DOOJ_0b8.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-CB8UpXHM.js → typescript-Bheo43G5.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-CSREHQM4.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-BM3Giig1.js → use-worktree-restore-CgABsWno.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-BPMFvTnQ.js → workspace-templates-BnFkigYg.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-uRjL1P43.js → xml-vHt2BD26.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-C9CFR4qV.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 +156 -12
- package/src/mcp-server/kobo-tasks-server.ts +73 -106
- package/src/mcp-server/workspace-dialogue-client.ts +18 -3
- package/src/client/dist/pwa/assets/ActivityFeed-BTRzc60o.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-BZAoCukb.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-CdCs8PRS.css +0 -1
- package/src/client/dist/pwa/assets/DiffViewer-D0K2PlZD.js +0 -8
- package/src/client/dist/pwa/assets/GitPanel-C-Ejlc9_.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-BQOOup9o.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-DXQ1ouYl.js +0 -2
- package/src/client/dist/pwa/assets/QSelect-MdPXvwm4.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-DcV6apqE.js +0 -9
- package/src/client/dist/pwa/assets/WorkspacePage-BwUkijpS.js +0 -4
- package/src/client/dist/pwa/assets/WorkspacePage-Bwfv559o.css +0 -1
- package/src/client/dist/pwa/assets/custom-sounds-CeSgjIY7.js +0 -1
- package/src/client/dist/pwa/assets/de-CHY2BAfL.js +0 -17
- package/src/client/dist/pwa/assets/es-DbcSKp38.js +0 -17
- package/src/client/dist/pwa/assets/fr-CyyJ8S2c.js +0 -17
- package/src/client/dist/pwa/assets/i18n-CobVQiTM.js +0 -1
- package/src/client/dist/pwa/assets/index-ImUH7Bbt.js +0 -20
- package/src/client/dist/pwa/assets/it-XQJW2Xkr.js +0 -17
- package/src/client/dist/pwa/assets/network-auth-D0Vm6a09.js +0 -1
- package/src/client/dist/pwa/assets/use-tours-6yxNVmjH.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)
|
|
@@ -873,6 +873,8 @@ export function renameSession(sessionId, workspaceId, name) {
|
|
|
873
873
|
const row = db.prepare('SELECT * FROM agent_sessions WHERE id = ?').get(sessionId);
|
|
874
874
|
return row ? mapSession(row) : null;
|
|
875
875
|
}
|
|
876
|
+
export class ReviewSessionInUseError extends Error {
|
|
877
|
+
}
|
|
876
878
|
/** Delete a stopped session and only the persisted conversation attached to it. */
|
|
877
879
|
export function deleteSession(sessionId, workspaceId) {
|
|
878
880
|
const db = getDb();
|
|
@@ -881,6 +883,15 @@ export function deleteSession(sessionId, workspaceId) {
|
|
|
881
883
|
.get(sessionId, workspaceId);
|
|
882
884
|
if (!session)
|
|
883
885
|
return false;
|
|
886
|
+
const pendingReview = db
|
|
887
|
+
.prepare(`SELECT 1 FROM pending_review_returns WHERE workspace_id=? AND (original_session_id=? OR review_session_id=?)`)
|
|
888
|
+
.get(workspaceId, sessionId, sessionId);
|
|
889
|
+
const pendingFinalReview = db
|
|
890
|
+
.prepare(`SELECT 1 FROM auto_loop_final_reviews r JOIN workspaces w ON w.id=r.workspace_id
|
|
891
|
+
WHERE r.workspace_id=? AND w.auto_loop=1 AND r.state IN ('reviewing','fixing','blocked') AND (r.original_session_id=? OR r.review_session_id=?)`)
|
|
892
|
+
.get(workspaceId, sessionId, sessionId);
|
|
893
|
+
if (pendingReview || pendingFinalReview)
|
|
894
|
+
throw new ReviewSessionInUseError('This session is required by a pending review. Cancel the review return or stop auto-loop before deleting it.');
|
|
884
895
|
if (session.status === 'running') {
|
|
885
896
|
throw new Error(`Cannot delete active session '${sessionId}'`);
|
|
886
897
|
}
|
|
@@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
|
|
|
3
3
|
import { getDb } from '../db/index.js';
|
|
4
4
|
import { writeJsonFileAtomically } from '../utils/atomic-json-file.js';
|
|
5
5
|
import { getWorkspaceTemplatesPath } from '../utils/paths.js';
|
|
6
|
+
import { getFinalReviewStatus, parseFinalReviewConfiguration } from './auto-loop-final-review-service.js';
|
|
6
7
|
import { getGlobalSettings } from './settings-service.js';
|
|
7
8
|
const FILE_VERSION = 1;
|
|
8
9
|
export const MAX_WORKSPACE_TEMPLATES = 100;
|
|
@@ -33,6 +34,14 @@ export function sanitizePreset(input) {
|
|
|
33
34
|
return {};
|
|
34
35
|
const raw = input;
|
|
35
36
|
const preset = {};
|
|
37
|
+
if (raw.autoLoopFinalReview !== undefined) {
|
|
38
|
+
try {
|
|
39
|
+
preset.autoLoopFinalReview = parseFinalReviewConfiguration(raw.autoLoopFinalReview);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* Unsupported saved configuration is omitted. */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
36
45
|
for (const key of STRING_KEYS) {
|
|
37
46
|
if (typeof raw[key] === 'string')
|
|
38
47
|
preset[key] = raw[key];
|
|
@@ -216,6 +225,7 @@ export function presetFromWorkspace(workspaceId) {
|
|
|
216
225
|
model: row.model,
|
|
217
226
|
reasoningEffort: row.reasoning_effort,
|
|
218
227
|
autoLoop: row.auto_loop === 1,
|
|
228
|
+
autoLoopFinalReview: getFinalReviewStatus(workspaceId).configuration,
|
|
219
229
|
...(row.brainstorm_model ? { brainstormModel: row.brainstorm_model } : {}),
|
|
220
230
|
...(row.description ? { description: row.description } : {}),
|
|
221
231
|
tasks: tasks.filter((t) => t.is_acceptance_criterion !== 1).map((t) => t.title),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
}
|