@loicngr/kobo 1.11.27 → 1.11.29
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 +19 -1
- package/CHANGELOG.md +10 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +17 -1
- package/dist/server/db/migrations.js +34 -0
- package/dist/server/db/schema.js +3 -1
- package/dist/server/index.js +80 -23
- package/dist/server/middleware/host-check-middleware.js +80 -0
- package/dist/server/routes/changelog.js +51 -3
- package/dist/server/routes/notion.js +1 -1
- package/dist/server/routes/pull-requests.js +6 -2
- package/dist/server/routes/sentry.js +1 -1
- package/dist/server/routes/settings.js +23 -8
- package/dist/server/routes/usage.js +12 -0
- package/dist/server/routes/workspaces.js +222 -80
- package/dist/server/services/agent/engines/claude-code/engine.js +3 -0
- package/dist/server/services/agent/engines/codex/engine.js +18 -0
- package/dist/server/services/agent/orchestrator.js +129 -34
- package/dist/server/services/agent/session-controller.js +28 -0
- package/dist/server/services/auto-loop-service.js +65 -0
- package/dist/server/services/awaiting-user-reminder-service.js +115 -0
- package/dist/server/services/change-source-branch-service.js +1 -1
- package/dist/server/services/comparison-stats-service.js +43 -0
- package/dist/server/services/forge/resolve.js +29 -1
- package/dist/server/services/lifecycle-hook-service.js +114 -0
- package/dist/server/services/network-access-service.js +187 -6
- package/dist/server/services/pr-watcher-service.js +27 -1
- package/dist/server/services/settings-service.js +147 -4
- package/dist/server/services/usage/reliability.js +81 -0
- package/dist/server/services/wakeup-service.js +23 -0
- package/dist/server/services/websocket-service.js +31 -3
- package/dist/server/services/workspace-service.js +36 -4
- package/dist/server/services/worktree-purge-service.js +1 -1
- package/dist/server/utils/git-ops.js +159 -1
- package/dist/server/utils/paths.js +19 -0
- package/dist/server/utils/safe-path.js +21 -1
- package/dist/server/utils/script-runner.js +2 -1
- package/dist/server/utils/workspace-name.js +37 -0
- package/dist/shared/consts.js +22 -0
- package/dist/shared/models.js +4 -4
- package/package.json +3 -2
- package/src/client/dist/pwa/assets/{ActivityFeed-LEd9NEbI.css → ActivityFeed-C6XSxdJ0.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-Dm-h5c8M.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-tLjrU4kh.js +1 -0
- package/src/client/dist/pwa/assets/ClosePopup-CekolbY5.js +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-CsdFl1JL.js +2 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-x1iuICgd.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-B1iRRP_d.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-CNTYrE-N.js +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-CxXPJSsu.js +8 -0
- package/src/client/dist/pwa/assets/{DiffViewer-BV8SvUIi.css → DiffViewer-DnR79ycb.css} +1 -1
- package/src/client/dist/pwa/assets/DocumentsPanel-Du7qG8uv.js +1 -0
- package/src/client/dist/pwa/assets/DrawerToggleButton-D0i0wUjb.js +1 -0
- package/src/client/dist/pwa/assets/GitPanel-tuTj7lhu.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-DL_RBg9X.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-CuPokl9k.js +2 -0
- package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
- package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
- package/src/client/dist/pwa/assets/QBtnDropdown-VaaGdcMX.js +1 -0
- package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
- package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-CyWvfNkF.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-c_MuOKwZ.js +1 -0
- package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-QkPo7j0B.js +10 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-HIuH2z-Y.js → TerminalPanel-Io_WshA3.js} +3 -3
- package/src/client/dist/pwa/assets/TouchPan-DO0Y0lcH.js +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-NqQsltOl.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-Ct8QYOU_.js +4 -0
- package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
- package/src/client/dist/pwa/assets/build-path-tree-CmeIBsJq.js +1 -0
- package/src/client/dist/pwa/assets/de-DUTfyzTm.js +17 -0
- package/src/client/dist/pwa/assets/documents-DuT_pUBs.js +1 -0
- package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-B7h7KULV.js +413 -0
- package/src/client/dist/pwa/assets/{efforts-Dn1yRgeo.js → efforts-CFRBNt5s.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-DmG1z2N2.js +1 -0
- package/src/client/dist/pwa/assets/es-ft4_w4dQ.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-DXzAXadA.js +1 -0
- package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
- package/src/client/dist/pwa/assets/format-klpA_BIb.js +435 -0
- package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
- package/src/client/dist/pwa/assets/fr-SGSKyQ_T.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-qQC_1HDe.js → freemarker2-AB32EM-g.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-pyc5TcAt.js → handlebars-C87HpXgr.js} +1 -1
- package/src/client/dist/pwa/assets/{html-DOaftBCT.js → html-fZ2Q-EN9.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-jUVcMUna.js +1 -0
- package/src/client/dist/pwa/assets/index-BgV6UXly.js +18 -0
- package/src/client/dist/pwa/assets/it-C5q5LNGX.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-BTsYFFLK.js → javascript-DaRn-B3h.js} +1 -1
- package/src/client/dist/pwa/assets/layout-DdQC-2Sz.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-BdTymdAY.js → liquid-BxIQD86X.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DzJktAFj.js → mdx-BB8ir9N_.js} +1 -1
- package/src/client/dist/pwa/assets/monaco-contributions-B0YHWDBM.js +51 -0
- package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
- package/src/client/dist/pwa/assets/monaco.contribution-Nxc5lpNj.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-0EqcSNjT.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-D1FTBWWF.js → notify-theme-CJQIBqdX.js} +1 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BTLtsmQt.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-BR2CNlno.js +36 -0
- package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-eoGJvY_F.js → project-color-CZmTicBX.js} +1 -1
- package/src/client/dist/pwa/assets/{python-onIjs5sJ.js → python-BdCzK6Il.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-DqQGiaiD.js → razor-ClnzXrTz.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-BwbknTum.js → render-chat-markdown-CzR8e-Uh.js} +1 -1
- package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/src/client/dist/pwa/assets/{typescript-BnI60_wU.js → typescript-Dk26pRlO.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-BwDLVLlc.js +2 -0
- package/src/client/dist/pwa/assets/use-quasar-B2sgdABh.js +1 -0
- package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
- package/src/client/dist/pwa/assets/{xml-DQUlBYuZ.js → xml-DdBTQmk2.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-D3GiqGbP.js → yaml-DTv5uGmK.js} +1 -1
- package/src/client/dist/pwa/index.html +5 -4
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
- package/src/client/dist/pwa/assets/ActivityFeed-XJeDwa82.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-Bjls89ZX.js +0 -1
- package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BzHQd4uE.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-DNPD0uVB.js +0 -8
- package/src/client/dist/pwa/assets/DocumentsPanel-Osdun-Om.js +0 -1
- package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
- package/src/client/dist/pwa/assets/GitPanel-8_xQ7V2r.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-CSEPHslq.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-Bdh2JQfn.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-X1T4sh65.js +0 -2
- package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
- package/src/client/dist/pwa/assets/QBtnDropdown-aUlDUJBU.js +0 -1
- package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-DIoiKpwZ.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CKNNtnwv.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-njeIse66.js +0 -16
- package/src/client/dist/pwa/assets/TouchPan-CMDAeqj6.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-Bk7pIvC0.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-B07ATM5W.js +0 -1
- package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
- package/src/client/dist/pwa/assets/cssMode-BvD76b3P.js +0 -1
- package/src/client/dist/pwa/assets/documents-D7AZJAiR.js +0 -1
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
- package/src/client/dist/pwa/assets/editor.api-GJX9JYVf.js +0 -847
- package/src/client/dist/pwa/assets/engineFeatures-C4wJRlwO.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-0hMEF4rZ.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
- package/src/client/dist/pwa/assets/htmlMode-DRXGGPOb.js +0 -1
- package/src/client/dist/pwa/assets/i18n-mEG61A5h.js +0 -1
- package/src/client/dist/pwa/assets/index-DZ4aWwQs.js +0 -82
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
- package/src/client/dist/pwa/assets/jsonMode-3Glcck88.js +0 -7
- package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-CvwRMMY9.js +0 -4
- package/src/client/dist/pwa/assets/network-auth-D59LKtK4.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-DrdnFOI2.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-_rSNmU9M.js +0 -36
- package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
- package/src/client/dist/pwa/assets/tsMode-B24qEjDH.js +0 -11
- package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
- package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding-WR8vvxBw.js +0 -2
- package/src/client/dist/pwa/assets/vs-DVt5kpUB.js +0 -56
- package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
- package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
- package/src/client/dist/pwa/assets/workers-D28S2iKq.js +0 -1
|
@@ -14,6 +14,7 @@ import * as autoLoopService from '../services/auto-loop-service.js';
|
|
|
14
14
|
import { changeSourceBranch } from '../services/change-source-branch-service.js';
|
|
15
15
|
import { listChatHistory, pushChatHistory } from '../services/chat-history-service.js';
|
|
16
16
|
import { renderCiFixTemplate } from '../services/ci-fix-template-service.js';
|
|
17
|
+
import { computeWorkspaceActivityStats } from '../services/comparison-stats-service.js';
|
|
17
18
|
import * as cronService from '../services/cron-service.js';
|
|
18
19
|
import * as devServerService from '../services/dev-server-service.js';
|
|
19
20
|
import { buildEngineHandoff } from '../services/engine-handoff-service.js';
|
|
@@ -44,6 +45,7 @@ import * as gitOps from '../utils/git-ops.js';
|
|
|
44
45
|
import { logError } from '../utils/logger.js';
|
|
45
46
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
46
47
|
import * as safePath from '../utils/safe-path.js';
|
|
48
|
+
import { resolveExtractedName } from '../utils/workspace-name.js';
|
|
47
49
|
import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
48
50
|
/** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
|
|
49
51
|
const app = new Hono();
|
|
@@ -231,26 +233,6 @@ function parsePrNumberFromUrl(prUrl) {
|
|
|
231
233
|
}
|
|
232
234
|
return number;
|
|
233
235
|
}
|
|
234
|
-
// Mirrors the private `WORKSPACE_NAME_MAX_LENGTH` in workspace-service.ts —
|
|
235
|
-
// duplicated rather than exported to avoid coupling this route to that
|
|
236
|
-
// module's internals (and the many `vi.mock(workspace-service.js, factory)`
|
|
237
|
-
// test files that would otherwise need updating for an export nothing else
|
|
238
|
-
// consumes).
|
|
239
|
-
const WORKSPACE_NAME_MAX_LENGTH = 200;
|
|
240
|
-
/**
|
|
241
|
-
* Cap an auto-derived title (Notion page title, Sentry issue title, PR/MR
|
|
242
|
-
* title) before handing it to `updateWorkspaceName`. That function throws on
|
|
243
|
-
* anything longer — the right behaviour for a name the user typed
|
|
244
|
-
* themselves, but titles pulled from an external source (a long exception
|
|
245
|
-
* message, a verbose PR title) are not user input: truncating them is the
|
|
246
|
-
* sane default, matching the existing `slugifyBranchSegment` precedent for
|
|
247
|
-
* auto-derived branch names.
|
|
248
|
-
*/
|
|
249
|
-
function truncateWorkspaceName(name) {
|
|
250
|
-
if (name.length <= WORKSPACE_NAME_MAX_LENGTH)
|
|
251
|
-
return name;
|
|
252
|
-
return `${name.slice(0, WORKSPACE_NAME_MAX_LENGTH - 1)}…`;
|
|
253
|
-
}
|
|
254
236
|
app.get('/', (c) => {
|
|
255
237
|
try {
|
|
256
238
|
const workspaces = workspaceService.listWorkspaces();
|
|
@@ -265,7 +247,7 @@ app.get('/', (c) => {
|
|
|
265
247
|
app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
|
|
266
248
|
try {
|
|
267
249
|
const id = c.req.param('id');
|
|
268
|
-
const body = await c.req.json();
|
|
250
|
+
const body = await c.req.json().catch(() => ({}));
|
|
269
251
|
const workspace = workspaceService.getWorkspaceWithTasks(id);
|
|
270
252
|
if (!workspace)
|
|
271
253
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
@@ -282,7 +264,9 @@ app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
|
|
|
282
264
|
app.post('/:id/switch-engine', migrationGuard, async (c) => {
|
|
283
265
|
try {
|
|
284
266
|
const id = c.req.param('id');
|
|
285
|
-
const body = await c.req
|
|
267
|
+
const body = await c.req
|
|
268
|
+
.json()
|
|
269
|
+
.catch(() => ({}));
|
|
286
270
|
const workspace = workspaceService.getWorkspaceWithTasks(id);
|
|
287
271
|
if (!workspace)
|
|
288
272
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
@@ -340,7 +324,9 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
340
324
|
// can read it to decide whether the setup-script caveat applies.
|
|
341
325
|
let setupScriptConfigured = false;
|
|
342
326
|
try {
|
|
343
|
-
const body = await c.req
|
|
327
|
+
const body = await c.req
|
|
328
|
+
.json()
|
|
329
|
+
.catch(() => ({}));
|
|
344
330
|
// workingBranch is derived from git when worktreePath is provided, so
|
|
345
331
|
// it's not required in that flow. The other 3 fields stay mandatory.
|
|
346
332
|
if (!body.name || !body.projectPath || !body.sourceBranch) {
|
|
@@ -357,6 +343,14 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
357
343
|
if (body.workingBranch && !gitOps.isValidBranchName(body.workingBranch)) {
|
|
358
344
|
return c.json({ error: `Invalid working branch name: ${body.workingBranch}` }, 400);
|
|
359
345
|
}
|
|
346
|
+
// Same reasoning for the source branch, which is always caller-supplied and
|
|
347
|
+
// reaches `git fetch <remote> <sourceBranch>` as a bare argument.
|
|
348
|
+
if (!gitOps.isValidBranchName(body.sourceBranch)) {
|
|
349
|
+
return c.json({ error: `Invalid source branch name: ${body.sourceBranch}` }, 400);
|
|
350
|
+
}
|
|
351
|
+
if (body.comparisonId !== undefined && typeof body.comparisonId !== 'string') {
|
|
352
|
+
return c.json({ error: 'comparisonId must be a string' }, 400);
|
|
353
|
+
}
|
|
360
354
|
creationId = typeof body.creationId === 'string' && body.creationId.length > 0 ? body.creationId : undefined;
|
|
361
355
|
currentStep = 'validate';
|
|
362
356
|
emitCreateProgress(creationId, currentStep);
|
|
@@ -397,7 +391,7 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
397
391
|
let baseRef = `origin/${body.sourceBranch}`;
|
|
398
392
|
let usedLocalFallback = false;
|
|
399
393
|
try {
|
|
400
|
-
gitOps.
|
|
394
|
+
await gitOps.fetchSourceBranchOrThrowAsync(body.projectPath, body.sourceBranch);
|
|
401
395
|
}
|
|
402
396
|
catch (err) {
|
|
403
397
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -603,7 +597,13 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
603
597
|
.replace(/^-|-$/g, '')
|
|
604
598
|
.substring(0, 50);
|
|
605
599
|
const safeSlug = titleSlug || 'task';
|
|
606
|
-
|
|
600
|
+
// An engine comparison posts here once per engine with the same
|
|
601
|
+
// ticket: without the suffix both halves collapse onto one branch and
|
|
602
|
+
// the second gets a random hash instead of its engine.
|
|
603
|
+
const engineSuffix = body.comparisonId && body.engine ? `-${body.engine}` : '';
|
|
604
|
+
workingBranch = ticketPrefix
|
|
605
|
+
? `${typePrefix}${ticketPrefix}--${safeSlug}${engineSuffix}`
|
|
606
|
+
: `${typePrefix}${safeSlug}${engineSuffix}`;
|
|
607
607
|
}
|
|
608
608
|
}
|
|
609
609
|
// Compute the project slug once and reuse it for both the prospective
|
|
@@ -661,6 +661,9 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
661
661
|
reasoningEffort: body.reasoningEffort,
|
|
662
662
|
agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings, body.engine ?? 'claude-code'),
|
|
663
663
|
engine: body.engine,
|
|
664
|
+
// Set by the engine-comparison flow, which posts here once per engine
|
|
665
|
+
// with the same id so the resulting workspaces can find each other.
|
|
666
|
+
comparisonId: body.comparisonId,
|
|
664
667
|
...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
|
|
665
668
|
});
|
|
666
669
|
createdWorkspace = workspace;
|
|
@@ -706,9 +709,11 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
706
709
|
// a custom name and Notion hasn't already filled it. Prefix with the Sentry
|
|
707
710
|
// short-id (e.g. "SEKUR-IOS-9 | TypeError: …") so the workspace stays
|
|
708
711
|
// identifiable in the sidebar without opening the panel.
|
|
709
|
-
if (sentryContent?.title && !notionContent?.title
|
|
712
|
+
if (sentryContent?.title && !notionContent?.title) {
|
|
710
713
|
const prefix = sentryContent.issueId ? `${sentryContent.issueId} | ` : '';
|
|
711
|
-
|
|
714
|
+
const renamed = resolveExtractedName(workspace.name, `${prefix}${sentryContent.title}`);
|
|
715
|
+
if (renamed)
|
|
716
|
+
workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
|
|
712
717
|
}
|
|
713
718
|
currentStep = 'create-tasks';
|
|
714
719
|
emitCreateProgress(creationId, currentStep);
|
|
@@ -732,16 +737,20 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
732
737
|
// Update workspace name with Notion page title only if user didn't
|
|
733
738
|
// provide a custom name. Prefix with the Notion unique-id (e.g.
|
|
734
739
|
// Use the Notion page title when the user left the generic placeholder.
|
|
735
|
-
if (notionContent.title
|
|
736
|
-
|
|
740
|
+
if (notionContent.title) {
|
|
741
|
+
const renamed = resolveExtractedName(workspace.name, notionContent.title);
|
|
742
|
+
if (renamed)
|
|
743
|
+
workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
|
|
737
744
|
}
|
|
738
745
|
}
|
|
739
746
|
// Update workspace name with the PR/MR title if the user did not provide
|
|
740
747
|
// a custom name and neither Notion nor Sentry already claimed it. This
|
|
741
748
|
// makes the PR a THIRD context source, lowest priority of the three
|
|
742
749
|
// (Notion > Sentry > PR), consistent with the checks above.
|
|
743
|
-
if (prContent && !notionContent?.title && !sentryContent?.title
|
|
744
|
-
|
|
750
|
+
if (prContent && !notionContent?.title && !sentryContent?.title) {
|
|
751
|
+
const renamed = resolveExtractedName(workspace.name, prContent.title);
|
|
752
|
+
if (renamed)
|
|
753
|
+
workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
|
|
745
754
|
}
|
|
746
755
|
// Create manual tasks/criteria if no Notion content was extracted
|
|
747
756
|
if (!notionContent && (Array.isArray(body.tasks) || Array.isArray(body.acceptanceCriteria))) {
|
|
@@ -814,6 +823,11 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
814
823
|
['.ai/thoughts/', 'thoughts/'],
|
|
815
824
|
['.ai/images/', 'images/'],
|
|
816
825
|
['.ai/.setup-script.tmp', '.setup-script.tmp'],
|
|
826
|
+
['.ai/.cleanup-script.tmp', '.cleanup-script.tmp'],
|
|
827
|
+
['.ai/.archive-script.tmp', '.archive-script.tmp'],
|
|
828
|
+
// A crash mid-hook leaves the temp script behind; the next agent
|
|
829
|
+
// would otherwise happily commit it.
|
|
830
|
+
['.ai/.hook-*.tmp', '.hook-*.tmp'],
|
|
817
831
|
['.mcp.json', ''],
|
|
818
832
|
];
|
|
819
833
|
const toAdd = [];
|
|
@@ -1401,6 +1415,40 @@ app.get('/info', (c) => {
|
|
|
1401
1415
|
return c.json({ error: message }, 500);
|
|
1402
1416
|
}
|
|
1403
1417
|
});
|
|
1418
|
+
// GET /api/workspaces/:id/comparison — the workspaces running the same task on
|
|
1419
|
+
// another engine, with the diff each one produced. Reads the git-stats cache the
|
|
1420
|
+
// pr-watcher already maintains rather than shelling out to git twice per request.
|
|
1421
|
+
app.get('/:id/comparison', (c) => {
|
|
1422
|
+
const id = c.req.param('id');
|
|
1423
|
+
try {
|
|
1424
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
1425
|
+
if (!workspace)
|
|
1426
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1427
|
+
if (!workspace.comparisonId)
|
|
1428
|
+
return c.json({ comparisonId: null, members: [] });
|
|
1429
|
+
const stats = getAllGitStats();
|
|
1430
|
+
const members = workspaceService.listComparisonMembers(workspace.comparisonId).map((member) => {
|
|
1431
|
+
// Same count as the auto-loop badge: every task, acceptance criteria
|
|
1432
|
+
// included. How finely each engine planned the task is visible before
|
|
1433
|
+
// a single line of code is, and it colours every later number.
|
|
1434
|
+
const tasks = workspaceService.listTasks(member.id);
|
|
1435
|
+
return {
|
|
1436
|
+
workspace: member,
|
|
1437
|
+
// Null, not zeros: "not measured yet" and "changed nothing" are
|
|
1438
|
+
// different answers, and only one of them is true here.
|
|
1439
|
+
gitStats: stats[member.id] ?? null,
|
|
1440
|
+
tasks: { done: tasks.filter((t) => t.status === 'done').length, total: tasks.length },
|
|
1441
|
+
// What it took: messages, questions, tools, tokens, over every session.
|
|
1442
|
+
activity: computeWorkspaceActivityStats(getDb(), member.id),
|
|
1443
|
+
};
|
|
1444
|
+
});
|
|
1445
|
+
return c.json({ comparisonId: workspace.comparisonId, members });
|
|
1446
|
+
}
|
|
1447
|
+
catch (err) {
|
|
1448
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1449
|
+
return c.json({ error: message }, 500);
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1404
1452
|
// POST /api/workspaces/pr-snapshot/refresh/:id — on-demand refresh of a single
|
|
1405
1453
|
// workspace's PR snapshot, driven by the Git tab refresh button. Static prefix
|
|
1406
1454
|
// keeps it ahead of `/:id` in the Hono matcher.
|
|
@@ -1458,7 +1506,13 @@ app.get('/auto-loop-states', (c) => {
|
|
|
1458
1506
|
// GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
|
|
1459
1507
|
app.get('/:id/auto-loop', (c) => {
|
|
1460
1508
|
try {
|
|
1461
|
-
|
|
1509
|
+
const id = c.req.param('id');
|
|
1510
|
+
// getStatus returns a default row for an unknown id, so without this the
|
|
1511
|
+
// caller gets a cheerful `auto_loop: false` for a workspace that does not
|
|
1512
|
+
// exist — the neighbouring handlers all 404.
|
|
1513
|
+
if (!workspaceService.getWorkspace(id))
|
|
1514
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1515
|
+
return c.json(autoLoopService.getStatus(id));
|
|
1462
1516
|
}
|
|
1463
1517
|
catch (err) {
|
|
1464
1518
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1848,7 +1902,9 @@ app.post('/:id/tasks', async (c) => {
|
|
|
1848
1902
|
if (!workspace) {
|
|
1849
1903
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1850
1904
|
}
|
|
1851
|
-
const body = await c.req
|
|
1905
|
+
const body = await c.req
|
|
1906
|
+
.json()
|
|
1907
|
+
.catch(() => ({}));
|
|
1852
1908
|
if (!body.title?.trim()) {
|
|
1853
1909
|
return c.json({ error: 'Title is required' }, 400);
|
|
1854
1910
|
}
|
|
@@ -1879,7 +1935,9 @@ app.patch('/:id/tasks/:taskId', async (c) => {
|
|
|
1879
1935
|
if (!task) {
|
|
1880
1936
|
return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
|
|
1881
1937
|
}
|
|
1882
|
-
const body = await c.req
|
|
1938
|
+
const body = await c.req
|
|
1939
|
+
.json()
|
|
1940
|
+
.catch(() => ({}));
|
|
1883
1941
|
if (body.status === undefined && body.title === undefined) {
|
|
1884
1942
|
return c.json({ error: 'At least one of status or title is required' }, 400);
|
|
1885
1943
|
}
|
|
@@ -2075,6 +2133,59 @@ function readableHistorySearchResult(type, rawPayload, query) {
|
|
|
2075
2133
|
snippet: `${start > 0 ? '…' : ''}${text.slice(start, end).replace(/\s+/g, ' ').trim()}${end < text.length ? '…' : ''}`,
|
|
2076
2134
|
};
|
|
2077
2135
|
}
|
|
2136
|
+
/**
|
|
2137
|
+
* GET /api/workspaces/:id/sessions/:sessionId/summary — why a session ended.
|
|
2138
|
+
*
|
|
2139
|
+
* A session can stop for many reasons (quota, transient failure, drain
|
|
2140
|
+
* watchdog, auto-loop stall, failed resume, manual stop, natural end) and none
|
|
2141
|
+
* of them were legible after the fact: you had to read the event feed to work
|
|
2142
|
+
* out what happened overnight. Everything here is already collected —
|
|
2143
|
+
* `agent_sessions` and `session_event_metrics` — it was just never put in one
|
|
2144
|
+
* place. Note that `end_reason` itself only records four values (completed /
|
|
2145
|
+
* error / killed / watchdog); quota, stall and failed resume are visible
|
|
2146
|
+
* through the workspace status and the auto-loop events, not through it.
|
|
2147
|
+
*/
|
|
2148
|
+
app.get('/:id/sessions/:sessionId/summary', (c) => {
|
|
2149
|
+
try {
|
|
2150
|
+
const id = c.req.param('id');
|
|
2151
|
+
const sessionId = c.req.param('sessionId');
|
|
2152
|
+
if (!workspaceService.getWorkspace(id))
|
|
2153
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
2154
|
+
const db = getDb();
|
|
2155
|
+
const session = db
|
|
2156
|
+
.prepare(`SELECT id, engine, model, status, end_reason, started_at, ended_at
|
|
2157
|
+
FROM agent_sessions WHERE id = ? AND workspace_id = ?`)
|
|
2158
|
+
.get(sessionId, id);
|
|
2159
|
+
if (!session)
|
|
2160
|
+
return c.json({ error: `Session '${sessionId}' not found` }, 404);
|
|
2161
|
+
const metrics = db
|
|
2162
|
+
.prepare(`SELECT tool_calls, errors, input_tokens, output_tokens
|
|
2163
|
+
FROM session_event_metrics WHERE workspace_id = ? AND session_id = ?`)
|
|
2164
|
+
.get(id, sessionId);
|
|
2165
|
+
const startedAt = Date.parse(session.started_at);
|
|
2166
|
+
const endedAt = session.ended_at ? Date.parse(session.ended_at) : null;
|
|
2167
|
+
return c.json({
|
|
2168
|
+
sessionId: session.id,
|
|
2169
|
+
engine: session.engine,
|
|
2170
|
+
model: session.model,
|
|
2171
|
+
status: session.status,
|
|
2172
|
+
// Null for sessions that ended before this was recorded, and for one
|
|
2173
|
+
// still running. The client says "unknown" rather than inventing a cause.
|
|
2174
|
+
endReason: session.end_reason,
|
|
2175
|
+
startedAt: session.started_at,
|
|
2176
|
+
endedAt: session.ended_at,
|
|
2177
|
+
durationMs: endedAt !== null && !Number.isNaN(startedAt) ? endedAt - startedAt : null,
|
|
2178
|
+
toolCalls: metrics?.tool_calls ?? 0,
|
|
2179
|
+
errors: metrics?.errors ?? 0,
|
|
2180
|
+
inputTokens: metrics?.input_tokens ?? 0,
|
|
2181
|
+
outputTokens: metrics?.output_tokens ?? 0,
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
catch (err) {
|
|
2185
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2186
|
+
return c.json({ error: message }, 500);
|
|
2187
|
+
}
|
|
2188
|
+
});
|
|
2078
2189
|
// GET /api/workspaces/:id/session-metrics — compact observability data for the timeline.
|
|
2079
2190
|
app.get('/:id/session-metrics', (c) => {
|
|
2080
2191
|
try {
|
|
@@ -2118,15 +2229,22 @@ app.get('/:id/diagnostic.json', (c) => {
|
|
|
2118
2229
|
const sessions = db
|
|
2119
2230
|
.prepare('SELECT id, engine_session_id, status, started_at, ended_at, model, name FROM agent_sessions WHERE workspace_id = ? ORDER BY started_at ASC')
|
|
2120
2231
|
.all(id);
|
|
2232
|
+
// Bounded on purpose: better-sqlite3 is synchronous, so an unbounded read
|
|
2233
|
+
// materialises every event the workspace ever produced and blocks the
|
|
2234
|
+
// event loop while `c.json` serialises them. A diagnostic bundle wants a
|
|
2235
|
+
// representative trace, not the entire history.
|
|
2236
|
+
const DIAGNOSTIC_EVENT_LIMIT = 50_000;
|
|
2121
2237
|
const events = db
|
|
2122
|
-
.prepare('SELECT id, session_id, type, created_at FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC')
|
|
2123
|
-
.all(id);
|
|
2238
|
+
.prepare('SELECT id, session_id, type, created_at FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
|
|
2239
|
+
.all(id, DIAGNOSTIC_EVENT_LIMIT);
|
|
2240
|
+
const eventsTruncated = events.length === DIAGNOSTIC_EVENT_LIMIT;
|
|
2124
2241
|
c.header('Content-Disposition', `attachment; filename="${workspace.name.replace(/[^a-z0-9]+/gi, '-').toLowerCase() || id}-diagnostic.json"`);
|
|
2125
2242
|
return c.json({
|
|
2126
2243
|
generatedAt: new Date().toISOString(),
|
|
2127
2244
|
workspace: { id, name: workspace.name, engine: workspace.engine },
|
|
2128
2245
|
sessions,
|
|
2129
2246
|
events,
|
|
2247
|
+
eventsTruncated,
|
|
2130
2248
|
});
|
|
2131
2249
|
}
|
|
2132
2250
|
catch (err) {
|
|
@@ -2218,21 +2336,24 @@ app.get('/:id/events', (c) => {
|
|
|
2218
2336
|
if (firstRow) {
|
|
2219
2337
|
const older = session
|
|
2220
2338
|
? db
|
|
2221
|
-
.prepare('SELECT
|
|
2339
|
+
.prepare('SELECT 1 as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) AND rowid < ? LIMIT 1')
|
|
2222
2340
|
.get(id, session, firstRow.rowid)
|
|
2223
2341
|
: db
|
|
2224
|
-
.prepare('SELECT
|
|
2342
|
+
.prepare('SELECT 1 as c FROM ws_events WHERE workspace_id = ? AND rowid < ? LIMIT 1')
|
|
2225
2343
|
.get(id, firstRow.rowid);
|
|
2226
|
-
hasMore = older
|
|
2344
|
+
hasMore = older !== undefined;
|
|
2227
2345
|
}
|
|
2228
2346
|
}
|
|
2229
2347
|
else {
|
|
2230
|
-
|
|
2348
|
+
// `LIMIT 1 OFFSET <page size>` answers "is there anything past this
|
|
2349
|
+
// page?" by reading one row, where COUNT(*) walked every event the
|
|
2350
|
+
// workspace ever produced — on each page, synchronously.
|
|
2351
|
+
const beyond = session
|
|
2231
2352
|
? db
|
|
2232
|
-
.prepare('SELECT
|
|
2233
|
-
.get(id, session)
|
|
2234
|
-
: db.prepare('SELECT
|
|
2235
|
-
hasMore =
|
|
2353
|
+
.prepare('SELECT 1 as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) LIMIT 1 OFFSET ?')
|
|
2354
|
+
.get(id, session, rows.length)
|
|
2355
|
+
: db.prepare('SELECT 1 as c FROM ws_events WHERE workspace_id = ? LIMIT 1 OFFSET ?').get(id, rows.length);
|
|
2356
|
+
hasMore = beyond !== undefined;
|
|
2236
2357
|
}
|
|
2237
2358
|
}
|
|
2238
2359
|
return c.json({ events, hasMore });
|
|
@@ -2427,7 +2548,7 @@ app.delete('/:id/pr-watch-disabled', async (c) => {
|
|
|
2427
2548
|
app.put('/:id/tags', async (c) => {
|
|
2428
2549
|
const { id } = c.req.param();
|
|
2429
2550
|
try {
|
|
2430
|
-
const body = await c.req.json();
|
|
2551
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2431
2552
|
if (!Array.isArray(body.tags)) {
|
|
2432
2553
|
return c.json({ error: 'tags must be an array of strings' }, 400);
|
|
2433
2554
|
}
|
|
@@ -2664,26 +2785,31 @@ app.post('/:id/run-setup-script', async (c) => {
|
|
|
2664
2785
|
if (setupScriptRunning.has(id)) {
|
|
2665
2786
|
return c.json({ error: 'Setup script is already running for this workspace' }, 409);
|
|
2666
2787
|
}
|
|
2667
|
-
//
|
|
2668
|
-
//
|
|
2669
|
-
|
|
2670
|
-
if (agentManager.getAgentStatus(id)) {
|
|
2671
|
-
await agentManager.stopAgentAndWait(id);
|
|
2672
|
-
}
|
|
2673
|
-
}
|
|
2674
|
-
catch (err) {
|
|
2675
|
-
console.error(`[workspaces] stopAgentAndWait before setup script failed for '${id}':`, err);
|
|
2676
|
-
}
|
|
2677
|
-
const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
2678
|
-
if (!effectiveSettings.setupScript) {
|
|
2679
|
-
return c.json({ error: 'No setup script configured' }, 400);
|
|
2680
|
-
}
|
|
2681
|
-
const worktreePath = workspace.worktreePath;
|
|
2682
|
-
if (!fs.existsSync(worktreePath)) {
|
|
2683
|
-
return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
|
|
2684
|
-
}
|
|
2788
|
+
// Claim before the first await. Checking here and claiming after the agent
|
|
2789
|
+
// stop below let two concurrent calls both pass the check and then run the
|
|
2790
|
+
// user's script twice, concurrently, in the same worktree.
|
|
2685
2791
|
setupScriptRunning.add(id);
|
|
2792
|
+
// Everything below releases the claim, including the validation failures:
|
|
2793
|
+
// a workspace left marked as running would refuse every later attempt.
|
|
2686
2794
|
try {
|
|
2795
|
+
// Stop the running agent before re-running the setup script — the script
|
|
2796
|
+
// rewrites the worktree the agent may still be writing to.
|
|
2797
|
+
try {
|
|
2798
|
+
if (agentManager.getAgentStatus(id)) {
|
|
2799
|
+
await agentManager.stopAgentAndWait(id);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
catch (err) {
|
|
2803
|
+
console.error(`[workspaces] stopAgentAndWait before setup script failed for '${id}':`, err);
|
|
2804
|
+
}
|
|
2805
|
+
const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
2806
|
+
if (!effectiveSettings.setupScript) {
|
|
2807
|
+
return c.json({ error: 'No setup script configured' }, 400);
|
|
2808
|
+
}
|
|
2809
|
+
const worktreePath = workspace.worktreePath;
|
|
2810
|
+
if (!fs.existsSync(worktreePath)) {
|
|
2811
|
+
return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
|
|
2812
|
+
}
|
|
2687
2813
|
const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
|
|
2688
2814
|
workspaceName: workspace.name,
|
|
2689
2815
|
branchName: workspace.workingBranch,
|
|
@@ -2802,7 +2928,7 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
|
2802
2928
|
// a few lines below: firing and forgetting here pulled the directory from
|
|
2803
2929
|
// under an agent still writing to it, destroying uncommitted work.
|
|
2804
2930
|
try {
|
|
2805
|
-
await agentManager.stopAgentAndWait(workspace.id);
|
|
2931
|
+
await agentManager.stopAgentAndWait(workspace.id, undefined, 'delete');
|
|
2806
2932
|
}
|
|
2807
2933
|
catch (err) {
|
|
2808
2934
|
console.error(`[workspaces] stopAgentAndWait during delete failed for '${workspace.name}':`, err);
|
|
@@ -3185,7 +3311,7 @@ app.post('/:id/rollback-file', async (c) => {
|
|
|
3185
3311
|
if (!workspace) {
|
|
3186
3312
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3187
3313
|
}
|
|
3188
|
-
const body = await c.req.json();
|
|
3314
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3189
3315
|
const filePath = typeof body?.path === 'string' ? body.path.trim() : '';
|
|
3190
3316
|
if (!filePath) {
|
|
3191
3317
|
return c.json({ error: 'Missing or invalid `path` field' }, 400);
|
|
@@ -3427,10 +3553,10 @@ app.post('/:id/push', async (c) => {
|
|
|
3427
3553
|
// no-options call shape identical to before for callers/tests that
|
|
3428
3554
|
// assert on argument count.
|
|
3429
3555
|
if (force) {
|
|
3430
|
-
gitOps.
|
|
3556
|
+
await gitOps.pushBranchAsync(worktreePath, workspace.workingBranch, { force: true });
|
|
3431
3557
|
}
|
|
3432
3558
|
else {
|
|
3433
|
-
gitOps.
|
|
3559
|
+
await gitOps.pushBranchAsync(worktreePath, workspace.workingBranch);
|
|
3434
3560
|
}
|
|
3435
3561
|
}
|
|
3436
3562
|
catch (err) {
|
|
@@ -3449,14 +3575,14 @@ app.post('/:id/push', async (c) => {
|
|
|
3449
3575
|
});
|
|
3450
3576
|
// POST /api/workspaces/:id/fetch — git fetch the workspace repo (all branches of origin).
|
|
3451
3577
|
// Read-only: updates remote-tracking refs, never touches the working tree.
|
|
3452
|
-
app.post('/:id/fetch', (c) => {
|
|
3578
|
+
app.post('/:id/fetch', async (c) => {
|
|
3453
3579
|
try {
|
|
3454
3580
|
const id = c.req.param('id');
|
|
3455
3581
|
const workspace = workspaceService.getWorkspace(id);
|
|
3456
3582
|
if (!workspace) {
|
|
3457
3583
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3458
3584
|
}
|
|
3459
|
-
gitOps.
|
|
3585
|
+
await gitOps.fetchAllBranchesAsync(workspace.worktreePath);
|
|
3460
3586
|
return c.json({ ok: true });
|
|
3461
3587
|
}
|
|
3462
3588
|
catch (err) {
|
|
@@ -3465,7 +3591,7 @@ app.post('/:id/fetch', (c) => {
|
|
|
3465
3591
|
}
|
|
3466
3592
|
});
|
|
3467
3593
|
// POST /api/workspaces/:id/pull — pull working branch from origin (fast-forward only)
|
|
3468
|
-
app.post('/:id/pull', (c) => {
|
|
3594
|
+
app.post('/:id/pull', async (c) => {
|
|
3469
3595
|
try {
|
|
3470
3596
|
const id = c.req.param('id');
|
|
3471
3597
|
const workspace = workspaceService.getWorkspace(id);
|
|
@@ -3475,7 +3601,7 @@ app.post('/:id/pull', (c) => {
|
|
|
3475
3601
|
const worktreePath = workspace.worktreePath;
|
|
3476
3602
|
const autostash = c.req.query('autostash') === '1';
|
|
3477
3603
|
try {
|
|
3478
|
-
gitOps.
|
|
3604
|
+
await gitOps.pullBranchAsync(worktreePath, workspace.workingBranch, 'origin', { autostash });
|
|
3479
3605
|
}
|
|
3480
3606
|
catch (err) {
|
|
3481
3607
|
if (err instanceof gitOps.DirtyWorktreeError) {
|
|
@@ -3495,7 +3621,7 @@ app.post('/:id/pull', (c) => {
|
|
|
3495
3621
|
}
|
|
3496
3622
|
});
|
|
3497
3623
|
/** Rebase the workspace branch onto its source branch. */
|
|
3498
|
-
app.post('/:id/rebase', (c) => {
|
|
3624
|
+
app.post('/:id/rebase', async (c) => {
|
|
3499
3625
|
try {
|
|
3500
3626
|
const id = c.req.param('id');
|
|
3501
3627
|
const workspace = workspaceService.getWorkspace(id);
|
|
@@ -3503,7 +3629,7 @@ app.post('/:id/rebase', (c) => {
|
|
|
3503
3629
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3504
3630
|
const autostash = c.req.query('autostash') === '1';
|
|
3505
3631
|
const worktreePath = workspace.worktreePath;
|
|
3506
|
-
gitOps.
|
|
3632
|
+
await gitOps.rebaseBranchAsync(worktreePath, workspace.sourceBranch, { autostash });
|
|
3507
3633
|
return c.json({ success: true });
|
|
3508
3634
|
}
|
|
3509
3635
|
catch (err) {
|
|
@@ -3518,7 +3644,7 @@ app.post('/:id/rebase', (c) => {
|
|
|
3518
3644
|
}
|
|
3519
3645
|
});
|
|
3520
3646
|
/** Merge the source branch into the workspace branch (non-fast-forward). */
|
|
3521
|
-
app.post('/:id/merge', (c) => {
|
|
3647
|
+
app.post('/:id/merge', async (c) => {
|
|
3522
3648
|
try {
|
|
3523
3649
|
const id = c.req.param('id');
|
|
3524
3650
|
const workspace = workspaceService.getWorkspace(id);
|
|
@@ -3526,7 +3652,7 @@ app.post('/:id/merge', (c) => {
|
|
|
3526
3652
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3527
3653
|
const autostash = c.req.query('autostash') === '1';
|
|
3528
3654
|
const worktreePath = workspace.worktreePath;
|
|
3529
|
-
gitOps.
|
|
3655
|
+
await gitOps.mergeBranchAsync(worktreePath, workspace.sourceBranch, { autostash });
|
|
3530
3656
|
return c.json({ success: true });
|
|
3531
3657
|
}
|
|
3532
3658
|
catch (err) {
|
|
@@ -3719,9 +3845,14 @@ Start now.`;
|
|
|
3719
3845
|
app.post('/:id/change-pr-base', async (c) => {
|
|
3720
3846
|
try {
|
|
3721
3847
|
const id = c.req.param('id');
|
|
3722
|
-
const body = await c.req.json();
|
|
3848
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3723
3849
|
if (!body.base)
|
|
3724
3850
|
return c.json({ error: 'Missing base parameter' }, 400);
|
|
3851
|
+
// Forwarded to `gh pr edit --base <base>` / the GitLab equivalent, so a
|
|
3852
|
+
// name starting with `-` would read as a CLI option rather than a branch.
|
|
3853
|
+
if (!gitOps.isValidBranchName(body.base)) {
|
|
3854
|
+
return c.json({ error: `Invalid base branch name: ${body.base}` }, 400);
|
|
3855
|
+
}
|
|
3725
3856
|
const workspace = workspaceService.getWorkspace(id);
|
|
3726
3857
|
if (!workspace)
|
|
3727
3858
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
@@ -3814,9 +3945,14 @@ app.post('/:id/change-source-branch', async (c) => {
|
|
|
3814
3945
|
const id = c.req.param('id');
|
|
3815
3946
|
const workspace = workspaceService.getWorkspace(id);
|
|
3816
3947
|
try {
|
|
3817
|
-
const body = await c.req.json();
|
|
3948
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3818
3949
|
if (!body.newBase)
|
|
3819
3950
|
return c.json({ error: 'Missing newBase parameter' }, 400);
|
|
3951
|
+
// Reaches git (fetch/reset/cherry-pick) and the forge CLI as a bare
|
|
3952
|
+
// argument, and is exported to the custom script as KOBO_NEW_BASE.
|
|
3953
|
+
if (!gitOps.isValidBranchName(body.newBase)) {
|
|
3954
|
+
return c.json({ error: `Invalid source branch name: ${body.newBase}` }, 400);
|
|
3955
|
+
}
|
|
3820
3956
|
if (!workspace)
|
|
3821
3957
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3822
3958
|
const result = await changeSourceBranch(id, body.newBase);
|
|
@@ -3847,9 +3983,15 @@ app.post('/:id/change-source-branch', async (c) => {
|
|
|
3847
3983
|
app.post('/:id/cancel-source-change', async (c) => {
|
|
3848
3984
|
try {
|
|
3849
3985
|
const id = c.req.param('id');
|
|
3850
|
-
const body = await c.req.json();
|
|
3986
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3851
3987
|
if (!body.previousBase)
|
|
3852
3988
|
return c.json({ error: 'Missing previousBase parameter' }, 400);
|
|
3989
|
+
// Persisted as the workspace source branch, and from there it reaches
|
|
3990
|
+
// `git fetch origin <sourceBranch>` as a bare argument — including from a
|
|
3991
|
+
// plain visit to the Diff tab, with no further user action.
|
|
3992
|
+
if (!gitOps.isValidBranchName(body.previousBase)) {
|
|
3993
|
+
return c.json({ error: `Invalid source branch name: ${body.previousBase}` }, 400);
|
|
3994
|
+
}
|
|
3853
3995
|
const workspace = workspaceService.getWorkspace(id);
|
|
3854
3996
|
if (!workspace)
|
|
3855
3997
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
@@ -3877,13 +4019,13 @@ app.post('/:id/cancel-source-change', async (c) => {
|
|
|
3877
4019
|
}
|
|
3878
4020
|
});
|
|
3879
4021
|
/** Force-push the working branch with --force-with-lease (after a history rewrite). */
|
|
3880
|
-
app.post('/:id/force-push', (c) => {
|
|
4022
|
+
app.post('/:id/force-push', async (c) => {
|
|
3881
4023
|
try {
|
|
3882
4024
|
const id = c.req.param('id');
|
|
3883
4025
|
const workspace = workspaceService.getWorkspace(id);
|
|
3884
4026
|
if (!workspace)
|
|
3885
4027
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3886
|
-
gitOps.
|
|
4028
|
+
await gitOps.pushBranchAsync(workspace.worktreePath, workspace.workingBranch, { force: true });
|
|
3887
4029
|
return c.json({ success: true });
|
|
3888
4030
|
}
|
|
3889
4031
|
catch (err) {
|
|
@@ -155,6 +155,7 @@ export function createClaudeCodeEngine() {
|
|
|
155
155
|
return;
|
|
156
156
|
}
|
|
157
157
|
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
158
|
+
resolver.cleanup = () => ctx.signal.removeEventListener('abort', onAbort);
|
|
158
159
|
onEvent({
|
|
159
160
|
kind: 'session:user-input-requested',
|
|
160
161
|
requestKind,
|
|
@@ -555,6 +556,7 @@ export function createClaudeCodeEngine() {
|
|
|
555
556
|
// covers natural iterator completion.
|
|
556
557
|
for (const resolver of pendingResolvers.values()) {
|
|
557
558
|
try {
|
|
559
|
+
resolver.cleanup?.();
|
|
558
560
|
resolver.resolve({ behavior: 'deny', message: 'session ended', interrupt: false });
|
|
559
561
|
}
|
|
560
562
|
catch {
|
|
@@ -634,6 +636,7 @@ export function createClaudeCodeEngine() {
|
|
|
634
636
|
if (!resolver)
|
|
635
637
|
return false;
|
|
636
638
|
pendingResolvers.delete(toolCallId);
|
|
639
|
+
resolver.cleanup?.();
|
|
637
640
|
// Re-evaluate rather than unconditionally resuming: a sibling
|
|
638
641
|
// request, a still-active subagent, or an in-progress compaction
|
|
639
642
|
// may each still have their own legitimate reason to keep the
|
|
@@ -12,6 +12,8 @@ import { spawnAppServer } from './spawn.js';
|
|
|
12
12
|
/** Long enough for normal tool work, short enough to recover a lost turn event. */
|
|
13
13
|
export const CODEX_TURN_IDLE_TIMEOUT_MS = 120_000;
|
|
14
14
|
export const CODEX_GRACEFUL_INTERRUPT_TIMEOUT_MS = 3_000;
|
|
15
|
+
/** Grace given to a SIGTERM before the child is killed outright. */
|
|
16
|
+
const CODEX_FORCE_KILL_TIMEOUT_MS = 3_000;
|
|
15
17
|
// Safety net while `turnLiveness` is paused for background subagents: that
|
|
16
18
|
// pause is deliberately unbounded (a legitimate subagent can run long), but
|
|
17
19
|
// if a thread never reports a terminal status (dropped notification, or the
|
|
@@ -508,6 +510,22 @@ export function createCodexEngine() {
|
|
|
508
510
|
catch {
|
|
509
511
|
// swallow
|
|
510
512
|
}
|
|
513
|
+
// Escalate. The SIGTERM in the `finally` below is a request, not a
|
|
514
|
+
// guarantee: a wedged `codex app-server` that ignores it outlives the
|
|
515
|
+
// stop, the archive, the delete and Kōbō's own shutdown, holding the
|
|
516
|
+
// worktree and its share of the model quota, with nothing left in
|
|
517
|
+
// this process tracking it.
|
|
518
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
519
|
+
child.kill('SIGTERM');
|
|
520
|
+
const exited = await Promise.race([
|
|
521
|
+
new Promise((resolve) => child.once('exit', () => resolve(true))),
|
|
522
|
+
wait(CODEX_FORCE_KILL_TIMEOUT_MS).then(() => false),
|
|
523
|
+
]);
|
|
524
|
+
if (!exited) {
|
|
525
|
+
console.warn('[codex] app-server ignored SIGTERM — sending SIGKILL');
|
|
526
|
+
child.kill('SIGKILL');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
511
529
|
},
|
|
512
530
|
resolvePendingUserInput(callId, response) {
|
|
513
531
|
const pending = pendingByCallId.get(callId);
|