@loicngr/kobo 1.11.26 → 1.11.28
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 +240 -91
- package/dist/server/services/agent/engines/claude-code/engine.js +112 -15
- package/dist/server/services/agent/engines/codex/engine.js +18 -0
- package/dist/server/services/agent/engines/codex/server-requests.js +5 -20
- package/dist/server/services/agent/orchestrator.js +143 -34
- package/dist/server/services/agent/session-controller.js +28 -0
- package/dist/server/services/agent/streaming-batcher.js +1 -0
- package/dist/server/services/agent/turn-liveness.js +12 -1
- 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/file-editor-service.js +2 -46
- package/dist/server/services/forge/bitbucket/provider.js +9 -8
- package/dist/server/services/forge/github/provider.js +4 -3
- package/dist/server/services/forge/gitlab/provider.js +4 -3
- package/dist/server/services/forge/parse-cli-json.js +14 -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-DUjY6lCm.css → ActivityFeed-C6XSxdJ0.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-LDzg5XPx.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-CjiWI9Qa.js +1 -0
- package/src/client/dist/pwa/assets/ClosePopup-CK6wvsMy.js +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-C0hI2D1O.css +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-D6eEciwQ.js +1 -0
- package/src/client/dist/pwa/assets/CreatePage-DHBa7ZL5.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-Xev4suQ7.js +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-D0BQ5Dip.js +8 -0
- package/src/client/dist/pwa/assets/DiffViewer-DnR79ycb.css +1 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-7hMCYUTd.js +1 -0
- package/src/client/dist/pwa/assets/DrawerToggleButton-BASeszPg.js +1 -0
- package/src/client/dist/pwa/assets/GitPanel-D-rwHnFn.js +3 -0
- package/src/client/dist/pwa/assets/{GitPanel-BKrtkPrV.css → GitPanel-DVs5BCU-.css} +1 -1
- package/src/client/dist/pwa/assets/HealthPage-BKXqP1li.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-DnPtpWgP.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-52YHuBbx.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-C7NK1hIK.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-Cm8chltA.js +1 -0
- package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-B4xs1RUl.js +10 -0
- package/src/client/dist/pwa/assets/TerminalPanel-CSPk8QtK.js +18 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-B6xJJs8p.css → TerminalPanel-DbVZk0vD.css} +1 -1
- package/src/client/dist/pwa/assets/TouchPan-CpO61wPy.js +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DZrbJ0vD.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-D0-FkRVJ.js +4 -0
- package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
- package/src/client/dist/pwa/assets/build-path-tree-DH4SSsEz.js +1 -0
- package/src/client/dist/pwa/assets/de-Bf_OsgDw.js +17 -0
- package/src/client/dist/pwa/assets/documents-CDZqd_g5.js +1 -0
- package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-CMsYdZTX.js +413 -0
- package/src/client/dist/pwa/assets/{efforts-BPZzMyrc.js → efforts-Db4w0alg.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-BjtQR0vB.js +1 -0
- package/src/client/dist/pwa/assets/es-1ib0e9jm.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-CTUNyJrv.css +1 -0
- package/src/client/dist/pwa/assets/expand-template-DY8ntqUb.js +1 -0
- package/src/client/dist/pwa/assets/format-C-9jMgWb.js +435 -0
- package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
- package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
- package/src/client/dist/pwa/assets/fr-nj0BrM3X.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-BqVyQSGe.js → freemarker2-KxB1BtrL.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-9K762gzW.js → handlebars-7TByd_Zc.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CRmp4AC_.js → html-BoVZ_gre.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BubYp85n.js +1 -0
- package/src/client/dist/pwa/assets/{index-BpmxQlZi.css → index-6H3m6vH7.css} +1 -1
- package/src/client/dist/pwa/assets/index-Ck1_qrff.js +18 -0
- package/src/client/dist/pwa/assets/it-B9MEgvg1.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-DB9a_ZGm.js → javascript-BLL8vPQy.js} +1 -1
- package/src/client/dist/pwa/assets/layout-CFuk4mdG.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-C67ZTBj7.js → liquid-DzcT-R6k.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-C_K9rc9_.js → mdx-Bguo999w.js} +1 -1
- package/src/client/dist/pwa/assets/monaco-contributions-CM7FH-iZ.js +51 -0
- package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
- package/src/client/dist/pwa/assets/monaco.contribution-Dkq0qtWA.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-C0moem8_.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-thUiJn9n.js → notify-theme-C7mNmzKx.js} +1 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BaQ_SNEF.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-CJZsT9Fl.js +36 -0
- package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-hgNWqp0x.js → project-color-54JZHVy2.js} +1 -1
- package/src/client/dist/pwa/assets/{python-D1CBO9Uf.js → python-D5RQNiPF.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BCiKSj6_.js → razor-CVwoDJol.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-Cw1_NLqs.js → render-chat-markdown-NAmPVIQp.js} +1 -1
- package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/src/client/dist/pwa/assets/{typescript-D-8UENST.js → typescript-qdgwClKe.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-DbYF8ZX_.js +2 -0
- package/src/client/dist/pwa/assets/use-quasar-B4eIgSfZ.js +1 -0
- package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
- package/src/client/dist/pwa/assets/{xml-BBPvJ9QP.js → xml-DNSfQSlk.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-D4q1p7HX.js → yaml-6gnX2NnP.js} +1 -1
- package/src/client/dist/pwa/index.html +6 -5
- 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-CGMEOMn5.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-rvISZfJb.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-BptAgVvu.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-ZrvQ0YWN.js +0 -8
- package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +0 -1
- package/src/client/dist/pwa/assets/DocumentsPanel-DuWbZiID.js +0 -1
- package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
- package/src/client/dist/pwa/assets/GitPanel-BiIXQPfL.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-DTrmhh3X.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-PSmlvfJd.js +0 -2
- package/src/client/dist/pwa/assets/MainLayout-Vq1iUPjx.css +0 -1
- package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
- package/src/client/dist/pwa/assets/QBtnDropdown-Ckvd3IVR.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-DJWMsAoh.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-BI8w9utc.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Dr8oSevA.js +0 -16
- package/src/client/dist/pwa/assets/TerminalPanel-DVC58DJb.js +0 -18
- package/src/client/dist/pwa/assets/TouchPan-pqksSni0.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DRP9Ypmz.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-CQ4zZqo0.js +0 -1
- package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
- package/src/client/dist/pwa/assets/cssMode-MGWKgqj7.js +0 -1
- package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +0 -1
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
- package/src/client/dist/pwa/assets/editor.api-Dq41Mm99.js +0 -847
- package/src/client/dist/pwa/assets/engineFeatures-Dd_dxoOS.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +0 -1
- package/src/client/dist/pwa/assets/expand-template-BfZqON3q.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
- package/src/client/dist/pwa/assets/htmlMode-CFxFF9iH.js +0 -1
- package/src/client/dist/pwa/assets/i18n-BsWCIFmf.js +0 -1
- package/src/client/dist/pwa/assets/index-xTf0VfFG.js +0 -82
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
- package/src/client/dist/pwa/assets/jsonMode-JhLTE1-C.js +0 -7
- package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-BIDd3ml0.js +0 -4
- package/src/client/dist/pwa/assets/network-auth-BPxfeLcl.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BdA8vrDx.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-D09rnNgv.js +0 -36
- package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
- package/src/client/dist/pwa/assets/tsMode-Db77HzVu.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-BafrD9nc.js +0 -2
- package/src/client/dist/pwa/assets/vs-Bb18eQC8.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-CImko7IX.js +0 -1
|
@@ -11,6 +11,7 @@ export function createTurnLiveness(input) {
|
|
|
11
11
|
let active = false;
|
|
12
12
|
let paused = false;
|
|
13
13
|
let timedOut = false;
|
|
14
|
+
let currentTimeoutMs = input.timeoutMs;
|
|
14
15
|
const arm = () => {
|
|
15
16
|
if (!active || paused || timedOut)
|
|
16
17
|
return;
|
|
@@ -22,7 +23,8 @@ export function createTurnLiveness(input) {
|
|
|
22
23
|
return;
|
|
23
24
|
timedOut = true;
|
|
24
25
|
input.onTimeout();
|
|
25
|
-
},
|
|
26
|
+
}, currentTimeoutMs);
|
|
27
|
+
timer.unref?.();
|
|
26
28
|
};
|
|
27
29
|
return {
|
|
28
30
|
start() {
|
|
@@ -53,5 +55,14 @@ export function createTurnLiveness(input) {
|
|
|
53
55
|
clearTimeout(timer);
|
|
54
56
|
timer = undefined;
|
|
55
57
|
},
|
|
58
|
+
setTimeoutMs(ms) {
|
|
59
|
+
if (ms === currentTimeoutMs)
|
|
60
|
+
return;
|
|
61
|
+
currentTimeoutMs = ms;
|
|
62
|
+
// Re-arm only if a deadline is currently pending; a paused/stopped
|
|
63
|
+
// liveness picks the new duration up on its next arm().
|
|
64
|
+
if (timer)
|
|
65
|
+
arm();
|
|
66
|
+
},
|
|
56
67
|
};
|
|
57
68
|
}
|
|
@@ -5,6 +5,7 @@ import { slugifyProjectName } from '../utils/project-slug.js';
|
|
|
5
5
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
6
|
import * as orchestrator from './agent/orchestrator.js';
|
|
7
7
|
import * as cleanupScriptService from './cleanup-script-service.js';
|
|
8
|
+
import * as lifecycleHookService from './lifecycle-hook-service.js';
|
|
8
9
|
import * as settingsService from './settings-service.js';
|
|
9
10
|
import { getSuitePrompts } from './skill-suite-prompts.js';
|
|
10
11
|
import { emit, emitEphemeral } from './websocket-service.js';
|
|
@@ -91,6 +92,13 @@ export function disable(workspaceId, reason) {
|
|
|
91
92
|
console.info(`[auto-loop] disabled workspace '${workspaceId}' reason=${reason}` +
|
|
92
93
|
` pending=${diagnostic.tasksPending} streak=${diagnostic.noProgressStreak} status=${diagnostic.status}`);
|
|
93
94
|
emitEphemeral(workspaceId, 'autoloop:disabled', diagnostic);
|
|
95
|
+
// The user's own hook, if they wrote one. Deliberately fired for EVERY
|
|
96
|
+
// reason, not only 'completed' like the cleanup script below: a loop that
|
|
97
|
+
// stalled is precisely the case worth being told about.
|
|
98
|
+
void lifecycleHookService.onAutoLoopDisabled(workspaceId, {
|
|
99
|
+
reason,
|
|
100
|
+
tasksPending: diagnostic.tasksPending,
|
|
101
|
+
});
|
|
94
102
|
// The loop finished every task — run the project's cleanup script. Other
|
|
95
103
|
// disable reasons (stall / error / user-action) leave tasks unfinished, so
|
|
96
104
|
// they intentionally skip the cleanup.
|
|
@@ -262,6 +270,51 @@ function computeIterationNumber(workspaceId) {
|
|
|
262
270
|
* Worktree-missing edge: if the worktree directory has been deleted on disk,
|
|
263
271
|
* `orchestrator.startAgent` throws during engine.start — caught below.
|
|
264
272
|
*/
|
|
273
|
+
/**
|
|
274
|
+
* Whether another unattended session may start right now. `0` — the default —
|
|
275
|
+
* means no limit. Only the auto-loop consults this: a session the user starts
|
|
276
|
+
* by hand always goes through.
|
|
277
|
+
*/
|
|
278
|
+
function hasFreeAgentSlot() {
|
|
279
|
+
const limit = settingsService.getGlobalSettings().maxConcurrentAgents;
|
|
280
|
+
if (!limit || limit <= 0)
|
|
281
|
+
return true;
|
|
282
|
+
return orchestrator.runningAgentCount() < limit;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Give every auto-loop workspace parked on "waiting for a free agent slot"
|
|
286
|
+
* another chance. Called by the orchestrator after ANY session ends: a parked
|
|
287
|
+
* workspace has no session of its own to end, so nothing else would ever ask
|
|
288
|
+
* spawnNextIteration on its behalf again.
|
|
289
|
+
*
|
|
290
|
+
* Stops as soon as the slots are full again, so with three parked and one
|
|
291
|
+
* free, exactly one starts and the other two keep waiting rather than each
|
|
292
|
+
* re-announcing that they wait.
|
|
293
|
+
*/
|
|
294
|
+
export function resumeWaitingWorkspaces() {
|
|
295
|
+
const db = getDb();
|
|
296
|
+
const rows = db
|
|
297
|
+
.prepare(`SELECT id, status FROM workspaces
|
|
298
|
+
WHERE auto_loop = 1 AND auto_loop_ready = 1 AND archived_at IS NULL
|
|
299
|
+
ORDER BY updated_at ASC`)
|
|
300
|
+
.all();
|
|
301
|
+
for (const row of rows) {
|
|
302
|
+
if (!hasFreeAgentSlot())
|
|
303
|
+
return;
|
|
304
|
+
if (orchestrator.hasController(row.id))
|
|
305
|
+
continue;
|
|
306
|
+
// Same guards as spawnNextIteration's own callers: a quota backoff or a
|
|
307
|
+
// pending question owns the next start of that workspace.
|
|
308
|
+
if (row.status === 'awaiting-user' || row.status === 'quota')
|
|
309
|
+
continue;
|
|
310
|
+
try {
|
|
311
|
+
spawnNextIteration(row.id);
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
console.error(`[auto-loop] resume after slot freed failed for workspace '${row.id}':`, err);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
265
318
|
function spawnNextIteration(workspaceId, opts = {}) {
|
|
266
319
|
const row = getRow(workspaceId);
|
|
267
320
|
if (!row)
|
|
@@ -274,6 +327,18 @@ function spawnNextIteration(workspaceId, opts = {}) {
|
|
|
274
327
|
disable(workspaceId, 'completed');
|
|
275
328
|
return;
|
|
276
329
|
}
|
|
330
|
+
// Hold off rather than pile on. Ten auto-loop workspaces waking together
|
|
331
|
+
// hammer the same rate limit and each land in their own backoff, so nobody
|
|
332
|
+
// gets through. The loop stays enabled: the next session to end anywhere
|
|
333
|
+
// calls back in here, and by then a slot has freed.
|
|
334
|
+
if (!hasFreeAgentSlot()) {
|
|
335
|
+
console.log(`[auto-loop] workspace '${workspaceId}' is waiting for a free agent slot`);
|
|
336
|
+
emitEphemeral(workspaceId, 'autoloop:waiting-for-slot', {
|
|
337
|
+
running: orchestrator.runningAgentCount(),
|
|
338
|
+
limit: settingsService.getGlobalSettings().maxConcurrentAgents,
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
277
342
|
const iterationNumber = computeIterationNumber(workspaceId);
|
|
278
343
|
// Override block: replaces the standard iteration prompt body when the task
|
|
279
344
|
// title carries a recognized prefix (case-sensitive, trailing space required).
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { getDb } from '../db/index.js';
|
|
2
|
+
import { getGlobalSettings } from './settings-service.js';
|
|
3
|
+
import { broadcastAll } from './websocket-service.js';
|
|
4
|
+
/** How often the reminder loop looks at the clock. */
|
|
5
|
+
const TICK_INTERVAL_MS = 60_000;
|
|
6
|
+
/**
|
|
7
|
+
* Which waiting workspaces are owed a reminder now.
|
|
8
|
+
*
|
|
9
|
+
* `state` is updated in place: entries appear when a workspace starts waiting
|
|
10
|
+
* and disappear when it stops, so answering a question resets its clock.
|
|
11
|
+
*
|
|
12
|
+
* Pure apart from that mutation, which is what makes the interval arithmetic
|
|
13
|
+
* — the part that is easy to get wrong — testable without a timer or a DB.
|
|
14
|
+
*/
|
|
15
|
+
export function computeDueReminders(params) {
|
|
16
|
+
const { awaiting, now, intervalMs, state } = params;
|
|
17
|
+
// Disabled. Clear the state too: re-enabling later should start from now,
|
|
18
|
+
// not from a clock that kept running while the feature was off.
|
|
19
|
+
if (intervalMs <= 0) {
|
|
20
|
+
state.clear();
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const stillWaiting = new Set(awaiting.map((w) => w.id));
|
|
24
|
+
for (const id of state.keys()) {
|
|
25
|
+
if (!stillWaiting.has(id))
|
|
26
|
+
state.delete(id);
|
|
27
|
+
}
|
|
28
|
+
const due = [];
|
|
29
|
+
for (const workspace of awaiting) {
|
|
30
|
+
const existing = state.get(workspace.id);
|
|
31
|
+
if (!existing) {
|
|
32
|
+
// First sight. The question was just asked, so nothing is overdue yet.
|
|
33
|
+
state.set(workspace.id, { firstSeenAt: now, lastRemindedAt: null, remindersSent: 0 });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// Measured from the LAST reminder (or the first sighting), not as a
|
|
37
|
+
// multiple of the interval since the start: that way a change of interval
|
|
38
|
+
// applies from the next reminder, and a laptop waking from sleep owing
|
|
39
|
+
// four reminders sends one, not four, for the same unanswered question.
|
|
40
|
+
const since = existing.lastRemindedAt ?? existing.firstSeenAt;
|
|
41
|
+
if (now - since < intervalMs)
|
|
42
|
+
continue;
|
|
43
|
+
existing.lastRemindedAt = now;
|
|
44
|
+
existing.remindersSent += 1;
|
|
45
|
+
due.push({
|
|
46
|
+
id: workspace.id,
|
|
47
|
+
name: workspace.name,
|
|
48
|
+
waitingMs: now - existing.firstSeenAt,
|
|
49
|
+
reminderNumber: existing.remindersSent,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return due;
|
|
53
|
+
}
|
|
54
|
+
const reminderState = new Map();
|
|
55
|
+
let timer = null;
|
|
56
|
+
function listAwaitingWorkspaces() {
|
|
57
|
+
return getDb()
|
|
58
|
+
.prepare(`SELECT id, name FROM workspaces WHERE status = 'awaiting-user' AND archived_at IS NULL`)
|
|
59
|
+
.all();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* One pass of the reminder loop. Broadcast rather than emitted to the
|
|
63
|
+
* workspace's subscribers: the whole point is to reach the user who is looking
|
|
64
|
+
* at something else, or at nothing.
|
|
65
|
+
*
|
|
66
|
+
* Never throws — it runs on a timer nobody is watching, and an exception here
|
|
67
|
+
* would silently end the loop for the rest of the process's life.
|
|
68
|
+
*/
|
|
69
|
+
export function runReminderTick(now = Date.now()) {
|
|
70
|
+
try {
|
|
71
|
+
const minutes = getGlobalSettings().awaitingUserReminderMinutes;
|
|
72
|
+
// The API refuses more than a day; a hand-edited settings.json gets the
|
|
73
|
+
// same ceiling here rather than a reminder that fires next week.
|
|
74
|
+
const intervalMs = typeof minutes === 'number' && minutes > 0 ? Math.min(minutes, 1440) * 60_000 : 0;
|
|
75
|
+
if (intervalMs <= 0) {
|
|
76
|
+
reminderState.clear();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const reminder of computeDueReminders({
|
|
80
|
+
awaiting: listAwaitingWorkspaces(),
|
|
81
|
+
now,
|
|
82
|
+
intervalMs,
|
|
83
|
+
state: reminderState,
|
|
84
|
+
})) {
|
|
85
|
+
broadcastAll('workspace:awaiting-reminder', {
|
|
86
|
+
workspaceId: reminder.id,
|
|
87
|
+
workspaceName: reminder.name,
|
|
88
|
+
waitingMinutes: Math.round(reminder.waitingMs / 60_000),
|
|
89
|
+
reminderNumber: reminder.reminderNumber,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.error('[awaiting-user-reminder] tick failed:', err);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Start the reminder loop. Idempotent. */
|
|
98
|
+
export function startAwaitingUserReminder() {
|
|
99
|
+
if (timer)
|
|
100
|
+
return;
|
|
101
|
+
timer = setInterval(() => runReminderTick(), TICK_INTERVAL_MS);
|
|
102
|
+
timer.unref?.();
|
|
103
|
+
}
|
|
104
|
+
/** Stop the reminder loop. */
|
|
105
|
+
export function stopAwaitingUserReminder() {
|
|
106
|
+
if (timer) {
|
|
107
|
+
clearInterval(timer);
|
|
108
|
+
timer = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** @internal */
|
|
112
|
+
export function _resetForTest() {
|
|
113
|
+
stopAwaitingUserReminder();
|
|
114
|
+
reminderState.clear();
|
|
115
|
+
}
|
|
@@ -109,7 +109,7 @@ async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, proj
|
|
|
109
109
|
// the branchExists check below is the authoritative gate for the new base
|
|
110
110
|
// (it throws a clean error, mapped to a 400, rather than a raw fetch error).
|
|
111
111
|
try {
|
|
112
|
-
gitOps.
|
|
112
|
+
await gitOps.fetchAllBranchesAsync(worktreePath);
|
|
113
113
|
}
|
|
114
114
|
catch {
|
|
115
115
|
// offline / no remote — proceed with local refs
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { realpathSync } from 'node:fs';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
2
|
import { getFileContent, writeFileInWorktree } from '../utils/git-ops.js';
|
|
3
|
+
import { resolvePathInside } from '../utils/safe-path.js';
|
|
5
4
|
const MAX_FILE_BYTES = 1024 * 1024;
|
|
6
5
|
export function shaOf(content) {
|
|
7
6
|
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
@@ -12,7 +11,7 @@ export function shaOf(content) {
|
|
|
12
11
|
* escapes the worktree (including via symlinks), or when content exceeds 1 MB.
|
|
13
12
|
*/
|
|
14
13
|
export function saveWorkspaceFile(worktreePath, relativePath, content, baseSha) {
|
|
15
|
-
const absPath =
|
|
14
|
+
const absPath = resolvePathInside(worktreePath, relativePath);
|
|
16
15
|
if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
|
|
17
16
|
throw new Error(`File too large (max ${MAX_FILE_BYTES / 1024 / 1024} MB)`);
|
|
18
17
|
}
|
|
@@ -24,46 +23,3 @@ export function saveWorkspaceFile(worktreePath, relativePath, content, baseSha)
|
|
|
24
23
|
writeFileInWorktree(absPath, content);
|
|
25
24
|
return { status: 'saved' };
|
|
26
25
|
}
|
|
27
|
-
function resolveSafe(worktreePath, relativePath) {
|
|
28
|
-
const abs = path.resolve(worktreePath, relativePath);
|
|
29
|
-
const lexicalRoot = path.resolve(worktreePath);
|
|
30
|
-
// Lexical containment check FIRST: a path that escapes the worktree via `..`
|
|
31
|
-
// (or an absolute path) must be reported as an escape deterministically —
|
|
32
|
-
// independent of whether the traversal happens to land on a directory that
|
|
33
|
-
// exists on disk. Without this, `../../etc/passwd` surfaces as "parent
|
|
34
|
-
// directory does not exist" instead of "escapes the worktree" whenever the
|
|
35
|
-
// worktree sits deep enough that the `..` chain lands on a missing dir.
|
|
36
|
-
if (abs !== lexicalRoot && !abs.startsWith(lexicalRoot + path.sep)) {
|
|
37
|
-
throw new Error(`Path '${relativePath}' escapes the worktree`);
|
|
38
|
-
}
|
|
39
|
-
const root = realpathSync(lexicalRoot);
|
|
40
|
-
const rootWithSep = root + path.sep;
|
|
41
|
-
// Resolve the parent's realpath (parent must exist; if it doesn't, the path
|
|
42
|
-
// is invalid anyway and we surface that). Then join the lexical basename so
|
|
43
|
-
// a non-existent leaf doesn't trigger ENOENT.
|
|
44
|
-
let realParent;
|
|
45
|
-
try {
|
|
46
|
-
realParent = realpathSync(path.dirname(abs));
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
throw new Error(`Path '${relativePath}' is invalid (parent directory does not exist)`);
|
|
50
|
-
}
|
|
51
|
-
const realAbs = path.join(realParent, path.basename(abs));
|
|
52
|
-
if (realAbs !== root && !realAbs.startsWith(rootWithSep)) {
|
|
53
|
-
throw new Error(`Path '${relativePath}' escapes the worktree`);
|
|
54
|
-
}
|
|
55
|
-
// If the leaf exists and is itself a symlink, follow it and re-check
|
|
56
|
-
// containment so we never write through a symlink that escapes the worktree.
|
|
57
|
-
try {
|
|
58
|
-
const leafReal = realpathSync(realAbs);
|
|
59
|
-
if (leafReal !== root && !leafReal.startsWith(rootWithSep)) {
|
|
60
|
-
throw new Error(`Path '${relativePath}' escapes the worktree`);
|
|
61
|
-
}
|
|
62
|
-
return leafReal;
|
|
63
|
-
}
|
|
64
|
-
catch (err) {
|
|
65
|
-
if (err.code === 'ENOENT')
|
|
66
|
-
return realAbs;
|
|
67
|
-
throw err;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { getGlobalSettings } from '../../settings-service.js';
|
|
4
|
+
import { parseCliJson } from '../parse-cli-json.js';
|
|
4
5
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
function record(value) {
|
|
@@ -233,14 +234,14 @@ async function resolveTarget(repoPath) {
|
|
|
233
234
|
if (bktEnv().BKT_TOKEN)
|
|
234
235
|
return { ...target, context: null };
|
|
235
236
|
const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'], bktOptions(repoPath));
|
|
236
|
-
const contexts = record(
|
|
237
|
+
const contexts = record(parseCliJson(contextOutput, 'bkt context list')).contexts;
|
|
237
238
|
const existing = (Array.isArray(contexts) ? contexts : [])
|
|
238
239
|
.map(record)
|
|
239
240
|
.find((context) => string(context.workspace) === target.workspace && string(context.default_repo) === target.repo);
|
|
240
241
|
if (existing && string(existing.name))
|
|
241
242
|
return { ...target, context: string(existing.name) };
|
|
242
243
|
const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
|
|
243
|
-
const hosts = record(
|
|
244
|
+
const hosts = record(parseCliJson(authOutput, 'bkt auth status')).hosts;
|
|
244
245
|
const host = (Array.isArray(hosts) ? hosts : []).map(record).find((item) => string(item.kind) === 'cloud');
|
|
245
246
|
const hostKey = string(host?.key);
|
|
246
247
|
if (!hostKey)
|
|
@@ -255,7 +256,7 @@ function targetArgs(target) {
|
|
|
255
256
|
async function fetchCi(repoPath, number, target) {
|
|
256
257
|
try {
|
|
257
258
|
const { stdout } = await execFileAsync('bkt', bktArgs(target, ['pr', 'checks', String(number), ...targetArgs(target), '--json']), bktOptions(repoPath));
|
|
258
|
-
const statuses = record(
|
|
259
|
+
const statuses = record(parseCliJson(stdout, 'bkt pr checks')).statuses;
|
|
259
260
|
const checks = (Array.isArray(statuses) ? statuses : []).map(mapCheck);
|
|
260
261
|
const rollup = checks.length === 0
|
|
261
262
|
? null
|
|
@@ -286,7 +287,7 @@ export const bitbucketProvider = {
|
|
|
286
287
|
async isAvailable(repoPath) {
|
|
287
288
|
try {
|
|
288
289
|
const { stdout } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
|
|
289
|
-
const status = record(
|
|
290
|
+
const status = record(parseCliJson(stdout, 'bkt auth status'));
|
|
290
291
|
const hosts = status.hosts;
|
|
291
292
|
if ((Array.isArray(hosts) && hosts.length > 0) || bktEnv().BKT_TOKEN)
|
|
292
293
|
return { available: true };
|
|
@@ -304,7 +305,7 @@ export const bitbucketProvider = {
|
|
|
304
305
|
// branch deletion, so do not stop at OPEN requests.
|
|
305
306
|
for (const state of ['OPEN', 'MERGED', 'DECLINED']) {
|
|
306
307
|
const { stdout: listOutput } = await execFileAsync('bkt', bktArgs(target, ['pr', 'list', '--state', state, '--limit', '50', ...targetArgs(target), '--json']), bktOptions(repoPath));
|
|
307
|
-
const pullRequests = record(
|
|
308
|
+
const pullRequests = record(parseCliJson(listOutput, 'bkt pr list')).pull_requests;
|
|
308
309
|
match = (Array.isArray(pullRequests) ? pullRequests : [])
|
|
309
310
|
.map(record)
|
|
310
311
|
.find((pullRequest) => branch(pullRequest, 'source', 'fromRef') === branchName);
|
|
@@ -314,7 +315,7 @@ export const bitbucketProvider = {
|
|
|
314
315
|
if (!match)
|
|
315
316
|
return null;
|
|
316
317
|
const { stdout: viewOutput } = await execFileAsync('bkt', bktArgs(target, ['pr', 'view', String(number(match.id)), ...targetArgs(target), '--json']), bktOptions(repoPath));
|
|
317
|
-
const raw = record(record(
|
|
318
|
+
const raw = record(record(parseCliJson(viewOutput, 'bkt pr view')).pull_request);
|
|
318
319
|
if (!number(raw.id))
|
|
319
320
|
return null;
|
|
320
321
|
const ci = await fetchCi(repoPath, number(raw.id), target);
|
|
@@ -340,7 +341,7 @@ export const bitbucketProvider = {
|
|
|
340
341
|
...targetArgs(target),
|
|
341
342
|
'--json',
|
|
342
343
|
]), bktOptions(repoPath));
|
|
343
|
-
const raw = record(
|
|
344
|
+
const raw = record(parseCliJson(stdout, 'bkt pr create'));
|
|
344
345
|
const prNumber = number(raw.id);
|
|
345
346
|
const prUrl = string(raw.url);
|
|
346
347
|
if (!prNumber || !prUrl)
|
|
@@ -363,7 +364,7 @@ export const bitbucketProvider = {
|
|
|
363
364
|
if (opts.cursor)
|
|
364
365
|
args.push('--page', opts.cursor);
|
|
365
366
|
const { stdout } = await execFileAsync('bkt', bktArgs(target, args), bktOptions(repoPath));
|
|
366
|
-
const page = mapBitbucketPage(
|
|
367
|
+
const page = mapBitbucketPage(parseCliJson(stdout || '{}', 'bkt pr list'));
|
|
367
368
|
if (opts.filter === 'all' && !opts.search?.trim())
|
|
368
369
|
return page;
|
|
369
370
|
const currentUser = getGlobalSettings().bitbucketUsername || undefined;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/server/services/forge/github/provider.ts
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
+
import { parseCliJson } from '../parse-cli-json.js';
|
|
4
5
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
const CLI_TIMEOUT_MS = 30_000;
|
|
@@ -174,7 +175,7 @@ export const githubProvider = {
|
|
|
174
175
|
const raw = stdout.trim();
|
|
175
176
|
if (!raw)
|
|
176
177
|
return null;
|
|
177
|
-
return mapGhPrToSnapshot(
|
|
178
|
+
return mapGhPrToSnapshot(parseCliJson(raw, 'gh pr view'));
|
|
178
179
|
}
|
|
179
180
|
catch {
|
|
180
181
|
return null;
|
|
@@ -198,7 +199,7 @@ export const githubProvider = {
|
|
|
198
199
|
},
|
|
199
200
|
async listPullRequests(repoPath, opts) {
|
|
200
201
|
const { stdout: repoJson } = await execFileAsync('gh', ['repo', 'view', '--json', 'nameWithOwner'], cliOptions(repoPath));
|
|
201
|
-
const nameWithOwner = String(
|
|
202
|
+
const nameWithOwner = String(parseCliJson(repoJson, 'gh repo view').nameWithOwner ?? '');
|
|
202
203
|
if (!nameWithOwner)
|
|
203
204
|
throw new Error('Could not determine the GitHub repository for this path');
|
|
204
205
|
const args = [
|
|
@@ -214,6 +215,6 @@ export const githubProvider = {
|
|
|
214
215
|
if (opts.cursor)
|
|
215
216
|
args.push('-f', `after=${opts.cursor}`);
|
|
216
217
|
const { stdout } = await execFileAsync('gh', args, cliOptions(repoPath));
|
|
217
|
-
return mapGithubSearchPage(
|
|
218
|
+
return mapGithubSearchPage(parseCliJson(stdout, 'gh api graphql (list PRs)'));
|
|
218
219
|
},
|
|
219
220
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/server/services/forge/gitlab/provider.ts
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
+
import { parseCliJson } from '../parse-cli-json.js';
|
|
4
5
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
const CLI_TIMEOUT_MS = 30_000;
|
|
@@ -108,7 +109,7 @@ async function fetchGlabCi(repoPath, branch) {
|
|
|
108
109
|
const raw = stdout.trim();
|
|
109
110
|
if (!raw)
|
|
110
111
|
return { rollup: null, checks: [] };
|
|
111
|
-
const pipeline =
|
|
112
|
+
const pipeline = parseCliJson(raw, 'glab ci get');
|
|
112
113
|
return {
|
|
113
114
|
rollup: mapPipelineRollup(pipeline.status),
|
|
114
115
|
checks: (pipeline.jobs ?? []).map(mapGlabJobToCheck),
|
|
@@ -192,7 +193,7 @@ export const gitlabProvider = {
|
|
|
192
193
|
const raw = stdout.trim();
|
|
193
194
|
if (!raw)
|
|
194
195
|
return null;
|
|
195
|
-
const snapshot = mapGlabMrToSnapshot(
|
|
196
|
+
const snapshot = mapGlabMrToSnapshot(parseCliJson(raw, 'glab mr view'));
|
|
196
197
|
snapshot.ci = await fetchGlabCi(repoPath, branch);
|
|
197
198
|
snapshot.readyToMerge = deriveReadyToMerge(snapshot);
|
|
198
199
|
return snapshot;
|
|
@@ -231,6 +232,6 @@ export const gitlabProvider = {
|
|
|
231
232
|
},
|
|
232
233
|
async listPullRequests(repoPath, opts) {
|
|
233
234
|
const { stdout } = await execFileAsync('glab', buildGitlabListArgs(opts), cliOptions(repoPath));
|
|
234
|
-
return mapGitlabPage(
|
|
235
|
+
return mapGitlabPage(parseCliJson(stdout || '[]', 'glab mr list'), opts);
|
|
235
236
|
},
|
|
236
237
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** JSON.parse with a message a user can act on: a truncated or non-JSON CLI
|
|
2
|
+
* response otherwise surfaces as a raw "Unexpected token…" 500. */
|
|
3
|
+
export function parseCliJson(raw, context) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(raw);
|
|
6
|
+
}
|
|
7
|
+
catch (err) {
|
|
8
|
+
// Keep the diagnostic trail: the user-facing message stays terse, but the
|
|
9
|
+
// original parse error and a sample of the offending output land in the
|
|
10
|
+
// server log so "unparsable output" reports can actually be investigated.
|
|
11
|
+
console.error(`[forge] ${context} returned unparsable output:`, err, raw.slice(0, 200));
|
|
12
|
+
throw new Error(`${context} returned unparsable output`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -18,12 +18,30 @@ function readRemoteUrl(projectPath) {
|
|
|
18
18
|
return execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
19
19
|
cwd: projectPath,
|
|
20
20
|
encoding: 'utf-8',
|
|
21
|
+
// Without a bound, a git hanging on a network filesystem blocks the whole
|
|
22
|
+
// event loop: this runs synchronously, and the pr-watcher calls it for
|
|
23
|
+
// every workspace on every tick.
|
|
24
|
+
timeout: 5_000,
|
|
21
25
|
}).trim();
|
|
22
26
|
}
|
|
23
27
|
catch {
|
|
24
28
|
return '';
|
|
25
29
|
}
|
|
26
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Auto-detected forge per project path. The remote of a checkout does not
|
|
33
|
+
* change while Kōbō runs, and resolving it costs a synchronous subprocess that
|
|
34
|
+
* the pr-watcher would otherwise pay per workspace, per 30 s tick, twice.
|
|
35
|
+
* Bounded by TTL so a remote that is added or repointed is picked up without a
|
|
36
|
+
* restart, and only the `auto` path is cached — an explicit setting is already
|
|
37
|
+
* free to read.
|
|
38
|
+
*/
|
|
39
|
+
const autoForgeCache = new Map();
|
|
40
|
+
const AUTO_FORGE_TTL_MS = 5 * 60_000;
|
|
41
|
+
/** Drop the cached auto-detection. Exported for tests. */
|
|
42
|
+
export function _clearForgeCache() {
|
|
43
|
+
autoForgeCache.clear();
|
|
44
|
+
}
|
|
27
45
|
/**
|
|
28
46
|
* Resolve the forge for a project: the explicit per-project setting wins;
|
|
29
47
|
* `auto` (the default) classifies the origin remote URL.
|
|
@@ -33,5 +51,15 @@ export function resolveForge(projectPath) {
|
|
|
33
51
|
if (setting === 'github' || setting === 'gitlab' || setting === 'bitbucket-community' || setting === 'none') {
|
|
34
52
|
return setting;
|
|
35
53
|
}
|
|
36
|
-
|
|
54
|
+
const cached = autoForgeCache.get(projectPath);
|
|
55
|
+
if (cached && Date.now() - cached.readAt < AUTO_FORGE_TTL_MS)
|
|
56
|
+
return cached.forge;
|
|
57
|
+
const remoteUrl = readRemoteUrl(projectPath);
|
|
58
|
+
const forge = forgeFromRemoteUrl(remoteUrl);
|
|
59
|
+
// A read that failed (git timing out on a network mount, a transient lock)
|
|
60
|
+
// is not knowledge: caching it would disable PR actions for five minutes on
|
|
61
|
+
// a project that has a perfectly good remote.
|
|
62
|
+
if (remoteUrl)
|
|
63
|
+
autoForgeCache.set(projectPath, { forge, readAt: Date.now() });
|
|
64
|
+
return forge;
|
|
37
65
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
4
|
+
import { runScript } from '../utils/script-runner.js';
|
|
5
|
+
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
|
+
import { getEffectiveSettings, getGlobalSettings, getProjectSettings } from './settings-service.js';
|
|
7
|
+
/** Which effective-settings key holds the script for each event. */
|
|
8
|
+
const SCRIPT_KEY = {
|
|
9
|
+
'session-ended': 'sessionEndedScript',
|
|
10
|
+
'pr-merged': 'prMergedScript',
|
|
11
|
+
'autoloop-disabled': 'autoLoopDisabledScript',
|
|
12
|
+
};
|
|
13
|
+
function getRow(workspaceId) {
|
|
14
|
+
const row = getDb()
|
|
15
|
+
.prepare(`SELECT id, name, project_path, working_branch, source_branch, worktree_path
|
|
16
|
+
FROM workspaces WHERE id = ?`)
|
|
17
|
+
.get(workspaceId);
|
|
18
|
+
return row ?? null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Run the user's hook for `event`, if they configured one.
|
|
22
|
+
*
|
|
23
|
+
* Resolves to `null` when there is nothing to run — no script, unknown
|
|
24
|
+
* workspace, or a worktree that is no longer on disk — and to the script's exit
|
|
25
|
+
* code otherwise. Never rejects: the caller is always in the middle of a
|
|
26
|
+
* lifecycle transition that must complete regardless.
|
|
27
|
+
*/
|
|
28
|
+
export async function runLifecycleHook(event, workspaceId, extraEnv) {
|
|
29
|
+
try {
|
|
30
|
+
const row = getRow(workspaceId);
|
|
31
|
+
if (!row)
|
|
32
|
+
return null;
|
|
33
|
+
const script = getEffectiveSettings(row.project_path)[SCRIPT_KEY[event]];
|
|
34
|
+
// Empty (or blank) means the user never wrote one. Hooks are opt-in: they
|
|
35
|
+
// run arbitrary shell, so absence of configuration is absence of a hook.
|
|
36
|
+
if (!script.trim())
|
|
37
|
+
return null;
|
|
38
|
+
const global = getGlobalSettings();
|
|
39
|
+
const projectSettings = getProjectSettings(row.project_path);
|
|
40
|
+
const projectSlug = global.worktreesPrefixByProject
|
|
41
|
+
? slugifyProjectName(projectSettings?.displayName ?? '', row.project_path)
|
|
42
|
+
: undefined;
|
|
43
|
+
const worktreePath = row.worktree_path ??
|
|
44
|
+
resolveWorkspaceWorktreePath(row.project_path, row.working_branch, global.worktreesPath, projectSlug);
|
|
45
|
+
// A purged or manually deleted worktree has no cwd to spawn in. Routine
|
|
46
|
+
// for `pr-merged` once auto-purge has run; for the other events it means
|
|
47
|
+
// the user's script silently did not run, which deserves a line in the log.
|
|
48
|
+
if (!fs.existsSync(worktreePath)) {
|
|
49
|
+
if (event !== 'pr-merged') {
|
|
50
|
+
console.warn(`[lifecycle-hook] '${event}' hook skipped for workspace '${workspaceId}': worktree missing at ${worktreePath}`);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return await runScript({
|
|
55
|
+
workspaceId,
|
|
56
|
+
worktreePath,
|
|
57
|
+
script,
|
|
58
|
+
// Namespaced per event: three hooks streaming into one `hook:output` feed
|
|
59
|
+
// would be indistinguishable in the UI.
|
|
60
|
+
eventPrefix: `hook:${event}`,
|
|
61
|
+
tmpFileName: `.hook-${event}.tmp`,
|
|
62
|
+
env: {
|
|
63
|
+
workspaceName: row.name,
|
|
64
|
+
branchName: row.working_branch,
|
|
65
|
+
sourceBranch: row.source_branch,
|
|
66
|
+
projectPath: row.project_path,
|
|
67
|
+
},
|
|
68
|
+
extraEnv: { ...extraEnv, KOBO_HOOK_EVENT: event },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.error(`[lifecycle-hook] '${event}' hook failed for workspace '${workspaceId}':`, err);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* An agent session ended. Fires for every real end — clean finish, error, user
|
|
78
|
+
* stop, watchdog kill — and lets the script tell them apart via
|
|
79
|
+
* `KOBO_SESSION_END_REASON`. Typical use: run the test suite, or notify.
|
|
80
|
+
*/
|
|
81
|
+
export async function onSessionEnded(workspaceId, outcome) {
|
|
82
|
+
await runLifecycleHook('session-ended', workspaceId, {
|
|
83
|
+
KOBO_SESSION_ID: outcome.sessionId,
|
|
84
|
+
KOBO_SESSION_END_REASON: outcome.reason,
|
|
85
|
+
// A missing exit code is an empty variable, not the string 'null': a shell
|
|
86
|
+
// script testing `-z "$KOBO_SESSION_EXIT_CODE"` should see nothing there.
|
|
87
|
+
KOBO_SESSION_EXIT_CODE: outcome.exitCode === null ? '' : String(outcome.exitCode),
|
|
88
|
+
KOBO_SESSION_STOP_CAUSE: outcome.stopCause ?? '',
|
|
89
|
+
// A hook that runs the test suite should know the next iteration may
|
|
90
|
+
// already be editing the same files.
|
|
91
|
+
KOBO_AUTOLOOP_ACTIVE: outcome.autoLoopActive ? '1' : '0',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A PR was merged. Runs before the workspace is archived or purged, so the
|
|
96
|
+
* worktree is still on disk and the script can act on it (deploy, tag, notify).
|
|
97
|
+
*/
|
|
98
|
+
export async function onPrMerged(workspaceId, pr) {
|
|
99
|
+
await runLifecycleHook('pr-merged', workspaceId, {
|
|
100
|
+
KOBO_PR_NUMBER: String(pr.prNumber),
|
|
101
|
+
KOBO_PR_URL: pr.prUrl,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Auto-loop turned itself off. `KOBO_AUTOLOOP_REASON` separates the loop
|
|
106
|
+
* finishing its work from it stalling or erroring out — the difference between
|
|
107
|
+
* a notification worth celebrating and one worth investigating.
|
|
108
|
+
*/
|
|
109
|
+
export async function onAutoLoopDisabled(workspaceId, info) {
|
|
110
|
+
await runLifecycleHook('autoloop-disabled', workspaceId, {
|
|
111
|
+
KOBO_AUTOLOOP_REASON: info.reason,
|
|
112
|
+
KOBO_TASKS_PENDING: String(info.tasksPending),
|
|
113
|
+
});
|
|
114
|
+
}
|