@loicngr/kobo 2.1.15 → 2.1.18

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.
Files changed (128) hide show
  1. package/AGENTS.md +61 -0
  2. package/CHANGELOG.md +12 -0
  3. package/CONFIGURATION.md +74 -17
  4. package/README.md +7 -3
  5. package/dist/mcp-server/kobo-tasks-server.js +19 -104
  6. package/dist/mcp-server/workspace-dialogue-client.js +15 -3
  7. package/dist/server/db/migrations.js +2 -0
  8. package/dist/server/db/schema.js +2 -0
  9. package/dist/server/db/workspace-message-batch-schema.js +9 -0
  10. package/dist/server/index.js +8 -0
  11. package/dist/server/middleware/workspace-creation-maintenance.js +13 -0
  12. package/dist/server/routes/maintenance.js +32 -0
  13. package/dist/server/routes/mcp.js +69 -3
  14. package/dist/server/routes/pull-requests.js +2 -1
  15. package/dist/server/routes/workspace-messages.js +22 -0
  16. package/dist/server/routes/workspaces.js +60 -4
  17. package/dist/server/services/agent/orchestrator.js +21 -6
  18. package/dist/server/services/agent/session-controller.js +4 -1
  19. package/dist/server/services/cron-service.js +7 -0
  20. package/dist/server/services/pr-checkout-rollback-service.js +51 -0
  21. package/dist/server/services/pr-checkout-service.js +21 -1
  22. package/dist/server/services/quota-backoff-service.js +7 -0
  23. package/dist/server/services/update-maintenance-service.js +44 -0
  24. package/dist/server/services/wakeup-service.js +21 -3
  25. package/dist/server/services/workspace-creation-mcp-service.js +214 -0
  26. package/dist/server/services/workspace-group-message-mcp-service.js +58 -0
  27. package/dist/server/services/workspace-group-message-service.js +182 -0
  28. package/dist/server/services/workspace-lifecycle-mcp-service.js +72 -0
  29. package/dist/server/services/workspace-message-service.js +5 -0
  30. package/dist/shared/pr-checkout-steps.js +86 -0
  31. package/dist/shared/workspace-creation-tools.js +182 -0
  32. package/dist/shared/workspace-group-message-tools.js +134 -0
  33. package/dist/shared/workspace-group-messages.js +27 -0
  34. package/dist/shared/workspace-lifecycle-tools.js +112 -0
  35. package/docker-compose.vps.yml +60 -0
  36. package/docs/deployment-vps.md +506 -0
  37. package/docs/getting-started.md +9 -0
  38. package/package.json +8 -1
  39. package/scripts/kobo-gh +9 -0
  40. package/scripts/kobo-github-app-token +190 -0
  41. package/scripts/kobo-install +392 -0
  42. package/scripts/kobo-update +499 -0
  43. package/src/client/dist/pwa/assets/ActivityFeed-S3XGw3WW.js +2 -0
  44. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-lKjfaC6V.js → AutoLoopFinalReviewSettings-CC8ICe_I.js} +1 -1
  45. package/src/client/dist/pwa/assets/{ChangelogPage-BplM7qEB.js → ChangelogPage-C1aiPBoi.js} +1 -1
  46. package/src/client/dist/pwa/assets/{ComparisonPanel-DxK5v9w_.js → ComparisonPanel-DElbn4ac.js} +1 -1
  47. package/src/client/dist/pwa/assets/CreatePage-Bx58t4YW.js +2 -0
  48. package/src/client/dist/pwa/assets/{DashboardPage-BsP-MATS.js → DashboardPage-D1S3QGp4.js} +1 -1
  49. package/src/client/dist/pwa/assets/DiffViewer-BH4X9ru1.js +8 -0
  50. package/src/client/dist/pwa/assets/{DocumentsPanel-CGrf8Q3H.js → DocumentsPanel-C4AvyZF3.js} +1 -1
  51. package/src/client/dist/pwa/assets/{DrawerToggleButton-BANeLSsR.js → DrawerToggleButton-LzIEeeJY.js} +1 -1
  52. package/src/client/dist/pwa/assets/GitPanel-BP3N_4nh.js +3 -0
  53. package/src/client/dist/pwa/assets/{HealthPage-BVTNx-92.js → HealthPage-Cm13vxOi.js} +1 -1
  54. package/src/client/dist/pwa/assets/MainLayout-DGmMkfuw.js +2 -0
  55. package/src/client/dist/pwa/assets/{MainLayout-C6nvIQjU.css → MainLayout-DoAEgANU.css} +1 -1
  56. package/src/client/dist/pwa/assets/{MemoryPanel-C71rTpv9.js → MemoryPanel-C6tzCv6D.js} +1 -1
  57. package/src/client/dist/pwa/assets/{MemoryProposalList-CoqhUGrv.js → MemoryProposalList-CzpzIirK.js} +1 -1
  58. package/src/client/dist/pwa/assets/{QBtnDropdown-U9Ey5W8h.js → QBtnDropdown-BnuKPevV.js} +1 -1
  59. package/src/client/dist/pwa/assets/{QExpansionItem-Be_A1MT1.js → QExpansionItem-DjaUhgjt.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QScrollArea-D4lYzAPQ.js → QScrollArea-Dpksslru.js} +1 -1
  61. package/src/client/dist/pwa/assets/QSelect-Dh4CjliE.js +1 -0
  62. package/src/client/dist/pwa/assets/{QTooltip-kStOgY0X.js → QTooltip-CH45NuVs.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SchedulePanel-C7nTGdWE.js → SchedulePanel-QAjXuTIN.js} +1 -1
  64. package/src/client/dist/pwa/assets/{SearchPage-C6J72pkQ.js → SearchPage-B1pbwDej.js} +1 -1
  65. package/src/client/dist/pwa/assets/SettingsPage-CUQefsDh.js +9 -0
  66. package/src/client/dist/pwa/assets/{SplitWorkspacePage-Bqn4gdVw.js → SplitWorkspacePage-CJ7e-KPh.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TerminalPanel-zLeDCReI.js → TerminalPanel-DVomz3oo.js} +10 -10
  68. package/src/client/dist/pwa/assets/{ToolCallItem-B8b_Sagj.js → ToolCallItem-BntDHc6p.js} +3 -3
  69. package/src/client/dist/pwa/assets/{TouchPan-Bl8VzIDK.js → TouchPan-DwqfsAxH.js} +1 -1
  70. package/src/client/dist/pwa/assets/{TourReplayButton-pL0HCboU.js → TourReplayButton-OIavXdUg.js} +1 -1
  71. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-9Eomx0rQ.js → WorkspaceAttentionLabels-CYwN1-Rf.js} +1 -1
  72. package/src/client/dist/pwa/assets/WorkspacePage-CAsY7Hl-.js +4 -0
  73. package/src/client/dist/pwa/assets/{browser-C-PJ4alw.js → browser-Cl0d1gbu.js} +1 -1
  74. package/src/client/dist/pwa/assets/{build-path-tree-SW9rj7B_.js → build-path-tree-J3YQeMuV.js} +1 -1
  75. package/src/client/dist/pwa/assets/{custom-sounds-toz-4drX.js → custom-sounds-D-T5rpgv.js} +1 -1
  76. package/src/client/dist/pwa/assets/de-C-RtXLqL.js +17 -0
  77. package/src/client/dist/pwa/assets/{editor.api-BJTPhnh8.js → editor.api-BoFiCjBZ.js} +1 -1
  78. package/src/client/dist/pwa/assets/{efforts-BO9xGn9q.js → efforts-Y6XotW97.js} +1 -1
  79. package/src/client/dist/pwa/assets/es-CBlW91ZK.js +17 -0
  80. package/src/client/dist/pwa/assets/{expand-template-BOZMtWau.js → expand-template-sfOHWudd.js} +1 -1
  81. package/src/client/dist/pwa/assets/{format-hEnpRyit.js → format-mWov137C.js} +1 -1
  82. package/src/client/dist/pwa/assets/{formatters-DD_eFc4z.js → formatters-B_XsveM0.js} +1 -1
  83. package/src/client/dist/pwa/assets/fr-Hfzp_sF5.js +17 -0
  84. package/src/client/dist/pwa/assets/{freemarker2-B6nUofvT.js → freemarker2-2pGNakhH.js} +1 -1
  85. package/src/client/dist/pwa/assets/{handlebars-4P3gtM8p.js → handlebars-Bn2V6Liw.js} +1 -1
  86. package/src/client/dist/pwa/assets/{html-C9ZzWo-y.js → html-BBhkJkf2.js} +1 -1
  87. package/src/client/dist/pwa/assets/i18n-sBCWwf8Y.js +1 -0
  88. package/src/client/dist/pwa/assets/index-DCpe-Tfm.js +20 -0
  89. package/src/client/dist/pwa/assets/it-Bl-v27qg.js +17 -0
  90. package/src/client/dist/pwa/assets/{javascript-BGEC5ggd.js → javascript-Ba0G68U2.js} +1 -1
  91. package/src/client/dist/pwa/assets/{liquid-DNtF630O.js → liquid-C5VrzHr_.js} +1 -1
  92. package/src/client/dist/pwa/assets/{mdx-CICwyOd0.js → mdx-DrPRiuKb.js} +1 -1
  93. package/src/client/dist/pwa/assets/{monaco-contributions-BkuwvgrT.js → monaco-contributions-qQUY6VS7.js} +1 -1
  94. package/src/client/dist/pwa/assets/{monaco.contribution-GX8XA-PE.js → monaco.contribution-Dtq2RsyB.js} +2 -2
  95. package/src/client/dist/pwa/assets/network-auth-BPJpXK4L.js +1 -0
  96. package/src/client/dist/pwa/assets/notify-theme-Bl9iRsZB.js +1 -0
  97. package/src/client/dist/pwa/assets/{project-color-B1SMFjSe.js → project-color-Dzvvmaml.js} +1 -1
  98. package/src/client/dist/pwa/assets/{python-n5uPdTVS.js → python-Cuf_7umq.js} +1 -1
  99. package/src/client/dist/pwa/assets/{razor-AOj0nsUt.js → razor-DcVpxFeh.js} +1 -1
  100. package/src/client/dist/pwa/assets/{typescript-Uhkrqihh.js → typescript-Bjcc3kYC.js} +1 -1
  101. package/src/client/dist/pwa/assets/{use-search-index-status-CW0BdpBZ.js → use-search-index-status-Bwf40BEI.js} +1 -1
  102. package/src/client/dist/pwa/assets/use-tours-CWafZt-m.js +2 -0
  103. package/src/client/dist/pwa/assets/{use-worktree-restore-BZxibZS5.js → use-worktree-restore-DkcM2FAZ.js} +1 -1
  104. package/src/client/dist/pwa/assets/{workspace-templates-W557e1-m.js → workspace-templates-DL_Cs7mD.js} +1 -1
  105. package/src/client/dist/pwa/assets/{xml-B-hnMgEs.js → xml-BmJwqJOz.js} +1 -1
  106. package/src/client/dist/pwa/assets/{yaml-DqXkTvgm.js → yaml-DAssfLhS.js} +1 -1
  107. package/src/client/dist/pwa/index.html +1 -1
  108. package/src/client/dist/pwa/sw.js +1 -1
  109. package/src/mcp-server/README.md +130 -12
  110. package/src/mcp-server/kobo-tasks-server.ts +27 -105
  111. package/src/mcp-server/workspace-dialogue-client.ts +18 -3
  112. package/src/client/dist/pwa/assets/ActivityFeed-D7jouiuP.js +0 -2
  113. package/src/client/dist/pwa/assets/CreatePage-D5ilP5kt.js +0 -2
  114. package/src/client/dist/pwa/assets/DiffViewer-Cd15vFWj.js +0 -8
  115. package/src/client/dist/pwa/assets/GitPanel-CEPV5Qw0.js +0 -3
  116. package/src/client/dist/pwa/assets/MainLayout-DBKtwilW.js +0 -2
  117. package/src/client/dist/pwa/assets/QSelect-fS0DLOd1.js +0 -1
  118. package/src/client/dist/pwa/assets/SettingsPage-SuH-aIzv.js +0 -9
  119. package/src/client/dist/pwa/assets/WorkspacePage-B6clgURl.js +0 -4
  120. package/src/client/dist/pwa/assets/de-Ddxe8cc6.js +0 -17
  121. package/src/client/dist/pwa/assets/es-DBJIQeWW.js +0 -17
  122. package/src/client/dist/pwa/assets/fr-yBzIIPaQ.js +0 -17
  123. package/src/client/dist/pwa/assets/i18n-2-ULabk2.js +0 -1
  124. package/src/client/dist/pwa/assets/index-Vk99jtxD.js +0 -20
  125. package/src/client/dist/pwa/assets/it-D44U-PT1.js +0 -17
  126. package/src/client/dist/pwa/assets/network-auth-KnWzjYIc.js +0 -1
  127. package/src/client/dist/pwa/assets/notify-theme-CWuZTz4Z.js +0 -1
  128. package/src/client/dist/pwa/assets/use-tours-CndFlyL-.js +0 -2
@@ -0,0 +1,44 @@
1
+ /** Process-local reservation: restart clears it; only its owner may cancel it. */
2
+ let owner;
3
+ const deferred = new Map();
4
+ const creations = new Set();
5
+ /** Reserve the entire creation/checkout, including asynchronous setup and rollback. */
6
+ export function beginWorkspaceCreation() {
7
+ if (owner !== undefined)
8
+ return null;
9
+ const creation = Symbol('workspace-creation');
10
+ creations.add(creation);
11
+ return () => {
12
+ creations.delete(creation);
13
+ };
14
+ }
15
+ export function isUpdateMaintenanceActive() {
16
+ return owner !== undefined;
17
+ }
18
+ /** No await between inspecting runtime ownership and closing admission. */
19
+ export function acquireUpdateMaintenance(candidate, isIdle) {
20
+ if (owner !== undefined)
21
+ return owner === candidate ? 'acquired' : 'conflict';
22
+ if (creations.size || !isIdle())
23
+ return 'busy';
24
+ owner = candidate;
25
+ return 'acquired';
26
+ }
27
+ /** Keep scheduler rows/deadlines untouched while delivery is suspended. */
28
+ export function deferForUpdateMaintenance(key, callback) {
29
+ if (owner === undefined)
30
+ return false;
31
+ deferred.set(key, callback);
32
+ return true;
33
+ }
34
+ export function releaseUpdateMaintenance(candidate) {
35
+ if (owner !== candidate)
36
+ return false;
37
+ owner = undefined;
38
+ for (const callback of deferred.values()) {
39
+ const timer = setTimeout(callback, 0);
40
+ timer.unref?.();
41
+ }
42
+ deferred.clear();
43
+ return true;
44
+ }
@@ -5,6 +5,7 @@ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
5
5
  import * as orchestrator from './agent/orchestrator.js';
6
6
  import * as autoLoopService from './auto-loop-service.js';
7
7
  import * as settingsService from './settings-service.js';
8
+ import { deferForUpdateMaintenance, isUpdateMaintenanceActive } from './update-maintenance-service.js';
8
9
  import { emitEphemeral } from './websocket-service.js';
9
10
  const MIN_DELAY_SECONDS = 60;
10
11
  const MAX_DELAY_SECONDS = 21600;
@@ -22,6 +23,17 @@ export function suspendForShutdown() {
22
23
  clearTimeout(timer);
23
24
  timers.clear();
24
25
  }
26
+ /** Claim valid pending wakeups before planned downtime, without changing their requested deadline. */
27
+ export function preserveForUpdateMaintenance() {
28
+ const cutoff = new Date(Date.now() - STALE_WAKEUP_GRACE_MS).toISOString();
29
+ getDb()
30
+ .prepare(`UPDATE pending_wakeups SET retry_at = target_at
31
+ WHERE retry_at IS NULL AND julianday(target_at) >= julianday(?)
32
+ AND workspace_id IN (
33
+ SELECT id FROM workspaces WHERE archived_at IS NULL AND worktree_purged_at IS NULL
34
+ )`)
35
+ .run(cutoff);
36
+ }
25
37
  function clamp(n, lo, hi) {
26
38
  return Math.max(lo, Math.min(hi, n));
27
39
  }
@@ -60,10 +72,10 @@ export function schedule(workspaceId, delaySeconds, prompt, reason, agentSession
60
72
  // the original durable wakeup and its timer intact, and fail the MCP receipt.
61
73
  const row = getDb()
62
74
  .prepare(`INSERT OR REPLACE INTO pending_wakeups
63
- (workspace_id, target_at, prompt, reason, created_at, agent_session_id)
64
- VALUES (?, ?, ?, ?, ?, ?)
75
+ (workspace_id, target_at, prompt, reason, created_at, agent_session_id, retry_at)
76
+ VALUES (?, ?, ?, ?, ?, ?, ?)
65
77
  RETURNING target_at, reason`)
66
- .get(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString(), agentSessionId ?? null);
78
+ .get(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString(), agentSessionId ?? null, isUpdateMaintenanceActive() ? targetAtIso : null);
67
79
  const pending = { targetAt: row.target_at, reason: row.reason ?? undefined };
68
80
  const existing = timers.get(workspaceId);
69
81
  if (existing)
@@ -168,6 +180,12 @@ export function rehydrate() {
168
180
  function fire(workspaceId) {
169
181
  if (suspended)
170
182
  return;
183
+ const pendingTimer = timers.get(workspaceId);
184
+ if (deferForUpdateMaintenance(`wakeup:${workspaceId}`, () => {
185
+ if (timers.get(workspaceId) === pendingTimer)
186
+ fire(workspaceId);
187
+ }))
188
+ return;
171
189
  try {
172
190
  const db = getDb();
173
191
  const row = db.prepare('SELECT * FROM pending_wakeups WHERE workspace_id = ?').get(workspaceId);
@@ -0,0 +1,214 @@
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
+ import { beginWorkspaceCreation } from './update-maintenance-service.js';
7
+ export class WorkspaceCreationMcpError extends Error {
8
+ status;
9
+ stage;
10
+ details;
11
+ constructor(message, status, stage, details = null) {
12
+ super(message);
13
+ this.status = status;
14
+ this.stage = stage;
15
+ this.details = details;
16
+ this.name = 'WorkspaceCreationMcpError';
17
+ }
18
+ }
19
+ async function request(dispatch, path, stage, body, init) {
20
+ const response = await dispatch(path, init ?? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) });
21
+ const payload = await response.json().catch(() => null);
22
+ if (!response.ok)
23
+ throw new WorkspaceCreationMcpError(payload?.error ?? `Workspace request failed (${response.status})`, response.status, stage, payload);
24
+ return payload;
25
+ }
26
+ function prIdentity(raw) {
27
+ const url = new URL(raw);
28
+ const pathname = url.pathname.replace(/\/+$/, '');
29
+ const last = pathname.split('/').at(-1);
30
+ const number = Number(last);
31
+ if (!/^[1-9]\d*$/.test(last) || !Number.isSafeInteger(number))
32
+ throw new WorkspaceCreationMcpError('pr_url must end with a PR/MR number', 400, 'validate');
33
+ return { number, identity: `${url.origin}${pathname}` };
34
+ }
35
+ function mapInput(input) {
36
+ const mapping = {
37
+ name: 'name',
38
+ project_path: 'projectPath',
39
+ source_branch: 'sourceBranch',
40
+ working_branch: 'workingBranch',
41
+ model: 'model',
42
+ reasoning_effort: 'reasoningEffort',
43
+ brainstorm_model: 'brainstormModel',
44
+ brainstorm_reasoning_effort: 'brainstormReasoningEffort',
45
+ engine: 'engine',
46
+ description: 'description',
47
+ tags: 'tags',
48
+ tasks: 'tasks',
49
+ acceptance_criteria: 'acceptanceCriteria',
50
+ agent_permission_mode: 'agentPermissionMode',
51
+ auto_loop: 'autoLoop',
52
+ auto_loop_session_mode: 'autoLoopSessionMode',
53
+ skip_setup_script: 'skipSetupScript',
54
+ workflow_policy: 'workflowPolicy',
55
+ notion_url: 'notionUrl',
56
+ notion_page_id: 'notionPageId',
57
+ sentry_url: 'sentryUrl',
58
+ pr_url: 'prUrl',
59
+ worktree_path: 'worktreePath',
60
+ comparison_id: 'comparisonId',
61
+ creation_id: 'creationId',
62
+ };
63
+ const body = {};
64
+ for (const [key, field] of Object.entries(mapping))
65
+ if (Object.hasOwn(input, key))
66
+ body[field] = input[key];
67
+ if (input.auto_loop_final_review !== undefined) {
68
+ const config = input.auto_loop_final_review;
69
+ body.autoLoopFinalReview =
70
+ config === null
71
+ ? null
72
+ : {
73
+ engine: config.engine,
74
+ model: config.model,
75
+ reasoningEffort: config.reasoning_effort,
76
+ additionalInstructions: config.additional_instructions ?? '',
77
+ };
78
+ }
79
+ return body;
80
+ }
81
+ /** Reuses the application's route validation, lifecycle guards, Git locks and rollback. */
82
+ export async function executeWorkspaceCreationTool(name, raw, dispatch) {
83
+ if (name !== 'create_workspace')
84
+ return executeCreation(name, raw, dispatch);
85
+ // One lease spans both in-process routes and any compensating checkout rollback.
86
+ // Each route also guards direct HTTP callers; nested leases are safe.
87
+ const release = beginWorkspaceCreation();
88
+ if (!release)
89
+ throw new WorkspaceCreationMcpError('Workspace creation is unavailable during update maintenance', 503, 'maintenance');
90
+ try {
91
+ return await executeCreation(name, raw, dispatch);
92
+ }
93
+ finally {
94
+ release();
95
+ }
96
+ }
97
+ async function executeCreation(name, raw, dispatch) {
98
+ let input;
99
+ try {
100
+ input = validateWorkspaceCreationArguments(name, raw);
101
+ }
102
+ catch (error) {
103
+ throw new WorkspaceCreationMcpError(error instanceof Error ? error.message : 'Invalid arguments', 400, 'validate');
104
+ }
105
+ const config = input.auto_loop_final_review;
106
+ if (config) {
107
+ const capabilities = config.engine === 'codex' ? CODEX_CAPABILITIES : CLAUDE_CODE_CAPABILITIES;
108
+ if (!capabilities.effortLevels?.some((effort) => effort.id === config.reasoning_effort))
109
+ throw new WorkspaceCreationMcpError('Unsupported review reasoning effort', 400, 'validate');
110
+ }
111
+ const body = mapInput(input);
112
+ let resolvedCheckout;
113
+ if (input.pr_url) {
114
+ const supplied = prIdentity(input.pr_url);
115
+ const diagnosis = await request(dispatch, '/api/pull-requests/diagnose', 'diagnose', {
116
+ projectPath: input.project_path,
117
+ prNumber: supplied.number,
118
+ });
119
+ const requiresAction = (reason) => ({
120
+ created: false,
121
+ requiresAction: true,
122
+ ...diagnosis,
123
+ ...(reason ? { reason } : {}),
124
+ });
125
+ if (diagnosis.pr && prIdentity(diagnosis.pr.url).identity !== supplied.identity)
126
+ throw new WorkspaceCreationMcpError('pr_url does not belong to the selected project', 400, 'diagnose', diagnosis);
127
+ if (name === 'diagnose_workspace_pr')
128
+ return diagnosis;
129
+ if (!diagnosis.pr)
130
+ return requiresAction('Pull request unavailable');
131
+ const { report, pr, fingerprint } = diagnosis;
132
+ const plan = input.pr_checkout;
133
+ if (plan && plan.fingerprint !== fingerprint)
134
+ throw new WorkspaceCreationMcpError('The repository changed since the diagnosis was taken', 409, 'diagnose', diagnosis);
135
+ const decisions = plan?.decisions ?? {};
136
+ if (report.workspace.state === 'active' && decisions.existingWorkspace === 'open')
137
+ return { created: false, workspaceId: report.workspace.id, workspace: report.workspace };
138
+ if (report.workspace.state === 'archived' && decisions.archivedWorkspace === 'unarchive') {
139
+ const workspace = await request(dispatch, `/api/workspaces/${encodeURIComponent(report.workspace.id)}/unarchive`, 'unarchive');
140
+ return { created: false, workspaceId: report.workspace.id, workspace };
141
+ }
142
+ // A purge or an existing association must never fall through to a second creation.
143
+ if (report.workspace.state !== 'none')
144
+ return requiresAction('Use the existing workspace; automatic duplicate creation is not supported');
145
+ if (decisions.ongoingOperation === 'cancel')
146
+ return { created: false, cancelled: true, ...diagnosis };
147
+ const error = checkoutDecisionError(report, decisions);
148
+ if (error)
149
+ throw new WorkspaceCreationMcpError(error, 400, 'validate-checkout', diagnosis);
150
+ const steps = deriveSteps(report);
151
+ if (steps.some((step) => step.blocking))
152
+ return requiresAction('Checkout is blocked');
153
+ if (!plan && (steps.length > 0 || report.worktree.state !== 'none'))
154
+ return requiresAction();
155
+ if (plan) {
156
+ // Every state requiring a choice needs an explicit decision. In particular,
157
+ // do not inherit the UI's defaults for reset/stash/abort or path selection.
158
+ const missing = steps.some((step) => (step.id === 'path' && !decisions.pathCollision) ||
159
+ (step.id === 'worktree' && !decisions.orphanWorktree) ||
160
+ (step.id === 'operation' && !decisions.ongoingOperation) ||
161
+ (step.id === 'changes' && !decisions.localChanges) ||
162
+ (step.id === 'divergence' && !decisions.divergence));
163
+ if (missing)
164
+ return requiresAction('Explicit checkout decisions are required');
165
+ }
166
+ body.sourceBranch = pr.baseBranch;
167
+ body.workingBranch = pr.headBranch;
168
+ body.prUrl = pr.url;
169
+ body.skipSetupScript = input.skip_setup_script ?? true;
170
+ if (input.worktree_path) {
171
+ const diagnosedPath = 'path' in report.worktree ? report.worktree.path : null;
172
+ if (diagnosedPath !== input.worktree_path)
173
+ throw new WorkspaceCreationMcpError('worktree_path does not match the diagnosed PR checkout', 400, 'validate-checkout', diagnosis);
174
+ }
175
+ const resolved = await request(dispatch, '/api/pull-requests/resolve', 'resolve', {
176
+ projectPath: input.project_path,
177
+ prNumber: pr.number,
178
+ headBranch: pr.headBranch,
179
+ baseBranch: pr.baseBranch,
180
+ decisions,
181
+ fingerprint,
182
+ });
183
+ body.worktreePath = resolved.worktreePath;
184
+ resolvedCheckout = resolved;
185
+ }
186
+ try {
187
+ const attachments = input.attachments;
188
+ if (attachments?.length) {
189
+ const form = new FormData();
190
+ form.append('workspace', JSON.stringify(body));
191
+ for (const file of attachments)
192
+ form.append('attachments', new Blob([Buffer.from(file.data_base64, 'base64')], { type: file.mime_type }), file.name);
193
+ return await request(dispatch, '/api/workspaces', 'create', undefined, { method: 'POST', body: form });
194
+ }
195
+ return await request(dispatch, '/api/workspaces', 'create', body);
196
+ }
197
+ catch (error) {
198
+ if (!resolvedCheckout)
199
+ throw error;
200
+ if (!(error instanceof WorkspaceCreationMcpError))
201
+ throw new WorkspaceCreationMcpError(error instanceof Error ? error.message : String(error), 500, 'create', {
202
+ checkoutRecovery: {
203
+ removed: false,
204
+ worktreePath: resolvedCheckout.worktreePath,
205
+ reason: 'Creation outcome is unknown; inspect the workspace and checkout before retrying',
206
+ },
207
+ });
208
+ const checkoutRecovery = await rollbackCreatedPrCheckout(input.project_path, resolvedCheckout);
209
+ throw new WorkspaceCreationMcpError(error.message, error.status, error.stage, {
210
+ ...(error.details && typeof error.details === 'object' ? error.details : {}),
211
+ checkoutRecovery,
212
+ });
213
+ }
214
+ }
@@ -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
+ }
@@ -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)