@loicngr/kobo 2.1.2 → 2.1.5
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/CHANGELOG.md +12 -0
- package/CONFIGURATION.md +11 -3
- package/dist/server/index.js +24 -91
- package/dist/server/routes/git.js +6 -7
- package/dist/server/routes/settings.js +21 -11
- package/dist/server/routes/workspaces.js +82 -41
- package/dist/server/services/agent/engines/claude-code/engine.js +87 -20
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +88 -22
- package/dist/server/services/agent/engines/claude-code/options-builder.js +1 -0
- package/dist/server/services/agent/engines/codex/engine.js +27 -5
- package/dist/server/services/agent/engines/codex/options-builder.js +1 -0
- package/dist/server/services/agent/integration-mcp.js +4 -3
- package/dist/server/services/agent/orchestrator.js +36 -1
- package/dist/server/services/agent/session-controller.js +12 -0
- package/dist/server/services/engine-handoff-service.js +120 -24
- package/dist/server/services/pr-checkout-service.js +90 -53
- package/dist/server/services/pr-watcher-service.js +13 -7
- package/dist/server/services/retention/worker.js +40 -0
- package/dist/server/services/review-service.js +8 -4
- package/dist/server/services/session-event-metrics.js +43 -0
- package/dist/server/services/session-handoff-service.js +6 -4
- package/dist/server/services/templates-service.js +67 -49
- package/dist/server/services/terminal-service.js +45 -14
- package/dist/server/services/terminal-websocket-service.js +143 -0
- package/dist/server/services/wakeup-service.js +23 -21
- package/dist/server/services/workspace-service.js +3 -44
- package/dist/server/services/workspace-template-service.js +16 -18
- package/dist/server/services/worktree-purge-service.js +1 -4
- package/dist/server/services/ws-events-retention-service.js +197 -104
- package/dist/server/services/ws-events-retention-worker-service.js +112 -0
- package/dist/server/utils/atomic-json-file.js +23 -0
- package/dist/server/utils/git-ops.js +262 -60
- package/dist/shared/codex-models.js +20 -3
- package/dist/shared/models.js +12 -0
- package/package.json +8 -8
- package/src/client/dist/pwa/assets/{ActivityFeed-DCmiJP-z.css → ActivityFeed-B4lTKMVE.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-DXWXioW9.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-BL5Jh8-v.js +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-DVyx9ZjV.css +1 -0
- package/src/client/dist/pwa/assets/{ClosePopup-BikuzYle.js → ClosePopup-C1a82pL9.js} +1 -1
- package/src/client/dist/pwa/assets/ComparisonPanel-Bv2SZrVZ.js +2 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-ByVbqYCs.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-CMyl2j8Z.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-d43ITOC1.js +2 -0
- package/src/client/dist/pwa/assets/DashboardPage-BEXXRguE.js +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-CtaHaVdf.css +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-B92w_jd7.js +8 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-C9JXyqHg.js +1 -0
- package/src/client/dist/pwa/assets/{DrawerToggleButton-Dfvkaro0.js → DrawerToggleButton-Btx8QwHo.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-C4wOcZV2.css → GitPanel-C3blk_Pd.css} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-Cgz9crLN.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-CDhgJQkL.js +1 -0
- package/src/client/dist/pwa/assets/HealthPage-DT8FGrKA.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-DTTtj9sq.js +2 -0
- package/src/client/dist/pwa/assets/{MainLayout-gHENJEFQ.css → MainLayout-knNNtkre.css} +1 -1
- package/src/client/dist/pwa/assets/QBadge-rGILsUYl.js +1 -0
- package/src/client/dist/pwa/assets/QBanner-7EWRdc4U.js +1 -0
- package/src/client/dist/pwa/assets/QBtn-b_baQtAt.js +1 -0
- package/src/client/dist/pwa/assets/QBtnDropdown-vjat4GZl.js +1 -0
- package/src/client/dist/pwa/assets/QBtnGroup-CUmAgBV8.js +1 -0
- package/src/client/dist/pwa/assets/QBtnToggle-COL46j6W.js +1 -0
- package/src/client/dist/pwa/assets/QChip-CG1kWhFm.js +1 -0
- package/src/client/dist/pwa/assets/QExpansionItem-BQuy89vv.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-BrVklYfM.js +1 -0
- package/src/client/dist/pwa/assets/QList-BVX-b8nc.js +1 -0
- package/src/client/dist/pwa/assets/QMarkupTable-B8_FKLvo.js +1 -0
- package/src/client/dist/pwa/assets/QPage-BCDXjfVj.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-DBW_TZri.js +1 -0
- package/src/client/dist/pwa/assets/QSpinnerDots-BsKmccpX.js +1 -0
- package/src/client/dist/pwa/assets/QTooltip-DzYbguTR.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-BX6iKh6S.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-CBM_GpS3.css +1 -0
- package/src/client/dist/pwa/assets/SearchPage-_In2gH2P.js +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-BqkNYzC5.js +9 -0
- package/src/client/dist/pwa/assets/{SettingsPage-BYdLXJEJ.css → SettingsPage-CpykRKq2.css} +1 -1
- package/src/client/dist/pwa/assets/SplitWorkspacePage-DRoXbSRP.js +1 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-B9LDe7uG.js → TerminalPanel-CfqiuSuk.js} +2 -2
- package/src/client/dist/pwa/assets/{TerminalPanel-DbVZk0vD.css → TerminalPanel-Dz9EE-Ph.css} +1 -1
- package/src/client/dist/pwa/assets/TouchPan-DcOCRf7x.js +1 -0
- package/src/client/dist/pwa/assets/{TourReplayButton-Dtv_UDaY.js → TourReplayButton-gp7m6tSV.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DSw8Upcp.css +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-IcoMGkJh.js +1 -0
- package/src/client/dist/pwa/assets/{WorkspacePage--5HsnMQ1.css → WorkspacePage-BnW-gp3u.css} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-CR2xAzW_.js +4 -0
- package/src/client/dist/pwa/assets/{browser-B15GGwtW.js → browser-BveHkbe-.js} +1 -1
- package/src/client/dist/pwa/assets/build-path-tree-Dtp7rlxS.js +1 -0
- package/src/client/dist/pwa/assets/custom-sounds-CBoWzJ85.js +1 -0
- package/src/client/dist/pwa/assets/de-BJsWkZro.js +17 -0
- package/src/client/dist/pwa/assets/defineProperty-BbfpZ9Tg.js +1 -0
- package/src/client/dist/pwa/assets/documents-sb2sQ4JC.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api-DhaLhNl-.js → editor.api-CbkPxoJf.js} +1 -1
- package/src/client/dist/pwa/assets/efforts-BCli1Unj.js +9 -0
- package/src/client/dist/pwa/assets/es-tNYw7itB.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-CfaEhb2f.js +1 -0
- package/src/client/dist/pwa/assets/{format-2JisC3s1.js → format-BRl2DUlm.js} +7 -7
- package/src/client/dist/pwa/assets/formatters-C5TjPSR_.js +1 -0
- package/src/client/dist/pwa/assets/fr-B5sFski4.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-PYrDqU04.js → freemarker2-CDmvOQyn.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-BuiY0fFf.js → handlebars-Bx_w8mx1.js} +1 -1
- package/src/client/dist/pwa/assets/{html-Du3cjdug.js → html-DVRfiRkS.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-Bm-L4nW3.js +1 -0
- package/src/client/dist/pwa/assets/i18n-D21JKsVt.js +19 -0
- package/src/client/dist/pwa/assets/index-C7vFf6p2.js +2 -0
- package/src/client/dist/pwa/assets/index-C8zAZ-k-.css +1 -0
- package/src/client/dist/pwa/assets/it-Bc2FncM9.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-Bb48hPli.js → javascript-CPvttU5J.js} +1 -1
- package/src/client/dist/pwa/assets/latest-request-CN4-B22z.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-Be77iQe3.js → liquid-CI0gQddb.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-CfohNJja.js → mdx-BveBwYJH.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-CyM4wsIg.js → monaco-contributions-CHXFh4P2.js} +1 -1
- package/src/client/dist/pwa/assets/monaco.contribution-DOK8t_9B.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-CItnNZDh.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-DvvaSyg1.js → notify-theme-BO6r1Rd7.js} +1 -1
- package/src/client/dist/pwa/assets/pending-diff-open-DpS4iyDM.js +1 -0
- package/src/client/dist/pwa/assets/{permissionModes-B-ciuUbz.js → permissionModes-Cc5VaOdC.js} +1 -1
- package/src/client/dist/pwa/assets/preload-helper-BZ1Pz5am.js +1 -0
- package/src/client/dist/pwa/assets/{private.use-form-Bk8ti264.js → private.use-form-vxvbh_VN.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-Be0t12-n.js → project-color-Bnv02P9D.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BNemmtx8.js → python-BesLCxpi.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-3NM8iFVf.js → razor-Dv1PMxuY.js} +1 -1
- package/src/client/dist/pwa/assets/{reactivity.esm-bundler-Bo9anJjg.js → reactivity.esm-bundler-tuEvQ47f.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-BWJNIDv3.js → render-chat-markdown-CafzCcmu.js} +1 -1
- package/src/client/dist/pwa/assets/runtime-core.esm-bundler-DOiZcasU.js +1 -0
- package/src/client/dist/pwa/assets/{typescript-DwyZ4CF7.js → typescript-26L2Xsu9.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-BLeif5-t.js +1 -0
- package/src/client/dist/pwa/assets/use-id-b4A2I8LB.js +1 -0
- package/src/client/dist/pwa/assets/use-is-mobile-DLIZxEo9.js +1 -0
- package/src/client/dist/pwa/assets/use-search-index-status-Mv3pIO6p.js +1 -0
- package/src/client/dist/pwa/assets/{use-slide-transition-CiaDm-tR.js → use-slide-transition-C5eDGOmu.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-CjASQcG5.js → use-tours-B5uIqxRS.js} +2 -2
- package/src/client/dist/pwa/assets/use-worktree-restore-BBChALUM.js +1 -0
- package/src/client/dist/pwa/assets/workspace-templates-CDY-MiH2.js +1 -0
- package/src/client/dist/pwa/assets/{xml-B1UujXdS.js → xml-CkRojTcc.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-mHN8kNgU.js → yaml-DKkQHwew.js} +1 -1
- package/src/client/dist/pwa/index.html +12 -9
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-CO5rOaa5.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-DKJkFiU_.css +0 -1
- package/src/client/dist/pwa/assets/ChangelogPage-DNRcPRtW.js +0 -1
- package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +0 -1
- package/src/client/dist/pwa/assets/ComparisonPanel-DZoDqgJI.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-wNH35uTG.js +0 -2
- package/src/client/dist/pwa/assets/DashboardPage-DUyy4SzS.js +0 -1
- package/src/client/dist/pwa/assets/DashboardPage-XiCj24i2.css +0 -1
- package/src/client/dist/pwa/assets/DiffViewer-CZ5QwXqi.js +0 -8
- package/src/client/dist/pwa/assets/DocumentsPanel-sBatgKA-.js +0 -1
- package/src/client/dist/pwa/assets/GitPanel-B8e1olfm.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-BwXYpczh.js +0 -1
- package/src/client/dist/pwa/assets/HealthPage-CFZ-tdKK.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-DFXIS_wv.js +0 -2
- package/src/client/dist/pwa/assets/QBadge-CQF1smL-.js +0 -1
- package/src/client/dist/pwa/assets/QBanner-bdDA-A5g.js +0 -1
- package/src/client/dist/pwa/assets/QBtn-CcQQrYA_.js +0 -1
- package/src/client/dist/pwa/assets/QBtnDropdown-C-1EhO9J.js +0 -1
- package/src/client/dist/pwa/assets/QBtnGroup-wd5lKRXu.js +0 -1
- package/src/client/dist/pwa/assets/QBtnToggle-BnC8rTql.js +0 -1
- package/src/client/dist/pwa/assets/QChip-D75nKi-L.js +0 -1
- package/src/client/dist/pwa/assets/QExpansionItem-RpMPvfxX.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-Bt9H35JJ.js +0 -1
- package/src/client/dist/pwa/assets/QList-D5msNbSZ.js +0 -1
- package/src/client/dist/pwa/assets/QMarkupTable-D3REW6RC.js +0 -1
- package/src/client/dist/pwa/assets/QPage-HAaeQ-cv.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-Dpru_T81.js +0 -1
- package/src/client/dist/pwa/assets/QSpinnerDots-DDsPDUH9.js +0 -1
- package/src/client/dist/pwa/assets/QTooltip-C31vXtse.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-D6LTiRFg.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-Dj487BdP.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-CJmgFiAa.js +0 -9
- package/src/client/dist/pwa/assets/SplitWorkspacePage-BGSDgGIO.js +0 -1
- package/src/client/dist/pwa/assets/TouchPan-DJqqxDBF.js +0 -1
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +0 -1
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-VlJCIkwU.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-B_SlX8YB.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-CU6uXunR.js +0 -1
- package/src/client/dist/pwa/assets/custom-sounds-CDJsgChY.js +0 -1
- package/src/client/dist/pwa/assets/de-B_Y4EArT.js +0 -17
- package/src/client/dist/pwa/assets/documents-Cf6nmu8Z.js +0 -1
- package/src/client/dist/pwa/assets/efforts-DoYZi0AZ.js +0 -9
- package/src/client/dist/pwa/assets/engineFeatures-C9XiCfvv.js +0 -1
- package/src/client/dist/pwa/assets/es-DtsTTShP.js +0 -17
- package/src/client/dist/pwa/assets/expand-template-LDLM-6iN.js +0 -1
- package/src/client/dist/pwa/assets/formatters-ZVNs2VOl.js +0 -1
- package/src/client/dist/pwa/assets/fr-C82MIVrA.js +0 -17
- package/src/client/dist/pwa/assets/i18n-BumeGGx_.js +0 -1
- package/src/client/dist/pwa/assets/index-DolbyIM4.js +0 -20
- package/src/client/dist/pwa/assets/index-VMFLfvp2.css +0 -1
- package/src/client/dist/pwa/assets/it-BJNuTcj2.js +0 -17
- package/src/client/dist/pwa/assets/monaco.contribution-BcIfN6Mi.js +0 -2
- package/src/client/dist/pwa/assets/network-auth-B1xAaHmx.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-B6N0vI5n.js +0 -1
- package/src/client/dist/pwa/assets/pinia-CTJ3OfP3.js +0 -1
- package/src/client/dist/pwa/assets/runtime-core.esm-bundler-CNFKm4ij.js +0 -1
- package/src/client/dist/pwa/assets/use-checkbox-2xzX4yYj.js +0 -1
- package/src/client/dist/pwa/assets/use-id-vOr8PRTG.js +0 -1
- package/src/client/dist/pwa/assets/use-is-mobile-CJfSpTHx.js +0 -1
- package/src/client/dist/pwa/assets/use-search-index-status-Bluf9L99.js +0 -1
- package/src/client/dist/pwa/assets/workspace-templates-BxYxJt3S.js +0 -1
- /package/src/client/dist/pwa/assets/{engineFeatures-B-9jHocY.css → use-worktree-restore-B-9jHocY.css} +0 -0
|
@@ -1,133 +1,226 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
|
|
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. */
|
|
1
|
+
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
2
|
+
/** Keep each write transaction short enough for live event writers. */
|
|
3
|
+
const BATCH_SIZE = 500;
|
|
8
4
|
const VACUUM_FREE_PAGE_RATIO = 0.25;
|
|
9
|
-
/** Retention is OPT-IN: absent or invalid settings mean "delete nothing". */
|
|
10
5
|
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
6
|
function normalize(value) {
|
|
14
|
-
return typeof value === 'number' && Number.
|
|
7
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : DISABLED;
|
|
15
8
|
}
|
|
16
|
-
/** Read the retention window from the global settings. */
|
|
17
9
|
export function resolveRetentionConfig(global) {
|
|
18
10
|
return {
|
|
19
11
|
retentionDays: normalize(global.wsEventsRetentionDays),
|
|
20
12
|
keepPerWorkspace: normalize(global.wsEventsKeepPerWorkspace),
|
|
21
13
|
};
|
|
22
14
|
}
|
|
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
15
|
function cutoffFor(config, nowMs) {
|
|
35
16
|
return new Date(nowMs - config.retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
36
17
|
}
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
18
|
+
/** Compute each protected tail once using the workspace/rowid index. Rows
|
|
19
|
+
* appended beyond the initial rowid are outside this pass. The age cutoff also
|
|
20
|
+
* protects newly emitted events while other workspaces continue writing. */
|
|
21
|
+
function workspaceBoundaries(db, keep) {
|
|
22
|
+
return db.transaction(() => {
|
|
23
|
+
const last = db.prepare('SELECT rowid AS rid FROM ws_events ORDER BY rowid DESC LIMIT 1').get();
|
|
24
|
+
if (!last)
|
|
25
|
+
return [];
|
|
26
|
+
const workspaces = db.prepare('SELECT id FROM workspaces ORDER BY id').all();
|
|
27
|
+
const tail = db.prepare('SELECT rowid AS rid FROM ws_events WHERE workspace_id = ? AND rowid <= ? ORDER BY rowid DESC LIMIT 1 OFFSET ?');
|
|
28
|
+
return workspaces.map(({ id }) => {
|
|
29
|
+
const boundary = keep > 0 ? tail.get(id, last.rid, keep - 1) : undefined;
|
|
30
|
+
return {
|
|
31
|
+
workspaceId: id,
|
|
32
|
+
beforeRowid: keep > 0 ? (boundary?.rid ?? 0) : last.rid + 1,
|
|
33
|
+
afterRowid: 0,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
})();
|
|
37
|
+
}
|
|
38
|
+
/** Read-only preview, using the same protected boundaries as deletion. */
|
|
42
39
|
export function countPrunableWsEvents(db, config, nowMs = Date.now()) {
|
|
43
40
|
if (config.retentionDays <= 0)
|
|
44
41
|
return 0;
|
|
45
|
-
const
|
|
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;
|
|
42
|
+
const count = db.prepare('SELECT COUNT(*) AS count FROM ws_events WHERE workspace_id = ? AND rowid < ? AND created_at < ?');
|
|
74
43
|
const cutoff = cutoffFor(config, nowMs);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
44
|
+
return workspaceBoundaries(db, config.keepPerWorkspace).reduce((total, boundary) => total + count.get(boundary.workspaceId, boundary.beforeRowid, cutoff).count, 0);
|
|
45
|
+
}
|
|
46
|
+
/** Aggregate outside the write transaction; concurrent WAL writers may keep emitting. */
|
|
47
|
+
function readSessionMetrics(db, workspaceId, sessionId) {
|
|
48
|
+
const signature = db
|
|
49
|
+
.prepare(`SELECT COUNT(*) AS rows, COALESCE(MAX(rowid), 0) AS maxRowid
|
|
50
|
+
FROM ws_events WHERE workspace_id = ? AND session_id = ?`)
|
|
51
|
+
.get(workspaceId, sessionId);
|
|
52
|
+
return { ...signature, ...readMetricsAfter(db, workspaceId, sessionId, 0) };
|
|
53
|
+
}
|
|
54
|
+
function readMetricsAfter(db, workspaceId, sessionId, afterRowid) {
|
|
55
|
+
return db
|
|
56
|
+
.prepare(`SELECT
|
|
57
|
+
COUNT(*) AS events,
|
|
58
|
+
COALESCE(SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END), 0) AS toolCalls,
|
|
59
|
+
COALESCE(SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'error'
|
|
60
|
+
OR (json_extract(e.payload, '$.kind') = 'tool:result'
|
|
61
|
+
AND json_extract(e.payload, '$.isError') = 1) THEN 1 ELSE 0 END), 0) AS errors,
|
|
62
|
+
COALESCE(MAX(CASE WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
63
|
+
AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
|
|
64
|
+
THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0 END), 0) AS inputTokens,
|
|
65
|
+
COALESCE(MAX(CASE WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
66
|
+
AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
|
|
67
|
+
THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0 END), 0) AS outputTokens
|
|
68
|
+
FROM ws_events e
|
|
69
|
+
JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
|
|
70
|
+
WHERE e.workspace_id = ? AND e.session_id = ? AND e.rowid > ?
|
|
71
|
+
AND e.type = 'agent:event' AND json_valid(e.payload)`)
|
|
72
|
+
.get(workspaceId, sessionId, afterRowid);
|
|
73
|
+
}
|
|
74
|
+
class RetentionPass {
|
|
75
|
+
db;
|
|
76
|
+
result;
|
|
77
|
+
boundaries;
|
|
78
|
+
cutoff;
|
|
79
|
+
selectBatch;
|
|
80
|
+
deleteOne;
|
|
81
|
+
touched = new Map();
|
|
82
|
+
workspaceIndex = 0;
|
|
83
|
+
constructor(db, config, nowMs) {
|
|
84
|
+
this.db = db;
|
|
85
|
+
const freePagesBefore = db.pragma('freelist_count', { simple: true });
|
|
86
|
+
this.result = {
|
|
87
|
+
deleted: 0,
|
|
88
|
+
sessionsRecomputed: 0,
|
|
89
|
+
vacuumed: false,
|
|
90
|
+
freePagesBefore,
|
|
91
|
+
freePagesAfter: freePagesBefore,
|
|
92
|
+
};
|
|
93
|
+
this.boundaries = config.retentionDays > 0 ? workspaceBoundaries(db, config.keepPerWorkspace) : [];
|
|
94
|
+
this.cutoff = config.retentionDays > 0 ? cutoffFor(config, nowMs) : '';
|
|
95
|
+
this.selectBatch = db.prepare(`SELECT id, rowid AS rid, session_id FROM ws_events
|
|
96
|
+
WHERE workspace_id = ? AND rowid > ? AND rowid < ? AND created_at < ?
|
|
97
|
+
ORDER BY rowid ASC LIMIT ?`);
|
|
98
|
+
this.deleteOne = db.prepare('DELETE FROM ws_events WHERE rowid = ? AND id = ?');
|
|
99
|
+
}
|
|
100
|
+
/** One indexed read and at most BATCH_SIZE deletes; no whole-table reranking. */
|
|
101
|
+
step() {
|
|
102
|
+
const boundary = this.boundaries[this.workspaceIndex];
|
|
103
|
+
if (!boundary)
|
|
104
|
+
return false;
|
|
105
|
+
const batch = this.selectBatch.all(boundary.workspaceId, boundary.afterRowid, boundary.beforeRowid, this.cutoff, BATCH_SIZE);
|
|
106
|
+
const touched = new Set();
|
|
107
|
+
const deleted = this.db
|
|
108
|
+
.transaction(() => {
|
|
109
|
+
let count = 0;
|
|
88
110
|
for (const row of batch) {
|
|
89
|
-
deleteOne.run(row.rid);
|
|
90
|
-
|
|
91
|
-
|
|
111
|
+
const change = this.deleteOne.run(row.rid, row.id).changes;
|
|
112
|
+
count += change;
|
|
113
|
+
if (change && row.session_id)
|
|
114
|
+
touched.add(row.session_id);
|
|
92
115
|
}
|
|
93
|
-
|
|
94
|
-
|
|
116
|
+
return count;
|
|
117
|
+
})
|
|
118
|
+
.immediate();
|
|
119
|
+
this.result.deleted += deleted;
|
|
120
|
+
if (touched.size) {
|
|
121
|
+
const sessions = this.touched.get(boundary.workspaceId) ?? new Set();
|
|
122
|
+
for (const sessionId of touched)
|
|
123
|
+
sessions.add(sessionId);
|
|
124
|
+
this.touched.set(boundary.workspaceId, sessions);
|
|
125
|
+
}
|
|
126
|
+
if (batch.length)
|
|
127
|
+
boundary.afterRowid = batch[batch.length - 1].rid;
|
|
95
128
|
if (batch.length < BATCH_SIZE)
|
|
96
|
-
|
|
129
|
+
this.workspaceIndex++;
|
|
130
|
+
return true;
|
|
97
131
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
result.sessionsRecomputed += 1;
|
|
106
|
-
}
|
|
107
|
-
catch (err) {
|
|
108
|
-
console.error(`[retention] metric recompute failed (workspace=${workspaceId}, session=${sessionId}):`, err);
|
|
132
|
+
async finishAsync() {
|
|
133
|
+
for (const [workspaceId, sessions] of this.touched) {
|
|
134
|
+
for (const sessionId of sessions) {
|
|
135
|
+
// Transactional recompute cannot overwrite metrics from a concurrent emit.
|
|
136
|
+
await this.recomputeAsync(workspaceId, sessionId);
|
|
137
|
+
await yieldToEventLoop();
|
|
138
|
+
}
|
|
109
139
|
}
|
|
140
|
+
return this.finish(false);
|
|
110
141
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
finishSync(compact) {
|
|
143
|
+
for (const [workspaceId, sessions] of this.touched) {
|
|
144
|
+
for (const sessionId of sessions)
|
|
145
|
+
this.recomputeSync(workspaceId, sessionId);
|
|
146
|
+
}
|
|
147
|
+
return this.finish(compact);
|
|
116
148
|
}
|
|
117
|
-
|
|
118
|
-
|
|
149
|
+
async recomputeAsync(workspaceId, sessionId) {
|
|
150
|
+
while (!this.recomputeOnce(workspaceId, sessionId))
|
|
151
|
+
await yieldToEventLoop();
|
|
152
|
+
this.result.sessionsRecomputed++;
|
|
119
153
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
db.exec('VACUUM');
|
|
125
|
-
result.vacuumed = true;
|
|
154
|
+
recomputeSync(workspaceId, sessionId) {
|
|
155
|
+
while (!this.recomputeOnce(workspaceId, sessionId)) {
|
|
156
|
+
// The synchronous maintenance API has no interleaved local writer;
|
|
157
|
+
// a second connection may still change this session between phases.
|
|
126
158
|
}
|
|
127
|
-
|
|
128
|
-
|
|
159
|
+
this.result.sessionsRecomputed++;
|
|
160
|
+
}
|
|
161
|
+
recomputeOnce(workspaceId, sessionId) {
|
|
162
|
+
const snapshot = this.db.transaction(() => readSessionMetrics(this.db, workspaceId, sessionId))();
|
|
163
|
+
return this.db
|
|
164
|
+
.transaction(() => {
|
|
165
|
+
const unchanged = this.db
|
|
166
|
+
.prepare(`SELECT COUNT(*) AS rows FROM ws_events
|
|
167
|
+
WHERE workspace_id = ? AND session_id = ? AND rowid <= ?`)
|
|
168
|
+
.get(workspaceId, sessionId, snapshot.maxRowid);
|
|
169
|
+
if (unchanged.rows !== snapshot.rows)
|
|
170
|
+
return false;
|
|
171
|
+
// Rows appended after the read snapshot are small and are aggregated
|
|
172
|
+
// under the lock so the insert trigger cannot race with our replacement.
|
|
173
|
+
const appended = readMetricsAfter(this.db, workspaceId, sessionId, snapshot.maxRowid);
|
|
174
|
+
this.db
|
|
175
|
+
.prepare('DELETE FROM session_event_metrics WHERE workspace_id = ? AND session_id = ?')
|
|
176
|
+
.run(workspaceId, sessionId);
|
|
177
|
+
if (snapshot.events + appended.events === 0)
|
|
178
|
+
return true;
|
|
179
|
+
this.db
|
|
180
|
+
.prepare(`INSERT INTO session_event_metrics (
|
|
181
|
+
workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
|
|
182
|
+
) SELECT ?, ?, ?, ?, ?, ? WHERE EXISTS (
|
|
183
|
+
SELECT 1 FROM agent_sessions WHERE id = ? AND workspace_id = ?
|
|
184
|
+
)`)
|
|
185
|
+
.run(workspaceId, sessionId, snapshot.toolCalls + appended.toolCalls, snapshot.errors + appended.errors, Math.max(snapshot.inputTokens, appended.inputTokens), Math.max(snapshot.outputTokens, appended.outputTokens), sessionId, workspaceId);
|
|
186
|
+
return true;
|
|
187
|
+
})
|
|
188
|
+
.immediate();
|
|
189
|
+
}
|
|
190
|
+
finish(compact) {
|
|
191
|
+
if (!this.result.deleted)
|
|
192
|
+
return this.result;
|
|
193
|
+
// PASSIVE never waits for live readers. Reusable pages remain available for
|
|
194
|
+
// new events. Full VACUUM is reserved for explicit offline maintenance.
|
|
195
|
+
this.db.pragma(`wal_checkpoint(${compact ? 'TRUNCATE' : 'PASSIVE'})`);
|
|
196
|
+
const freelist = this.db.pragma('freelist_count', { simple: true });
|
|
197
|
+
const pageCount = this.db.pragma('page_count', { simple: true });
|
|
198
|
+
if (compact && pageCount > 0 && freelist / pageCount >= VACUUM_FREE_PAGE_RATIO) {
|
|
199
|
+
this.db.exec('VACUUM');
|
|
200
|
+
this.result.vacuumed = true;
|
|
129
201
|
}
|
|
202
|
+
this.result.freePagesAfter = this.db.pragma('freelist_count', { simple: true });
|
|
203
|
+
return this.result;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Synchronous helper for offline maintenance and fixtures. Never call from
|
|
207
|
+
* HTTP handlers or the server scheduler; use the dedicated worker service. */
|
|
208
|
+
export function pruneWsEvents(db, config, nowMs = Date.now(), options = {}) {
|
|
209
|
+
const pass = new RetentionPass(db, config, nowMs);
|
|
210
|
+
while (pass.step()) { }
|
|
211
|
+
return pass.finishSync(options.compact ?? true);
|
|
212
|
+
}
|
|
213
|
+
/** Worker-only maintenance: yield between bounded transactions so stop requests
|
|
214
|
+
* are handled promptly. Always repair touched session metrics before closing. */
|
|
215
|
+
export async function pruneWsEventsInBatches(db, config, nowMs = Date.now(), shouldStop = () => false) {
|
|
216
|
+
const pass = new RetentionPass(db, config, nowMs);
|
|
217
|
+
try {
|
|
218
|
+
while (!shouldStop() && pass.step())
|
|
219
|
+
await yieldToEventLoop();
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
await pass.finishAsync();
|
|
223
|
+
throw error;
|
|
130
224
|
}
|
|
131
|
-
|
|
132
|
-
return result;
|
|
225
|
+
return pass.finishAsync();
|
|
133
226
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
let active;
|
|
4
|
+
let requestId = 0;
|
|
5
|
+
function start() {
|
|
6
|
+
const dbPath = getDb().name;
|
|
7
|
+
if (active) {
|
|
8
|
+
if (active.stopping)
|
|
9
|
+
throw new Error('Retention worker stopped');
|
|
10
|
+
if (active.dbPath !== dbPath)
|
|
11
|
+
throw new Error('Stop the retention worker before replacing its database');
|
|
12
|
+
return active;
|
|
13
|
+
}
|
|
14
|
+
if (!dbPath || dbPath === ':memory:')
|
|
15
|
+
throw new Error('Retention worker requires a file-backed database');
|
|
16
|
+
const development = import.meta.url.endsWith('.ts');
|
|
17
|
+
const entry = new URL(`./retention/worker.${development ? 'ts' : 'js'}`, import.meta.url);
|
|
18
|
+
const worker = development
|
|
19
|
+
? new Worker(`const { workerData } = require('node:worker_threads'); import('tsx/esm/api').then(({ tsImport }) => tsImport(workerData.entry, workerData.parent));`, { eval: true, workerData: { dbPath, entry: entry.href, parent: import.meta.url } })
|
|
20
|
+
: new Worker(entry, { workerData: { dbPath } });
|
|
21
|
+
const state = { worker, dbPath, pending: new Map() };
|
|
22
|
+
active = state;
|
|
23
|
+
worker.unref();
|
|
24
|
+
const fail = (error) => {
|
|
25
|
+
for (const request of state.pending.values())
|
|
26
|
+
request.reject(error);
|
|
27
|
+
state.pending.clear();
|
|
28
|
+
};
|
|
29
|
+
worker.on('error', fail);
|
|
30
|
+
worker.on('exit', (code) => {
|
|
31
|
+
fail(new Error(`Retention worker stopped (${code})`));
|
|
32
|
+
if (active === state)
|
|
33
|
+
active = undefined;
|
|
34
|
+
});
|
|
35
|
+
worker.on('message', (message) => {
|
|
36
|
+
const pending = state.pending.get(message.id);
|
|
37
|
+
if (!pending)
|
|
38
|
+
return;
|
|
39
|
+
state.pending.delete(message.id);
|
|
40
|
+
if (message.error)
|
|
41
|
+
pending.reject(new Error(message.error));
|
|
42
|
+
else if (message.result)
|
|
43
|
+
pending.resolve(message.result);
|
|
44
|
+
else
|
|
45
|
+
pending.reject(new Error('Retention worker returned no result'));
|
|
46
|
+
if (!state.pending.size && !state.stopping)
|
|
47
|
+
worker.unref();
|
|
48
|
+
});
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
function request(config, nowMs, preview) {
|
|
52
|
+
let state;
|
|
53
|
+
try {
|
|
54
|
+
state = start();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return Promise.reject(error);
|
|
58
|
+
}
|
|
59
|
+
// Coalesce the same policy around the first request's snapshot time; distinct
|
|
60
|
+
// configurations remain serialized in
|
|
61
|
+
// the same worker, so two maintenance passes never compete for write locks.
|
|
62
|
+
const key = JSON.stringify([preview, config.retentionDays, config.keepPerWorkspace]);
|
|
63
|
+
for (const pending of state.pending.values())
|
|
64
|
+
if (pending.key === key)
|
|
65
|
+
return pending.promise;
|
|
66
|
+
const id = ++requestId;
|
|
67
|
+
let resolve;
|
|
68
|
+
let reject;
|
|
69
|
+
const promise = new Promise((res, rej) => {
|
|
70
|
+
resolve = res;
|
|
71
|
+
reject = rej;
|
|
72
|
+
});
|
|
73
|
+
state.pending.set(id, { key, promise, resolve, reject });
|
|
74
|
+
state.worker.ref();
|
|
75
|
+
state.worker.postMessage({ id, config, nowMs, preview });
|
|
76
|
+
return promise;
|
|
77
|
+
}
|
|
78
|
+
/** Daily/boot maintenance runs off the HTTP thread and never VACUUMs live data. */
|
|
79
|
+
export function runWsEventsRetention(config, nowMs = Date.now()) {
|
|
80
|
+
if (config.retentionDays <= 0) {
|
|
81
|
+
return Promise.resolve({
|
|
82
|
+
deleted: 0,
|
|
83
|
+
sessionsRecomputed: 0,
|
|
84
|
+
vacuumed: false,
|
|
85
|
+
freePagesBefore: 0,
|
|
86
|
+
freePagesAfter: 0,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return request(config, nowMs, false);
|
|
90
|
+
}
|
|
91
|
+
/** The confirmation preview is also off the live event loop. */
|
|
92
|
+
export function previewWsEventsRetention(config, nowMs = Date.now()) {
|
|
93
|
+
return request(config, nowMs, true);
|
|
94
|
+
}
|
|
95
|
+
/** Stop after the current bounded transaction, repair affected metrics and
|
|
96
|
+
* confirm worker exit before the server closes its main SQLite connection. */
|
|
97
|
+
export function stopWsEventsRetention() {
|
|
98
|
+
const state = active;
|
|
99
|
+
if (!state)
|
|
100
|
+
return Promise.resolve();
|
|
101
|
+
if (state.stopping)
|
|
102
|
+
return state.stopping;
|
|
103
|
+
for (const pending of state.pending.values())
|
|
104
|
+
pending.reject(new Error('Retention worker stopped'));
|
|
105
|
+
state.pending.clear();
|
|
106
|
+
state.worker.ref();
|
|
107
|
+
state.stopping = new Promise((resolve) => {
|
|
108
|
+
state.worker.once('exit', () => resolve());
|
|
109
|
+
state.worker.postMessage({ stop: true });
|
|
110
|
+
});
|
|
111
|
+
return state.stopping;
|
|
112
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/** Replace a JSON file only after a complete write in the same filesystem. */
|
|
5
|
+
export function writeJsonFileAtomically(filePath, value) {
|
|
6
|
+
const contents = JSON.stringify(value, null, 2);
|
|
7
|
+
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
|
8
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
9
|
+
try {
|
|
10
|
+
writeFileSync(temporaryPath, contents, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
11
|
+
renameSync(temporaryPath, filePath);
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
try {
|
|
15
|
+
unlinkSync(temporaryPath);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code !== 'ENOENT') {
|
|
19
|
+
console.error('[atomic-json-file] Failed to remove temporary file:', error);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|