@loicngr/kobo 1.11.16 → 1.11.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +13 -0
- package/dist/mcp-server/kobo-tasks-server.js +8 -7
- package/dist/server/db/migrations.js +57 -0
- package/dist/server/db/schema.js +10 -39
- package/dist/server/index.js +55 -7
- package/dist/server/routes/git.js +8 -3
- package/dist/server/routes/health.js +19 -2
- package/dist/server/routes/settings.js +24 -0
- package/dist/server/routes/workspaces.js +379 -72
- package/dist/server/services/agent/engines/claude-code/engine.js +39 -35
- package/dist/server/services/agent/engines/codex/client.js +16 -4
- package/dist/server/services/agent/engines/codex/engine.js +23 -2
- package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +44 -5
- package/dist/server/services/agent/engines/types.js +13 -0
- package/dist/server/services/agent/orchestrator.js +354 -133
- package/dist/server/services/agent/session-controller.js +27 -0
- package/dist/server/services/agent/{engines/codex/turn-liveness.js → turn-liveness.js} +6 -2
- package/dist/server/services/change-source-branch-service.js +115 -11
- package/dist/server/services/db-backup-service.js +27 -16
- package/dist/server/services/pr-watcher-service.js +17 -2
- package/dist/server/services/settings-service.js +39 -0
- package/dist/server/services/websocket-service.js +114 -48
- package/dist/server/services/workspace-service.js +54 -2
- package/dist/server/services/worktree-purge-service.js +19 -5
- package/dist/server/services/worktree-service.js +38 -1
- package/dist/server/services/ws-events-retention-service.js +133 -0
- package/dist/server/utils/git-ops.js +209 -14
- package/dist/server/utils/git-repo-lock.js +45 -0
- package/dist/server/utils/logger.js +108 -0
- package/dist/server/utils/script-runner.js +11 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-BlDBh5jB.js +8 -0
- package/src/client/dist/pwa/assets/ActivityFeed-tC8sR0GV.css +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-DACtknly.js +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-D_WOVByV.css +1 -0
- package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +1 -0
- package/src/client/dist/pwa/assets/CreatePage-Bb-Qn-Rd.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-DKt6gFi4.js +8 -0
- package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +1 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-B8QY7FTj.css +1 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-DhP1jk11.js +1 -0
- package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +1 -0
- package/src/client/dist/pwa/assets/GitPanel-BKrtkPrV.css +1 -0
- package/src/client/dist/pwa/assets/GitPanel-DNCCB0G_.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-B1J4-dOH.js +1 -0
- package/src/client/dist/pwa/assets/HealthPage-BbqLoFQj.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-F8ShDnAy.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-qWwDy1B5.js +2 -0
- package/src/client/dist/pwa/assets/QBadge-BgS0-MXB.js +1 -0
- package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +1 -0
- package/src/client/dist/pwa/assets/QBtn-B7WFdfmy.js +1 -0
- package/src/client/dist/pwa/assets/QBtnGroup-BMQlX2YS.js +1 -0
- package/src/client/dist/pwa/assets/QBtnToggle-eMu_yinY.js +1 -0
- package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +1 -0
- package/src/client/dist/pwa/assets/QExpansionItem-C8CWo-aP.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +1 -0
- package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +1 -0
- package/src/client/dist/pwa/assets/QPage-RpDxx5ah.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-D9bHHxAA.js +1 -0
- package/src/client/dist/pwa/assets/QSlideTransition-6lWJvjLP.js +1 -0
- package/src/client/dist/pwa/assets/QSpace-D3PNLwa4.js +1 -0
- package/src/client/dist/pwa/assets/QSpinnerDots-DWOFEezR.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-Brtqtz1H.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +1 -0
- package/src/client/dist/pwa/assets/SearchPage-CvZxJiJs.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-DdWM01rq.css +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-JVje8OPs.js +16 -0
- package/src/client/dist/pwa/assets/SettingsPage-dMAg4JP8.css +1 -0
- package/src/client/dist/pwa/assets/TerminalPanel-B6xJJs8p.css +1 -0
- package/src/client/dist/pwa/assets/TerminalPanel-Dtc6B3US.js +18 -0
- package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-BVxtefbj.js +4 -0
- package/src/client/dist/pwa/assets/WorkspacePage-DN-A6XpI.css +1 -0
- package/src/client/dist/pwa/assets/build-path-tree-BU8Y8vRw.js +1 -0
- package/src/client/dist/pwa/assets/clipboard-t9qe6lK7.js +1 -0
- package/src/client/dist/pwa/assets/{cssMode-DNEi2cLH.js → cssMode-D_JDSbNP.js} +1 -1
- package/src/client/dist/pwa/assets/debounce-CDIE7CV6.js +1 -0
- package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api-_tFrtCR7.js → editor.api-Ccbr7NXr.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-DW9PydRL.js +1 -0
- package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +1 -0
- package/src/client/dist/pwa/assets/expand-template-Xq6JwXsl.js +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-kfVsFQjE.js → freemarker2-CUvQzJdx.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-EwBi9SHt.js → handlebars-BXiAPlES.js} +1 -1
- package/src/client/dist/pwa/assets/{html-BOOCP_vn.js → html-CLmfyTMC.js} +1 -1
- package/src/client/dist/pwa/assets/{htmlMode-IN9oEIXW.js → htmlMode-DWYo6_e_.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-CJclRByY.js +1 -0
- package/src/client/dist/pwa/assets/index-Bri8-39E.css +1 -0
- package/src/client/dist/pwa/assets/index-Dv1p-1sA.js +82 -0
- package/src/client/dist/pwa/assets/{javascript-CfV8qmeH.js → javascript-C9gWFLC_.js} +1 -1
- package/src/client/dist/pwa/assets/{jsonMode-C0b3m4VO.js → jsonMode-CbsjeFb8.js} +1 -1
- package/src/client/dist/pwa/assets/kobo-commands-DGygmOvc.js +9 -0
- package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-D3YK93J-.js → liquid-BqJ3SIha.js} +1 -1
- package/src/client/dist/pwa/assets/{lspLanguageFeatures-PklP2gEH.js → lspLanguageFeatures-BB0y1B51.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DjZcevPS.js → mdx-rIxQLZDB.js} +1 -1
- package/src/client/dist/pwa/assets/network-auth-pzKTXRYP.js +1 -0
- package/src/client/dist/pwa/assets/notify-theme-D7RrwvJa.js +1 -0
- package/src/client/dist/pwa/assets/pending-diff-open-Cia8sndE.js +1 -0
- package/src/client/dist/pwa/assets/private.use-form-D8Yuc3aU.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-Cc9yKGk3.js → project-color-Dur1z6Gn.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BrgzhqrC.js → python-C6d5du9h.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-g8NeeeQc.js → razor-Aek1yDc0.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-CZyJL1-w.js → render-chat-markdown-B6sa44Xr.js} +2 -2
- package/src/client/dist/pwa/assets/skill-suite-prompts-DdoGRwhz.js +36 -0
- package/src/client/dist/pwa/assets/{tsMode-DAopv4hH.js → tsMode-rxNEyqyY.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-C2jZCVPF.js → typescript-DndxWYhC.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +1 -0
- package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-BzSwN4xT.js +2 -0
- package/src/client/dist/pwa/assets/{vs-mk-rn9sP.js → vs-De8HKJ9r.js} +3 -3
- package/src/client/dist/pwa/assets/{workers-CP5iY9Kz.js → workers-DdNkyVm9.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-D3pYNERH.js → xml-Cl1k1mQr.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BTRPY5XP.js → yaml-C8G_k5TR.js} +1 -1
- package/src/client/dist/pwa/fonts/GEIST-LICENSE.txt +92 -0
- package/src/client/dist/pwa/fonts/Geist-Variable.woff2 +0 -0
- package/src/client/dist/pwa/fonts/GeistMono-Variable.woff2 +0 -0
- package/src/client/dist/pwa/index.html +7 -5
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-server.ts +8 -7
- package/dist/server/utils/process-tracker.js +0 -49
- package/src/client/dist/pwa/assets/ActivityFeed-BrjeLlhi.css +0 -1
- package/src/client/dist/pwa/assets/ActivityFeed-WBBk7DgE.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-Cr5cW9Wx.js +0 -1
- package/src/client/dist/pwa/assets/ChangelogPage-DGhfnkax.css +0 -1
- package/src/client/dist/pwa/assets/ClosePopup-2KSSXX6P.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-Bl1Vjr27.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-DQk8TrUP.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-C9xV8dh-.js +0 -8
- package/src/client/dist/pwa/assets/DiffViewer-CS3P9Ngi.css +0 -1
- package/src/client/dist/pwa/assets/DrawerToggleButton-CjM28eaw.js +0 -1
- package/src/client/dist/pwa/assets/HealthPage-BbLpLRjV.js +0 -1
- package/src/client/dist/pwa/assets/HealthPage-BqirO5le.css +0 -1
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BCOjigVR.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-Ocp2tNig.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-6jEn9xEl.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CCs8I9zJ.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-BLaboPi0.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-DHuK6FBl.woff +0 -0
- package/src/client/dist/pwa/assets/MainLayout-BUril7SK.js +0 -19
- package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +0 -1
- package/src/client/dist/pwa/assets/QBadge-DS9cpnyx.js +0 -1
- package/src/client/dist/pwa/assets/QBanner-tANb95XA.js +0 -1
- package/src/client/dist/pwa/assets/QBtn-DJGkBel8.js +0 -1
- package/src/client/dist/pwa/assets/QBtnToggle-D-Hsqmcb.js +0 -1
- package/src/client/dist/pwa/assets/QChip-iLXQ5Khf.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-B-5iFRBZ.js +0 -1
- package/src/client/dist/pwa/assets/QList-D-yd1vEv.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-TFRaHbzG.js +0 -36
- package/src/client/dist/pwa/assets/QSeparator-C_2BuEw9.js +0 -1
- package/src/client/dist/pwa/assets/QSpace-BuMMeXnK.js +0 -1
- package/src/client/dist/pwa/assets/QSpinnerDots-C-tv95pD.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CPM7L9_M.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CngtAqls.css +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-BRYycuYY.js +0 -16
- package/src/client/dist/pwa/assets/SettingsPage-D5BacmAr.css +0 -1
- package/src/client/dist/pwa/assets/TouchPan-79_u9-Fd.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DB1n1bmg.js +0 -4
- package/src/client/dist/pwa/assets/WorkspacePage-hxEpXqBA.css +0 -1
- package/src/client/dist/pwa/assets/documents-BMvE9Q_U.js +0 -1
- package/src/client/dist/pwa/assets/engineFeatures-JkVb6hry.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-D2yUa54D.css +0 -1
- package/src/client/dist/pwa/assets/expand-template-Dl4wUjF8.js +0 -1
- package/src/client/dist/pwa/assets/i18n-Dezgz2hU.js +0 -1
- package/src/client/dist/pwa/assets/index-B12jWMl8.js +0 -82
- package/src/client/dist/pwa/assets/index-CK1nk7qr.css +0 -1
- package/src/client/dist/pwa/assets/kobo-commands-DznaKlq6.js +0 -9
- package/src/client/dist/pwa/assets/layout-me0gwmBV.js +0 -1
- package/src/client/dist/pwa/assets/network-auth-BMTuTKHx.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BG3IOnWw.js +0 -1
- package/src/client/dist/pwa/assets/use-checkbox-D-9LXk-S.js +0 -1
- package/src/client/dist/pwa/assets/use-is-mobile-DX2Fiee-.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding-BNo8mb65.js +0 -2
|
@@ -6,6 +6,22 @@ export class SessionController {
|
|
|
6
6
|
_engineProcess;
|
|
7
7
|
_startPromise;
|
|
8
8
|
_status = 'running';
|
|
9
|
+
/**
|
|
10
|
+
* Wall-clock creation time. The watchdog needs it: `engineProcess` stays
|
|
11
|
+
* undefined until `engine.start` resolves, and without a grace window any
|
|
12
|
+
* engine whose start becomes blocking would see every fresh session
|
|
13
|
+
* declared dead on the first sweep.
|
|
14
|
+
*/
|
|
15
|
+
startedAt = Date.now();
|
|
16
|
+
/**
|
|
17
|
+
* Wall-clock time of the last event this controller relayed. Serialized to
|
|
18
|
+
* the client so a stalled agent is visible as "last event 22 min ago"
|
|
19
|
+
* instead of an indefinite "the agent is busy".
|
|
20
|
+
*/
|
|
21
|
+
_lastEventAt = Date.now();
|
|
22
|
+
get lastEventAt() {
|
|
23
|
+
return this._lastEventAt;
|
|
24
|
+
}
|
|
9
25
|
get engineProcess() {
|
|
10
26
|
return this._engineProcess;
|
|
11
27
|
}
|
|
@@ -48,6 +64,16 @@ export class SessionController {
|
|
|
48
64
|
}
|
|
49
65
|
async stop() {
|
|
50
66
|
this._status = 'stopping';
|
|
67
|
+
// `startEngine` may still be in flight: `_engineProcess` stays undefined
|
|
68
|
+
// until `engine.start` resolves, so without this wait `stop()` would
|
|
69
|
+
// resolve instantly while the engine keeps spinning up in the
|
|
70
|
+
// background — the caller (e.g. worktree removal) would then race ahead
|
|
71
|
+
// of a process that hasn't even started yet. `startEngine` itself
|
|
72
|
+
// already stops the process it just got if it sees `_status ===
|
|
73
|
+
// 'stopping'`, so waiting here is enough; a failed start must not fail
|
|
74
|
+
// the stop, hence the swallowed rejection.
|
|
75
|
+
if (this._startPromise)
|
|
76
|
+
await this._startPromise.catch(() => { });
|
|
51
77
|
if (this._engineProcess)
|
|
52
78
|
await this._engineProcess.stop();
|
|
53
79
|
}
|
|
@@ -61,6 +87,7 @@ export class SessionController {
|
|
|
61
87
|
return this._engineProcess?.engineSessionId;
|
|
62
88
|
}
|
|
63
89
|
handle(ev) {
|
|
90
|
+
this._lastEventAt = Date.now();
|
|
64
91
|
this.onEvent(ev);
|
|
65
92
|
}
|
|
66
93
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tracks a single active
|
|
3
|
-
*
|
|
2
|
+
* Tracks a single active agent turn. Shared by every engine (D2): one module,
|
|
3
|
+
* armed BEFORE the handshake, re-armed on ANY event, paused while a human
|
|
4
|
+
* decision is pending, stopped in the cleanup block.
|
|
5
|
+
*
|
|
6
|
+
* A pending user decision deliberately pauses the deadline: the agent is
|
|
7
|
+
* waiting on a human, not stalled.
|
|
4
8
|
*/
|
|
5
9
|
export function createTurnLiveness(input) {
|
|
6
10
|
let timer;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as gitOps from '../utils/git-ops.js';
|
|
5
|
+
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
5
6
|
import { getAgentStatus } from './agent/orchestrator.js';
|
|
6
7
|
import { getForgeProvider } from './forge/registry.js';
|
|
7
8
|
import { resolveForge } from './forge/resolve.js';
|
|
@@ -9,8 +10,33 @@ import { getEffectiveSettings } from './settings-service.js';
|
|
|
9
10
|
import { getWorkspace, updateWorkspaceSourceBranch } from './workspace-service.js';
|
|
10
11
|
/** Above this many proper commits, refuse and ask for a manual rebase. */
|
|
11
12
|
export const MAX_PROPER_COMMITS = 50;
|
|
13
|
+
/** Label written by `stashPush` and read back by `stashPop` — see F27. */
|
|
14
|
+
const STASH_LABEL = 'kobo-change-source-branch';
|
|
12
15
|
/** Wall-clock limit on the custom change-source-branch script. */
|
|
13
16
|
const SCRIPT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
17
|
+
/**
|
|
18
|
+
* Rotate the backup branches, best-effort. Called on EVERY exit path that has
|
|
19
|
+
* already created one — a conflict is precisely the case that repeats, and
|
|
20
|
+
* rotating only on full success let the failure scenario grow without bound.
|
|
21
|
+
* Returns the warnings so a caller can surface them instead of burying them in
|
|
22
|
+
* the server log; never throws, since the outcome being returned (or the error
|
|
23
|
+
* being rethrown) always matters more than the housekeeping.
|
|
24
|
+
*/
|
|
25
|
+
function rotateBackupBranches(worktreePath, workingBranch) {
|
|
26
|
+
try {
|
|
27
|
+
const { warnings } = gitOps.pruneBackupBranches(worktreePath, workingBranch, BACKUP_BRANCHES_KEPT);
|
|
28
|
+
for (const warning of warnings)
|
|
29
|
+
console.warn(`[change-source-branch] ${warning}`);
|
|
30
|
+
return warnings;
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
34
|
+
console.warn(`[change-source-branch] backup rotation failed: ${message}`);
|
|
35
|
+
return [`Backup branch rotation failed: ${message}`];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Three kept means two levels of fallback beyond the branch just created. */
|
|
39
|
+
const BACKUP_BRANCHES_KEPT = 3;
|
|
14
40
|
/**
|
|
15
41
|
* Re-target a workspace onto `newBase`: reconstruct its working branch via
|
|
16
42
|
* cherry-pick of its proper commits, update the `source_branch` metadata, and
|
|
@@ -30,12 +56,53 @@ export async function changeSourceBranch(workspaceId, newBase) {
|
|
|
30
56
|
throw new Error('New source branch is required');
|
|
31
57
|
if (trimmedNew === oldBase)
|
|
32
58
|
throw new Error(`The source branch is already '${oldBase}'`);
|
|
59
|
+
const worktreePath = workspace.worktreePath;
|
|
60
|
+
const workingBranch = workspace.workingBranch;
|
|
33
61
|
const effective = getEffectiveSettings(workspace.projectPath);
|
|
34
|
-
|
|
62
|
+
const customScript = effective.changeSourceBranchScript?.trim() ?? '';
|
|
63
|
+
if (customScript.length > 0) {
|
|
64
|
+
// Un script personnalisé remplace la STRATEGIE, jamais les GARANTIES.
|
|
65
|
+
// Le script par défaut fait `git reset --hard`, qui détruit sans recours
|
|
66
|
+
// le travail non commité et les fichiers non suivis. Le chemin intégré
|
|
67
|
+
// sait mettre de côté puis restaurer quand la branche est alignée ; un
|
|
68
|
+
// script arbitraire étant une boîte noire, on ne peut pas le supposer —
|
|
69
|
+
// donc tout worktree sale est refusé ici, sans exception.
|
|
70
|
+
// Fail-closed: `worktreeHasChanges` swallows any git failure and reports
|
|
71
|
+
// "clean" (see its doc comment), which is exactly wrong on a destructive
|
|
72
|
+
// path — an unknown state must never be read as "safe to reset --hard".
|
|
73
|
+
// `worktreeHasChangesStrict` lets the error through instead, and here we
|
|
74
|
+
// treat that failure the same as "dirty": refuse rather than guess.
|
|
75
|
+
let dirty;
|
|
76
|
+
try {
|
|
77
|
+
dirty = gitOps.worktreeHasChangesStrict(worktreePath);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
console.error('[change-source-branch] could not determine worktree state, refusing as a precaution:', err);
|
|
81
|
+
dirty = true;
|
|
82
|
+
}
|
|
83
|
+
if (dirty) {
|
|
84
|
+
return { status: 'dirty', forcePushNeeded: false, commitCount: 0 };
|
|
85
|
+
}
|
|
86
|
+
// A stale index.lock makes every git write fail half-way through, and the
|
|
87
|
+
// raw git message tells the user nothing. Typical origin: a setup/cleanup
|
|
88
|
+
// script SIGKILLed at its timeout during a commit. The custom-script path
|
|
89
|
+
// does not go through the repository lock, so the check is immediate here.
|
|
90
|
+
gitOps.assertNoIndexLock(worktreePath);
|
|
35
91
|
return runCustomScript(workspace, oldBase, trimmedNew, effective.changeSourceBranchScript);
|
|
36
92
|
}
|
|
37
|
-
|
|
38
|
-
|
|
93
|
+
// The whole built-in strategy runs under the repository lock: it fetches,
|
|
94
|
+
// resets and cherry-picks, all of which touch the COMMON git dir shared by
|
|
95
|
+
// every worktree of the project. A concurrent fetch from the PR watcher
|
|
96
|
+
// during the cherry-pick leaves it aborted mid-way.
|
|
97
|
+
return withGitRepoLock(worktreePath, () => runBuiltInStrategy(workspaceId, worktreePath, workingBranch, workspace.projectPath, oldBase, trimmedNew));
|
|
98
|
+
}
|
|
99
|
+
/** The built-in cherry-pick strategy. Always called under the repository lock. */
|
|
100
|
+
async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, projectPath, oldBase, trimmedNew) {
|
|
101
|
+
// Index-lock guard FIRST, and here rather than at the entry point: the
|
|
102
|
+
// repository lock above may have queued us for a while, so a check made at
|
|
103
|
+
// request time describes a state that no longer holds — it would miss a lock
|
|
104
|
+
// taken since, or report one that has been released in the meantime.
|
|
105
|
+
gitOps.assertNoIndexLock(worktreePath);
|
|
39
106
|
// Fetch every branch so all `origin/*` refs are current: the proper-commit
|
|
40
107
|
// computation and the `reset --hard` target both depend on fresh refs.
|
|
41
108
|
// Best-effort — offline still lets us proceed with whatever is local, and
|
|
@@ -56,13 +123,25 @@ export async function changeSourceBranch(workspaceId, newBase) {
|
|
|
56
123
|
return { status: 'too-many', forcePushNeeded, commitCount: commits.length };
|
|
57
124
|
}
|
|
58
125
|
const isAligned = commits.length === 0;
|
|
59
|
-
|
|
126
|
+
// Fail-closed, same rule as the custom-script path above: an indeterminate
|
|
127
|
+
// worktree state must never be read as "nothing to lose". Refuse right
|
|
128
|
+
// here, before either branch below — in particular before the
|
|
129
|
+
// `isAligned && dirty` stash path, since stashing a state we could not
|
|
130
|
+
// inspect would be worse than refusing outright.
|
|
131
|
+
let dirty;
|
|
132
|
+
try {
|
|
133
|
+
dirty = gitOps.worktreeHasChangesStrict(worktreePath);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
console.error('[change-source-branch] could not determine worktree state, refusing as a precaution:', err);
|
|
137
|
+
return { status: 'dirty', forcePushNeeded, commitCount: commits.length };
|
|
138
|
+
}
|
|
60
139
|
if (!isAligned && dirty) {
|
|
61
140
|
return { status: 'dirty', forcePushNeeded, commitCount: commits.length };
|
|
62
141
|
}
|
|
63
142
|
const stashed = isAligned && dirty;
|
|
64
143
|
if (stashed)
|
|
65
|
-
gitOps.stashPush(worktreePath,
|
|
144
|
+
gitOps.stashPush(worktreePath, STASH_LABEL);
|
|
66
145
|
try {
|
|
67
146
|
gitOps.reconstructBranchOnto(worktreePath, workingBranch, trimmedNew, commits);
|
|
68
147
|
}
|
|
@@ -73,7 +152,12 @@ export async function changeSourceBranch(workspaceId, newBase) {
|
|
|
73
152
|
// or abort. `stashed` is always false here — a conflict needs commits,
|
|
74
153
|
// the stash path is aligned-only — so no stash is stranded.
|
|
75
154
|
updateWorkspaceSourceBranch(workspaceId, trimmedNew);
|
|
76
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
status: 'conflict',
|
|
157
|
+
forcePushNeeded,
|
|
158
|
+
commitCount: commits.length,
|
|
159
|
+
warnings: emptyToUndefined(rotateBackupBranches(worktreePath, workingBranch)),
|
|
160
|
+
};
|
|
77
161
|
}
|
|
78
162
|
// Non-conflict failure (e.g. the `reset --hard` itself failed) — best
|
|
79
163
|
// effort restore of the stash before letting the error propagate, same
|
|
@@ -81,12 +165,16 @@ export async function changeSourceBranch(workspaceId, newBase) {
|
|
|
81
165
|
// work on an unrelated failure.
|
|
82
166
|
if (stashed) {
|
|
83
167
|
try {
|
|
84
|
-
gitOps.stashPop(worktreePath);
|
|
168
|
+
gitOps.stashPop(worktreePath, STASH_LABEL);
|
|
85
169
|
}
|
|
86
170
|
catch {
|
|
87
171
|
/* best-effort — the original error is more relevant than this one */
|
|
88
172
|
}
|
|
89
173
|
}
|
|
174
|
+
// The backup branch is created first, so it exists even when the reset that
|
|
175
|
+
// follows blew up. Rotate before rethrowing; its own warnings can only be
|
|
176
|
+
// logged here, the error is what reaches the caller.
|
|
177
|
+
rotateBackupBranches(worktreePath, workingBranch);
|
|
90
178
|
throw err;
|
|
91
179
|
}
|
|
92
180
|
// The reset/reconstruct already landed on disk at this point. Update the
|
|
@@ -97,14 +185,19 @@ export async function changeSourceBranch(workspaceId, newBase) {
|
|
|
97
185
|
updateWorkspaceSourceBranch(workspaceId, trimmedNew);
|
|
98
186
|
if (stashed) {
|
|
99
187
|
try {
|
|
100
|
-
gitOps.stashPop(worktreePath);
|
|
188
|
+
gitOps.stashPop(worktreePath, STASH_LABEL);
|
|
101
189
|
}
|
|
102
190
|
catch {
|
|
103
|
-
return {
|
|
191
|
+
return {
|
|
192
|
+
status: 'conflict',
|
|
193
|
+
forcePushNeeded,
|
|
194
|
+
commitCount: commits.length,
|
|
195
|
+
warnings: emptyToUndefined(rotateBackupBranches(worktreePath, workingBranch)),
|
|
196
|
+
};
|
|
104
197
|
}
|
|
105
198
|
}
|
|
106
199
|
try {
|
|
107
|
-
const provider = getForgeProvider(resolveForge(
|
|
200
|
+
const provider = getForgeProvider(resolveForge(projectPath));
|
|
108
201
|
if (provider.capabilities.canChangePrBase) {
|
|
109
202
|
const pr = await provider.getPrStatus(worktreePath, workingBranch);
|
|
110
203
|
if (pr)
|
|
@@ -114,7 +207,18 @@ export async function changeSourceBranch(workspaceId, newBase) {
|
|
|
114
207
|
catch (err) {
|
|
115
208
|
console.error('[change-source-branch] PR base update failed (non-fatal):', err);
|
|
116
209
|
}
|
|
117
|
-
|
|
210
|
+
// Each backup pins a whole previous history and git can never reclaim it.
|
|
211
|
+
const warnings = rotateBackupBranches(worktreePath, workingBranch);
|
|
212
|
+
return {
|
|
213
|
+
status: isAligned ? 'aligned' : 'done',
|
|
214
|
+
forcePushNeeded,
|
|
215
|
+
commitCount: commits.length,
|
|
216
|
+
warnings: emptyToUndefined(warnings),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** Keep `warnings` out of the JSON response entirely when there is nothing to say. */
|
|
220
|
+
function emptyToUndefined(warnings) {
|
|
221
|
+
return warnings.length > 0 ? warnings : undefined;
|
|
118
222
|
}
|
|
119
223
|
/** Spawn the script with `bash -c`, return the standard result on exit 0, throw on non-zero. */
|
|
120
224
|
async function runCustomScript(workspace, oldBase, newBase, script) {
|
|
@@ -8,12 +8,12 @@ let backupSequence = 0;
|
|
|
8
8
|
export function _resetBackupSequenceForTests() {
|
|
9
9
|
backupSequence = 0;
|
|
10
10
|
}
|
|
11
|
-
function listBackups(dir) {
|
|
11
|
+
function listBackups(dir, prefix = BACKUP_PREFIX) {
|
|
12
12
|
if (!fs.existsSync(dir))
|
|
13
13
|
return [];
|
|
14
14
|
return fs
|
|
15
15
|
.readdirSync(dir)
|
|
16
|
-
.filter((f) => f.startsWith(
|
|
16
|
+
.filter((f) => f.startsWith(prefix))
|
|
17
17
|
.map((f) => {
|
|
18
18
|
const full = path.join(dir, f);
|
|
19
19
|
const stat = fs.statSync(full);
|
|
@@ -21,6 +21,23 @@ function listBackups(dir) {
|
|
|
21
21
|
})
|
|
22
22
|
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
23
23
|
}
|
|
24
|
+
/** Delete every backup of `prefix` beyond the `keepCount` most recent. */
|
|
25
|
+
function rotateBackups(dir, prefix, keepCount) {
|
|
26
|
+
const deleted = [];
|
|
27
|
+
const all = listBackups(dir, prefix);
|
|
28
|
+
if (all.length <= keepCount)
|
|
29
|
+
return deleted;
|
|
30
|
+
for (const entry of all.slice(keepCount)) {
|
|
31
|
+
try {
|
|
32
|
+
fs.unlinkSync(entry.path);
|
|
33
|
+
deleted.push(entry.path);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
console.error(`[kobo] Failed to delete old DB backup ${entry.path}:`, err);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return deleted;
|
|
40
|
+
}
|
|
24
41
|
/**
|
|
25
42
|
* Create a WAL-safe snapshot of the Kōbō DB if no backup has been made in the
|
|
26
43
|
* last 24h. Rotates old backups, keeping only the `keepCount` most recent.
|
|
@@ -46,18 +63,7 @@ export async function createDailyDbBackupIfNeeded(db, dbPath, keepCount = DEFAUL
|
|
|
46
63
|
const backupPath = path.join(dir, `${BACKUP_PREFIX}${stamp}-${backupSequence}`);
|
|
47
64
|
await db.backup(backupPath);
|
|
48
65
|
result.created = backupPath;
|
|
49
|
-
|
|
50
|
-
if (all.length > keepCount) {
|
|
51
|
-
for (const entry of all.slice(keepCount)) {
|
|
52
|
-
try {
|
|
53
|
-
fs.unlinkSync(entry.path);
|
|
54
|
-
result.deleted.push(entry.path);
|
|
55
|
-
}
|
|
56
|
-
catch (err) {
|
|
57
|
-
console.error(`[kobo] Failed to delete old DB backup ${entry.path}:`, err);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
66
|
+
result.deleted.push(...rotateBackups(dir, BACKUP_PREFIX, keepCount));
|
|
61
67
|
}
|
|
62
68
|
catch (err) {
|
|
63
69
|
console.error('[kobo] DB daily backup failed:', err);
|
|
@@ -65,14 +71,19 @@ export async function createDailyDbBackupIfNeeded(db, dbPath, keepCount = DEFAUL
|
|
|
65
71
|
return result;
|
|
66
72
|
}
|
|
67
73
|
const PREMIGRATION_PREFIX = 'kobo.db.premigration-';
|
|
68
|
-
|
|
74
|
+
/** Backups of this prefix kept. One full-size copy was added per shipped
|
|
75
|
+
* migration — of schema AND of settings — and nothing ever removed them. */
|
|
76
|
+
const DEFAULT_PREMIGRATION_KEEP = 5;
|
|
77
|
+
export async function createPreMigrationBackup(db, dbPath, tag, keepCount = DEFAULT_PREMIGRATION_KEEP) {
|
|
69
78
|
const dir = path.dirname(dbPath);
|
|
70
79
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
71
80
|
backupSequence += 1;
|
|
72
81
|
const backupPath = path.join(dir, `${PREMIGRATION_PREFIX}${tag}-${stamp}-${backupSequence}`);
|
|
73
82
|
try {
|
|
74
83
|
await db.backup(backupPath);
|
|
75
|
-
|
|
84
|
+
// Rotation is scoped to the pre-migration prefix: the daily backups are a
|
|
85
|
+
// separate net with its own retention and must never be touched here.
|
|
86
|
+
return { created: backupPath, deleted: rotateBackups(dir, PREMIGRATION_PREFIX, keepCount) };
|
|
76
87
|
}
|
|
77
88
|
catch (err) {
|
|
78
89
|
console.error('[kobo] Pre-migration backup failed:', err);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { fetchSourceBranchAsync, isGitWorktree } from '../utils/git-ops.js';
|
|
3
|
+
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
3
4
|
import { stopDevServer } from './dev-server-service.js';
|
|
4
5
|
import { getForgeProvider } from './forge/registry.js';
|
|
5
6
|
import { resolveForge } from './forge/resolve.js';
|
|
@@ -162,7 +163,13 @@ export async function checkPrStatuses() {
|
|
|
162
163
|
// logic below. Its own try/catch so a git failure neither skips PR
|
|
163
164
|
// transitions nor poisons other workspaces.
|
|
164
165
|
try {
|
|
165
|
-
|
|
166
|
+
// AWAITED, and serialised on the repository's common git dir: remote
|
|
167
|
+
// refs live there, shared by every worktree of the project. Firing this
|
|
168
|
+
// without awaiting made the stats below read the refs from BEFORE the
|
|
169
|
+
// fetch, so ahead/behind counters were always one tick stale; firing it
|
|
170
|
+
// concurrently across four worktrees made them fight over the same file
|
|
171
|
+
// lock, with the error swallowed.
|
|
172
|
+
await withGitRepoLock(ws.worktreePath, () => fetchSourceBranchAsync(ws.worktreePath, ws.sourceBranch));
|
|
166
173
|
lastKnownGitStats.set(ws.id, await computeGitStats(ws, pr));
|
|
167
174
|
}
|
|
168
175
|
catch (err) {
|
|
@@ -219,7 +226,15 @@ export async function checkPrStatuses() {
|
|
|
219
226
|
try {
|
|
220
227
|
const { autoPurgeOnPrMerged } = getGlobalSettings();
|
|
221
228
|
if (autoPurgeOnPrMerged) {
|
|
222
|
-
void purgeWorktree(ws.id)
|
|
229
|
+
void purgeWorktree(ws.id)
|
|
230
|
+
.then((result) => {
|
|
231
|
+
// Auto-purge runs with nobody watching: without this trace an
|
|
232
|
+
// outcome of 'removal-failed' left no record anywhere.
|
|
233
|
+
if (result.outcome !== 'purged') {
|
|
234
|
+
console.warn(`[pr-watcher] auto-purge for '${ws.name}' ended as '${result.outcome}':`, result.warnings.join('\n') || '(no warning)');
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
.catch((err) => {
|
|
223
238
|
console.error(`[pr-watcher] auto-purge failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
224
239
|
});
|
|
225
240
|
}
|
|
@@ -832,6 +832,27 @@ const settingsMigrations = [
|
|
|
832
832
|
global.whipVolume = normalizeWhipVolume(global.whipVolume);
|
|
833
833
|
},
|
|
834
834
|
},
|
|
835
|
+
{
|
|
836
|
+
version: 54,
|
|
837
|
+
name: 'add-ws-events-retention',
|
|
838
|
+
migrate: ({ global }) => {
|
|
839
|
+
// Seeded DISABLED (0), on fresh installs as well as migrated ones. This
|
|
840
|
+
// feature permanently deletes conversation history, so it is opt-in: a
|
|
841
|
+
// silent loss of history is worse than a growing file. A window the user
|
|
842
|
+
// already chose survives untouched; only a negative or non-integer value
|
|
843
|
+
// falls back to 0.
|
|
844
|
+
if (typeof global.wsEventsRetentionDays !== 'number' ||
|
|
845
|
+
!Number.isInteger(global.wsEventsRetentionDays) ||
|
|
846
|
+
global.wsEventsRetentionDays < 0) {
|
|
847
|
+
global.wsEventsRetentionDays = 0;
|
|
848
|
+
}
|
|
849
|
+
if (typeof global.wsEventsKeepPerWorkspace !== 'number' ||
|
|
850
|
+
!Number.isInteger(global.wsEventsKeepPerWorkspace) ||
|
|
851
|
+
global.wsEventsKeepPerWorkspace < 0) {
|
|
852
|
+
global.wsEventsKeepPerWorkspace = 0;
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
},
|
|
835
856
|
];
|
|
836
857
|
/** Current settings schema version — always equals the highest migration version. */
|
|
837
858
|
export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
|
|
@@ -879,6 +900,8 @@ function defaultSettings() {
|
|
|
879
900
|
terminalCommand: '',
|
|
880
901
|
autoPurgeOnPrMerged: false,
|
|
881
902
|
autoLoopMaxRetries: 5,
|
|
903
|
+
wsEventsRetentionDays: 0,
|
|
904
|
+
wsEventsKeepPerWorkspace: 0,
|
|
882
905
|
networkAccessEnabled: false,
|
|
883
906
|
networkAccessToken: '',
|
|
884
907
|
networkAccessBehindProxy: false,
|
|
@@ -1270,6 +1293,20 @@ export function updateGlobalSettings(data) {
|
|
|
1270
1293
|
delete data.autoLoopMaxRetries;
|
|
1271
1294
|
}
|
|
1272
1295
|
}
|
|
1296
|
+
// Same shape as the autoLoopMaxRetries guard above: an invalid value is
|
|
1297
|
+
// dropped so the stored one survives. Retention deletes history — a bad write
|
|
1298
|
+
// here would delete more than the user ever asked for. `0` is NOT invalid:
|
|
1299
|
+
// it is the default, and it means "never delete anything".
|
|
1300
|
+
for (const key of ['wsEventsRetentionDays', 'wsEventsKeepPerWorkspace']) {
|
|
1301
|
+
if (key in data) {
|
|
1302
|
+
const value = data[key];
|
|
1303
|
+
const max = key === 'wsEventsRetentionDays' ? 3650 : 1_000_000;
|
|
1304
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > max) {
|
|
1305
|
+
console.warn(`[settings] Invalid ${key} value rejected: ${value}`);
|
|
1306
|
+
delete data[key];
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1273
1310
|
const allowedGlobalKeys = [
|
|
1274
1311
|
'defaultModelByEngine',
|
|
1275
1312
|
'dangerouslySkipPermissions',
|
|
@@ -1291,6 +1328,8 @@ export function updateGlobalSettings(data) {
|
|
|
1291
1328
|
'terminalCommand',
|
|
1292
1329
|
'autoPurgeOnPrMerged',
|
|
1293
1330
|
'autoLoopMaxRetries',
|
|
1331
|
+
'wsEventsRetentionDays',
|
|
1332
|
+
'wsEventsKeepPerWorkspace',
|
|
1294
1333
|
'browserNotifications',
|
|
1295
1334
|
'audioNotifications',
|
|
1296
1335
|
'audioQuestionNotifications',
|