@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
|
@@ -105,8 +105,16 @@ app.use('/:id/*', async (c, next) => {
|
|
|
105
105
|
}
|
|
106
106
|
await next();
|
|
107
107
|
});
|
|
108
|
-
//
|
|
109
|
-
|
|
108
|
+
// Branch/path mutations must also exclude teardown across awaits.
|
|
109
|
+
// Purge/delete/restore own their guard in the service.
|
|
110
|
+
for (const route of [
|
|
111
|
+
'/:id/archive',
|
|
112
|
+
'/:id/run-setup-script',
|
|
113
|
+
'/:id/cancel-source-change',
|
|
114
|
+
'/:id/rollback-file',
|
|
115
|
+
'/:id/rename-branch',
|
|
116
|
+
'/:id/resync-branch',
|
|
117
|
+
]) {
|
|
110
118
|
app.use(route, async (c, next) => {
|
|
111
119
|
try {
|
|
112
120
|
await withWorkspaceLifecycleGuard(c.req.param('id'), next);
|
|
@@ -570,6 +578,7 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
570
578
|
// the working branch from git itself — the body.workingBranch is ignored.
|
|
571
579
|
let useReusedWorktree = false;
|
|
572
580
|
let reusedDerivedBranch = null;
|
|
581
|
+
let reusedIdentity = null;
|
|
573
582
|
if (body.worktreePath) {
|
|
574
583
|
currentStep = 'inspect-worktree';
|
|
575
584
|
emitCreateProgress(creationId, currentStep);
|
|
@@ -598,6 +607,14 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
598
607
|
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
599
608
|
}
|
|
600
609
|
reusedDerivedBranch = branch;
|
|
610
|
+
const directory = fs.statSync(body.worktreePath);
|
|
611
|
+
const gitEntry = fs.statSync(path.join(body.worktreePath, '.git'));
|
|
612
|
+
reusedIdentity = {
|
|
613
|
+
device: directory.dev,
|
|
614
|
+
inode: directory.ino,
|
|
615
|
+
gitDevice: gitEntry.dev,
|
|
616
|
+
gitInode: gitEntry.ino,
|
|
617
|
+
};
|
|
601
618
|
}
|
|
602
619
|
catch (err) {
|
|
603
620
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -781,6 +798,36 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
781
798
|
const creation = await withGitRepoLock(body.projectPath, async () => {
|
|
782
799
|
let prospectiveWorktreePath;
|
|
783
800
|
if (useReusedWorktree) {
|
|
801
|
+
// Imports above may yield long enough for a failed PR creation to remove
|
|
802
|
+
// its checkout. Recheck the exact directory and branch under the same
|
|
803
|
+
// repository lock as compensation before any record/context files exist.
|
|
804
|
+
try {
|
|
805
|
+
const worktreePath = body.worktreePath;
|
|
806
|
+
const directory = fs.statSync(worktreePath);
|
|
807
|
+
const gitEntry = fs.statSync(path.join(worktreePath, '.git'));
|
|
808
|
+
const commonDir = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
809
|
+
encoding: 'utf-8',
|
|
810
|
+
timeout: 30_000,
|
|
811
|
+
}).trim();
|
|
812
|
+
const branch = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
813
|
+
encoding: 'utf-8',
|
|
814
|
+
timeout: 30_000,
|
|
815
|
+
}).trim();
|
|
816
|
+
if (!reusedIdentity ||
|
|
817
|
+
!directory.isDirectory() ||
|
|
818
|
+
directory.dev !== reusedIdentity.device ||
|
|
819
|
+
directory.ino !== reusedIdentity.inode ||
|
|
820
|
+
gitEntry.dev !== reusedIdentity.gitDevice ||
|
|
821
|
+
gitEntry.ino !== reusedIdentity.gitInode ||
|
|
822
|
+
realPathOrResolved(commonDir) !== realPathOrResolved(path.join(body.projectPath, '.git')) ||
|
|
823
|
+
branch !== reusedDerivedBranch)
|
|
824
|
+
throw new Error('The worktree directory, repository or branch changed during creation; retry from a fresh diagnosis');
|
|
825
|
+
}
|
|
826
|
+
catch (err) {
|
|
827
|
+
const failure = `Failed to revalidate worktree: ${err instanceof Error ? err.message : String(err)}`;
|
|
828
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
829
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
830
|
+
}
|
|
784
831
|
// Another attachment may have completed while this request waited for the lock.
|
|
785
832
|
const existing = getDb().prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
|
|
786
833
|
if (existing) {
|
|
@@ -3180,6 +3227,12 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
|
3180
3227
|
return c.json({ error: message }, workspaceErrorStatus(err));
|
|
3181
3228
|
}
|
|
3182
3229
|
});
|
|
3230
|
+
class WorkspaceBranchConfirmationError extends Error {
|
|
3231
|
+
code = 'branch-confirmation-mismatch';
|
|
3232
|
+
constructor() {
|
|
3233
|
+
super('The workspace branch changed or does not match confirmationBranch. Confirm its current branch before deleting.');
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3183
3236
|
// Shared teardown for a single workspace: stops the agent, destroys the
|
|
3184
3237
|
// terminal, removes the owned worktree, optionally deletes local/remote
|
|
3185
3238
|
// branches, then deletes the DB row (cascades to tasks/sessions/events).
|
|
@@ -3191,6 +3244,8 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
|
3191
3244
|
const current = workspaceService.getWorkspace(workspace.id);
|
|
3192
3245
|
if (!current)
|
|
3193
3246
|
throw new Error(`Workspace '${workspace.id}' not found`);
|
|
3247
|
+
if (opts.confirmationBranch !== undefined && opts.confirmationBranch !== current.workingBranch)
|
|
3248
|
+
throw new WorkspaceBranchConfirmationError();
|
|
3194
3249
|
// A bulk deletion may have captured the list before restoration completed.
|
|
3195
3250
|
if (workspace.archivedAt && !current.archivedAt) {
|
|
3196
3251
|
throw new Error('Workspace was unarchived while deletion was pending. Retry from its current state.');
|
|
@@ -3335,7 +3390,7 @@ app.delete('/:id', migrationGuard, async (c) => {
|
|
|
3335
3390
|
return c.json({ ok: true, warnings }, 200);
|
|
3336
3391
|
}
|
|
3337
3392
|
catch (err) {
|
|
3338
|
-
if (err instanceof WorkspaceLifecycleBusyError)
|
|
3393
|
+
if (err instanceof WorkspaceLifecycleBusyError || err instanceof WorkspaceBranchConfirmationError)
|
|
3339
3394
|
return c.json({ code: err.code, error: err.message }, 409);
|
|
3340
3395
|
const message = err instanceof Error ? err.message : String(err);
|
|
3341
3396
|
return c.json({ error: message }, workspaceErrorStatus(err));
|
|
@@ -1947,8 +1947,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId, befor
|
|
|
1947
1947
|
throw new Error(`Session '${expectedSessionId}' is not active for workspace '${workspaceId}'`);
|
|
1948
1948
|
}
|
|
1949
1949
|
wakeupService.cancel(workspaceId, 'user-message');
|
|
1950
|
-
beforeDispatch
|
|
1951
|
-
await ctrl.sendMessage(content);
|
|
1950
|
+
await ctrl.sendMessage(content, beforeDispatch);
|
|
1952
1951
|
}
|
|
1953
1952
|
export async function sendMessageForFallback(workspaceId, content) {
|
|
1954
1953
|
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
@@ -106,7 +106,7 @@ export class SessionController {
|
|
|
106
106
|
}
|
|
107
107
|
this._status = 'running';
|
|
108
108
|
}
|
|
109
|
-
async sendMessage(content) {
|
|
109
|
+
async sendMessage(content, beforeDispatch) {
|
|
110
110
|
if (!this._startPromise)
|
|
111
111
|
throw new Error('SessionController not started');
|
|
112
112
|
await this._startPromise;
|
|
@@ -114,6 +114,9 @@ export class SessionController {
|
|
|
114
114
|
throw new Error('SessionController is stopping');
|
|
115
115
|
if (!this._engineProcess)
|
|
116
116
|
throw new Error('SessionController not started');
|
|
117
|
+
// Startup may have yielded while the workspace's delivery mode changed.
|
|
118
|
+
// Validate immediately before handing the message to the engine.
|
|
119
|
+
beforeDispatch?.();
|
|
117
120
|
await this._engineProcess.sendMessage(content);
|
|
118
121
|
}
|
|
119
122
|
interrupt() {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getDb } from '../db/index.js';
|
|
5
|
+
import { buildNonInteractiveGitEnv } from '../utils/git-ops.js';
|
|
6
|
+
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
7
|
+
/** Undo only our unchanged checkout, never the PR branch or earlier checkout decisions. */
|
|
8
|
+
export async function rollbackCreatedPrCheckout(projectPath, checkout) {
|
|
9
|
+
const retained = (reason) => ({ removed: false, worktreePath: checkout.worktreePath, reason });
|
|
10
|
+
if (!checkout.createdWorktree)
|
|
11
|
+
return retained('The checkout existed before this request');
|
|
12
|
+
try {
|
|
13
|
+
return await withGitRepoLock(projectPath, () => {
|
|
14
|
+
// Workspace adoption uses this same lock. Keep checks and removal synchronous
|
|
15
|
+
// so another request cannot attach this checkout between the ownership read and removal.
|
|
16
|
+
const owners = getDb().prepare('SELECT worktree_path FROM workspaces').all();
|
|
17
|
+
const resolved = fs.realpathSync(checkout.worktreePath);
|
|
18
|
+
if (owners.some(({ worktree_path }) => {
|
|
19
|
+
try {
|
|
20
|
+
return fs.realpathSync(worktree_path) === resolved;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return path.resolve(worktree_path) === resolved;
|
|
24
|
+
}
|
|
25
|
+
}))
|
|
26
|
+
return retained('The checkout is attached to a workspace');
|
|
27
|
+
const stat = fs.lstatSync(checkout.worktreePath);
|
|
28
|
+
const identity = checkout.createdWorktree;
|
|
29
|
+
if (!stat.isDirectory() || stat.dev !== identity.device || stat.ino !== identity.inode)
|
|
30
|
+
return retained('The checkout directory was replaced');
|
|
31
|
+
const git = (cwd, args) => execFileSync('git', args, {
|
|
32
|
+
cwd,
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
timeout: 30_000,
|
|
35
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
36
|
+
env: buildNonInteractiveGitEnv(),
|
|
37
|
+
}).trim();
|
|
38
|
+
if (git(resolved, ['rev-parse', 'HEAD']) !== identity.head ||
|
|
39
|
+
git(resolved, ['symbolic-ref', '--short', 'HEAD']) !== checkout.workingBranch)
|
|
40
|
+
return retained('The checkout branch or HEAD changed');
|
|
41
|
+
if (git(resolved, ['status', '--porcelain', '--untracked-files=all', '--ignored']))
|
|
42
|
+
return retained('The checkout contains local or ignored files');
|
|
43
|
+
// No --force: Git performs its own final dirty/locked-worktree checks.
|
|
44
|
+
git(projectPath, ['worktree', 'remove', '--', resolved]);
|
|
45
|
+
return { removed: true, worktreePath: checkout.worktreePath };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return retained(error instanceof Error ? error.message : String(error));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -227,6 +227,8 @@ export async function applyBranchStrategy(repoPath, branch, strategy) {
|
|
|
227
227
|
if (strategy === 'keep')
|
|
228
228
|
return { strategy, backupBranch: null };
|
|
229
229
|
if (strategy === 'fast-forward') {
|
|
230
|
+
// A caller cannot turn fast-forward into a silent reset of local commits.
|
|
231
|
+
await readGitAsync(repoPath, ['merge-base', '--is-ancestor', branch, remoteRef]);
|
|
230
232
|
await runGitAsync(repoPath, ['branch', '--force', branch, remoteRef]);
|
|
231
233
|
return { strategy, backupBranch: null };
|
|
232
234
|
}
|
|
@@ -301,6 +303,9 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
301
303
|
const fresh = await diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
|
|
302
304
|
if ((await computeFingerprint(fresh)) !== input.fingerprint)
|
|
303
305
|
throw new StaleDiagnosisError(fresh);
|
|
306
|
+
if (input.decisions.divergence === 'fast-forward' &&
|
|
307
|
+
(fresh.branch.state === 'ahead' || fresh.branch.state === 'diverged'))
|
|
308
|
+
throw new Error('Fast-forward cannot discard local commits. Keep, rebase or explicitly reset the branch.');
|
|
304
309
|
if ((fresh.worktree.state === 'orphan' || fresh.worktree.state === 'attached') &&
|
|
305
310
|
input.decisions.orphanWorktree === 'create-elsewhere') {
|
|
306
311
|
throw new Error('Branch is already checked out. Attach its existing worktree instead.');
|
|
@@ -392,7 +397,16 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
392
397
|
// realignment from step 4 (if any) is NOT undone here — a `reset-hard`
|
|
393
398
|
// strategy already left its own `kobo-backup/<branch>-<ts>` safety net,
|
|
394
399
|
// and fast-forward/rebase are non-destructive by construction.
|
|
400
|
+
let createdWorktree;
|
|
395
401
|
try {
|
|
402
|
+
if (created) {
|
|
403
|
+
const stat = fs.lstatSync(worktreePath);
|
|
404
|
+
createdWorktree = {
|
|
405
|
+
device: stat.dev,
|
|
406
|
+
inode: stat.ino,
|
|
407
|
+
head: await runGitAsync(worktreePath, ['rev-parse', 'HEAD']),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
396
410
|
input.afterWorktreeHook?.();
|
|
397
411
|
}
|
|
398
412
|
catch (err) {
|
|
@@ -409,7 +423,13 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
409
423
|
}
|
|
410
424
|
throw err;
|
|
411
425
|
}
|
|
412
|
-
return {
|
|
426
|
+
return {
|
|
427
|
+
worktreePath,
|
|
428
|
+
workingBranch: input.headBranch,
|
|
429
|
+
sourceBranch: input.baseBranch,
|
|
430
|
+
applied,
|
|
431
|
+
...(createdWorktree ? { createdWorktree } : {}),
|
|
432
|
+
};
|
|
413
433
|
});
|
|
414
434
|
}
|
|
415
435
|
/** Realign a branch from inside its own worktree, where ref surgery is refused. */
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { checkoutDecisionError, deriveSteps } from '../../shared/pr-checkout-steps.js';
|
|
2
|
+
import { validateWorkspaceCreationArguments } from '../../shared/workspace-creation-tools.js';
|
|
3
|
+
import { CLAUDE_CODE_CAPABILITIES } from './agent/engines/claude-code/capabilities.js';
|
|
4
|
+
import { CODEX_CAPABILITIES } from './agent/engines/codex/capabilities.js';
|
|
5
|
+
import { rollbackCreatedPrCheckout } from './pr-checkout-rollback-service.js';
|
|
6
|
+
export class WorkspaceCreationMcpError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
stage;
|
|
9
|
+
details;
|
|
10
|
+
constructor(message, status, stage, details = null) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.stage = stage;
|
|
14
|
+
this.details = details;
|
|
15
|
+
this.name = 'WorkspaceCreationMcpError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function request(dispatch, path, stage, body, init) {
|
|
19
|
+
const response = await dispatch(path, init ?? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
|
20
|
+
const payload = await response.json().catch(() => null);
|
|
21
|
+
if (!response.ok)
|
|
22
|
+
throw new WorkspaceCreationMcpError(payload?.error ?? `Workspace request failed (${response.status})`, response.status, stage, payload);
|
|
23
|
+
return payload;
|
|
24
|
+
}
|
|
25
|
+
function prIdentity(raw) {
|
|
26
|
+
const url = new URL(raw);
|
|
27
|
+
const pathname = url.pathname.replace(/\/+$/, '');
|
|
28
|
+
const last = pathname.split('/').at(-1);
|
|
29
|
+
const number = Number(last);
|
|
30
|
+
if (!/^[1-9]\d*$/.test(last) || !Number.isSafeInteger(number))
|
|
31
|
+
throw new WorkspaceCreationMcpError('pr_url must end with a PR/MR number', 400, 'validate');
|
|
32
|
+
return { number, identity: `${url.origin}${pathname}` };
|
|
33
|
+
}
|
|
34
|
+
function mapInput(input) {
|
|
35
|
+
const mapping = {
|
|
36
|
+
name: 'name',
|
|
37
|
+
project_path: 'projectPath',
|
|
38
|
+
source_branch: 'sourceBranch',
|
|
39
|
+
working_branch: 'workingBranch',
|
|
40
|
+
model: 'model',
|
|
41
|
+
reasoning_effort: 'reasoningEffort',
|
|
42
|
+
brainstorm_model: 'brainstormModel',
|
|
43
|
+
brainstorm_reasoning_effort: 'brainstormReasoningEffort',
|
|
44
|
+
engine: 'engine',
|
|
45
|
+
description: 'description',
|
|
46
|
+
tags: 'tags',
|
|
47
|
+
tasks: 'tasks',
|
|
48
|
+
acceptance_criteria: 'acceptanceCriteria',
|
|
49
|
+
agent_permission_mode: 'agentPermissionMode',
|
|
50
|
+
auto_loop: 'autoLoop',
|
|
51
|
+
auto_loop_session_mode: 'autoLoopSessionMode',
|
|
52
|
+
skip_setup_script: 'skipSetupScript',
|
|
53
|
+
workflow_policy: 'workflowPolicy',
|
|
54
|
+
notion_url: 'notionUrl',
|
|
55
|
+
notion_page_id: 'notionPageId',
|
|
56
|
+
sentry_url: 'sentryUrl',
|
|
57
|
+
pr_url: 'prUrl',
|
|
58
|
+
worktree_path: 'worktreePath',
|
|
59
|
+
comparison_id: 'comparisonId',
|
|
60
|
+
creation_id: 'creationId',
|
|
61
|
+
};
|
|
62
|
+
const body = {};
|
|
63
|
+
for (const [key, field] of Object.entries(mapping))
|
|
64
|
+
if (Object.hasOwn(input, key))
|
|
65
|
+
body[field] = input[key];
|
|
66
|
+
if (input.auto_loop_final_review !== undefined) {
|
|
67
|
+
const config = input.auto_loop_final_review;
|
|
68
|
+
body.autoLoopFinalReview =
|
|
69
|
+
config === null
|
|
70
|
+
? null
|
|
71
|
+
: {
|
|
72
|
+
engine: config.engine,
|
|
73
|
+
model: config.model,
|
|
74
|
+
reasoningEffort: config.reasoning_effort,
|
|
75
|
+
additionalInstructions: config.additional_instructions ?? '',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return body;
|
|
79
|
+
}
|
|
80
|
+
/** Reuses the application's route validation, lifecycle guards, Git locks and rollback. */
|
|
81
|
+
export async function executeWorkspaceCreationTool(name, raw, dispatch) {
|
|
82
|
+
let input;
|
|
83
|
+
try {
|
|
84
|
+
input = validateWorkspaceCreationArguments(name, raw);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new WorkspaceCreationMcpError(error instanceof Error ? error.message : 'Invalid arguments', 400, 'validate');
|
|
88
|
+
}
|
|
89
|
+
const config = input.auto_loop_final_review;
|
|
90
|
+
if (config) {
|
|
91
|
+
const capabilities = config.engine === 'codex' ? CODEX_CAPABILITIES : CLAUDE_CODE_CAPABILITIES;
|
|
92
|
+
if (!capabilities.effortLevels?.some((effort) => effort.id === config.reasoning_effort))
|
|
93
|
+
throw new WorkspaceCreationMcpError('Unsupported review reasoning effort', 400, 'validate');
|
|
94
|
+
}
|
|
95
|
+
const body = mapInput(input);
|
|
96
|
+
let resolvedCheckout;
|
|
97
|
+
if (input.pr_url) {
|
|
98
|
+
const supplied = prIdentity(input.pr_url);
|
|
99
|
+
const diagnosis = await request(dispatch, '/api/pull-requests/diagnose', 'diagnose', {
|
|
100
|
+
projectPath: input.project_path,
|
|
101
|
+
prNumber: supplied.number,
|
|
102
|
+
});
|
|
103
|
+
const requiresAction = (reason) => ({
|
|
104
|
+
created: false,
|
|
105
|
+
requiresAction: true,
|
|
106
|
+
...diagnosis,
|
|
107
|
+
...(reason ? { reason } : {}),
|
|
108
|
+
});
|
|
109
|
+
if (diagnosis.pr && prIdentity(diagnosis.pr.url).identity !== supplied.identity)
|
|
110
|
+
throw new WorkspaceCreationMcpError('pr_url does not belong to the selected project', 400, 'diagnose', diagnosis);
|
|
111
|
+
if (name === 'diagnose_workspace_pr')
|
|
112
|
+
return diagnosis;
|
|
113
|
+
if (!diagnosis.pr)
|
|
114
|
+
return requiresAction('Pull request unavailable');
|
|
115
|
+
const { report, pr, fingerprint } = diagnosis;
|
|
116
|
+
const plan = input.pr_checkout;
|
|
117
|
+
if (plan && plan.fingerprint !== fingerprint)
|
|
118
|
+
throw new WorkspaceCreationMcpError('The repository changed since the diagnosis was taken', 409, 'diagnose', diagnosis);
|
|
119
|
+
const decisions = plan?.decisions ?? {};
|
|
120
|
+
if (report.workspace.state === 'active' && decisions.existingWorkspace === 'open')
|
|
121
|
+
return { created: false, workspaceId: report.workspace.id, workspace: report.workspace };
|
|
122
|
+
if (report.workspace.state === 'archived' && decisions.archivedWorkspace === 'unarchive') {
|
|
123
|
+
const workspace = await request(dispatch, `/api/workspaces/${encodeURIComponent(report.workspace.id)}/unarchive`, 'unarchive');
|
|
124
|
+
return { created: false, workspaceId: report.workspace.id, workspace };
|
|
125
|
+
}
|
|
126
|
+
// A purge or an existing association must never fall through to a second creation.
|
|
127
|
+
if (report.workspace.state !== 'none')
|
|
128
|
+
return requiresAction('Use the existing workspace; automatic duplicate creation is not supported');
|
|
129
|
+
if (decisions.ongoingOperation === 'cancel')
|
|
130
|
+
return { created: false, cancelled: true, ...diagnosis };
|
|
131
|
+
const error = checkoutDecisionError(report, decisions);
|
|
132
|
+
if (error)
|
|
133
|
+
throw new WorkspaceCreationMcpError(error, 400, 'validate-checkout', diagnosis);
|
|
134
|
+
const steps = deriveSteps(report);
|
|
135
|
+
if (steps.some((step) => step.blocking))
|
|
136
|
+
return requiresAction('Checkout is blocked');
|
|
137
|
+
if (!plan && (steps.length > 0 || report.worktree.state !== 'none'))
|
|
138
|
+
return requiresAction();
|
|
139
|
+
if (plan) {
|
|
140
|
+
// Every state requiring a choice needs an explicit decision. In particular,
|
|
141
|
+
// do not inherit the UI's defaults for reset/stash/abort or path selection.
|
|
142
|
+
const missing = steps.some((step) => (step.id === 'path' && !decisions.pathCollision) ||
|
|
143
|
+
(step.id === 'worktree' && !decisions.orphanWorktree) ||
|
|
144
|
+
(step.id === 'operation' && !decisions.ongoingOperation) ||
|
|
145
|
+
(step.id === 'changes' && !decisions.localChanges) ||
|
|
146
|
+
(step.id === 'divergence' && !decisions.divergence));
|
|
147
|
+
if (missing)
|
|
148
|
+
return requiresAction('Explicit checkout decisions are required');
|
|
149
|
+
}
|
|
150
|
+
body.sourceBranch = pr.baseBranch;
|
|
151
|
+
body.workingBranch = pr.headBranch;
|
|
152
|
+
body.prUrl = pr.url;
|
|
153
|
+
body.skipSetupScript = input.skip_setup_script ?? true;
|
|
154
|
+
if (input.worktree_path) {
|
|
155
|
+
const diagnosedPath = 'path' in report.worktree ? report.worktree.path : null;
|
|
156
|
+
if (diagnosedPath !== input.worktree_path)
|
|
157
|
+
throw new WorkspaceCreationMcpError('worktree_path does not match the diagnosed PR checkout', 400, 'validate-checkout', diagnosis);
|
|
158
|
+
}
|
|
159
|
+
const resolved = await request(dispatch, '/api/pull-requests/resolve', 'resolve', {
|
|
160
|
+
projectPath: input.project_path,
|
|
161
|
+
prNumber: pr.number,
|
|
162
|
+
headBranch: pr.headBranch,
|
|
163
|
+
baseBranch: pr.baseBranch,
|
|
164
|
+
decisions,
|
|
165
|
+
fingerprint,
|
|
166
|
+
});
|
|
167
|
+
body.worktreePath = resolved.worktreePath;
|
|
168
|
+
resolvedCheckout = resolved;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const attachments = input.attachments;
|
|
172
|
+
if (attachments?.length) {
|
|
173
|
+
const form = new FormData();
|
|
174
|
+
form.append('workspace', JSON.stringify(body));
|
|
175
|
+
for (const file of attachments)
|
|
176
|
+
form.append('attachments', new Blob([Buffer.from(file.data_base64, 'base64')], { type: file.mime_type }), file.name);
|
|
177
|
+
return await request(dispatch, '/api/workspaces', 'create', undefined, { method: 'POST', body: form });
|
|
178
|
+
}
|
|
179
|
+
return await request(dispatch, '/api/workspaces', 'create', body);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (!resolvedCheckout)
|
|
183
|
+
throw error;
|
|
184
|
+
if (!(error instanceof WorkspaceCreationMcpError))
|
|
185
|
+
throw new WorkspaceCreationMcpError(error instanceof Error ? error.message : String(error), 500, 'create', {
|
|
186
|
+
checkoutRecovery: {
|
|
187
|
+
removed: false,
|
|
188
|
+
worktreePath: resolvedCheckout.worktreePath,
|
|
189
|
+
reason: 'Creation outcome is unknown; inspect the workspace and checkout before retrying',
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
const checkoutRecovery = await rollbackCreatedPrCheckout(input.project_path, resolvedCheckout);
|
|
193
|
+
throw new WorkspaceCreationMcpError(error.message, error.status, error.stage, {
|
|
194
|
+
...(error.details && typeof error.details === 'object' ? error.details : {}),
|
|
195
|
+
checkoutRecovery,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { validateWorkspaceGroupMessageArguments } from '../../shared/workspace-group-message-tools.js';
|
|
2
|
+
import { MAX_GROUP_MESSAGE_RECIPIENTS, matchesGroupMessageFilters } from '../../shared/workspace-group-messages.js';
|
|
3
|
+
import { GroupMessageError, getGroupMessageBatch, startGroupMessageBatch } from './workspace-group-message-service.js';
|
|
4
|
+
import { listWorkspaces } from './workspace-service.js';
|
|
5
|
+
export class WorkspaceGroupMessageMcpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(message, status) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function executeWorkspaceGroupMessageTool(name, raw, source) {
|
|
13
|
+
let input;
|
|
14
|
+
try {
|
|
15
|
+
input = validateWorkspaceGroupMessageArguments(name, raw);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
throw new WorkspaceGroupMessageMcpError(error instanceof Error ? error.message : 'Invalid arguments', 400);
|
|
19
|
+
}
|
|
20
|
+
if (name === 'preview_workspace_group_message') {
|
|
21
|
+
const filters = {
|
|
22
|
+
tags: input.tags,
|
|
23
|
+
statuses: input.statuses,
|
|
24
|
+
devServerRunning: input.dev_server_running,
|
|
25
|
+
};
|
|
26
|
+
const candidates = listWorkspaces()
|
|
27
|
+
.filter((workspace) => !workspace.archivedAt && !workspace.worktreePurgedAt && matchesGroupMessageFilters(workspace, filters))
|
|
28
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
29
|
+
const offset = input.offset ?? 0;
|
|
30
|
+
const limit = input.limit ?? MAX_GROUP_MESSAGE_RECIPIENTS;
|
|
31
|
+
return {
|
|
32
|
+
total: candidates.length,
|
|
33
|
+
offset,
|
|
34
|
+
limit,
|
|
35
|
+
recipients: candidates.slice(offset, offset + limit).map((workspace) => ({
|
|
36
|
+
workspaceId: workspace.id,
|
|
37
|
+
name: workspace.name,
|
|
38
|
+
tags: workspace.tags,
|
|
39
|
+
status: workspace.status,
|
|
40
|
+
delivery: workspace.autoLoop ? 'next_iteration' : 'immediate',
|
|
41
|
+
})),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (name === 'get_workspace_group_message') {
|
|
45
|
+
const batch = getGroupMessageBatch(input.request_id);
|
|
46
|
+
if (!batch)
|
|
47
|
+
throw new WorkspaceGroupMessageMcpError('Group message request not found', 404);
|
|
48
|
+
return batch;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return startGroupMessageBatch({ requestId: input.request_id, workspaceIds: input.workspace_ids, content: input.content }, source);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof GroupMessageError)
|
|
55
|
+
throw new WorkspaceGroupMessageMcpError(error.message, error.status);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|