@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.
- package/AGENTS.md +61 -0
- package/CHANGELOG.md +12 -0
- package/CONFIGURATION.md +74 -17
- package/README.md +7 -3
- 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 +8 -0
- package/dist/server/middleware/workspace-creation-maintenance.js +13 -0
- package/dist/server/routes/maintenance.js +32 -0
- package/dist/server/routes/mcp.js +69 -3
- package/dist/server/routes/pull-requests.js +2 -1
- package/dist/server/routes/workspace-messages.js +22 -0
- package/dist/server/routes/workspaces.js +60 -4
- package/dist/server/services/agent/orchestrator.js +21 -6
- package/dist/server/services/agent/session-controller.js +4 -1
- package/dist/server/services/cron-service.js +7 -0
- 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/quota-backoff-service.js +7 -0
- package/dist/server/services/update-maintenance-service.js +44 -0
- package/dist/server/services/wakeup-service.js +21 -3
- package/dist/server/services/workspace-creation-mcp-service.js +214 -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/docker-compose.vps.yml +60 -0
- package/docs/deployment-vps.md +506 -0
- package/docs/getting-started.md +9 -0
- package/package.json +8 -1
- package/scripts/kobo-gh +9 -0
- package/scripts/kobo-github-app-token +190 -0
- package/scripts/kobo-install +392 -0
- package/scripts/kobo-update +499 -0
- package/src/client/dist/pwa/assets/ActivityFeed-S3XGw3WW.js +2 -0
- package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-lKjfaC6V.js → AutoLoopFinalReviewSettings-CC8ICe_I.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-BplM7qEB.js → ChangelogPage-C1aiPBoi.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-DxK5v9w_.js → ComparisonPanel-DElbn4ac.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-Bx58t4YW.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-BsP-MATS.js → DashboardPage-D1S3QGp4.js} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-BH4X9ru1.js +8 -0
- package/src/client/dist/pwa/assets/{DocumentsPanel-CGrf8Q3H.js → DocumentsPanel-C4AvyZF3.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-BANeLSsR.js → DrawerToggleButton-LzIEeeJY.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-BP3N_4nh.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-BVTNx-92.js → HealthPage-Cm13vxOi.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-DGmMkfuw.js +2 -0
- package/src/client/dist/pwa/assets/{MainLayout-C6nvIQjU.css → MainLayout-DoAEgANU.css} +1 -1
- package/src/client/dist/pwa/assets/{MemoryPanel-C71rTpv9.js → MemoryPanel-C6tzCv6D.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-CoqhUGrv.js → MemoryProposalList-CzpzIirK.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-U9Ey5W8h.js → QBtnDropdown-BnuKPevV.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-Be_A1MT1.js → QExpansionItem-DjaUhgjt.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-D4lYzAPQ.js → QScrollArea-Dpksslru.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-Dh4CjliE.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-kStOgY0X.js → QTooltip-CH45NuVs.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-C7nTGdWE.js → SchedulePanel-QAjXuTIN.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-C6J72pkQ.js → SearchPage-B1pbwDej.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-CUQefsDh.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-Bqn4gdVw.js → SplitWorkspacePage-CJ7e-KPh.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-zLeDCReI.js → TerminalPanel-DVomz3oo.js} +10 -10
- package/src/client/dist/pwa/assets/{ToolCallItem-B8b_Sagj.js → ToolCallItem-BntDHc6p.js} +3 -3
- package/src/client/dist/pwa/assets/{TouchPan-Bl8VzIDK.js → TouchPan-DwqfsAxH.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-pL0HCboU.js → TourReplayButton-OIavXdUg.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-9Eomx0rQ.js → WorkspaceAttentionLabels-CYwN1-Rf.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-CAsY7Hl-.js +4 -0
- package/src/client/dist/pwa/assets/{browser-C-PJ4alw.js → browser-Cl0d1gbu.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-SW9rj7B_.js → build-path-tree-J3YQeMuV.js} +1 -1
- package/src/client/dist/pwa/assets/{custom-sounds-toz-4drX.js → custom-sounds-D-T5rpgv.js} +1 -1
- package/src/client/dist/pwa/assets/de-C-RtXLqL.js +17 -0
- package/src/client/dist/pwa/assets/{editor.api-BJTPhnh8.js → editor.api-BoFiCjBZ.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-BO9xGn9q.js → efforts-Y6XotW97.js} +1 -1
- package/src/client/dist/pwa/assets/es-CBlW91ZK.js +17 -0
- package/src/client/dist/pwa/assets/{expand-template-BOZMtWau.js → expand-template-sfOHWudd.js} +1 -1
- package/src/client/dist/pwa/assets/{format-hEnpRyit.js → format-mWov137C.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-DD_eFc4z.js → formatters-B_XsveM0.js} +1 -1
- package/src/client/dist/pwa/assets/fr-Hfzp_sF5.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-B6nUofvT.js → freemarker2-2pGNakhH.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-4P3gtM8p.js → handlebars-Bn2V6Liw.js} +1 -1
- package/src/client/dist/pwa/assets/{html-C9ZzWo-y.js → html-BBhkJkf2.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-sBCWwf8Y.js +1 -0
- package/src/client/dist/pwa/assets/index-DCpe-Tfm.js +20 -0
- package/src/client/dist/pwa/assets/it-Bl-v27qg.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-BGEC5ggd.js → javascript-Ba0G68U2.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-DNtF630O.js → liquid-C5VrzHr_.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-CICwyOd0.js → mdx-DrPRiuKb.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-BkuwvgrT.js → monaco-contributions-qQUY6VS7.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-GX8XA-PE.js → monaco.contribution-Dtq2RsyB.js} +2 -2
- package/src/client/dist/pwa/assets/network-auth-BPJpXK4L.js +1 -0
- package/src/client/dist/pwa/assets/notify-theme-Bl9iRsZB.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-B1SMFjSe.js → project-color-Dzvvmaml.js} +1 -1
- package/src/client/dist/pwa/assets/{python-n5uPdTVS.js → python-Cuf_7umq.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-AOj0nsUt.js → razor-DcVpxFeh.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-Uhkrqihh.js → typescript-Bjcc3kYC.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-CW0BdpBZ.js → use-search-index-status-Bwf40BEI.js} +1 -1
- package/src/client/dist/pwa/assets/use-tours-CWafZt-m.js +2 -0
- package/src/client/dist/pwa/assets/{use-worktree-restore-BZxibZS5.js → use-worktree-restore-DkcM2FAZ.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-W557e1-m.js → workspace-templates-DL_Cs7mD.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-B-hnMgEs.js → xml-BmJwqJOz.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-DqXkTvgm.js → yaml-DAssfLhS.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/de-Ddxe8cc6.js +0 -17
- 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
package/dist/server/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import gitRouter from './routes/git.js';
|
|
|
20
20
|
import healthRouter from './routes/health.js';
|
|
21
21
|
import imagesRouter from './routes/images.js';
|
|
22
22
|
import integrationsRouter from './routes/integrations.js';
|
|
23
|
+
import maintenanceRouter from './routes/maintenance.js';
|
|
23
24
|
import mcpRouter from './routes/mcp.js';
|
|
24
25
|
import memoryRouter from './routes/memory.js';
|
|
25
26
|
import { migrationRouter } from './routes/migration.js';
|
|
@@ -32,6 +33,7 @@ import soundsRouter from './routes/sounds.js';
|
|
|
32
33
|
import templatesRouter from './routes/templates.js';
|
|
33
34
|
import usageRoutes from './routes/usage.js';
|
|
34
35
|
import voiceRouter from './routes/voice.js';
|
|
36
|
+
import workspaceMessagesRouter from './routes/workspace-messages.js';
|
|
35
37
|
import workspaceTemplatesRouter from './routes/workspace-templates.js';
|
|
36
38
|
import workspacesRouter from './routes/workspaces.js';
|
|
37
39
|
import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, resumePendingReviewReturns, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
@@ -57,6 +59,7 @@ import { startUpdateChecker, stopUpdateChecker } from './services/update-check-s
|
|
|
57
59
|
import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
|
|
58
60
|
import * as wakeupService from './services/wakeup-service.js';
|
|
59
61
|
import { handleConnection, setMessageHandler } from './services/websocket-service.js';
|
|
62
|
+
import { reconcileGroupMessageBatches, stopGroupMessageBatches } from './services/workspace-group-message-service.js';
|
|
60
63
|
import { deliverWorkspaceMessage } from './services/workspace-message-service.js';
|
|
61
64
|
import { getWorkspace } from './services/workspace-service.js';
|
|
62
65
|
import { resolveRetentionConfig } from './services/ws-events-retention-service.js';
|
|
@@ -84,6 +87,7 @@ catch (err) {
|
|
|
84
87
|
}
|
|
85
88
|
runMigrations(db);
|
|
86
89
|
reconcileMessageRequests(db);
|
|
90
|
+
reconcileGroupMessageBatches(db);
|
|
87
91
|
reconcileMemoryContextsOnStartup();
|
|
88
92
|
startSearchIndex();
|
|
89
93
|
// Event retention. OPT-IN: disabled by default, so this is a no-op until the
|
|
@@ -155,6 +159,7 @@ app.use('/api/*', networkAuthMiddleware);
|
|
|
155
159
|
app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion() }));
|
|
156
160
|
// Mount route sub-routers
|
|
157
161
|
app.route('/api/workspaces', workspacesRouter);
|
|
162
|
+
app.route('/api/workspace-messages', workspaceMessagesRouter);
|
|
158
163
|
app.route('/api/mcp', mcpRouter);
|
|
159
164
|
app.route('/api/memory', memoryRouter);
|
|
160
165
|
app.route('/api/pull-requests', pullRequestsRouter);
|
|
@@ -174,6 +179,7 @@ app.route('/api/workspaces', documentsRouter);
|
|
|
174
179
|
app.route('/api/workspaces', exportRouter);
|
|
175
180
|
app.route('/api/search', searchRouter);
|
|
176
181
|
app.route('/api/health', healthRouter);
|
|
182
|
+
app.route('/api/maintenance', maintenanceRouter);
|
|
177
183
|
app.route('/api/engines', enginesRouter);
|
|
178
184
|
app.route('/api/environment', environmentRouter);
|
|
179
185
|
app.route('/api/integrations', integrationsRouter);
|
|
@@ -413,6 +419,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
|
|
|
413
419
|
return;
|
|
414
420
|
isShuttingDown = true;
|
|
415
421
|
stopUpdateChecker();
|
|
422
|
+
const groupMessagesStopped = stopGroupMessageBatches();
|
|
416
423
|
console.log(`\n[kobo] Received ${signal}, shutting down gracefully…`);
|
|
417
424
|
const forceExitTimer = setTimeout(() => {
|
|
418
425
|
console.error('[kobo] Graceful shutdown timed out; forcing exit');
|
|
@@ -479,6 +486,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
|
|
|
479
486
|
finally {
|
|
480
487
|
try {
|
|
481
488
|
const stopped = await Promise.allSettled([
|
|
489
|
+
groupMessagesStopped,
|
|
482
490
|
stopWsEventsRetention(),
|
|
483
491
|
stopSearchIndex(),
|
|
484
492
|
dailyBackupScheduler.stop(),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { beginWorkspaceCreation } from '../services/update-maintenance-service.js';
|
|
2
|
+
/** Admission remains held across every await and the handler's rollback paths. */
|
|
3
|
+
export const workspaceCreationMaintenance = async (c, next) => {
|
|
4
|
+
const release = beginWorkspaceCreation();
|
|
5
|
+
if (!release)
|
|
6
|
+
return c.json({ code: 'update-maintenance', error: 'Workspace creation is unavailable during update maintenance' }, 503);
|
|
7
|
+
try {
|
|
8
|
+
await next();
|
|
9
|
+
}
|
|
10
|
+
finally {
|
|
11
|
+
release();
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { isIdleForUpdate, resumeAfterUpdateMaintenance } from '../services/agent/orchestrator.js';
|
|
3
|
+
import { acquireUpdateMaintenance, isUpdateMaintenanceActive, releaseUpdateMaintenance, } from '../services/update-maintenance-service.js';
|
|
4
|
+
import { preserveForUpdateMaintenance } from '../services/wakeup-service.js';
|
|
5
|
+
/** Protected by the same network-token/Host/Origin middleware as other APIs. */
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
app.get('/update', (c) => c.json({ protocol: 1, active: isUpdateMaintenanceActive() }));
|
|
8
|
+
app.on(['POST', 'DELETE'], '/update', async (c) => {
|
|
9
|
+
const body = await c.req.json().catch(() => null);
|
|
10
|
+
if (!body || typeof body.owner !== 'string' || !/^[a-f0-9]{32}$/.test(body.owner)) {
|
|
11
|
+
return c.json({ error: 'Expected a 32-character update reservation identifier' }, 400);
|
|
12
|
+
}
|
|
13
|
+
if (c.req.method === 'DELETE') {
|
|
14
|
+
if (!releaseUpdateMaintenance(body.owner))
|
|
15
|
+
return c.json({ error: 'Update reservation belongs to another owner' }, 409);
|
|
16
|
+
resumeAfterUpdateMaintenance();
|
|
17
|
+
return c.json({ protocol: 1, active: false });
|
|
18
|
+
}
|
|
19
|
+
// Both operations are synchronous: no agent can start between this check and reservation.
|
|
20
|
+
const result = acquireUpdateMaintenance(body.owner, () => {
|
|
21
|
+
if (!isIdleForUpdate())
|
|
22
|
+
return false;
|
|
23
|
+
// Commit deferral before acknowledging admission. Future wakeups may become
|
|
24
|
+
// overdue while the stopped volume is backed up and migrated.
|
|
25
|
+
preserveForUpdateMaintenance();
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
28
|
+
if (result !== 'acquired')
|
|
29
|
+
return c.json({ error: result === 'busy' ? 'Kōbō is busy' : 'Update already reserved' }, 409);
|
|
30
|
+
return c.json({ protocol: 1, active: true });
|
|
31
|
+
});
|
|
32
|
+
export default app;
|
|
@@ -4,21 +4,73 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
5
|
import { bodyLimit } from 'hono/body-limit';
|
|
6
6
|
import { EXTERNAL_MEMORY_TOOL_DEFINITIONS, isMemoryToolName } from '../../shared/memory-tools.js';
|
|
7
|
+
import { isWorkspaceCreationTool, MAX_MCP_CREATION_REQUEST_BYTES, WORKSPACE_CREATION_TOOLS, } from '../../shared/workspace-creation-tools.js';
|
|
7
8
|
import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
|
|
9
|
+
import { isWorkspaceGroupMessageTool, WORKSPACE_GROUP_MESSAGE_TOOLS, } from '../../shared/workspace-group-message-tools.js';
|
|
10
|
+
import { isWorkspaceLifecycleTool, WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
|
|
8
11
|
import { createMcpClientContext } from '../services/mcp-client-context.js';
|
|
9
12
|
import { executeMemoryMcpTool } from '../services/memory-mcp-service.js';
|
|
13
|
+
import { executeWorkspaceCreationTool, WorkspaceCreationMcpError } from '../services/workspace-creation-mcp-service.js';
|
|
10
14
|
import { executeWorkspaceDialogueTool } from '../services/workspace-dialogue-service.js';
|
|
15
|
+
import { executeWorkspaceGroupMessageTool, WorkspaceGroupMessageMcpError, } from '../services/workspace-group-message-mcp-service.js';
|
|
16
|
+
import { executeWorkspaceLifecycleTool, WorkspaceLifecycleMcpError, } from '../services/workspace-lifecycle-mcp-service.js';
|
|
11
17
|
import { prepareMemoryToolEnvelope } from '../utils/memory-token-budget.js';
|
|
12
18
|
const app = new Hono();
|
|
13
|
-
|
|
19
|
+
// Only creation envelopes may carry the larger, base64 attachment payload.
|
|
20
|
+
// Other tools retain their existing 1 MiB limit, including malformed envelopes.
|
|
21
|
+
app.use('*', bodyLimit({ maxSize: MAX_MCP_CREATION_REQUEST_BYTES }));
|
|
22
|
+
app.use('*', async (c, next) => {
|
|
23
|
+
if (c.req.raw.body) {
|
|
24
|
+
const body = await c.req.raw.clone().arrayBuffer();
|
|
25
|
+
if (body.byteLength > 1024 * 1024) {
|
|
26
|
+
let creation = false;
|
|
27
|
+
try {
|
|
28
|
+
const envelope = JSON.parse(new TextDecoder().decode(body));
|
|
29
|
+
creation = envelope?.method === 'tools/call' && envelope?.params?.name === 'create_workspace';
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* Preserve the normal body-size rejection for malformed input. */
|
|
33
|
+
}
|
|
34
|
+
if (!creation)
|
|
35
|
+
return c.json({ error: 'MCP request is too large' }, 413);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return next();
|
|
39
|
+
});
|
|
40
|
+
/** Dispatch only the supported MCP operations through the existing UI handlers. */
|
|
41
|
+
async function dispatchWorkspaceRequest(pathname, init) {
|
|
42
|
+
if (pathname === '/api/workspaces/archived')
|
|
43
|
+
throw new Error('Bulk operations are not supported by workspace MCP tools');
|
|
44
|
+
const isWorkspaceOperation = (init.method === 'POST' &&
|
|
45
|
+
(pathname === '/api/workspaces' ||
|
|
46
|
+
/^\/api\/workspaces\/[^/]+\/(unarchive|archive|purge-worktree|restore-worktree)$/.test(pathname))) ||
|
|
47
|
+
((init.method === 'GET' || init.method === 'DELETE') && /^\/api\/workspaces\/[^/]+$/.test(pathname));
|
|
48
|
+
if (isWorkspaceOperation) {
|
|
49
|
+
const { default: routes } = await import('./workspaces.js');
|
|
50
|
+
return routes.request(`http://kobo.internal${pathname.slice('/api/workspaces'.length) || '/'}`, init);
|
|
51
|
+
}
|
|
52
|
+
if (init.method === 'POST' &&
|
|
53
|
+
(pathname === '/api/pull-requests/diagnose' || pathname === '/api/pull-requests/resolve')) {
|
|
54
|
+
const { default: routes } = await import('./pull-requests.js');
|
|
55
|
+
return routes.request(`http://kobo.internal${pathname.slice('/api/pull-requests'.length)}`, init);
|
|
56
|
+
}
|
|
57
|
+
throw new Error('Unsupported workspace operation');
|
|
58
|
+
}
|
|
14
59
|
app.all('/', async (c) => {
|
|
15
60
|
const server = new Server({ name: 'kobo-workspaces', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
16
61
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
17
62
|
sessionIdGenerator: undefined,
|
|
18
63
|
enableJsonResponse: true,
|
|
64
|
+
maxRequestBodySize: MAX_MCP_CREATION_REQUEST_BYTES,
|
|
19
65
|
});
|
|
20
66
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
21
|
-
tools: [
|
|
67
|
+
tools: [
|
|
68
|
+
...EXTERNAL_MCP_TOOLS,
|
|
69
|
+
...WORKSPACE_CREATION_TOOLS,
|
|
70
|
+
...WORKSPACE_LIFECYCLE_TOOLS,
|
|
71
|
+
...WORKSPACE_GROUP_MESSAGE_TOOLS,
|
|
72
|
+
...EXTERNAL_MEMORY_TOOL_DEFINITIONS,
|
|
73
|
+
],
|
|
22
74
|
}));
|
|
23
75
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
24
76
|
const isMemory = isMemoryToolName(request.params.name);
|
|
@@ -26,7 +78,13 @@ app.all('/', async (c) => {
|
|
|
26
78
|
const source = createMcpClientContext(c.req.header('X-Kobo-Client-Name'), c.req.header('X-Kobo-Client-Transport') === 'stdio' ? 'stdio' : 'http', c.req.header('X-Kobo-Client-Name-Encoding'));
|
|
27
79
|
const data = isMemory
|
|
28
80
|
? executeMemoryMcpTool(request.params.name, request.params.arguments ?? {}, source)
|
|
29
|
-
:
|
|
81
|
+
: isWorkspaceCreationTool(request.params.name)
|
|
82
|
+
? await executeWorkspaceCreationTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
|
|
83
|
+
: isWorkspaceLifecycleTool(request.params.name)
|
|
84
|
+
? await executeWorkspaceLifecycleTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
|
|
85
|
+
: isWorkspaceGroupMessageTool(request.params.name)
|
|
86
|
+
? executeWorkspaceGroupMessageTool(request.params.name, request.params.arguments ?? {}, source)
|
|
87
|
+
: await executeWorkspaceDialogueTool(request.params.name, request.params.arguments ?? {}, source);
|
|
30
88
|
if (isMemory) {
|
|
31
89
|
const bounded = prepareMemoryToolEnvelope(data, { targetTokens: 1_000, transport: 'external' });
|
|
32
90
|
if (bounded.data.memoryOutputSuppressed === true)
|
|
@@ -46,6 +104,14 @@ app.all('/', async (c) => {
|
|
|
46
104
|
};
|
|
47
105
|
}
|
|
48
106
|
catch (error) {
|
|
107
|
+
if (error instanceof WorkspaceGroupMessageMcpError) {
|
|
108
|
+
const data = { error: error.message, status: error.status };
|
|
109
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
|
|
110
|
+
}
|
|
111
|
+
if (error instanceof WorkspaceCreationMcpError || error instanceof WorkspaceLifecycleMcpError) {
|
|
112
|
+
const data = { error: error.message, status: error.status, stage: error.stage, details: error.details };
|
|
113
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
|
|
114
|
+
}
|
|
49
115
|
if (isMemory) {
|
|
50
116
|
const bounded = prepareMemoryToolEnvelope({
|
|
51
117
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
+
import { workspaceCreationMaintenance } from '../middleware/workspace-creation-maintenance.js';
|
|
3
4
|
import { getForgeProvider } from '../services/forge/registry.js';
|
|
4
5
|
import { resolveForge } from '../services/forge/resolve.js';
|
|
5
6
|
import * as prCheckout from '../services/pr-checkout-service.js';
|
|
@@ -86,7 +87,7 @@ app.post('/diagnose', async (c) => {
|
|
|
86
87
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
87
88
|
}
|
|
88
89
|
});
|
|
89
|
-
app.post('/resolve', async (c) => {
|
|
90
|
+
app.post('/resolve', workspaceCreationMaintenance, async (c) => {
|
|
90
91
|
try {
|
|
91
92
|
const body = await c.req
|
|
92
93
|
.json()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
3
|
+
import { MAX_GROUP_MESSAGE_REQUEST_BYTES } from '../../shared/workspace-group-messages.js';
|
|
4
|
+
import { GroupMessageError, getGroupMessageBatch, startGroupMessageBatch, } from '../services/workspace-group-message-service.js';
|
|
5
|
+
const app = new Hono();
|
|
6
|
+
app.use('*', bodyLimit({ maxSize: MAX_GROUP_MESSAGE_REQUEST_BYTES }));
|
|
7
|
+
app.post('/', async (c) => {
|
|
8
|
+
const body = await c.req.json().catch(() => null);
|
|
9
|
+
try {
|
|
10
|
+
return c.json(startGroupMessageBatch(body), 202);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error instanceof GroupMessageError)
|
|
14
|
+
return c.json({ error: error.message }, error.status);
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
app.get('/:id', (c) => {
|
|
19
|
+
const batch = getGroupMessageBatch(c.req.param('id'));
|
|
20
|
+
return batch ? c.json(batch) : c.json({ error: 'Group message not found' }, 404);
|
|
21
|
+
});
|
|
22
|
+
export default app;
|
|
@@ -19,6 +19,7 @@ import { MAX_ATTACHMENT_REQUEST_BYTES } from '../../shared/attachments.js';
|
|
|
19
19
|
import { AUTO_LOOP_HARD_RULES, buildAutoLoopGroomingSteps, buildGroomingIntro } from '../../shared/auto-loop-prompts.js';
|
|
20
20
|
import { getDb } from '../db/index.js';
|
|
21
21
|
import { migrationGuard } from '../middleware/migration-guard.js';
|
|
22
|
+
import { workspaceCreationMaintenance } from '../middleware/workspace-creation-maintenance.js';
|
|
22
23
|
import { listEngines } from '../services/agent/engines/registry.js';
|
|
23
24
|
import * as agentManager from '../services/agent/orchestrator.js';
|
|
24
25
|
import * as archiveScriptService from '../services/archive-script-service.js';
|
|
@@ -105,8 +106,16 @@ app.use('/:id/*', async (c, next) => {
|
|
|
105
106
|
}
|
|
106
107
|
await next();
|
|
107
108
|
});
|
|
108
|
-
//
|
|
109
|
-
|
|
109
|
+
// Branch/path mutations must also exclude teardown across awaits.
|
|
110
|
+
// Purge/delete/restore own their guard in the service.
|
|
111
|
+
for (const route of [
|
|
112
|
+
'/:id/archive',
|
|
113
|
+
'/:id/run-setup-script',
|
|
114
|
+
'/:id/cancel-source-change',
|
|
115
|
+
'/:id/rollback-file',
|
|
116
|
+
'/:id/rename-branch',
|
|
117
|
+
'/:id/resync-branch',
|
|
118
|
+
]) {
|
|
110
119
|
app.use(route, async (c, next) => {
|
|
111
120
|
try {
|
|
112
121
|
await withWorkspaceLifecycleGuard(c.req.param('id'), next);
|
|
@@ -444,7 +453,7 @@ const creationBodyLimit = bodyLimit({
|
|
|
444
453
|
maxSize: MAX_ATTACHMENT_REQUEST_BYTES,
|
|
445
454
|
onError: (c) => c.json({ error: 'Workspace creation request is too large', step: 'validate' }, 413),
|
|
446
455
|
});
|
|
447
|
-
app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
456
|
+
app.post('/', workspaceCreationMaintenance, migrationGuard, creationBodyLimit, async (c) => {
|
|
448
457
|
// Declared outside the try so the outermost catch (any unforeseen throw,
|
|
449
458
|
// including a malformed request body) can still name a step instead of
|
|
450
459
|
// leaving the create-progress channel silent. Updated in lockstep with
|
|
@@ -570,6 +579,7 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
570
579
|
// the working branch from git itself — the body.workingBranch is ignored.
|
|
571
580
|
let useReusedWorktree = false;
|
|
572
581
|
let reusedDerivedBranch = null;
|
|
582
|
+
let reusedIdentity = null;
|
|
573
583
|
if (body.worktreePath) {
|
|
574
584
|
currentStep = 'inspect-worktree';
|
|
575
585
|
emitCreateProgress(creationId, currentStep);
|
|
@@ -598,6 +608,14 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
598
608
|
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
599
609
|
}
|
|
600
610
|
reusedDerivedBranch = branch;
|
|
611
|
+
const directory = fs.statSync(body.worktreePath);
|
|
612
|
+
const gitEntry = fs.statSync(path.join(body.worktreePath, '.git'));
|
|
613
|
+
reusedIdentity = {
|
|
614
|
+
device: directory.dev,
|
|
615
|
+
inode: directory.ino,
|
|
616
|
+
gitDevice: gitEntry.dev,
|
|
617
|
+
gitInode: gitEntry.ino,
|
|
618
|
+
};
|
|
601
619
|
}
|
|
602
620
|
catch (err) {
|
|
603
621
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -781,6 +799,36 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
781
799
|
const creation = await withGitRepoLock(body.projectPath, async () => {
|
|
782
800
|
let prospectiveWorktreePath;
|
|
783
801
|
if (useReusedWorktree) {
|
|
802
|
+
// Imports above may yield long enough for a failed PR creation to remove
|
|
803
|
+
// its checkout. Recheck the exact directory and branch under the same
|
|
804
|
+
// repository lock as compensation before any record/context files exist.
|
|
805
|
+
try {
|
|
806
|
+
const worktreePath = body.worktreePath;
|
|
807
|
+
const directory = fs.statSync(worktreePath);
|
|
808
|
+
const gitEntry = fs.statSync(path.join(worktreePath, '.git'));
|
|
809
|
+
const commonDir = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
810
|
+
encoding: 'utf-8',
|
|
811
|
+
timeout: 30_000,
|
|
812
|
+
}).trim();
|
|
813
|
+
const branch = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
814
|
+
encoding: 'utf-8',
|
|
815
|
+
timeout: 30_000,
|
|
816
|
+
}).trim();
|
|
817
|
+
if (!reusedIdentity ||
|
|
818
|
+
!directory.isDirectory() ||
|
|
819
|
+
directory.dev !== reusedIdentity.device ||
|
|
820
|
+
directory.ino !== reusedIdentity.inode ||
|
|
821
|
+
gitEntry.dev !== reusedIdentity.gitDevice ||
|
|
822
|
+
gitEntry.ino !== reusedIdentity.gitInode ||
|
|
823
|
+
realPathOrResolved(commonDir) !== realPathOrResolved(path.join(body.projectPath, '.git')) ||
|
|
824
|
+
branch !== reusedDerivedBranch)
|
|
825
|
+
throw new Error('The worktree directory, repository or branch changed during creation; retry from a fresh diagnosis');
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
const failure = `Failed to revalidate worktree: ${err instanceof Error ? err.message : String(err)}`;
|
|
829
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
830
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
831
|
+
}
|
|
784
832
|
// Another attachment may have completed while this request waited for the lock.
|
|
785
833
|
const existing = getDb().prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
|
|
786
834
|
if (existing) {
|
|
@@ -3180,6 +3228,12 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
|
3180
3228
|
return c.json({ error: message }, workspaceErrorStatus(err));
|
|
3181
3229
|
}
|
|
3182
3230
|
});
|
|
3231
|
+
class WorkspaceBranchConfirmationError extends Error {
|
|
3232
|
+
code = 'branch-confirmation-mismatch';
|
|
3233
|
+
constructor() {
|
|
3234
|
+
super('The workspace branch changed or does not match confirmationBranch. Confirm its current branch before deleting.');
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3183
3237
|
// Shared teardown for a single workspace: stops the agent, destroys the
|
|
3184
3238
|
// terminal, removes the owned worktree, optionally deletes local/remote
|
|
3185
3239
|
// branches, then deletes the DB row (cascades to tasks/sessions/events).
|
|
@@ -3191,6 +3245,8 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
|
3191
3245
|
const current = workspaceService.getWorkspace(workspace.id);
|
|
3192
3246
|
if (!current)
|
|
3193
3247
|
throw new Error(`Workspace '${workspace.id}' not found`);
|
|
3248
|
+
if (opts.confirmationBranch !== undefined && opts.confirmationBranch !== current.workingBranch)
|
|
3249
|
+
throw new WorkspaceBranchConfirmationError();
|
|
3194
3250
|
// A bulk deletion may have captured the list before restoration completed.
|
|
3195
3251
|
if (workspace.archivedAt && !current.archivedAt) {
|
|
3196
3252
|
throw new Error('Workspace was unarchived while deletion was pending. Retry from its current state.');
|
|
@@ -3335,7 +3391,7 @@ app.delete('/:id', migrationGuard, async (c) => {
|
|
|
3335
3391
|
return c.json({ ok: true, warnings }, 200);
|
|
3336
3392
|
}
|
|
3337
3393
|
catch (err) {
|
|
3338
|
-
if (err instanceof WorkspaceLifecycleBusyError)
|
|
3394
|
+
if (err instanceof WorkspaceLifecycleBusyError || err instanceof WorkspaceBranchConfirmationError)
|
|
3339
3395
|
return c.json({ code: err.code, error: err.message }, 409);
|
|
3340
3396
|
const message = err instanceof Error ? err.message : String(err);
|
|
3341
3397
|
return c.json({ error: message }, workspaceErrorStatus(err));
|
|
@@ -20,6 +20,7 @@ import { buildReviewReturnPrompt, claimReviewReturn, completeReviewReturn, getRe
|
|
|
20
20
|
import { activateSession, SESSION_RECENCY_ORDER } from '../session-activity-service.js';
|
|
21
21
|
import { requestHandoffStop, suspendHandoffTransfers } from '../session-handoff-runtime.js';
|
|
22
22
|
import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
|
|
23
|
+
import { isUpdateMaintenanceActive } from '../update-maintenance-service.js';
|
|
23
24
|
import { refreshNow } from '../usage/poller.js';
|
|
24
25
|
import * as wakeupService from '../wakeup-service.js';
|
|
25
26
|
import { emit, emitEphemeral } from '../websocket-service.js';
|
|
@@ -67,7 +68,7 @@ let shuttingDown = false;
|
|
|
67
68
|
let capacityTimer;
|
|
68
69
|
const stoppedCapacityOwners = new Set();
|
|
69
70
|
function notifyCapacityAvailable(stoppedWorkspaceId) {
|
|
70
|
-
if (
|
|
71
|
+
if (isShuttingDown())
|
|
71
72
|
return;
|
|
72
73
|
stoppedCapacityOwners.add(stoppedWorkspaceId);
|
|
73
74
|
if (capacityTimer)
|
|
@@ -77,7 +78,7 @@ function notifyCapacityAvailable(stoppedWorkspaceId) {
|
|
|
77
78
|
capacityTimer = undefined;
|
|
78
79
|
const excluded = new Set(stoppedCapacityOwners);
|
|
79
80
|
stoppedCapacityOwners.clear();
|
|
80
|
-
if (
|
|
81
|
+
if (isShuttingDown())
|
|
81
82
|
return;
|
|
82
83
|
try {
|
|
83
84
|
resumePendingReviewReturns();
|
|
@@ -1365,7 +1366,7 @@ function revokeControllerMemoryCapability(controller) {
|
|
|
1365
1366
|
const reviewReturnDispatchOwners = new Map();
|
|
1366
1367
|
/** Resume durable review work only after the previous runtime has closed and admission allows it. */
|
|
1367
1368
|
export function resumePendingReviewReturns(onlyWorkspaceId) {
|
|
1368
|
-
if (
|
|
1369
|
+
if (isShuttingDown())
|
|
1369
1370
|
return;
|
|
1370
1371
|
for (const pending of listReviewReturns()) {
|
|
1371
1372
|
const id = pending.workspaceId;
|
|
@@ -1465,6 +1466,8 @@ export function cancelReviewReturn(workspaceId) {
|
|
|
1465
1466
|
* query the controller via `_getControllers()` for tests.
|
|
1466
1467
|
*/
|
|
1467
1468
|
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch, launch) {
|
|
1469
|
+
if (isUpdateMaintenanceActive())
|
|
1470
|
+
throw new Error('Cannot start an agent during update maintenance');
|
|
1468
1471
|
if (shuttingDown)
|
|
1469
1472
|
throw new Error('Cannot start an agent while the server is shutting down');
|
|
1470
1473
|
assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
|
|
@@ -1914,7 +1917,20 @@ export async function stopAllAgents(timeoutMs = 3_000) {
|
|
|
1914
1917
|
}
|
|
1915
1918
|
/** Automatic continuations must preserve their durable intent during teardown. */
|
|
1916
1919
|
export function isShuttingDown() {
|
|
1917
|
-
return shuttingDown;
|
|
1920
|
+
return shuttingDown || isUpdateMaintenanceActive();
|
|
1921
|
+
}
|
|
1922
|
+
/** Used synchronously by update reservation; include runtimes still closing and pending replacements. */
|
|
1923
|
+
export function isIdleForUpdate() {
|
|
1924
|
+
if (shuttingDown || controllers.size || pendingStarts.size)
|
|
1925
|
+
return false;
|
|
1926
|
+
const db = getDb();
|
|
1927
|
+
return (!db.prepare("SELECT 1 FROM agent_sessions WHERE status = 'running' LIMIT 1").get() &&
|
|
1928
|
+
!db
|
|
1929
|
+
.prepare("SELECT 1 FROM workspaces WHERE status IN ('extracting','brainstorming','executing','compacting') LIMIT 1")
|
|
1930
|
+
.get());
|
|
1931
|
+
}
|
|
1932
|
+
export function resumeAfterUpdateMaintenance() {
|
|
1933
|
+
notifyCapacityAvailable('');
|
|
1918
1934
|
}
|
|
1919
1935
|
/** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
|
|
1920
1936
|
export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
|
|
@@ -1947,8 +1963,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId, befor
|
|
|
1947
1963
|
throw new Error(`Session '${expectedSessionId}' is not active for workspace '${workspaceId}'`);
|
|
1948
1964
|
}
|
|
1949
1965
|
wakeupService.cancel(workspaceId, 'user-message');
|
|
1950
|
-
beforeDispatch
|
|
1951
|
-
await ctrl.sendMessage(content);
|
|
1966
|
+
await ctrl.sendMessage(content, beforeDispatch);
|
|
1952
1967
|
}
|
|
1953
1968
|
export async function sendMessageForFallback(workspaceId, content) {
|
|
1954
1969
|
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() {
|
|
@@ -7,6 +7,7 @@ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
|
7
7
|
import * as orchestrator from './agent/orchestrator.js';
|
|
8
8
|
import * as autoLoopService from './auto-loop-service.js';
|
|
9
9
|
import * as settingsService from './settings-service.js';
|
|
10
|
+
import { deferForUpdateMaintenance } from './update-maintenance-service.js';
|
|
10
11
|
import { emitEphemeral } from './websocket-service.js';
|
|
11
12
|
export const MIN_DELAY_BETWEEN_FIRES_SECONDS = 60;
|
|
12
13
|
// Node `setTimeout` stores the delay as a 32-bit signed int. Anything above
|
|
@@ -185,6 +186,12 @@ export function listAll() {
|
|
|
185
186
|
function fireOrSkip(id) {
|
|
186
187
|
if (suspended)
|
|
187
188
|
return;
|
|
189
|
+
const pendingTimer = timers.get(id);
|
|
190
|
+
if (deferForUpdateMaintenance(`cron:${id}`, () => {
|
|
191
|
+
if (timers.get(id) === pendingTimer)
|
|
192
|
+
fireOrSkip(id);
|
|
193
|
+
}))
|
|
194
|
+
return;
|
|
188
195
|
try {
|
|
189
196
|
timers.delete(id);
|
|
190
197
|
const db = getDb();
|
|
@@ -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. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getDb } from '../db/index.js';
|
|
2
|
+
import { deferForUpdateMaintenance } from './update-maintenance-service.js';
|
|
2
3
|
import { emitEphemeral } from './websocket-service.js';
|
|
3
4
|
import { getWorkspace } from './workspace-service.js';
|
|
4
5
|
const timers = new Map();
|
|
@@ -142,6 +143,12 @@ export function restoreOnBoot(onFire) {
|
|
|
142
143
|
function fireOrSkip(workspaceId) {
|
|
143
144
|
if (suspended)
|
|
144
145
|
return;
|
|
146
|
+
const pendingTimer = timers.get(workspaceId);
|
|
147
|
+
if (deferForUpdateMaintenance(`quota-backoff:${workspaceId}`, () => {
|
|
148
|
+
if (timers.get(workspaceId) === pendingTimer)
|
|
149
|
+
fireOrSkip(workspaceId);
|
|
150
|
+
}))
|
|
151
|
+
return;
|
|
145
152
|
timers.delete(workspaceId);
|
|
146
153
|
const pending = getPending(workspaceId);
|
|
147
154
|
if (!pending)
|