@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
|
@@ -4,9 +4,10 @@ import { updateWorkspaceEngineConfiguration } from './workspace-service.js';
|
|
|
4
4
|
export function registerReviewReturn(pending) {
|
|
5
5
|
getDb()
|
|
6
6
|
.prepare(`INSERT INTO pending_review_returns
|
|
7
|
-
(workspace_id, review_session_id, original_session_id, original_configuration, review_configuration, created_at)
|
|
8
|
-
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
9
|
-
.run(pending.workspaceId, pending.reviewSessionId, pending.originalSessionId, JSON.stringify(pending.original), JSON.stringify(pending.review), new Date().toISOString());
|
|
7
|
+
(workspace_id, review_session_id, original_session_id, original_configuration, review_configuration, created_at, review_prompt)
|
|
8
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
9
|
+
.run(pending.workspaceId, pending.reviewSessionId, pending.originalSessionId, JSON.stringify(pending.original), JSON.stringify(pending.review), new Date().toISOString(), pending.reviewPrompt ?? null);
|
|
10
|
+
emitEphemeral(pending.workspaceId, 'review:return-status', getReviewReturnStatus(pending.workspaceId));
|
|
10
11
|
}
|
|
11
12
|
export function getReviewReturn(workspaceId, reviewSessionId) {
|
|
12
13
|
const row = getDb().prepare('SELECT * FROM pending_review_returns WHERE workspace_id = ?').get(workspaceId);
|
|
@@ -18,26 +19,73 @@ export function getReviewReturn(workspaceId, reviewSessionId) {
|
|
|
18
19
|
originalSessionId: row.original_session_id,
|
|
19
20
|
original: JSON.parse(row.original_configuration),
|
|
20
21
|
review: JSON.parse(row.review_configuration),
|
|
22
|
+
phase: row.phase,
|
|
23
|
+
reviewPrompt: row.review_prompt,
|
|
24
|
+
returnPrompt: row.return_prompt,
|
|
25
|
+
error: row.last_error,
|
|
21
26
|
};
|
|
22
27
|
}
|
|
23
|
-
/**
|
|
24
|
-
export function restoreReviewConfiguration(workspaceId, reviewSessionId) {
|
|
28
|
+
/** Restore initial settings; cancellation removes intent, suspension preserves it. */
|
|
29
|
+
export function restoreReviewConfiguration(workspaceId, reviewSessionId, preserve = false) {
|
|
25
30
|
const pending = getReviewReturn(workspaceId, reviewSessionId);
|
|
26
31
|
if (!pending)
|
|
27
32
|
return null;
|
|
28
33
|
getDb().transaction(() => {
|
|
29
|
-
|
|
34
|
+
if (!preserve)
|
|
35
|
+
getDb().prepare('DELETE FROM pending_review_returns WHERE workspace_id = ?').run(workspaceId);
|
|
30
36
|
const original = pending.original;
|
|
31
37
|
updateWorkspaceEngineConfiguration(workspaceId, original.engine, original.model, original.reasoningEffort, original.agentPermissionMode);
|
|
32
38
|
})();
|
|
33
39
|
emitEphemeral(workspaceId, 'workspace:configuration', pending.original);
|
|
40
|
+
if (!preserve)
|
|
41
|
+
emitEphemeral(workspaceId, 'review:return-status', null);
|
|
34
42
|
return pending;
|
|
35
43
|
}
|
|
36
|
-
/**
|
|
44
|
+
/** Restore user settings without discarding an unfinished review/return. */
|
|
37
45
|
export function reconcileReviewReturns() {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
for (const pending of listReviewReturns()) {
|
|
47
|
+
if (pending.phase === 'dispatching')
|
|
48
|
+
setReviewReturnPhase(pending.workspaceId, 'unknown', 'The server stopped while sending the review report. Check the original session before retrying to avoid duplicate delivery.');
|
|
49
|
+
restoreReviewConfiguration(pending.workspaceId, undefined, true);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function getReviewReturnStatus(workspaceId) {
|
|
53
|
+
const pending = getReviewReturn(workspaceId);
|
|
54
|
+
return pending
|
|
55
|
+
? {
|
|
56
|
+
reviewSessionId: pending.reviewSessionId,
|
|
57
|
+
originalSessionId: pending.originalSessionId,
|
|
58
|
+
phase: pending.phase ?? 'reviewing',
|
|
59
|
+
error: pending.error ?? null,
|
|
60
|
+
}
|
|
61
|
+
: null;
|
|
62
|
+
}
|
|
63
|
+
export function listReviewReturns() {
|
|
64
|
+
return getDb().prepare('SELECT workspace_id FROM pending_review_returns').all()
|
|
65
|
+
.map((row) => getReviewReturn(row.workspace_id))
|
|
66
|
+
.filter((row) => row !== null);
|
|
67
|
+
}
|
|
68
|
+
export function setReviewReturnPhase(workspaceId, phase, error = null, prompt) {
|
|
69
|
+
getDb()
|
|
70
|
+
.prepare('UPDATE pending_review_returns SET phase = ?, last_error = ?, return_prompt = COALESCE(?, return_prompt) WHERE workspace_id = ?')
|
|
71
|
+
.run(phase, error, prompt ?? null, workspaceId);
|
|
72
|
+
emitEphemeral(workspaceId, 'review:return-status', getReviewReturnStatus(workspaceId));
|
|
73
|
+
}
|
|
74
|
+
/** Claim the known-unsent report before any asynchronous engine dispatch. */
|
|
75
|
+
export function claimReviewReturn(workspaceId) {
|
|
76
|
+
const claimed = getDb()
|
|
77
|
+
.prepare("UPDATE pending_review_returns SET phase = 'dispatching', last_error = NULL WHERE workspace_id = ? AND phase = 'ready'")
|
|
78
|
+
.run(workspaceId).changes === 1;
|
|
79
|
+
if (claimed)
|
|
80
|
+
emitEphemeral(workspaceId, 'review:return-status', getReviewReturnStatus(workspaceId));
|
|
81
|
+
return claimed;
|
|
82
|
+
}
|
|
83
|
+
export function completeReviewReturn(workspaceId) {
|
|
84
|
+
const removed = getDb()
|
|
85
|
+
.prepare("DELETE FROM pending_review_returns WHERE workspace_id = ? AND phase = 'dispatching'")
|
|
86
|
+
.run(workspaceId).changes;
|
|
87
|
+
if (removed)
|
|
88
|
+
emitEphemeral(workspaceId, 'review:return-status', null);
|
|
41
89
|
}
|
|
42
90
|
/** Reassemble the last assistant message, handling deltas and final snapshots. */
|
|
43
91
|
export function buildReviewReturnPrompt(workspaceId, reviewSessionId) {
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { reviewConfigurationChanged } from '../../shared/review.js';
|
|
4
|
+
import { getDb } from '../db/index.js';
|
|
4
5
|
import { assertAgentStopped } from '../utils/agent-stop-result.js';
|
|
5
6
|
import * as gitOps from '../utils/git-ops.js';
|
|
6
7
|
import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
|
|
7
8
|
import { listEngines } from './agent/engines/registry.js';
|
|
8
9
|
import * as agentManager from './agent/orchestrator.js';
|
|
10
|
+
import { bindReviewSession, getFinalReviewSourceSession } from './auto-loop-final-review-service.js';
|
|
11
|
+
import { canStartAutomatically } from './auto-loop-service.js';
|
|
12
|
+
import { beginReviewLaunch, finishReviewLaunch } from './review-launch-runtime.js';
|
|
9
13
|
import { getReviewReturn, registerReviewReturn, restoreReviewConfiguration } from './review-return-service.js';
|
|
10
14
|
import { getActiveReviewTemplate, renderReviewTemplate } from './review-template-service.js';
|
|
11
15
|
import * as wsService from './websocket-service.js';
|
|
12
16
|
import * as workspaceService from './workspace-service.js';
|
|
13
17
|
const execFileAsync = promisify(execFile);
|
|
14
|
-
const launching = new Set();
|
|
15
18
|
export class ReviewRequestError extends Error {
|
|
16
19
|
status;
|
|
17
20
|
constructor(message, status) {
|
|
@@ -19,6 +22,11 @@ export class ReviewRequestError extends Error {
|
|
|
19
22
|
this.status = status;
|
|
20
23
|
}
|
|
21
24
|
}
|
|
25
|
+
export class ReviewAdmissionDeferred extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super('Automatic review is waiting for agent capacity');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
22
30
|
export function parseReviewRequest(input) {
|
|
23
31
|
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
24
32
|
throw new ReviewRequestError('Request body must be a JSON object', 400);
|
|
@@ -34,7 +42,7 @@ export function parseReviewRequest(input) {
|
|
|
34
42
|
}
|
|
35
43
|
return body;
|
|
36
44
|
}
|
|
37
|
-
function resolveConfiguration(workspace, body) {
|
|
45
|
+
function resolveConfiguration(workspace, body, allowCustomModel = false) {
|
|
38
46
|
const engineId = body.engine ?? workspace.engine;
|
|
39
47
|
const engine = listEngines().find((e) => e.id === engineId);
|
|
40
48
|
if (!engine)
|
|
@@ -48,7 +56,8 @@ function resolveConfiguration(workspace, body) {
|
|
|
48
56
|
agentPermissionMode: body.returnToSession ? 'plan' : (body.agentPermissionMode ?? workspace.agentPermissionMode),
|
|
49
57
|
};
|
|
50
58
|
// Preserve an existing custom model when unchanged; reject incompatible selections.
|
|
51
|
-
if (
|
|
59
|
+
if (!allowCustomModel &&
|
|
60
|
+
(changedEngine || config.model !== workspace.model) &&
|
|
52
61
|
!engine.capabilities.models.some((m) => m.id === config.model))
|
|
53
62
|
throw new ReviewRequestError(`Model '${config.model}' is not supported by '${engineId}'`, 400);
|
|
54
63
|
if ((changedEngine || config.reasoningEffort !== workspace.reasoningEffort) &&
|
|
@@ -62,22 +71,25 @@ function configure(id, config) {
|
|
|
62
71
|
workspaceService.updateWorkspaceEngineConfiguration(id, config.engine, config.model, config.reasoningEffort, config.agentPermissionMode);
|
|
63
72
|
wsService.emitEphemeral(id, 'workspace:configuration', config);
|
|
64
73
|
}
|
|
65
|
-
export async function startWorkspaceReview(id, input) {
|
|
66
|
-
|
|
74
|
+
export async function startWorkspaceReview(id, input, internal) {
|
|
75
|
+
const launch = beginReviewLaunch(id);
|
|
76
|
+
if (!launch)
|
|
67
77
|
throw new ReviewRequestError('A review is already being started for this workspace', 409);
|
|
68
|
-
launching.add(id);
|
|
69
78
|
try {
|
|
70
79
|
const workspace = workspaceService.getWorkspace(id);
|
|
71
80
|
if (!workspace)
|
|
72
81
|
throw new ReviewRequestError(`Workspace '${id}' not found`, 404);
|
|
73
82
|
const body = parseReviewRequest(input);
|
|
74
|
-
const configuration = resolveConfiguration(workspace, body);
|
|
83
|
+
const configuration = resolveConfiguration(workspace, body, internal?.autoLoopFinalReview);
|
|
75
84
|
assertWorkspaceLifecycleAvailable(id);
|
|
76
85
|
if (workspace.archivedAt || workspace.worktreePurgedAt)
|
|
77
86
|
throw new ReviewRequestError('Restore the workspace before starting a review', 409);
|
|
78
87
|
if (getReviewReturn(id))
|
|
79
88
|
throw new ReviewRequestError('A review with an automatic return is already in progress', 409);
|
|
80
|
-
const
|
|
89
|
+
const sourceSessionId = internal?.autoLoopFinalReview ? getFinalReviewSourceSession(id) : null;
|
|
90
|
+
const originalSession = internal?.autoLoopFinalReview
|
|
91
|
+
? workspaceService.listSessions(id).find((session) => session.id === sourceSessionId)
|
|
92
|
+
: workspaceService.getActiveSession(id);
|
|
81
93
|
const changed = reviewConfigurationChanged(workspace, configuration);
|
|
82
94
|
const differsFromSession = !!originalSession &&
|
|
83
95
|
((!!originalSession.engine && originalSession.engine !== configuration.engine) ||
|
|
@@ -127,20 +139,34 @@ export async function startWorkspaceReview(id, input) {
|
|
|
127
139
|
'\n\nThis is a read-only review session; these rules override any template instruction or skill that suggests fixing issues. Do not modify, create or delete files, and do not commit or push: the original agent session applies the fixes. Running read-only checks such as tests or linters is fine. Do not ask the user questions or wait for input: note any decision that needs the user in your summary instead.' +
|
|
128
140
|
'\n\nFinish with a standalone summary of your review findings, including severity, file/line references, recommended fixes and any remaining uncertainty. This final message will be handed back to the original agent session. Do not rely on earlier messages to explain your findings.';
|
|
129
141
|
assertWorkspaceLifecycleAvailable(id);
|
|
142
|
+
if (launch.cancelled)
|
|
143
|
+
throw new ReviewRequestError('Review launch was cancelled', 409);
|
|
130
144
|
const current = workspaceService.getWorkspace(id);
|
|
131
145
|
if (!current || current.archivedAt || current.worktreePurgedAt || current.status === 'compacting')
|
|
132
146
|
throw new ReviewRequestError('The workspace is no longer available for a review', 409);
|
|
133
147
|
if (reviewConfigurationChanged(current, workspace))
|
|
134
148
|
throw new ReviewRequestError('The workspace configuration changed; reopen the review dialog', 409);
|
|
135
|
-
if (body.returnToSession &&
|
|
149
|
+
if (body.returnToSession &&
|
|
150
|
+
(internal?.autoLoopFinalReview ? getFinalReviewSourceSession(id) : workspaceService.getActiveSession(id)?.id) !==
|
|
151
|
+
originalSession?.id)
|
|
136
152
|
throw new ReviewRequestError('The current session changed; reopen the review dialog', 409);
|
|
153
|
+
if (internal?.autoLoopFinalReview && (!current.autoLoop || internal.shouldLaunch?.() === false))
|
|
154
|
+
throw new ReviewRequestError('Final review was cancelled before launch', 409);
|
|
137
155
|
let emitSessionId;
|
|
138
156
|
if (newSession) {
|
|
139
157
|
assertAgentStopped(await agentManager.stopAgentAndWait(id, undefined, 'replacement'));
|
|
140
158
|
assertWorkspaceLifecycleAvailable(id);
|
|
159
|
+
if (launch.cancelled)
|
|
160
|
+
throw new ReviewRequestError('Review launch was cancelled', 409);
|
|
161
|
+
if (internal?.autoLoopFinalReview &&
|
|
162
|
+
(!workspaceService.getWorkspace(id)?.autoLoop || internal.shouldLaunch?.() === false))
|
|
163
|
+
throw new ReviewRequestError('Final review was cancelled before launch', 409);
|
|
141
164
|
if (agentManager.getAgentStatus(id))
|
|
142
165
|
throw new ReviewRequestError('Another agent has started in this workspace', 409);
|
|
166
|
+
if (internal?.autoLoopFinalReview && !canStartAutomatically(id))
|
|
167
|
+
throw new ReviewAdmissionDeferred();
|
|
143
168
|
let pendingSessionId;
|
|
169
|
+
let reviewLaunch = null;
|
|
144
170
|
try {
|
|
145
171
|
if (body.returnToSession && originalSession) {
|
|
146
172
|
pendingSessionId = workspaceService.createIdleSession(id).id;
|
|
@@ -156,11 +182,33 @@ export async function startWorkspaceReview(id, input) {
|
|
|
156
182
|
agentPermissionMode: workspace.agentPermissionMode,
|
|
157
183
|
},
|
|
158
184
|
review: configuration,
|
|
185
|
+
reviewPrompt: rendered,
|
|
159
186
|
});
|
|
160
187
|
}
|
|
188
|
+
if (internal?.autoLoopFinalReview && pendingSessionId && originalSession) {
|
|
189
|
+
reviewLaunch = bindReviewSession(id, pendingSessionId, originalSession.id);
|
|
190
|
+
rendered += reviewLaunch.promptSuffix;
|
|
191
|
+
getDb()
|
|
192
|
+
.prepare('UPDATE pending_review_returns SET review_prompt = ? WHERE workspace_id = ?')
|
|
193
|
+
.run(rendered, id);
|
|
194
|
+
}
|
|
161
195
|
if (changed)
|
|
162
196
|
configure(id, configuration);
|
|
163
|
-
const
|
|
197
|
+
const startArgs = [
|
|
198
|
+
id,
|
|
199
|
+
worktreePath,
|
|
200
|
+
rendered,
|
|
201
|
+
configuration.model,
|
|
202
|
+
false,
|
|
203
|
+
configuration.agentPermissionMode,
|
|
204
|
+
pendingSessionId,
|
|
205
|
+
configuration.reasoningEffort,
|
|
206
|
+
];
|
|
207
|
+
if (reviewLaunch) {
|
|
208
|
+
startArgs[8] = undefined;
|
|
209
|
+
startArgs[9] = { mcpEnv: reviewLaunch.mcpEnv };
|
|
210
|
+
}
|
|
211
|
+
const agent = agentManager.startAgent(...startArgs);
|
|
164
212
|
workspaceService.updateWorkspaceStatus(id, 'executing');
|
|
165
213
|
emitSessionId = agent.agentSessionId;
|
|
166
214
|
}
|
|
@@ -190,6 +238,6 @@ export async function startWorkspaceReview(id, input) {
|
|
|
190
238
|
return { ok: true, messageSent: true, newSession };
|
|
191
239
|
}
|
|
192
240
|
finally {
|
|
193
|
-
|
|
241
|
+
finishReviewLaunch(id);
|
|
194
242
|
}
|
|
195
243
|
}
|
|
@@ -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
|
+
}
|