@shawnstack/quickforge 1.7.10 → 1.7.12

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.
Files changed (124) hide show
  1. package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-CApCpsDd.js} +1 -1
  2. package/dist/assets/ChatPanelHost-COTO7swS.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +1 -0
  4. package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-BIbIDfWY.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-BOqmBNCx.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-BCRtycRN.js} +304 -304
  7. package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +1 -0
  8. package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-DJa0M0El.js} +1 -1
  9. package/dist/assets/TerminalDock-Cqz_9mTC.js +2 -0
  10. package/dist/assets/WorkspaceInspector-C-GOK81D.js +36 -0
  11. package/dist/assets/{abnfDiagram-VRR7QNED-CMhVoYUh.js → abnfDiagram-VRR7QNED-DGklo3PO.js} +1 -1
  12. package/dist/assets/{architectureDiagram-ZJ3FMSHR-C52qL6Hg.js → architectureDiagram-ZJ3FMSHR-CioWLVTp.js} +1 -1
  13. package/dist/assets/{blockDiagram-677ZJIJ3-CM3XbLol.js → blockDiagram-677ZJIJ3-BJWBq5e9.js} +1 -1
  14. package/dist/assets/{c4Diagram-LMCZKHZV-D7Bh1ui1.js → c4Diagram-LMCZKHZV-BacRvqFK.js} +1 -1
  15. package/dist/assets/channel-CeXY-wZo.js +1 -0
  16. package/dist/assets/{chunk-32BRIVSS-xgrtbT8e.js → chunk-32BRIVSS-B53BNE2C.js} +1 -1
  17. package/dist/assets/{chunk-52WLFC77-CDbaHw80.js → chunk-52WLFC77-CYq4hT4f.js} +1 -1
  18. package/dist/assets/{chunk-C7G6YPKG-ZMLUvfLy.js → chunk-C7G6YPKG-KFaGpP_Z.js} +1 -1
  19. package/dist/assets/{chunk-EX3LRPZG-BcQhaqt7.js → chunk-EX3LRPZG-DqVd_ihG.js} +1 -1
  20. package/dist/assets/{chunk-FWX5IMBZ-DrnrdrSu.js → chunk-FWX5IMBZ-BxRjZkOP.js} +2 -2
  21. package/dist/assets/{chunk-HOUHSVGY-tdc-Z9eW.js → chunk-HOUHSVGY-C9QO8omv.js} +1 -1
  22. package/dist/assets/{chunk-ICXQ74PX-Cd7igVGt.js → chunk-ICXQ74PX-ClbGPgLV.js} +1 -1
  23. package/dist/assets/{chunk-MOJQB5TN-CLTjV5ov.js → chunk-MOJQB5TN-Ba4wlF1J.js} +1 -1
  24. package/dist/assets/{chunk-OGEWGWER-TgvkXkFX.js → chunk-OGEWGWER-DczhQmcx.js} +1 -1
  25. package/dist/assets/{chunk-PUDLZKDR-C5GMVYup.js → chunk-PUDLZKDR-CPWpcKUF.js} +1 -1
  26. package/dist/assets/{chunk-Q4XR5HBZ-fo5-HsqY.js → chunk-Q4XR5HBZ-CiF6T--X.js} +1 -1
  27. package/dist/assets/{chunk-V7JOEXUC-CEDazV6w.js → chunk-V7JOEXUC-Dxv0zwmw.js} +1 -1
  28. package/dist/assets/{chunk-VAUOI2AC-CWYjg8zE.js → chunk-VAUOI2AC-D3J5bw1P.js} +1 -1
  29. package/dist/assets/{chunk-VR4S4FIN-C_-eEj-9.js → chunk-VR4S4FIN-BXQSwwII.js} +1 -1
  30. package/dist/assets/{chunk-WYO6CB5R-NF-MsJJR.js → chunk-WYO6CB5R-C0azl-fk.js} +2 -2
  31. package/dist/assets/{chunk-ZGVPDNZ5-CRrAYi4g.js → chunk-ZGVPDNZ5-D_8F4Akx.js} +1 -1
  32. package/dist/assets/classDiagram-OUVF2IWQ-B8M6sf49.js +1 -0
  33. package/dist/assets/classDiagram-v2-EOCWNBFH-BY7fzBDH.js +1 -0
  34. package/dist/assets/{cynefinDiagram-TSTJHNR4-D4sZ7LYI.js → cynefinDiagram-TSTJHNR4-BFlsWz0B.js} +1 -1
  35. package/dist/assets/{dagre-VKFMJZFB-BlsnSh-Q.js → dagre-VKFMJZFB-DXhpjlEs.js} +1 -1
  36. package/dist/assets/{diagram-FQU43EPY-DW1yQiRO.js → diagram-FQU43EPY-DRVVOD9k.js} +1 -1
  37. package/dist/assets/{diagram-G47NLZAW-BTUAsFjb.js → diagram-G47NLZAW-BV9Gw1tL.js} +1 -1
  38. package/dist/assets/{diagram-NH7WQ7WH-BcaomqSd.js → diagram-NH7WQ7WH-CrUkXfRY.js} +1 -1
  39. package/dist/assets/{diagram-OA4YK3LP-DDLX7Xwr.js → diagram-OA4YK3LP-CuILjn7v.js} +1 -1
  40. package/dist/assets/{diagram-WEI45ONY-CIave0An.js → diagram-WEI45ONY-nlNN3tWB.js} +1 -1
  41. package/dist/assets/{ebnfDiagram-CCIWWBDH-Cr1Y6lPu.js → ebnfDiagram-CCIWWBDH-BuDgZt9n.js} +1 -1
  42. package/dist/assets/{erDiagram-Q63AITRT-D3EgiVVH.js → erDiagram-Q63AITRT-MEwxjmtP.js} +1 -1
  43. package/dist/assets/flowDiagram-23GEKE2U-BiUP-Scq.js +1 -0
  44. package/dist/assets/{ganttDiagram-NO4QXBWP-B0oyDIqK.js → ganttDiagram-NO4QXBWP-DNw5qghE.js} +1 -1
  45. package/dist/assets/{gitGraphDiagram-IHSO6WYX-uQtr6IqK.js → gitGraphDiagram-IHSO6WYX-CaTcakAt.js} +1 -1
  46. package/dist/assets/index-BWf6y3Kh.css +3 -0
  47. package/dist/assets/index-D3GWkXJR.js +66 -0
  48. package/dist/assets/{infoDiagram-FWYZ7A6U-B_GzlLIM.js → infoDiagram-FWYZ7A6U-DwfyP7dU.js} +1 -1
  49. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CKQIsSdU.js → ishikawaDiagram-FXEZZL3T-CmrzbOCV.js} +1 -1
  50. package/dist/assets/{journeyDiagram-5HDEW3XC-DrrvzZL1.js → journeyDiagram-5HDEW3XC-CYqe3sgr.js} +1 -1
  51. package/dist/assets/{kanban-definition-HUTT4EX6-CJQRTdXz.js → kanban-definition-HUTT4EX6-BMDO_mv0.js} +1 -1
  52. package/dist/assets/{line-B4DxX7tC.js → line-SbxJR8ZT.js} +1 -1
  53. package/dist/assets/lit-vendor-CinchGmI.js +2 -0
  54. package/dist/assets/local-tools-DY0yEU3T.js +350 -0
  55. package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-7Xwu2RVW.js} +2 -2
  56. package/dist/assets/{mermaid.core-DWpShXCs.js → mermaid.core-CwXTzYjs.js} +3 -3
  57. package/dist/assets/{mindmap-definition-LN4V7U3C-CEVwZD78.js → mindmap-definition-LN4V7U3C-CLyJ78mO.js} +1 -1
  58. package/dist/assets/{pegDiagram-2B236MQR-CBLkBdnQ.js → pegDiagram-2B236MQR-4qFznrSw.js} +1 -1
  59. package/dist/assets/{pi-web-ui-DV5jKjSg.js → pi-web-ui-BNuvqKDT.js} +289 -289
  60. package/dist/assets/{pieDiagram-ENE6RG2P-8tCnwtWS.js → pieDiagram-ENE6RG2P-141sGxVe.js} +1 -1
  61. package/dist/assets/{quadrantDiagram-ABIIQ3AL-CWd28cHF.js → quadrantDiagram-ABIIQ3AL-SUI44pAX.js} +1 -1
  62. package/dist/assets/{railroadDiagram-RFXS5EU6-CI4ouOBs.js → railroadDiagram-RFXS5EU6-1oiZuYBB.js} +1 -1
  63. package/dist/assets/{requirementDiagram-TGXJPOKE-DtFz7sw4.js → requirementDiagram-TGXJPOKE-CfVishLi.js} +1 -1
  64. package/dist/assets/{sankeyDiagram-HTMAVEWB--VeOwwk9.js → sankeyDiagram-HTMAVEWB-DVu7J_Z9.js} +1 -1
  65. package/dist/assets/{sequenceDiagram-DBY2YBRQ-B8AHk5Oe.js → sequenceDiagram-DBY2YBRQ-D-Ip2yiS.js} +1 -1
  66. package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-C_JvWbAa.js} +3 -3
  67. package/dist/assets/{stateDiagram-2N3HPSRC-etXnJ8X8.js → stateDiagram-2N3HPSRC-Cg0hWwrp.js} +1 -1
  68. package/dist/assets/stateDiagram-v2-6OUMAXLB-DhmzNQd9.js +1 -0
  69. package/dist/assets/{swimlanes-5IMT3BWC-pabIKAmn.js → swimlanes-5IMT3BWC-Co5D-cGU.js} +1 -1
  70. package/dist/assets/swimlanesDiagram-G3AALYLV-BEvomQNa.js +8 -0
  71. package/dist/assets/{timeline-definition-FHXFAJF6-C55MKm3L.js → timeline-definition-FHXFAJF6-CBk4c_Ua.js} +1 -1
  72. package/dist/assets/{vennDiagram-L72KCM5P-CgNf1Q9W.js → vennDiagram-L72KCM5P-BII7wdRx.js} +1 -1
  73. package/dist/assets/{wardleyDiagram-EHGQE667-Drt5ytec.js → wardleyDiagram-EHGQE667-CkyJJIXP.js} +1 -1
  74. package/dist/assets/{xychartDiagram-FW5EYKEG-hhoEo_Ar.js → xychartDiagram-FW5EYKEG-B1A66Ks0.js} +1 -1
  75. package/dist/index.html +6 -6
  76. package/package.json +1 -1
  77. package/server/acp/server.mjs +3 -6
  78. package/server/agent-manager.mjs +192 -48
  79. package/server/approval-store.mjs +1 -0
  80. package/server/ask-store.mjs +78 -0
  81. package/server/auto-archive.mjs +55 -8
  82. package/server/index.mjs +172 -51
  83. package/server/lan-access-cutover.mjs +21 -4
  84. package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
  85. package/server/maintenance/export-session-state-v1.mjs +1 -11
  86. package/server/routes/agent.mjs +9 -0
  87. package/server/routes/backup.mjs +1 -1
  88. package/server/routes/storage.mjs +63 -49
  89. package/server/scheduled-runs-cutover.mjs +15 -1
  90. package/server/session-index-service.mjs +57 -391
  91. package/server/session-state-backup.mjs +56 -5
  92. package/server/session-state-import.mjs +174 -0
  93. package/server/session-state-maintenance.mjs +126 -0
  94. package/server/session-state-service.mjs +128 -210
  95. package/server/share-cutover.mjs +21 -4
  96. package/server/sqlite/database.mjs +142 -7
  97. package/server/sqlite/lan-access-repository.mjs +6 -4
  98. package/server/sqlite/migrations.mjs +109 -0
  99. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  100. package/server/sqlite/session-index-repository.mjs +59 -208
  101. package/server/sqlite/session-state-repository.mjs +337 -387
  102. package/server/sqlite/share-repository.mjs +56 -45
  103. package/server/startup-state.mjs +107 -0
  104. package/server/storage.mjs +218 -454
  105. package/server/tools/definitions.mjs +18 -0
  106. package/server/tools/index.mjs +14 -2
  107. package/server/utils/logger.mjs +15 -4
  108. package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
  109. package/dist/assets/CloudAccountSettingsPage-CDkKN1Sz.js +0 -1
  110. package/dist/assets/ScheduledTasksPage-C9lOTi75.js +0 -2
  111. package/dist/assets/ShareLinksSettingsPage-CH_t33A3.js +0 -1
  112. package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
  113. package/dist/assets/WorkspaceInspector-DPXgLIum.js +0 -36
  114. package/dist/assets/channel-Duu1CuH9.js +0 -1
  115. package/dist/assets/classDiagram-OUVF2IWQ-Cc63fLWP.js +0 -1
  116. package/dist/assets/classDiagram-v2-EOCWNBFH-CoXU0qT6.js +0 -1
  117. package/dist/assets/flowDiagram-23GEKE2U-BS7KXcqn.js +0 -1
  118. package/dist/assets/index-DQBOm9gt.js +0 -66
  119. package/dist/assets/index-ps85guTs.css +0 -3
  120. package/dist/assets/lit-vendor-DvaQX_UQ.js +0 -2
  121. package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
  122. package/dist/assets/stateDiagram-v2-6OUMAXLB-Dms3pICj.js +0 -1
  123. package/dist/assets/swimlanesDiagram-G3AALYLV-B14aWjv6.js +0 -8
  124. package/server/session-state-cutover.mjs +0 -563
@@ -1,3 +1,4 @@
1
+ import { readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
1
2
  import { mkdir } from 'node:fs/promises'
2
3
  import os from 'node:os'
3
4
  import path from 'node:path'
@@ -7,10 +8,28 @@ import { applySqliteMigrations, inspectSqliteMigrationState, SQLITE_MIGRATIONS }
7
8
 
8
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000
9
10
  export const SQLITE_JOURNAL_MODE = 'wal'
10
- export const SQLITE_SYNCHRONOUS = 1
11
+ export const SQLITE_SYNCHRONOUS = 2
12
+
13
+ // Startup quick_check tax optimization. All four storage domains share one
14
+ // quickforge.sqlite3 file, and `PRAGMA quick_check` scans the whole file, so
15
+ // four per-domain startup checks used to pay the full-scan cost four times
16
+ // (~30s on a 3GB production library). One gated scan per database per process
17
+ // (sharedQuickCheckCache) plus a cross-restart marker file reduce it to at
18
+ // most one real scan per database per 7 days.
19
+ //
20
+ // Safety argument: WAL + synchronous=FULL make committed transactions immune
21
+ // to torn writes (a crash either replays or rolls back the WAL). quick_check
22
+ // therefore only guards against bit-rot / filesystem-level corruption, which
23
+ // evolves on disk timescales, not per startup; the 7-day cadence plus the
24
+ // manual force entry points (maintenance verify-session-integrity endpoint,
25
+ // QUICKFORGE_SQLITE_QUICK_CHECK=force) keep the blind window bounded. When a
26
+ // quick_check is skipped, all other SQL-level count/join checks still run.
27
+ export const SQLITE_QUICK_CHECK_MARKER_FILENAME = 'quickforge-quick-check.marker.json'
28
+ export const SQLITE_QUICK_CHECK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
11
29
 
12
30
  const sqliteLogger = logger.child({ component: 'sqlite' })
13
31
  const TRANSACTION_MODES = new Set(['deferred', 'immediate', 'exclusive'])
32
+ const sharedQuickCheckCache = new Map()
14
33
  let initializationPromise = null
15
34
  let openState = null
16
35
  let savepointCounter = 0
@@ -65,22 +84,138 @@ function verifyPragmas(database) {
65
84
 
66
85
  function configurePragmas(database) {
67
86
  database.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`)
87
+ // Session-domain deletes reclaim disk pages through incremental_vacuum (see
88
+ // the session-state repository), which requires auto_vacuum = INCREMENTAL.
89
+ // The flag only takes effect on a freshly created (empty) database; an
90
+ // existing library keeps its current layout until a full VACUUM rebuilds
91
+ // it, so applying it here is best-effort for old databases. PRAGMA cannot
92
+ // run inside a transaction — this executes before applySqliteMigrations
93
+ // opens its BEGIN IMMEDIATE — and it must come AFTER busy_timeout: during
94
+ // a multi-process first initialization another worker can already hold the
95
+ // fresh database inside its migration transaction, and without the busy
96
+ // timeout this pragma would fail immediately with SQLITE_BUSY.
97
+ database.exec('PRAGMA auto_vacuum = INCREMENTAL')
68
98
  database.exec('PRAGMA foreign_keys = ON')
69
99
  database.exec('PRAGMA journal_mode = WAL')
70
- database.exec('PRAGMA synchronous = NORMAL')
100
+ // FULL (not NORMAL): decision finalized in docs/architecture/sqlite-storage-foundation.zh-CN.md §3.1
101
+ // — measured cost is ~0.46 ms/op on the save hot path, in exchange for zero committed-transaction
102
+ // loss on OS crash/power loss.
103
+ database.exec('PRAGMA synchronous = FULL')
71
104
  return verifyPragmas(database)
72
105
  }
73
106
 
107
+ function quickCheckMarkerPath(databasePath) {
108
+ return path.join(path.dirname(databasePath), SQLITE_QUICK_CHECK_MARKER_FILENAME)
109
+ }
110
+
111
+ // Resolve the main database file path from any storage surface (raw
112
+ // DatabaseSync, the controlled handle, or test duck-typed handles). Returns
113
+ // null for in-memory databases, where no marker is possible and every call
114
+ // runs a real scan.
115
+ function resolveQuickCheckDatabasePath(database) {
116
+ try {
117
+ const rows = database.prepare('PRAGMA database_list').all()
118
+ const main = Array.isArray(rows) ? rows.find((row) => row && row.name === 'main') : null
119
+ const file = typeof main?.file === 'string' ? main.file.trim() : ''
120
+ return file ? path.resolve(file) : null
121
+ } catch {
122
+ return null
123
+ }
124
+ }
125
+
126
+ // Marker read failures (missing, unreadable, malformed) are treated as "no
127
+ // marker": the caller falls through to a real scan.
128
+ function readQuickCheckMarker(databasePath) {
129
+ try {
130
+ const marker = JSON.parse(readFileSync(quickCheckMarkerPath(databasePath), 'utf8'))
131
+ const lastOkAt = Number(marker?.lastOkAt)
132
+ return Number.isFinite(lastOkAt) ? { ...marker, lastOkAt } : null
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+
138
+ // Best-effort atomic marker write: failures only warn, never affect the
139
+ // quick_check verdict.
140
+ function writeQuickCheckMarker(databasePath, okAt, database) {
141
+ try {
142
+ const markerPath = quickCheckMarkerPath(databasePath)
143
+ const temporaryPath = `${markerPath}.${process.pid}.tmp`
144
+ let sqliteVersion = null
145
+ try {
146
+ sqliteVersion = String(database.prepare('SELECT sqlite_version() AS version').get().version)
147
+ } catch { /* Optional diagnostic field. */ }
148
+ let databaseBytes = null
149
+ try {
150
+ databaseBytes = statSync(databasePath).size
151
+ } catch { /* Optional diagnostic field. */ }
152
+ writeFileSync(temporaryPath, `${JSON.stringify({ app: 'quickforge', lastOkAt: okAt, sqliteVersion, databaseBytes })}\n`, 'utf8')
153
+ renameSync(temporaryPath, markerPath)
154
+ } catch (error) {
155
+ sqliteLogger.warn('SQLite quick_check marker write failed', {
156
+ error: error instanceof Error ? error.message : String(error),
157
+ })
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Shared startup `PRAGMA quick_check` gate (see the block comment above for
163
+ * the rationale and safety argument). Semantics:
164
+ * - In-process: one successful scan per database file path is reused for the
165
+ * process lifetime (unless `maxAgeMs` elapses or `resetSharedQuickCheckCache`
166
+ * is called); `force` skips this reuse but still refreshes it after success.
167
+ * - Cross-restart: a marker file next to the database (`lastOkAt`) skips the
168
+ * real scan while younger than `maxAgeMs` (default 7 days). Missing or
169
+ * unreadable markers are treated as absent.
170
+ * - On a real scan success the marker is rewritten (atomic, best-effort).
171
+ * - On failure the quick_check error is re-thrown as-is: no marker is written
172
+ * and the process cache never caches failures, so the next call scans again.
173
+ * - QUICKFORGE_SQLITE_QUICK_CHECK=force forces a real scan every call.
174
+ * Returns `{ ok: true, skipped: null | 'process' | 'marker', lastOkAt? }`.
175
+ */
176
+ export function runSharedSqliteQuickCheck(database, {
177
+ force = false, maxAgeMs = SQLITE_QUICK_CHECK_MAX_AGE_MS, now = Date.now, writeMarker = true, databasePath = null,
178
+ } = {}) {
179
+ const forced = force === true || process.env.QUICKFORGE_SQLITE_QUICK_CHECK === 'force'
180
+ const resolvedPath = databasePath ? path.resolve(databasePath) : resolveQuickCheckDatabasePath(database)
181
+ if (!forced && resolvedPath) {
182
+ const cached = sharedQuickCheckCache.get(resolvedPath)
183
+ if (cached && now() - cached.okAt < maxAgeMs) {
184
+ return { ok: true, skipped: 'process', lastOkAt: cached.okAt }
185
+ }
186
+ const marker = readQuickCheckMarker(resolvedPath)
187
+ if (marker && now() - marker.lastOkAt < maxAgeMs) {
188
+ sharedQuickCheckCache.set(resolvedPath, { okAt: marker.lastOkAt })
189
+ return { ok: true, skipped: 'marker', lastOkAt: marker.lastOkAt }
190
+ }
191
+ }
192
+ const result = database.prepare('PRAGMA quick_check').all().map((row) => row.quick_check)
193
+ if (result.length !== 1 || result[0] !== 'ok') {
194
+ throw new Error(`SQLite quick_check failed: ${result.join(', ')}`)
195
+ }
196
+ const okAt = now()
197
+ if (resolvedPath) sharedQuickCheckCache.set(resolvedPath, { okAt })
198
+ if (writeMarker !== false && resolvedPath) {
199
+ writeQuickCheckMarker(resolvedPath, okAt, database)
200
+ }
201
+ return { ok: true, skipped: null, lastOkAt: okAt }
202
+ }
203
+
204
+ /** Test helper: clears the in-process quick_check cache, marker files stay. */
205
+ export function resetSharedQuickCheckCache() {
206
+ sharedQuickCheckCache.clear()
207
+ }
208
+
74
209
  function publicHealth(state, { quickCheck = false } = {}) {
75
210
  const alive = Number(state.database.prepare('SELECT 1 AS ok').get().ok) === 1
76
211
  const pragmas = verifyPragmas(state.database)
77
212
  const migration = inspectSqliteMigrationState(state.database, { migrations: state.migrations })
78
213
  let quickCheckResult = null
79
214
  if (quickCheck) {
80
- quickCheckResult = state.database.prepare('PRAGMA quick_check').all().map((row) => row.quick_check)
81
- if (quickCheckResult.length !== 1 || quickCheckResult[0] !== 'ok') {
82
- throw new Error(`SQLite quick_check failed: ${quickCheckResult.join(', ')}`)
83
- }
215
+ // Shared gate: process-level dedupe + marker-based cadence; throws on
216
+ // failure exactly like the previous inline PRAGMA quick_check did.
217
+ runSharedSqliteQuickCheck(state.database, { databasePath: state.databasePath })
218
+ quickCheckResult = ['ok']
84
219
  }
85
220
  return {
86
221
  ok: alive && migration.consistent,
@@ -91,7 +226,7 @@ function publicHealth(state, { quickCheck = false } = {}) {
91
226
  journalMode: pragmas.journalMode,
92
227
  busyTimeout: pragmas.busyTimeout,
93
228
  foreignKeys: pragmas.foreignKeys,
94
- synchronous: 'normal',
229
+ synchronous: 'full',
95
230
  ...(quickCheck ? { quickCheck: quickCheckResult[0] } : {}),
96
231
  }
97
232
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { getSqliteStorage } from './database.mjs'
2
+ import { getSqliteStorage, runSharedSqliteQuickCheck } from './database.mjs'
3
3
  import { createRandomToken, safeHashEqual, sha256Base64Url } from '../utils/password-auth.mjs'
4
4
 
5
5
  // F11 LAN access storage: `lan_access_state` (single config row with a fixed
@@ -544,10 +544,12 @@ export function createLanAccessRepository(storageHandle, { now = () => new Date(
544
544
  return exportSnapshot().digest
545
545
  }
546
546
 
547
- function verifyIntegrity({ quickCheck = false } = {}) {
547
+ function verifyIntegrity({ quickCheck = false, forceQuickCheck = false } = {}) {
548
548
  if (quickCheck) {
549
- const result = storage.prepare('PRAGMA quick_check').all().map((row) => row.quick_check)
550
- if (result.length !== 1 || result[0] !== 'ok') throw new Error(`SQLite quick_check failed: ${result.join(', ')}`)
549
+ // Shared quick_check gate (process cache + marker cadence) see
550
+ // runSharedSqliteQuickCheck in database.mjs; `forceQuickCheck` is the
551
+ // manual-maintenance escape hatch that always runs a real scan.
552
+ runSharedSqliteQuickCheck(storage, { force: forceQuickCheck === true })
551
553
  }
552
554
  const row = currentConfigRow(storage)
553
555
  let invalidRecords = 0
@@ -460,6 +460,115 @@ export const SQLITE_MIGRATIONS = Object.freeze([
460
460
  `)
461
461
  },
462
462
  }),
463
+ Object.freeze({
464
+ // Root cause: session_states is WITHOUT ROWID, so the PK b-tree IS the
465
+ // table and stores the multi-MB state_json payload inline. metadata_json /
466
+ // metadata_digest sit AFTER state_json in the record, so every metadata
467
+ // read must walk the preceding state_json overflow-page chain — an
468
+ // implicit full-database read (2.93GB cold DB: ~45s for a metadata-only
469
+ // projection). This covering index carries the metadata columns next to
470
+ // the PK prefix, making metadata reads index-only (KB-sized entries); the
471
+ // planner picks it up automatically with zero query changes.
472
+ // Note: CREATE INDEX scans the table once — on first upgrade of a large
473
+ // existing database this migration is a one-time cost paid inside
474
+ // initializeSqliteStorage, before the server starts listening.
475
+ version: 10,
476
+ name: 'session_states_metadata_covering_index',
477
+ up(database) {
478
+ database.exec(`
479
+ CREATE INDEX session_states_metadata_cover_idx
480
+ ON session_states (scope, project_id, session_id, metadata_json, metadata_digest);
481
+ `)
482
+ },
483
+ }),
484
+ Object.freeze({
485
+ // v11 session-domain rebuild (session storage v2): one small `sessions`
486
+ // row per session (no giant state_json column — the multi-GB bloat root
487
+ // cause), messages always stored row-per-message in `session_messages`
488
+ // (append-only, no inline/split dual mode), deletes tombstone into
489
+ // `session_tombstones` and space is reclaimed through incremental_vacuum
490
+ // (auto_vacuum=INCREMENTAL, set in database.mjs). The JSON mirror outbox
491
+ // chain, the session_index derived table and the session_storage_state
492
+ // phase machine are retired with this schema. Old session-domain tables
493
+ // are RENAMED (not dropped) as a rollback safety net; the maintenance
494
+ // lock and the share/lan-access/scheduled-runs domains are untouched.
495
+ // A fresh database runs v1..v11 sequentially, so every renamed table
496
+ // necessarily exists by the time v11 executes. Renamed tables keep their
497
+ // old index names (index names are schema-global), so the new tables use
498
+ // the idx_* naming convention to avoid collisions.
499
+ version: 11,
500
+ name: 'session_state_v2_storage',
501
+ up(database) {
502
+ database.exec(`
503
+ ALTER TABLE session_states RENAME TO session_states_v10_backup;
504
+ ALTER TABLE session_messages RENAME TO session_messages_v10_backup;
505
+ ALTER TABLE session_index RENAME TO session_index_v10_backup;
506
+ ALTER TABLE session_state_tombstones RENAME TO session_state_tombstones_v10_backup;
507
+ ALTER TABLE session_json_mirror_queue RENAME TO session_json_mirror_queue_v10_backup;
508
+ ALTER TABLE session_storage_state RENAME TO session_storage_state_v10_backup;
509
+
510
+ CREATE TABLE sessions (
511
+ scope TEXT NOT NULL CHECK (scope IN ('global', 'project')),
512
+ project_id TEXT NOT NULL,
513
+ session_id TEXT NOT NULL CHECK (length(trim(session_id)) > 0),
514
+ title TEXT NOT NULL DEFAULT '',
515
+ title_source TEXT,
516
+ created_at TEXT NOT NULL CHECK (length(trim(created_at)) > 0),
517
+ updated_at TEXT NOT NULL CHECK (length(trim(updated_at)) > 0),
518
+ message_count INTEGER NOT NULL DEFAULT 0 CHECK (typeof(message_count) = 'integer' AND message_count >= 0),
519
+ state_version INTEGER NOT NULL DEFAULT 0 CHECK (typeof(state_version) = 'integer' AND state_version >= 0),
520
+ harness TEXT,
521
+ task_status TEXT,
522
+ archived_at TEXT,
523
+ pinned_at TEXT,
524
+ body_json TEXT NOT NULL CHECK (json_valid(body_json) AND json_type(body_json) = 'object'),
525
+ meta_json TEXT NOT NULL CHECK (json_valid(meta_json) AND json_type(meta_json) = 'object'),
526
+ revision INTEGER NOT NULL CHECK (typeof(revision) = 'integer' AND revision > 0),
527
+ updated_at_ms INTEGER NOT NULL CHECK (typeof(updated_at_ms) = 'integer' AND updated_at_ms >= 0),
528
+ PRIMARY KEY (scope, project_id, session_id),
529
+ CHECK (
530
+ (scope = 'global' AND project_id = '') OR
531
+ (scope = 'project' AND length(trim(project_id)) > 0)
532
+ )
533
+ );
534
+
535
+ CREATE INDEX idx_sessions_list ON sessions (scope, project_id, archived_at, updated_at DESC);
536
+ CREATE INDEX idx_sessions_pinned ON sessions (scope, project_id, updated_at DESC) WHERE pinned_at IS NOT NULL;
537
+ CREATE INDEX idx_sessions_session_id ON sessions (session_id);
538
+
539
+ CREATE TABLE session_messages (
540
+ scope TEXT NOT NULL CHECK (scope IN ('global', 'project')),
541
+ project_id TEXT NOT NULL,
542
+ session_id TEXT NOT NULL CHECK (length(trim(session_id)) > 0),
543
+ seq INTEGER NOT NULL CHECK (typeof(seq) = 'integer' AND seq >= 0),
544
+ message_id TEXT CHECK (message_id IS NULL OR length(trim(message_id)) > 0),
545
+ message_json TEXT NOT NULL CHECK (json_valid(message_json) AND json_type(message_json) = 'object'),
546
+ message_digest TEXT NOT NULL CHECK (length(message_digest) = 64 AND message_digest NOT GLOB '*[^0-9a-f]*'),
547
+ PRIMARY KEY (scope, project_id, session_id, seq),
548
+ UNIQUE (scope, project_id, session_id, message_id),
549
+ FOREIGN KEY (scope, project_id, session_id) REFERENCES sessions (scope, project_id, session_id) ON DELETE CASCADE,
550
+ CHECK (
551
+ (scope = 'global' AND project_id = '') OR
552
+ (scope = 'project' AND length(trim(project_id)) > 0)
553
+ )
554
+ );
555
+
556
+ CREATE INDEX idx_session_messages_session_id ON session_messages (session_id);
557
+
558
+ CREATE TABLE session_tombstones (
559
+ scope TEXT NOT NULL CHECK (scope IN ('global', 'project')),
560
+ project_id TEXT NOT NULL,
561
+ session_id TEXT NOT NULL CHECK (length(trim(session_id)) > 0),
562
+ deleted_at INTEGER NOT NULL CHECK (typeof(deleted_at) = 'integer' AND deleted_at >= 0),
563
+ PRIMARY KEY (scope, project_id, session_id),
564
+ CHECK (
565
+ (scope = 'global' AND project_id = '') OR
566
+ (scope = 'project' AND length(trim(project_id)) > 0)
567
+ )
568
+ ) WITHOUT ROWID;
569
+ `)
570
+ },
571
+ }),
463
572
  ])
464
573
 
465
574
  function readUserVersion(database) {
@@ -314,8 +314,10 @@ export function createScheduledTaskRunsRepository(storageHandle, options = {}) {
314
314
  return database.prepare('SELECT * FROM scheduled_task_runs WHERE task_id = ? AND id = ?').get(taskId, runId)
315
315
  }
316
316
 
317
- function insert(database, value) {
318
- database.prepare(`INSERT INTO scheduled_task_runs (${INSERT_COLUMNS}) VALUES (${INSERT_PLACEHOLDERS})`).run(...insertParameters(value))
317
+ // The optional statement lets the cutover replaceAll reuse one precompiled insert.
318
+ function insert(database, value, statement = null) {
319
+ const insertStatement = statement ?? database.prepare(`INSERT INTO scheduled_task_runs (${INSERT_COLUMNS}) VALUES (${INSERT_PLACEHOLDERS})`)
320
+ insertStatement.run(...insertParameters(value))
319
321
  }
320
322
 
321
323
  function upsert(database, value) {
@@ -392,7 +394,8 @@ export function createScheduledTaskRunsRepository(storageHandle, options = {}) {
392
394
  }
393
395
  return storage.transaction((database) => {
394
396
  database.prepare('DELETE FROM scheduled_task_runs').run()
395
- for (const value of values) insert(database, value)
397
+ const insertStatement = database.prepare(`INSERT INTO scheduled_task_runs (${INSERT_COLUMNS}) VALUES (${INSERT_PLACEHOLDERS})`)
398
+ for (const value of values) insert(database, value, insertStatement)
396
399
  return values.length
397
400
  })
398
401
  },