@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
|
@@ -4,6 +4,10 @@ import { getAllPersistedSnapshots } from './usage/db.js';
|
|
|
4
4
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
5
5
|
/** Maps each WS client to the set of workspaceIds they are subscribed to */
|
|
6
6
|
const clients = new Map();
|
|
7
|
+
/** Above this many bytes queued for a single client, we stop sending it events
|
|
8
|
+
* rather than growing the process heap on its behalf. It will catch up through
|
|
9
|
+
* `sync:request` on its next reconnect. */
|
|
10
|
+
const MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
7
11
|
let messageHandler = null;
|
|
8
12
|
/** Register the handler that processes routed WS messages (e.g. chat:message, workspace:start). */
|
|
9
13
|
export function setMessageHandler(handler) {
|
|
@@ -82,14 +86,28 @@ export function handleConnection(ws) {
|
|
|
82
86
|
ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
|
|
83
87
|
}
|
|
84
88
|
});
|
|
85
|
-
//
|
|
89
|
+
// Heartbeat with a verdict. Pinging without ever waiting for a pong detected
|
|
90
|
+
// nothing: half-open sockets stayed in `clients` for ever, and every emit
|
|
91
|
+
// kept serialising a message for a peer that will never read it.
|
|
92
|
+
let isAlive = true;
|
|
93
|
+
ws.on('pong', () => {
|
|
94
|
+
isAlive = true;
|
|
95
|
+
});
|
|
86
96
|
const pingInterval = setInterval(() => {
|
|
87
|
-
if (ws.readyState
|
|
88
|
-
|
|
97
|
+
if (ws.readyState !== 1 /* WebSocket.OPEN */) {
|
|
98
|
+
clearInterval(pingInterval);
|
|
99
|
+
clients.delete(ws);
|
|
100
|
+
return;
|
|
89
101
|
}
|
|
90
|
-
|
|
102
|
+
if (!isAlive) {
|
|
103
|
+
console.warn('[ws] client missed the heartbeat — terminating the connection');
|
|
91
104
|
clearInterval(pingInterval);
|
|
105
|
+
clients.delete(ws);
|
|
106
|
+
ws.terminate();
|
|
107
|
+
return;
|
|
92
108
|
}
|
|
109
|
+
isAlive = false;
|
|
110
|
+
ws.ping();
|
|
93
111
|
}, 30_000);
|
|
94
112
|
ws.on('close', () => {
|
|
95
113
|
clearInterval(pingInterval);
|
|
@@ -127,6 +145,13 @@ export function emit(workspaceId, type, payload, sessionId) {
|
|
|
127
145
|
let emitSendErrorLogged = false;
|
|
128
146
|
for (const [ws, subs] of clients) {
|
|
129
147
|
if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
|
|
148
|
+
if ((ws.bufferedAmount ?? 0) > MAX_BUFFERED_BYTES) {
|
|
149
|
+
if (!emitSendErrorLogged) {
|
|
150
|
+
console.warn(`[ws] client backlogged, dropping live events (workspace=${workspaceId}, type=${type})`);
|
|
151
|
+
emitSendErrorLogged = true;
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
130
155
|
try {
|
|
131
156
|
ws.send(message);
|
|
132
157
|
}
|
|
@@ -152,6 +177,13 @@ export function emitEphemeral(workspaceId, type, payload) {
|
|
|
152
177
|
let sendErrorLogged = false;
|
|
153
178
|
for (const [ws, subs] of clients) {
|
|
154
179
|
if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
|
|
180
|
+
if ((ws.bufferedAmount ?? 0) > MAX_BUFFERED_BYTES) {
|
|
181
|
+
if (!sendErrorLogged) {
|
|
182
|
+
console.warn(`[ws] client backlogged, dropping ephemeral event (workspace=${workspaceId}, type=${type})`);
|
|
183
|
+
sendErrorLogged = true;
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
155
187
|
try {
|
|
156
188
|
ws.send(message);
|
|
157
189
|
}
|
|
@@ -180,66 +212,100 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
|
180
212
|
ws.send(JSON.stringify({ type: 'sync:empty', payload: { message: 'No subscriptions' } }));
|
|
181
213
|
return;
|
|
182
214
|
}
|
|
183
|
-
const db = getDb();
|
|
184
|
-
// Build a query with placeholders for all subscribed workspaces
|
|
185
|
-
const placeholders = resolvedIds.map(() => '?').join(', ');
|
|
186
|
-
let rows;
|
|
187
215
|
// Initial window size: on a fresh connection (no lastEventId), we only
|
|
188
216
|
// replay the most recent slice of history. The client fetches older
|
|
189
217
|
// events on-demand via GET /api/workspaces/:id/events as the user scrolls
|
|
190
218
|
// up. This keeps first-paint fast on long-lived workspaces with tens of
|
|
191
219
|
// thousands of events without ever deleting anything from the DB.
|
|
192
220
|
const INITIAL_WINDOW = 300;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
221
|
+
/** Hard ceiling on one replay message. A tab left open overnight used to ask
|
|
222
|
+
* for every row since its cursor at once; the client simply asks again with
|
|
223
|
+
* the new cursor when `truncated` is set. */
|
|
224
|
+
const MAX_REPLAY_EVENTS = 2_000;
|
|
225
|
+
try {
|
|
226
|
+
const db = getDb();
|
|
227
|
+
let mode = 'snapshot';
|
|
228
|
+
let truncated = false;
|
|
229
|
+
const rows = [];
|
|
230
|
+
const lastRow = lastEventId
|
|
231
|
+
? db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(lastEventId)
|
|
232
|
+
: undefined;
|
|
233
|
+
// One bounded query PER workspace. With `workspace_id IN (...)` SQLite
|
|
234
|
+
// materialised and sorted the whole history of every requested workspace
|
|
235
|
+
// before applying the limit — so the limit protected the message, not the
|
|
236
|
+
// server.
|
|
199
237
|
if (lastRow) {
|
|
200
238
|
mode = 'delta';
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
.all(
|
|
239
|
+
const statement = db.prepare('SELECT rowid AS rid, * FROM ws_events WHERE workspace_id = ? AND rowid > ? ORDER BY rowid ASC LIMIT ?');
|
|
240
|
+
for (const workspaceId of resolvedIds) {
|
|
241
|
+
const batch = statement.all(workspaceId, lastRow.rowid, MAX_REPLAY_EVENTS);
|
|
242
|
+
if (batch.length === MAX_REPLAY_EVENTS)
|
|
243
|
+
truncated = true;
|
|
244
|
+
rows.push(...batch);
|
|
245
|
+
}
|
|
204
246
|
}
|
|
205
247
|
else {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
.all(
|
|
209
|
-
|
|
248
|
+
const statement = db.prepare('SELECT rowid AS rid, * FROM ws_events WHERE workspace_id = ? ORDER BY rowid DESC LIMIT ?');
|
|
249
|
+
for (const workspaceId of resolvedIds) {
|
|
250
|
+
const batch = statement.all(workspaceId, INITIAL_WINDOW);
|
|
251
|
+
rows.push(...batch);
|
|
252
|
+
}
|
|
210
253
|
}
|
|
254
|
+
rows.sort((a, b) => a.rid - b.rid);
|
|
255
|
+
if (rows.length > MAX_REPLAY_EVENTS) {
|
|
256
|
+
if (mode === 'delta') {
|
|
257
|
+
// Keep the OLDEST slice: the client's cursor then advances and its next
|
|
258
|
+
// sync:request picks up exactly where this message stopped.
|
|
259
|
+
rows.length = MAX_REPLAY_EVENTS;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// Snapshot mode has no cursor to advance — the client is looking at a
|
|
263
|
+
// fresh window and wants what just happened. Dropping the head here
|
|
264
|
+
// (rather than the tail) is the difference between "the last few
|
|
265
|
+
// minutes" and "ancient history with no way to reach the present".
|
|
266
|
+
rows.splice(0, rows.length - MAX_REPLAY_EVENTS);
|
|
267
|
+
}
|
|
268
|
+
truncated = true;
|
|
269
|
+
}
|
|
270
|
+
const events = rows.map((row) => {
|
|
271
|
+
let parsedPayload;
|
|
272
|
+
try {
|
|
273
|
+
parsedPayload = JSON.parse(row.payload);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
console.error('[ws] corrupt ws_events row, falling back to raw:', {
|
|
277
|
+
id: row.id,
|
|
278
|
+
workspace_id: row.workspace_id,
|
|
279
|
+
type: row.type,
|
|
280
|
+
});
|
|
281
|
+
parsedPayload = { raw: row.payload };
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
id: row.id,
|
|
285
|
+
workspaceId: row.workspace_id,
|
|
286
|
+
type: row.type,
|
|
287
|
+
payload: parsedPayload,
|
|
288
|
+
sessionId: row.session_id ?? undefined,
|
|
289
|
+
createdAt: row.created_at,
|
|
290
|
+
replayable: true,
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
ws.send(JSON.stringify({ type: 'sync:response', payload: { events, mode, truncated } }));
|
|
211
294
|
}
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
.all(...resolvedIds, INITIAL_WINDOW);
|
|
217
|
-
rows.reverse();
|
|
218
|
-
}
|
|
219
|
-
const events = rows.map((row) => {
|
|
220
|
-
let parsedPayload;
|
|
295
|
+
catch (err) {
|
|
296
|
+
// This handler runs inside ws's 'message' callback: an uncaught SQLite
|
|
297
|
+
// error here used to reach the process, not the client.
|
|
298
|
+
console.error('[ws] sync request failed:', err);
|
|
221
299
|
try {
|
|
222
|
-
|
|
300
|
+
ws.send(JSON.stringify({
|
|
301
|
+
type: 'sync:error',
|
|
302
|
+
payload: { message: err instanceof Error ? err.message : String(err) },
|
|
303
|
+
}));
|
|
223
304
|
}
|
|
224
305
|
catch {
|
|
225
|
-
|
|
226
|
-
id: row.id,
|
|
227
|
-
workspace_id: row.workspace_id,
|
|
228
|
-
type: row.type,
|
|
229
|
-
});
|
|
230
|
-
parsedPayload = { raw: row.payload };
|
|
306
|
+
/* the client is gone too — nothing left to tell it */
|
|
231
307
|
}
|
|
232
|
-
|
|
233
|
-
id: row.id,
|
|
234
|
-
workspaceId: row.workspace_id,
|
|
235
|
-
type: row.type,
|
|
236
|
-
payload: parsedPayload,
|
|
237
|
-
sessionId: row.session_id ?? undefined,
|
|
238
|
-
createdAt: row.created_at,
|
|
239
|
-
replayable: true,
|
|
240
|
-
};
|
|
241
|
-
});
|
|
242
|
-
ws.send(JSON.stringify({ type: 'sync:response', payload: { events, mode } }));
|
|
308
|
+
}
|
|
243
309
|
}
|
|
244
310
|
// ── Utilities ──────────────────────────────────────────────────────────────────
|
|
245
311
|
/**
|
|
@@ -10,8 +10,13 @@ import { emitEphemeral } from './websocket-service.js';
|
|
|
10
10
|
/** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
|
|
11
11
|
const VALID_TRANSITIONS = {
|
|
12
12
|
created: ['extracting', 'brainstorming', 'idle', 'error'],
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// `quota` is reachable from every state that can be running a live session.
|
|
14
|
+
// It used to be missing here and on `brainstorming`, so an auto-loop
|
|
15
|
+
// workspace with a distinct brainstormModel silently lost its backoff: the
|
|
16
|
+
// transition threw, the error was swallowed, and the very next lines
|
|
17
|
+
// cancelled the timer that had just been armed.
|
|
18
|
+
extracting: ['extracting', 'brainstorming', 'idle', 'error', 'awaiting-user', 'quota'],
|
|
19
|
+
brainstorming: ['executing', 'completed', 'idle', 'error', 'awaiting-user', 'quota'],
|
|
15
20
|
executing: ['completed', 'idle', 'error', 'quota', 'awaiting-user'],
|
|
16
21
|
'awaiting-user': ['executing', 'brainstorming', 'extracting', 'idle', 'error', 'completed', 'quota'],
|
|
17
22
|
completed: ['idle', 'executing'],
|
|
@@ -840,3 +845,50 @@ export function deleteSession(sessionId, workspaceId) {
|
|
|
840
845
|
transaction();
|
|
841
846
|
return true;
|
|
842
847
|
}
|
|
848
|
+
/**
|
|
849
|
+
* Recompute one session's persisted metrics from the events that remain, on an
|
|
850
|
+
* explicit database handle.
|
|
851
|
+
*
|
|
852
|
+
* Until v36 an `AFTER DELETE ... FOR EACH ROW` trigger did this per deleted
|
|
853
|
+
* row, re-aggregating the whole session every time — 79.7 s for 20 000 events,
|
|
854
|
+
* with the SQLite write lock held throughout. The trigger is gone; every code
|
|
855
|
+
* path that deletes `agent:event` rows while KEEPING the session calls this
|
|
856
|
+
* once, at the end of its transaction.
|
|
857
|
+
*/
|
|
858
|
+
export function recomputeSessionMetricsOn(db, workspaceId, sessionId) {
|
|
859
|
+
db.prepare('DELETE FROM session_event_metrics WHERE workspace_id = ? AND session_id = ?').run(workspaceId, sessionId);
|
|
860
|
+
db.prepare(`INSERT INTO session_event_metrics (
|
|
861
|
+
workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
|
|
862
|
+
)
|
|
863
|
+
SELECT
|
|
864
|
+
e.workspace_id,
|
|
865
|
+
e.session_id,
|
|
866
|
+
SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
|
|
867
|
+
SUM(CASE
|
|
868
|
+
WHEN json_extract(e.payload, '$.kind') = 'error'
|
|
869
|
+
OR (json_extract(e.payload, '$.kind') = 'tool:result'
|
|
870
|
+
AND json_extract(e.payload, '$.isError') = 1)
|
|
871
|
+
THEN 1 ELSE 0
|
|
872
|
+
END),
|
|
873
|
+
MAX(CASE
|
|
874
|
+
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
875
|
+
AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
|
|
876
|
+
THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
|
|
877
|
+
END),
|
|
878
|
+
MAX(CASE
|
|
879
|
+
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
880
|
+
AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
|
|
881
|
+
THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
|
|
882
|
+
END)
|
|
883
|
+
FROM ws_events e
|
|
884
|
+
JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
|
|
885
|
+
WHERE e.workspace_id = ?
|
|
886
|
+
AND e.session_id = ?
|
|
887
|
+
AND e.type = 'agent:event'
|
|
888
|
+
AND json_valid(e.payload)
|
|
889
|
+
GROUP BY e.workspace_id, e.session_id`).run(workspaceId, sessionId);
|
|
890
|
+
}
|
|
891
|
+
/** Same, on the singleton connection. */
|
|
892
|
+
export function recomputeSessionMetrics(workspaceId, sessionId) {
|
|
893
|
+
recomputeSessionMetricsOn(getDb(), workspaceId, sessionId);
|
|
894
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { logError, logWarn } from '../utils/logger.js';
|
|
2
3
|
import * as agentManager from './agent/orchestrator.js';
|
|
3
4
|
import * as devServerService from './dev-server-service.js';
|
|
4
5
|
import { getForgeProvider } from './forge/registry.js';
|
|
@@ -16,19 +17,21 @@ export async function purgeWorktree(workspaceId) {
|
|
|
16
17
|
if (!workspace.worktreeOwned)
|
|
17
18
|
return { outcome: 'worktree-not-owned', warnings: [] };
|
|
18
19
|
const warnings = [];
|
|
20
|
+
// `removeWorktree` runs later in this same function — the agent must be dead
|
|
21
|
+
// by then, not merely asked to stop.
|
|
19
22
|
try {
|
|
20
|
-
agentManager.
|
|
23
|
+
await agentManager.stopAgentAndWait(workspaceId);
|
|
21
24
|
}
|
|
22
25
|
catch (err) {
|
|
23
26
|
const msg = err instanceof Error ? err.message : String(err);
|
|
24
|
-
|
|
27
|
+
logError('purge', `stopAgent failed for '${workspace.name}'`, { workspaceId: workspace.id, error: msg });
|
|
25
28
|
}
|
|
26
29
|
try {
|
|
27
30
|
await devServerService.stopDevServer(workspaceId);
|
|
28
31
|
}
|
|
29
32
|
catch (err) {
|
|
30
33
|
const msg = err instanceof Error ? err.message : String(err);
|
|
31
|
-
|
|
34
|
+
logError('purge', `stopDevServer failed for '${workspace.name}'`, { workspaceId: workspace.id, error: msg });
|
|
32
35
|
}
|
|
33
36
|
try {
|
|
34
37
|
destroyTerminal(workspaceId);
|
|
@@ -49,13 +52,21 @@ export async function purgeWorktree(workspaceId) {
|
|
|
49
52
|
}
|
|
50
53
|
if (fs.existsSync(workspace.worktreePath)) {
|
|
51
54
|
try {
|
|
52
|
-
removeWorktree(workspace.projectPath, workspace.worktreePath);
|
|
55
|
+
await removeWorktree(workspace.projectPath, workspace.worktreePath);
|
|
53
56
|
}
|
|
54
57
|
catch (err) {
|
|
55
58
|
const msg = err instanceof Error ? err.message : String(err);
|
|
56
59
|
warnings.push(buildRemovalFailureMessage(workspace.worktreePath, workspace.projectPath, msg));
|
|
57
60
|
}
|
|
58
61
|
}
|
|
62
|
+
// `worktree_purged_at` is not cosmetic: it drives the "restore" UI and the
|
|
63
|
+
// PR watcher's auto-restore probe, which tests whether the folder reappeared.
|
|
64
|
+
// Setting it on a directory that never left puts both in contradiction with
|
|
65
|
+
// the disk — and told the user the space was reclaimed when it wasn't.
|
|
66
|
+
if (fs.existsSync(workspace.worktreePath)) {
|
|
67
|
+
emitEphemeral(workspaceId, 'workspace:worktree-purge-failed', { workspaceId, warnings });
|
|
68
|
+
return { outcome: 'removal-failed', warnings };
|
|
69
|
+
}
|
|
59
70
|
try {
|
|
60
71
|
markWorktreePurged(workspaceId, restoreData);
|
|
61
72
|
}
|
|
@@ -101,7 +112,10 @@ async function captureRestoreData(workspace) {
|
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
catch (err) {
|
|
104
|
-
|
|
115
|
+
logWarn('purge', 'PR lookup for restore data failed', {
|
|
116
|
+
workspaceId: workspace.id,
|
|
117
|
+
error: err instanceof Error ? err.message : String(err),
|
|
118
|
+
});
|
|
105
119
|
}
|
|
106
120
|
}
|
|
107
121
|
return {
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { isGitBranchExistsError } from '../utils/git-ops.js';
|
|
5
|
+
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
5
6
|
import { resolveWorkspaceWorktreePath, resolveWorktreesRoot } from '../utils/worktree-paths.js';
|
|
6
7
|
function git(repoPath, args) {
|
|
7
8
|
return execFileSync('git', args, {
|
|
@@ -93,6 +94,10 @@ export function createWorktree(projectPath, branchName, baseRef, worktreesPath,
|
|
|
93
94
|
}
|
|
94
95
|
const worktreePath = resolveWorkspaceWorktreePath(projectPath, branchName, worktreesPath, projectSlug);
|
|
95
96
|
const base = baseRef.startsWith('origin/') ? 'origin' : 'local';
|
|
97
|
+
// Tracks which of the two git commands below actually ran. Callers use it to
|
|
98
|
+
// decide whether a rollback may delete the branch: deleting one we merely
|
|
99
|
+
// attached to would destroy commits Kobo never created.
|
|
100
|
+
let branchCreated = true;
|
|
96
101
|
try {
|
|
97
102
|
git(projectPath, ['worktree', 'add', '-b', branchName, worktreePath, baseRef]);
|
|
98
103
|
}
|
|
@@ -101,13 +106,33 @@ export function createWorktree(projectPath, branchName, baseRef, worktreesPath,
|
|
|
101
106
|
// If branch already exists, add worktree without creating the branch
|
|
102
107
|
if (isGitBranchExistsError(message)) {
|
|
103
108
|
git(projectPath, ['worktree', 'add', worktreePath, branchName]);
|
|
109
|
+
branchCreated = false;
|
|
104
110
|
}
|
|
105
111
|
else {
|
|
106
112
|
throw new Error(`Failed to create worktree for branch '${branchName}': ${message}`);
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
addToExclude(projectPath, worktreePath);
|
|
110
|
-
return { worktreePath, base };
|
|
116
|
+
return { worktreePath, base, branchCreated };
|
|
117
|
+
}
|
|
118
|
+
/** `git worktree remove` drops the administrative entry even when it fails to
|
|
119
|
+
* delete the directory, and the prune only ever ran in the Docker fallback.
|
|
120
|
+
* Prune the stale metadata, then refuse to report success while the folder is
|
|
121
|
+
* still there — the purge feature marked workspaces as purged on that false
|
|
122
|
+
* success, telling the user the disk space was reclaimed when it wasn't.
|
|
123
|
+
*
|
|
124
|
+
* Callers hold the repository lock: acquiring it again here would deadlock on
|
|
125
|
+
* the same key, since the chain is strictly sequential per common git dir. */
|
|
126
|
+
function assertWorktreeGone(projectPath, worktreePath) {
|
|
127
|
+
try {
|
|
128
|
+
git(projectPath, ['worktree', 'prune']);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
console.warn(`[worktree] prune after removing '${worktreePath}' failed:`, err instanceof Error ? err.message : err);
|
|
132
|
+
}
|
|
133
|
+
if (fs.existsSync(worktreePath)) {
|
|
134
|
+
throw new Error(`Failed to remove worktree '${worktreePath}': the directory is still on disk after removal`);
|
|
135
|
+
}
|
|
111
136
|
}
|
|
112
137
|
/** Remove a git worktree and clean up the .git/info/exclude entry.
|
|
113
138
|
*
|
|
@@ -116,6 +141,16 @@ export function createWorktree(projectPath, branchName, baseRef, worktreesPath,
|
|
|
116
141
|
* ownership with a throwaway container (`chown -R <uid>:<gid>`) and retry once.
|
|
117
142
|
* Otherwise rethrow so the caller's recovery toast (sudo rm -rf …) fires. */
|
|
118
143
|
export function removeWorktree(projectPath, worktreePath) {
|
|
144
|
+
// `git worktree remove` and `git worktree prune` both rewrite the worktree
|
|
145
|
+
// administrative files of the COMMON git dir — the very state a concurrent
|
|
146
|
+
// source-branch change (fetch / reset / cherry-pick) is manipulating under
|
|
147
|
+
// the same lock. The whole removal sequence is held, Docker chown included:
|
|
148
|
+
// it sits between the `remove` and the `prune` that repairs its metadata, so
|
|
149
|
+
// releasing in the middle would expose a half-removed repository.
|
|
150
|
+
return withGitRepoLock(projectPath, () => removeWorktreeLocked(projectPath, worktreePath));
|
|
151
|
+
}
|
|
152
|
+
/** The actual removal. Always called with the repository lock held. */
|
|
153
|
+
function removeWorktreeLocked(projectPath, worktreePath) {
|
|
119
154
|
try {
|
|
120
155
|
git(projectPath, ['worktree', 'remove', worktreePath, '--force']);
|
|
121
156
|
}
|
|
@@ -136,6 +171,7 @@ export function removeWorktree(projectPath, worktreePath) {
|
|
|
136
171
|
git(projectPath, ['worktree', 'prune']);
|
|
137
172
|
console.log(`[worktree] Docker cleanup succeeded; removed '${worktreePath}'`);
|
|
138
173
|
removeFromExclude(projectPath, worktreePath);
|
|
174
|
+
assertWorktreeGone(projectPath, worktreePath);
|
|
139
175
|
return;
|
|
140
176
|
}
|
|
141
177
|
catch (retryErr) {
|
|
@@ -147,6 +183,7 @@ export function removeWorktree(projectPath, worktreePath) {
|
|
|
147
183
|
throw new Error(`Failed to remove worktree '${worktreePath}': ${message}`);
|
|
148
184
|
}
|
|
149
185
|
removeFromExclude(projectPath, worktreePath);
|
|
186
|
+
assertWorktreeGone(projectPath, worktreePath);
|
|
150
187
|
}
|
|
151
188
|
/** List all git worktrees for a repository by parsing `git worktree list --porcelain`. */
|
|
152
189
|
export function listWorktrees(projectPath) {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { recomputeSessionMetricsOn } from './workspace-service.js';
|
|
2
|
+
/** Rows deleted per transaction. Small enough that the SQLite write lock is
|
|
3
|
+
* never held for more than a few milliseconds at a time — the whole point of
|
|
4
|
+
* doing this AFTER the quadratic AFTER DELETE trigger was removed. */
|
|
5
|
+
const BATCH_SIZE = 5_000;
|
|
6
|
+
/** Free-page ratio above which a full VACUUM is worth its cost. Production was
|
|
7
|
+
* measured at 620 free pages out of 656 (95 %) for zero remaining rows. */
|
|
8
|
+
const VACUUM_FREE_PAGE_RATIO = 0.25;
|
|
9
|
+
/** Retention is OPT-IN: absent or invalid settings mean "delete nothing". */
|
|
10
|
+
const DISABLED = 0;
|
|
11
|
+
/** Anything that is not a non-negative integer falls back to "disabled": a bad
|
|
12
|
+
* value must never be read as a licence to delete. */
|
|
13
|
+
function normalize(value) {
|
|
14
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : DISABLED;
|
|
15
|
+
}
|
|
16
|
+
/** Read the retention window from the global settings. */
|
|
17
|
+
export function resolveRetentionConfig(global) {
|
|
18
|
+
return {
|
|
19
|
+
retentionDays: normalize(global.wsEventsRetentionDays),
|
|
20
|
+
keepPerWorkspace: normalize(global.wsEventsKeepPerWorkspace),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Shared candidate selection: rows older than the cutoff that fall outside the
|
|
24
|
+
* per-workspace tail. Used both to count (preview) and to delete. */
|
|
25
|
+
const CANDIDATE_CTE = `
|
|
26
|
+
WITH ranked AS (
|
|
27
|
+
SELECT rowid AS rid,
|
|
28
|
+
workspace_id,
|
|
29
|
+
session_id,
|
|
30
|
+
created_at,
|
|
31
|
+
ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY rowid DESC) AS rn
|
|
32
|
+
FROM ws_events
|
|
33
|
+
)`;
|
|
34
|
+
function cutoffFor(config, nowMs) {
|
|
35
|
+
return new Date(nowMs - config.retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* How many events `config` would delete right now. Deletes nothing — this backs
|
|
39
|
+
* the confirmation dialog, so the user is told the real volume before agreeing
|
|
40
|
+
* to destroy it.
|
|
41
|
+
*/
|
|
42
|
+
export function countPrunableWsEvents(db, config, nowMs = Date.now()) {
|
|
43
|
+
if (config.retentionDays <= 0)
|
|
44
|
+
return 0;
|
|
45
|
+
const row = db
|
|
46
|
+
.prepare(`${CANDIDATE_CTE}
|
|
47
|
+
SELECT COUNT(*) AS c FROM ranked WHERE created_at < ? AND rn > ?`)
|
|
48
|
+
.get(cutoffFor(config, nowMs), config.keepPerWorkspace);
|
|
49
|
+
return row.c;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Delete `ws_events` rows older than the retention window, batch by batch, then
|
|
53
|
+
* recompute the metrics of the sessions touched and reclaim the freed pages.
|
|
54
|
+
*
|
|
55
|
+
* Deletes agent output ONLY: workspaces, tasks, sessions, branches and worktrees
|
|
56
|
+
* are never touched.
|
|
57
|
+
*
|
|
58
|
+
* Order matters: this must never run while the removed `AFTER DELETE` trigger
|
|
59
|
+
* is present — it re-aggregated the whole session per deleted row and held the
|
|
60
|
+
* write lock long enough to make every concurrent WebSocket emit time out and
|
|
61
|
+
* lose its event.
|
|
62
|
+
*/
|
|
63
|
+
export function pruneWsEvents(db, config, nowMs = Date.now()) {
|
|
64
|
+
const freePagesBefore = db.pragma('freelist_count', { simple: true }) ?? 0;
|
|
65
|
+
const result = {
|
|
66
|
+
deleted: 0,
|
|
67
|
+
sessionsRecomputed: 0,
|
|
68
|
+
vacuumed: false,
|
|
69
|
+
freePagesBefore,
|
|
70
|
+
freePagesAfter: freePagesBefore,
|
|
71
|
+
};
|
|
72
|
+
if (config.retentionDays <= 0)
|
|
73
|
+
return result;
|
|
74
|
+
const cutoff = cutoffFor(config, nowMs);
|
|
75
|
+
const selectBatch = db.prepare(`${CANDIDATE_CTE}
|
|
76
|
+
SELECT rid, workspace_id, session_id
|
|
77
|
+
FROM ranked
|
|
78
|
+
WHERE created_at < ? AND rn > ?
|
|
79
|
+
ORDER BY rid ASC
|
|
80
|
+
LIMIT ?`);
|
|
81
|
+
const deleteOne = db.prepare('DELETE FROM ws_events WHERE rowid = ?');
|
|
82
|
+
const touched = new Set();
|
|
83
|
+
for (;;) {
|
|
84
|
+
const batch = selectBatch.all(cutoff, config.keepPerWorkspace, BATCH_SIZE);
|
|
85
|
+
if (batch.length === 0)
|
|
86
|
+
break;
|
|
87
|
+
db.transaction(() => {
|
|
88
|
+
for (const row of batch) {
|
|
89
|
+
deleteOne.run(row.rid);
|
|
90
|
+
if (row.session_id)
|
|
91
|
+
touched.add(`${row.workspace_id}::${row.session_id}`);
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
result.deleted += batch.length;
|
|
95
|
+
if (batch.length < BATCH_SIZE)
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
// Once per session, at the very end — never per deleted row.
|
|
99
|
+
for (const key of touched) {
|
|
100
|
+
const separator = key.indexOf('::');
|
|
101
|
+
const workspaceId = key.slice(0, separator);
|
|
102
|
+
const sessionId = key.slice(separator + 2);
|
|
103
|
+
try {
|
|
104
|
+
recomputeSessionMetricsOn(db, workspaceId, sessionId);
|
|
105
|
+
result.sessionsRecomputed += 1;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
console.error(`[retention] metric recompute failed (workspace=${workspaceId}, session=${sessionId}):`, err);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (result.deleted === 0)
|
|
112
|
+
return result;
|
|
113
|
+
// Truncate the WAL, then compact only when it is actually worth it.
|
|
114
|
+
try {
|
|
115
|
+
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
console.error('[retention] wal_checkpoint failed:', err);
|
|
119
|
+
}
|
|
120
|
+
const freelist = db.pragma('freelist_count', { simple: true }) ?? 0;
|
|
121
|
+
const pageCount = db.pragma('page_count', { simple: true }) ?? 0;
|
|
122
|
+
if (pageCount > 0 && freelist / pageCount >= VACUUM_FREE_PAGE_RATIO) {
|
|
123
|
+
try {
|
|
124
|
+
db.exec('VACUUM');
|
|
125
|
+
result.vacuumed = true;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
console.error('[retention] VACUUM failed:', err);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
result.freePagesAfter = db.pragma('freelist_count', { simple: true }) ?? 0;
|
|
132
|
+
return result;
|
|
133
|
+
}
|