@shawnstack/quickforge 1.7.9 → 1.7.11

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 (53) hide show
  1. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
  2. package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
  3. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
  4. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
  7. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
  9. package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
  11. package/dist/assets/index-BgMvQW1b.js +66 -0
  12. package/dist/assets/index-CB1g2D0f.css +3 -0
  13. package/dist/assets/local-tools-Dopjg9xq.js +270 -0
  14. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
  15. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +3 -6
  19. package/server/agent-manager.mjs +125 -43
  20. package/server/auto-archive.mjs +55 -8
  21. package/server/auto-compaction.mjs +0 -2
  22. package/server/context-usage.mjs +9 -7
  23. package/server/index.mjs +172 -51
  24. package/server/lan-access-cutover.mjs +21 -4
  25. package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
  26. package/server/maintenance/export-session-state-v1.mjs +5 -13
  27. package/server/routes/backup.mjs +8 -1
  28. package/server/routes/storage.mjs +85 -48
  29. package/server/scheduled-runs-cutover.mjs +15 -1
  30. package/server/session-index-service.mjs +57 -391
  31. package/server/session-persistence-lock.mjs +17 -6
  32. package/server/session-state-backup.mjs +60 -7
  33. package/server/session-state-import.mjs +157 -0
  34. package/server/session-state-maintenance.mjs +126 -0
  35. package/server/session-state-service.mjs +133 -194
  36. package/server/share-cutover.mjs +21 -4
  37. package/server/sqlite/database.mjs +142 -7
  38. package/server/sqlite/lan-access-repository.mjs +6 -4
  39. package/server/sqlite/migrations.mjs +109 -0
  40. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  41. package/server/sqlite/session-index-repository.mjs +59 -208
  42. package/server/sqlite/session-state-repository.mjs +359 -289
  43. package/server/sqlite/share-repository.mjs +56 -45
  44. package/server/startup-state.mjs +107 -0
  45. package/server/storage.mjs +240 -448
  46. package/server/utils/logger.mjs +15 -4
  47. package/server/utils/process-tree.mjs +14 -2
  48. package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
  49. package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
  50. package/dist/assets/index-D-J_8Smf.css +0 -3
  51. package/dist/assets/index-DFASc15l.js +0 -66
  52. package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
  53. package/server/session-state-cutover.mjs +0 -370
@@ -1,25 +1,15 @@
1
1
  import { createSessionStateRepository, MESSAGES_PAGE_LIMIT_MAX, MESSAGES_SPLIT_VALUE, messageDigest } from './sqlite/session-state-repository.mjs'
2
2
  import { getSqliteStorage } from './sqlite/database.mjs'
3
3
 
4
- export const SESSION_STORAGE_PHASES = Object.freeze({
5
- JSON_AUTHORITATIVE: 'json_authoritative',
6
- CUTOVER_RUNNING: 'cutover_running',
7
- JSON_PENDING: 'sqlite_authoritative_json_pending',
8
- AUTHORITATIVE: 'authoritative',
9
- })
10
-
11
- // F9 split-on-write gate: sessions whose message array reaches this length are
12
- // stored incrementally in `session_messages` (body keeps only the split
13
- // marker). Sessions below the threshold stay inline for backward compatibility.
14
- export const MESSAGES_SPLIT_THRESHOLD = 200
4
+ // Storage v2 integration: SQLite is the single authoritative session store.
5
+ // The JSON→SQLite phase machine (cutover, mirror queue, JSON write barrier) is
6
+ // retired; the v2 `sessions`/`session_messages`/`session_tombstones` schema
7
+ // (migration v11) is the only representation, and legacy JSON session files are
8
+ // consumed once by importSessionStateFromJson during startup.
15
9
 
16
10
  let repositoryInstance = null
17
- let cachedPhase = SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE
18
- let jsonAdapter = null
19
- let mirrorAdapter = null
20
- let drainPromise = null
21
- let mirrorTimer = null
22
- const MIRROR_DRAIN_INTERVAL_MS = 1000
11
+ let repositoryInstanceHandle = null
12
+ let initializedAt = null
23
13
 
24
14
  function isPlainObject(value) {
25
15
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
@@ -45,68 +35,58 @@ function storage() {
45
35
  }
46
36
 
47
37
  function repository() {
48
- if (!repositoryInstance) repositoryInstance = createSessionStateRepository(storage())
38
+ // Rebuild when the process-wide SQLite handle changed (close/reopen cycles
39
+ // in tests and restart flows): cached statements are bound to one handle.
40
+ const handle = storage()
41
+ if (!repositoryInstance || (repositoryInstanceHandle !== null && repositoryInstanceHandle !== handle)) {
42
+ repositoryInstance = createSessionStateRepository(handle)
43
+ repositoryInstanceHandle = handle
44
+ }
49
45
  return repositoryInstance
50
46
  }
51
47
 
52
- function stateRow() {
53
- return storage().prepare('SELECT * FROM session_storage_state WHERE singleton = 1').get()
54
- }
55
-
56
- function sqliteReadable() {
57
- return cachedPhase === SESSION_STORAGE_PHASES.JSON_PENDING || cachedPhase === SESSION_STORAGE_PHASES.AUTHORITATIVE
58
- }
59
-
60
- function requireJsonAdapter(method) {
61
- if (typeof jsonAdapter?.[method] !== 'function') throw new Error(`JSON authoritative session adapter does not implement ${method}`)
62
- return jsonAdapter[method].bind(jsonAdapter)
63
- }
64
-
65
- export function configureSessionStateService({ repository: configuredRepository, json, mirror, phase } = {}) {
66
- if (configuredRepository !== undefined) repositoryInstance = configuredRepository
67
- if (json !== undefined) jsonAdapter = json
68
- if (mirror !== undefined) mirrorAdapter = mirror
69
- if (phase !== undefined) {
70
- if (!Object.values(SESSION_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid session storage phase: ${phase}`)
71
- cachedPhase = phase
48
+ // v2: only the repository override remains testable. The retired phase
49
+ // machine's `json`/`mirror`/`phase` options are accepted and ignored so old
50
+ // call sites keep working while they are cleaned up.
51
+ export function configureSessionStateService({ repository: configuredRepository } = {}) {
52
+ if (configuredRepository !== undefined) {
53
+ repositoryInstance = configuredRepository
54
+ repositoryInstanceHandle = null
72
55
  }
73
56
  }
74
57
 
75
- export function setSessionStoragePhase(phase, values = {}) {
76
- if (!Object.values(SESSION_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid session storage phase: ${phase}`)
77
- const updatedAt = new Date().toISOString()
78
- storage().prepare(`UPDATE session_storage_state SET phase = ?, state_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ? WHERE singleton = 1`)
79
- .run(phase, values.stateCount ?? null, values.digest ?? null, values.backupFile ?? null, values.diagnostic ? JSON.stringify(values.diagnostic) : null, updatedAt)
80
- cachedPhase = phase
81
- return readSessionStorageState()
82
- }
83
-
58
+ // Constant authoritative state (startup-state/诊断展示用): there is no
59
+ // session_storage_state table any more SQLite is authoritative by
60
+ // construction, and the live session count comes straight from the store.
84
61
  export function readSessionStorageState() {
85
- const row = stateRow()
86
- if (!row) throw new Error('Session storage state is missing')
87
- cachedPhase = row.phase
62
+ let stateCount = null
63
+ try {
64
+ stateCount = repository().count()
65
+ } catch {
66
+ // SQLite not initialized yet (early startup diagnostics); the phase stays
67
+ // authoritative, the count is simply unknown until the store opens.
68
+ }
88
69
  return {
89
- phase: row.phase,
90
- stateCount: row.state_count === null ? null : Number(row.state_count),
91
- digest: row.digest,
92
- backupFile: row.backup_file,
93
- diagnostic: row.diagnostic_json ? JSON.parse(row.diagnostic_json) : null,
94
- updatedAt: row.updated_at,
70
+ phase: 'authoritative',
71
+ stateCount,
72
+ digest: null,
73
+ backupFile: null,
74
+ diagnostic: null,
75
+ updatedAt: initializedAt,
95
76
  }
96
77
  }
97
78
 
98
79
  export function initializeSessionStateService() {
80
+ initializedAt = new Date().toISOString()
99
81
  return readSessionStorageState()
100
82
  }
101
83
 
102
- export function getSessionStoragePhase() {
103
- return cachedPhase
104
- }
105
-
106
84
  export function isSessionStateAuthoritative() {
107
- return sqliteReadable()
85
+ return true
108
86
  }
109
87
 
88
+ export function stopSessionStateService() {}
89
+
110
90
  function normalizeBucket(value, fallback = null) {
111
91
  const scope = value?.scope ?? fallback?.scope ?? 'global'
112
92
  if (scope === 'project') {
@@ -204,56 +184,39 @@ function assembleState(record) {
204
184
  }
205
185
 
206
186
  // Decide how an incoming full state should be persisted relative to the
207
- // currently stored session:
208
- // - inline: legacy whole-body storage (body keeps its `messages` array).
209
- // - body-only: split session whose messages are unchanged; body/metadata saved.
210
- // - replace: full message rewrite (first split / truncation / boundary change).
187
+ // currently stored session. Storage v2 keeps every session's messages in
188
+ // `session_messages` (the repository extracts any `messages` array on save),
189
+ // so only the incremental tail decision remains:
190
+ // - body-only: messages are unchanged (or absent); only the body is saved.
191
+ // - replace: full message rewrite (first save / truncation / in-place edit).
211
192
  // - append: only the new tail rows are written (incremental).
212
193
  function messageStoragePlan(state, existing) {
213
194
  const incoming = state?.messages
214
- const isSplit = existing?.state?.messageStorage === MESSAGES_SPLIT_VALUE
215
- if (incoming === undefined) return isSplit ? { mode: 'body-only' } : { mode: 'inline' }
195
+ if (incoming === undefined) return { mode: 'body-only' }
216
196
  if (!Array.isArray(incoming)) throw new TypeError('state.messages must be an array')
217
- if (isSplit) {
218
- const storedCount = repository().messageCount({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId })
219
- if (incoming.length < storedCount) return { mode: 'replace', messages: incoming }
220
- const tail = incoming.slice(storedCount)
221
- if (tail.length === 0) {
222
- if (storedCount > 0) {
223
- const last = repository().readMessagesPage({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId, limit: 1, offset: storedCount - 1 })
224
- if (last.messages.length > 0 && last.messages[0].digest !== messageDigest(incoming[storedCount - 1])) {
225
- return { mode: 'replace', messages: incoming }
226
- }
197
+ if (!existing) return { mode: 'replace', messages: incoming }
198
+ if (incoming.length === 0) return { mode: 'replace', messages: incoming }
199
+ const storedCount = repository().messageCount({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId })
200
+ if (incoming.length < storedCount) return { mode: 'replace', messages: incoming }
201
+ const tail = incoming.slice(storedCount)
202
+ if (tail.length === 0) {
203
+ if (storedCount > 0) {
204
+ const last = repository().readLastMessage({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId })
205
+ if (last && last.digest !== messageDigest(incoming[storedCount - 1])) {
206
+ return { mode: 'replace', messages: incoming }
207
+ }
208
+ // Same-length in-place edits in the middle keep count and tail digest
209
+ // identical; probe one deterministic middle row so the edit is not
210
+ // silently dropped. A missing row also forces the full rewrite.
211
+ const midSeq = Math.floor(storedCount / 2)
212
+ if (midSeq !== storedCount - 1
213
+ && repository().readMessageDigestAt({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId, seq: midSeq }) !== messageDigest(incoming[midSeq])) {
214
+ return { mode: 'replace', messages: incoming }
227
215
  }
228
- return { mode: 'body-only' }
229
216
  }
230
- return { mode: 'append', messages: tail }
217
+ return { mode: 'body-only' }
231
218
  }
232
- if (incoming.length === 0) return { mode: 'inline' }
233
- if (incoming.length >= MESSAGES_SPLIT_THRESHOLD) return { mode: 'replace', messages: incoming }
234
- return { mode: 'inline' }
235
- }
236
-
237
- function scheduleSessionJsonMirrorDrain() {
238
- if (!sqliteReadable() || !mirrorAdapter || mirrorTimer) return
239
- mirrorTimer = setTimeout(() => {
240
- mirrorTimer = null
241
- void drainSessionJsonMirror().then(({ pending }) => {
242
- if (pending > 0) scheduleSessionJsonMirrorDrain()
243
- }).catch(() => {
244
- scheduleSessionJsonMirrorDrain()
245
- })
246
- }, MIRROR_DRAIN_INTERVAL_MS)
247
- mirrorTimer.unref?.()
248
- }
249
-
250
- export function requestSessionJsonMirrorDrain() {
251
- scheduleSessionJsonMirrorDrain()
252
- }
253
-
254
- export function stopSessionStateService() {
255
- if (mirrorTimer) clearTimeout(mirrorTimer)
256
- mirrorTimer = null
219
+ return { mode: 'append', messages: tail }
257
220
  }
258
221
 
259
222
  function savePair(state, metadata, options = {}) {
@@ -262,12 +225,7 @@ function savePair(state, metadata, options = {}) {
262
225
  const plan = messageStoragePlan(state, existing)
263
226
  const record = synchronize(state, metadata, sessionId, existing)
264
227
  const finalMetadata = { ...record.metadata }
265
- if (plan.mode === 'inline') {
266
- if (Array.isArray(state.messages)) {
267
- finalMetadata.messageCount = state.messages.length
268
- if (finalMetadata.preview === undefined) finalMetadata.preview = previewFromMessages(state.messages)
269
- }
270
- } else if (plan.mode === 'replace' || plan.mode === 'append') {
228
+ if (plan.mode === 'replace' || plan.mode === 'append') {
271
229
  const fullMessages = Array.isArray(state.messages) ? state.messages : plan.messages
272
230
  finalMetadata.messageCount = plan.mode === 'append' ? fullMessages.length : plan.messages.length
273
231
  if (finalMetadata.preview === undefined) finalMetadata.preview = previewFromMessages(fullMessages)
@@ -290,37 +248,25 @@ function savePair(state, metadata, options = {}) {
290
248
  expectedStateVersion: options.expectedStateVersion,
291
249
  })
292
250
  }
293
- requestSessionJsonMirrorDrain()
294
- // F9 Phase 3: surface the storage plan and the exact persisted message count
295
- // so agent-manager can maintain its conflict-detection counters without
251
+ // Surface the storage plan and the exact persisted message count so
252
+ // agent-manager can maintain its conflict-detection counters without
296
253
  // re-reading the message table on every save.
297
- const totalMessageCount = plan.mode === 'inline'
298
- ? (Array.isArray(state.messages) ? state.messages.length : finalMetadata.messageCount)
299
- : repository().messageCount({ scope: saved.scope, projectId: saved.projectId, sessionId: saved.sessionId })
254
+ const totalMessageCount = repository().messageCount({ scope: saved.scope, projectId: saved.projectId, sessionId: saved.sessionId })
300
255
  return { ...saved, messageStoragePlan: plan.mode, messageCount: totalMessageCount }
301
256
  }
302
257
 
303
258
  /**
304
259
  * Current message representation of a stored session (conflict detection aid):
305
- * - `split`: messages live in `session_messages`; `count`/`tailDigest` describe
306
- * the stored rows (tail digest = row digest of the last message).
307
- * - non-split: messages are inline in the body; `count`/`tailDigest` are derived
308
- * from the body for parity, but agent-manager conflict detection relies on the
309
- * body canonical comparison in that case.
260
+ * every v2 session is split (`split: true`); `count`/`tailDigest` describe the
261
+ * stored rows (tail digest = row digest of the last message).
310
262
  */
311
263
  export function storedMessagesState(sessionId) {
312
- if (!sqliteReadable()) return { split: false, count: 0, tailDigest: '' }
313
264
  const record = repository().findBySessionId(sessionId)
314
265
  if (!record) return { split: false, count: 0, tailDigest: '' }
315
- if (record.state?.messageStorage !== MESSAGES_SPLIT_VALUE) {
316
- const messages = Array.isArray(record.state.messages) ? record.state.messages : []
317
- return { split: false, count: messages.length, tailDigest: messages.length ? messageDigest(messages[messages.length - 1]) : '' }
318
- }
319
266
  const count = repository().messageCount({ scope: record.scope, projectId: record.projectId, sessionId })
320
267
  let tailDigest = ''
321
268
  if (count > 0) {
322
- const last = repository().readMessagesPage({ scope: record.scope, projectId: record.projectId, sessionId, limit: 1, offset: count - 1 })
323
- tailDigest = last.messages[0]?.digest ?? ''
269
+ tailDigest = repository().readLastMessage({ scope: record.scope, projectId: record.projectId, sessionId })?.digest ?? ''
324
270
  }
325
271
  return { split: true, count, tailDigest }
326
272
  }
@@ -336,17 +282,14 @@ export function sessionMessagesTailDigest(messages) {
336
282
  }
337
283
 
338
284
  export function readSessionStateRecord(sessionId) {
339
- if (!sqliteReadable()) return requireJsonAdapter('readRecord')(sessionId)
340
285
  return repository().findBySessionId(sessionId)
341
286
  }
342
287
 
343
288
  export function readSessionStateValue(sessionId) {
344
- if (!sqliteReadable()) return requireJsonAdapter('readState')(sessionId)
345
289
  return assembleState(repository().findBySessionId(sessionId))
346
290
  }
347
291
 
348
292
  export function readSessionMetadataValue(sessionId) {
349
- if (!sqliteReadable()) return requireJsonAdapter('readMetadata')(sessionId)
350
293
  return repository().findBySessionId(sessionId)?.metadata ?? null
351
294
  }
352
295
 
@@ -373,8 +316,13 @@ function applyMetadataToState(existing, metadata) {
373
316
  }
374
317
 
375
318
  function metadataBucketChanges(scope, projectId, updateFn) {
376
- const snapshot = repository().exportSnapshot().records.filter((record) => !scope || (record.scope === scope && (scope !== 'project' || record.projectId === projectId)) )
377
- const current = Object.fromEntries(snapshot.map((record) => [record.sessionId, structuredClone(record.metadata)]))
319
+ // Metadata-only projection (readSessionMetadataBuckets shape): building the
320
+ // bucket's current map must never materialize state bodies or message rows.
321
+ const current = {}
322
+ for (const bucket of readSessionMetadataBuckets()) {
323
+ if (scope && (bucket.scope !== scope || (scope === 'project' && bucket.projectId !== projectId))) continue
324
+ Object.assign(current, bucket.metadata)
325
+ }
378
326
  const updated = updateFn(structuredClone(current))
379
327
  if (!isPlainObject(updated)) throw new TypeError('Updated metadata bucket must be a plain object')
380
328
  const upserts = []
@@ -401,7 +349,6 @@ function metadataBucketChanges(scope, projectId, updateFn) {
401
349
  }
402
350
 
403
351
  export function updateSessionMetadataBucket(scope, projectId, updateFn) {
404
- if (!sqliteReadable()) return requireJsonAdapter('updateMetadataBucket')(scope, projectId, updateFn)
405
352
  const changes = metadataBucketChanges(scope, projectId, updateFn)
406
353
  if (changes.deletes.length > 0) {
407
354
  const error = new TypeError('Metadata bucket updates cannot delete session bodies; use full session delete')
@@ -411,13 +358,24 @@ export function updateSessionMetadataBucket(scope, projectId, updateFn) {
411
358
  }
412
359
  if (changes.upserts.length > 0) {
413
360
  repository().applyBatch({ upserts: changes.upserts })
414
- requestSessionJsonMirrorDrain()
415
361
  }
416
362
  return changes.updated
417
363
  }
418
364
 
419
365
  export function readSessionStateStore(storeName, { scope, projectId } = {}) {
420
- if (!sqliteReadable()) return requireJsonAdapter('readStore')(storeName, { scope, projectId })
366
+ // JSON-era provenance: 'sessions-metadata' has always been a metadata-only
367
+ // bucket store ({sessionId: metadata}). Loading it must never materialize
368
+ // state bodies or message rows — read the metadata-only projection straight
369
+ // from the sessions table; without a filter this returns every bucket's map
370
+ // merged, matching the JSON-era merged-store read contract.
371
+ if (storeName === 'sessions-metadata') {
372
+ const merged = {}
373
+ for (const bucket of readSessionMetadataBuckets()) {
374
+ if (scope && (bucket.scope !== scope || (scope === 'project' && bucket.projectId !== projectId))) continue
375
+ Object.assign(merged, bucket.metadata)
376
+ }
377
+ return merged
378
+ }
421
379
  const records = repository().exportSnapshot().records.filter((record) => {
422
380
  if (!scope) return true
423
381
  if (record.scope !== scope) return false
@@ -426,14 +384,28 @@ export function readSessionStateStore(storeName, { scope, projectId } = {}) {
426
384
  return Object.fromEntries(records.map((record) => [record.sessionId, storeName === 'sessions' ? assembleState(record) : record.metadata]))
427
385
  }
428
386
 
387
+ // Metadata bucket summaries straight from the authoritative sessions table
388
+ // (meta_json projection only — state bodies and message rows are never
389
+ // materialized; the historical OOM lesson). Consumed by session-index
390
+ // wiring and the storage facade's per-bucket metadata updates.
391
+ export function readSessionMetadataBuckets() {
392
+ const rows = storage().prepare('SELECT scope, project_id, session_id, meta_json FROM sessions ORDER BY scope, project_id, session_id').all()
393
+ const buckets = new Map()
394
+ for (const row of rows) {
395
+ const projectId = row.scope === 'project' ? row.project_id : null
396
+ const key = `${row.scope}\0${projectId || ''}`
397
+ if (!buckets.has(key)) buckets.set(key, { scope: row.scope, projectId, metadata: {} })
398
+ buckets.get(key).metadata[row.session_id] = JSON.parse(row.meta_json)
399
+ }
400
+ return [...buckets.values()]
401
+ }
402
+
429
403
  export function saveSessionStatePair({ state, metadata, expectedRevision = null, expectedStateVersion = null } = {}) {
430
- if (!sqliteReadable()) return requireJsonAdapter('savePair')({ state, metadata, expectedRevision, expectedStateVersion })
431
404
  return savePair(state, metadata ?? deriveMetadata(state), { expectedRevision, expectedStateVersion })
432
405
  }
433
406
 
434
407
  export function saveSessionBody(sessionId, value, { expectedRevision = null } = {}) {
435
408
  if (!isPlainObject(value)) throw new TypeError('Session body must be a plain object')
436
- if (!sqliteReadable()) return requireJsonAdapter('saveBody')(sessionId, value, { expectedRevision })
437
409
  const existing = repository().findBySessionId(sessionId)
438
410
  const state = { ...(existing?.state || {}), ...structuredClone(value), id: sessionId }
439
411
  const metadata = deriveMetadata(state, existing?.metadata || {})
@@ -442,7 +414,6 @@ export function saveSessionBody(sessionId, value, { expectedRevision = null } =
442
414
 
443
415
  export function saveSessionMetadata(sessionId, value, { expectedRevision = null } = {}) {
444
416
  if (!isPlainObject(value)) throw new TypeError('Session metadata must be a plain object')
445
- if (!sqliteReadable()) return requireJsonAdapter('saveMetadata')(sessionId, value, { expectedRevision })
446
417
  const existing = repository().findBySessionId(sessionId)
447
418
  if (!existing) {
448
419
  const error = new Error(`Session state does not exist: ${sessionId}`)
@@ -452,23 +423,17 @@ export function saveSessionMetadata(sessionId, value, { expectedRevision = null
452
423
  }
453
424
  const metadata = mergeMetadata(existing.metadata, value, sessionId)
454
425
  const synchronized = applyMetadataToState(existing, metadata)
455
- const saved = repository().save(synchronized, { expectedRevision: expectedRevision ?? existing.revision })
456
- requestSessionJsonMirrorDrain()
457
- return saved
426
+ return repository().save(synchronized, { expectedRevision: expectedRevision ?? existing.revision })
458
427
  }
459
428
 
460
429
  export function deleteSessionState(sessionId, { expectedRevision = null } = {}) {
461
- if (!sqliteReadable()) return requireJsonAdapter('delete')(sessionId, { expectedRevision })
462
430
  const existing = repository().findBySessionId(sessionId)
463
431
  if (!existing) return false
464
- const deleted = repository().deleteBySessionId(sessionId, { expectedRevision: expectedRevision ?? existing.revision })
465
- requestSessionJsonMirrorDrain()
466
- return deleted
432
+ return repository().deleteBySessionId(sessionId, { expectedRevision: expectedRevision ?? existing.revision })
467
433
  }
468
434
 
469
435
  export function replaceSessionStateStore(storeName, values) {
470
436
  if (!isPlainObject(values)) throw new TypeError('Session store must be a plain object')
471
- if (!sqliteReadable()) return requireJsonAdapter('replaceStore')(storeName, values)
472
437
  const current = new Map(repository().exportSnapshot().records.map((record) => [record.sessionId, record]))
473
438
  const records = []
474
439
  if (storeName === 'sessions') {
@@ -500,12 +465,10 @@ export function replaceSessionStateStore(storeName, values) {
500
465
  throw new TypeError(`Unsupported session state store: ${storeName}`)
501
466
  }
502
467
  repository().replaceAll(records)
503
- requestSessionJsonMirrorDrain()
504
468
  return values
505
469
  }
506
470
 
507
471
  export async function atomicSessionRecordUpdate(sessionId, updateFn, { maxRetries = 3 } = {}) {
508
- if (!sqliteReadable()) return requireJsonAdapter('atomicRecordUpdate')(sessionId, updateFn)
509
472
  for (let attempt = 0; attempt < maxRetries; attempt += 1) {
510
473
  const existing = repository().findBySessionId(sessionId)
511
474
  if (!existing) return null
@@ -532,7 +495,6 @@ export async function atomicSessionRecordUpdate(sessionId, updateFn, { maxRetrie
532
495
  }
533
496
 
534
497
  export async function atomicSessionStateUpdate(sessionId, updateFn, { maxRetries = 3 } = {}) {
535
- if (!sqliteReadable()) return requireJsonAdapter('atomicStateUpdate')(sessionId, updateFn)
536
498
  for (let attempt = 0; attempt < maxRetries; attempt += 1) {
537
499
  const existing = repository().findBySessionId(sessionId)
538
500
  if (!existing) return null
@@ -547,7 +509,6 @@ export async function atomicSessionStateUpdate(sessionId, updateFn, { maxRetries
547
509
  }
548
510
 
549
511
  export async function atomicSessionMetadataStateUpdate(scope, projectId, updateFn, { maxRetries = 3 } = {}) {
550
- if (!sqliteReadable()) return requireJsonAdapter('atomicMetadataUpdate')(scope, projectId, updateFn)
551
512
  for (let attempt = 0; attempt < maxRetries; attempt += 1) {
552
513
  try {
553
514
  return updateSessionMetadataBucket(scope, projectId, updateFn)
@@ -560,7 +521,15 @@ export async function atomicSessionMetadataStateUpdate(scope, projectId, updateF
560
521
 
561
522
  export function applySessionBatch(operations) {
562
523
  if (!Array.isArray(operations) || operations.length === 0) throw new TypeError('Session batch operations are required')
563
- if (!sqliteReadable()) return requireJsonAdapter('applyBatch')(operations)
524
+ // pi-web-ui's SessionsStore.delete() emits a `sessions` delete AND a
525
+ // `sessions-metadata` delete for the same key in one transaction. The
526
+ // metadata delete is subsumed by the grouped full delete (idempotent no-op);
527
+ // only a metadata delete without a paired body delete stays rejected.
528
+ const fullDeleteKeys = new Set(
529
+ operations
530
+ .filter((operation) => operation?.type === 'delete' && operation?.store === 'sessions')
531
+ .map((operation) => operation.key),
532
+ )
564
533
  const grouped = new Map()
565
534
  for (const operation of operations) {
566
535
  if (!['sessions', 'sessions-metadata'].includes(operation?.store)) throw new TypeError('Session batch only accepts sessions and sessions-metadata')
@@ -568,8 +537,11 @@ export function applySessionBatch(operations) {
568
537
  if (typeof operation.key !== 'string' || !operation.key) throw new TypeError('Session batch key is required')
569
538
  const entry = grouped.get(operation.key) || { sessionId: operation.key }
570
539
  if (operation.type === 'delete') {
571
- if (operation.store === 'sessions-metadata') throw new TypeError('Metadata-only delete is not allowed')
572
- entry.delete = true
540
+ if (operation.store === 'sessions-metadata') {
541
+ if (!fullDeleteKeys.has(operation.key)) throw new TypeError('Metadata-only delete is not allowed')
542
+ } else {
543
+ entry.delete = true
544
+ }
573
545
  } else if (operation.store === 'sessions') entry.state = operation.value
574
546
  else entry.metadata = operation.value
575
547
  if (operation.expectedRevision !== undefined) entry.expectedRevision = operation.expectedRevision
@@ -623,12 +595,10 @@ export function applySessionBatch(operations) {
623
595
  // succeeds instead of tripping the repository's empty-change guard.
624
596
  if (upserts.length === 0 && deletes.length === 0) return { saved: 0, deleted: 0, revisions: [] }
625
597
  const result = repository().applyBatch({ upserts, deletes })
626
- requestSessionJsonMirrorDrain()
627
598
  return { saved: result.saved.length, deleted: result.deleted.filter(Boolean).length, revisions: result.saved.map((record) => ({ sessionId: record.sessionId, revision: record.revision, stateVersion: record.stateVersion })) }
628
599
  }
629
600
 
630
601
  export function exportSessionStateSnapshot() {
631
- if (!sqliteReadable()) return requireJsonAdapter('exportSnapshot')()
632
602
  const snapshot = repository().exportSnapshot()
633
603
  return {
634
604
  sessions: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, assembleState(record)])),
@@ -654,56 +624,25 @@ export function normalizeSessionSnapshotValues({ sessions, sessionsMetadata }) {
654
624
 
655
625
  export function replaceSessionStateSnapshot({ sessions, sessionsMetadata }, { merge = false } = {}) {
656
626
  if (!isPlainObject(sessions) || !isPlainObject(sessionsMetadata)) throw new TypeError('sessions and sessionsMetadata must be objects')
657
- if (!sqliteReadable()) return requireJsonAdapter('replaceSnapshot')({ sessions, sessionsMetadata }, { merge })
658
627
  const current = merge ? exportSessionStateSnapshot() : { sessions: {}, sessionsMetadata: {} }
659
628
  const targetSessions = { ...current.sessions, ...sessions }
660
629
  const targetMetadata = { ...current.sessionsMetadata, ...sessionsMetadata }
661
630
  const records = normalizeSessionSnapshotValues({ sessions: targetSessions, sessionsMetadata: targetMetadata })
662
631
  repository().replaceAll(records)
663
- requestSessionJsonMirrorDrain()
664
632
  return { sessions: records.length, sessionsMetadata: records.length }
665
633
  }
666
634
 
667
- export async function drainSessionJsonMirror() {
668
- if (drainPromise) return drainPromise
669
- drainPromise = (async () => {
670
- if (!mirrorAdapter) return { drained: 0, pending: repository().listMirrorQueue().length }
671
- let drained = 0
672
- for (const entry of repository().listMirrorQueue()) {
673
- try {
674
- if (entry.operation === 'upsert') {
675
- const state = entry.state?.messageStorage === MESSAGES_SPLIT_VALUE
676
- ? { ...entry.state, messages: allMessages(entry) }
677
- : entry.state
678
- await mirrorAdapter.upsert({ ...entry, state })
679
- } else {
680
- await mirrorAdapter.delete(entry)
681
- }
682
- repository().acknowledgeMirror(entry)
683
- drained += 1
684
- } catch (error) {
685
- repository().failMirror(entry, error)
686
- }
687
- }
688
- return { drained, pending: repository().listMirrorQueue().length }
689
- })().finally(() => { drainPromise = null })
690
- const result = await drainPromise
691
- if (result.pending > 0) scheduleSessionJsonMirrorDrain()
692
- return result
693
- }
694
-
695
635
  export function getSessionStateDiagnostics() {
696
- const state = (() => {
697
- try { return readSessionStorageState() } catch { return { phase: cachedPhase } }
698
- })()
699
- if (!sqliteReadable()) return { ...state, authority: 'json', integrity: null, mirrorPending: null }
636
+ const state = readSessionStorageState()
700
637
  let integrity
701
- let mirrorPending = null
702
638
  try {
703
- integrity = repository().verifyIntegrity()
704
- mirrorPending = repository().listMirrorQueue().length
639
+ // Lightweight (SQL-level) integrity: startup diagnostics on large stores
640
+ // must not re-parse every stored body. The result carries
641
+ // `lightweight: true`; full per-row verification stays on maintenance
642
+ // entry points (backup export/restore, downgrade tooling).
643
+ integrity = repository().verifyIntegrity({ quickCheck: true })
705
644
  } catch (error) {
706
645
  integrity = { ok: false, error: error?.message || String(error) }
707
646
  }
708
- return { ...state, authority: 'sqlite', integrity, mirrorPending }
647
+ return { ...state, authority: 'sqlite', integrity }
709
648
  }
@@ -14,6 +14,7 @@ import {
14
14
  import { readSharesJsonFile } from './share-json-file.mjs'
15
15
  import { createDefaultShareMirror } from './share-service.mjs'
16
16
  import { storageDir } from './storage.mjs'
17
+ import { logger } from './utils/logger.mjs'
17
18
 
18
19
  const DEFAULT_LOCK_TTL_MS = 60_000
19
20
  const DEFAULT_WAIT_TIMEOUT_MS = 65_000
@@ -213,23 +214,29 @@ export async function initializeShareCutover(options = {}) {
213
214
  const storage = options.storage || getSqliteStorage()
214
215
  const repository = options.repository || createShareRepository(storage)
215
216
  configureShareService({ repository, mirror: options.mirror || createDefaultShareMirror() })
216
- return runShareMaintenance(async () => {
217
+ const log = options.logger || logger
218
+ const migrate = async () => {
217
219
  const current = readShareStorageState()
218
220
  if (current.phase === SHARE_STORAGE_PHASES.JSON_PENDING) {
219
221
  const integrity = repository.verifyIntegrity({ quickCheck: true })
220
222
  if (!integrity.ok) throw new Error('Share state pending integrity verification failed')
221
223
  const drained = await drainShareJsonMirror()
222
- if (drained.pending === 0) setShareStoragePhase(SHARE_STORAGE_PHASES.AUTHORITATIVE, { shareCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
224
+ if (drained.pending === 0) {
225
+ setShareStoragePhase(SHARE_STORAGE_PHASES.AUTHORITATIVE, { shareCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
226
+ log.info('Share storage cutover promoted to authoritative', { domain: 'share', phase: 'authoritative', count: integrity.count })
227
+ }
223
228
  return readShareStorageState()
224
229
  }
225
230
  if (current.phase === SHARE_STORAGE_PHASES.AUTHORITATIVE) {
226
231
  const integrity = repository.verifyIntegrity({ quickCheck: true })
227
232
  if (!integrity.ok) throw new Error('Share state authoritative integrity verification failed')
228
233
  await drainShareJsonMirror()
234
+ log.info('Share storage cutover startup check passed', { domain: 'share', phase: 'authoritative' })
229
235
  return readShareStorageState()
230
236
  }
231
237
 
232
238
  if (current.phase === SHARE_STORAGE_PHASES.CUTOVER_RUNNING && current.backupFile) {
239
+ log.warn('Share storage cutover recovery: rerunning the JSON migration', { domain: 'share', phase: 'cutover_running', backupFile: current.backupFile })
233
240
  setShareStoragePhase(SHARE_STORAGE_PHASES.JSON_AUTHORITATIVE, {
234
241
  backupFile: current.backupFile,
235
242
  diagnostic: { operation: 'cutover_recovery', recoveredFrom: SHARE_STORAGE_PHASES.CUTOVER_RUNNING },
@@ -244,6 +251,7 @@ export async function initializeShareCutover(options = {}) {
244
251
  if (first.count !== second.count || first.digest !== second.digest) {
245
252
  throw new Error('Share JSON source changed during cutover double read')
246
253
  }
254
+ log.info('Share storage cutover migration started', { domain: 'share', count: first.count })
247
255
  if (!backupFile) backupFile = await writeShareCutoverBackup(first, options.backupDirectory)
248
256
  const third = buildShareJsonSnapshot(await readJson())
249
257
  if (first.count !== third.count || first.digest !== third.digest) {
@@ -280,7 +288,9 @@ export async function initializeShareCutover(options = {}) {
280
288
  diagnostic: first.diagnostics,
281
289
  })
282
290
  }
283
- return readShareStorageState()
291
+ const result = readShareStorageState()
292
+ log.info('Share storage cutover migration complete', { domain: 'share', phase: result.phase, count: first.count })
293
+ return result
284
294
  } catch (error) {
285
295
  const state = readShareStorageState()
286
296
  if (![SHARE_STORAGE_PHASES.JSON_PENDING, SHARE_STORAGE_PHASES.AUTHORITATIVE].includes(state.phase)) {
@@ -288,9 +298,16 @@ export async function initializeShareCutover(options = {}) {
288
298
  backupFile,
289
299
  diagnostic: { operation: 'cutover', errorName: error?.name || 'Error', error: error?.message || String(error) },
290
300
  })
301
+ log.warn('Share storage cutover failed; keeping the JSON store path', { domain: 'share', phase: 'json_authoritative', errorName: error?.name || 'Error', errorMessage: error?.message })
291
302
  return readShareStorageState()
292
303
  }
293
304
  throw error
294
305
  }
295
- }, { ...options, storage, operation: 'share-cutover' })
306
+ }
307
+ try {
308
+ return await runShareMaintenance(migrate, { ...options, storage, operation: 'share-cutover' })
309
+ } catch (error) {
310
+ log.error('Share storage cutover failed and blocked startup', { domain: 'share', errorName: error?.name || 'Error', errorMessage: error?.message })
311
+ throw error
312
+ }
296
313
  }