@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,30 +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
15
-
16
- // Mirror drain page size: the queue rows carry full state_json payloads, so
17
- // the drain pulls a bounded batch at a time instead of loading the whole
18
- // outbox (large cutover imports can enqueue thousands of entries).
19
- const MIRROR_DRAIN_BATCH_LIMIT = 8
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.
20
9
 
21
10
  let repositoryInstance = null
22
- let cachedPhase = SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE
23
- let jsonAdapter = null
24
- let mirrorAdapter = null
25
- let drainPromise = null
26
- let mirrorTimer = null
27
- const MIRROR_DRAIN_INTERVAL_MS = 1000
11
+ let repositoryInstanceHandle = null
12
+ let initializedAt = null
28
13
 
29
14
  function isPlainObject(value) {
30
15
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
@@ -50,68 +35,58 @@ function storage() {
50
35
  }
51
36
 
52
37
  function repository() {
53
- 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
+ }
54
45
  return repositoryInstance
55
46
  }
56
47
 
57
- function stateRow() {
58
- return storage().prepare('SELECT * FROM session_storage_state WHERE singleton = 1').get()
59
- }
60
-
61
- function sqliteReadable() {
62
- return cachedPhase === SESSION_STORAGE_PHASES.JSON_PENDING || cachedPhase === SESSION_STORAGE_PHASES.AUTHORITATIVE
63
- }
64
-
65
- function requireJsonAdapter(method) {
66
- if (typeof jsonAdapter?.[method] !== 'function') throw new Error(`JSON authoritative session adapter does not implement ${method}`)
67
- return jsonAdapter[method].bind(jsonAdapter)
68
- }
69
-
70
- export function configureSessionStateService({ repository: configuredRepository, json, mirror, phase } = {}) {
71
- if (configuredRepository !== undefined) repositoryInstance = configuredRepository
72
- if (json !== undefined) jsonAdapter = json
73
- if (mirror !== undefined) mirrorAdapter = mirror
74
- if (phase !== undefined) {
75
- if (!Object.values(SESSION_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid session storage phase: ${phase}`)
76
- 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
77
55
  }
78
56
  }
79
57
 
80
- export function setSessionStoragePhase(phase, values = {}) {
81
- if (!Object.values(SESSION_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid session storage phase: ${phase}`)
82
- const updatedAt = new Date().toISOString()
83
- storage().prepare(`UPDATE session_storage_state SET phase = ?, state_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ? WHERE singleton = 1`)
84
- .run(phase, values.stateCount ?? null, values.digest ?? null, values.backupFile ?? null, values.diagnostic ? JSON.stringify(values.diagnostic) : null, updatedAt)
85
- cachedPhase = phase
86
- return readSessionStorageState()
87
- }
88
-
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.
89
61
  export function readSessionStorageState() {
90
- const row = stateRow()
91
- if (!row) throw new Error('Session storage state is missing')
92
- 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
+ }
93
69
  return {
94
- phase: row.phase,
95
- stateCount: row.state_count === null ? null : Number(row.state_count),
96
- digest: row.digest,
97
- backupFile: row.backup_file,
98
- diagnostic: row.diagnostic_json ? JSON.parse(row.diagnostic_json) : null,
99
- updatedAt: row.updated_at,
70
+ phase: 'authoritative',
71
+ stateCount,
72
+ digest: null,
73
+ backupFile: null,
74
+ diagnostic: null,
75
+ updatedAt: initializedAt,
100
76
  }
101
77
  }
102
78
 
103
79
  export function initializeSessionStateService() {
80
+ initializedAt = new Date().toISOString()
104
81
  return readSessionStorageState()
105
82
  }
106
83
 
107
- export function getSessionStoragePhase() {
108
- return cachedPhase
109
- }
110
-
111
84
  export function isSessionStateAuthoritative() {
112
- return sqliteReadable()
85
+ return true
113
86
  }
114
87
 
88
+ export function stopSessionStateService() {}
89
+
115
90
  function normalizeBucket(value, fallback = null) {
116
91
  const scope = value?.scope ?? fallback?.scope ?? 'global'
117
92
  if (scope === 'project') {
@@ -209,56 +184,39 @@ function assembleState(record) {
209
184
  }
210
185
 
211
186
  // Decide how an incoming full state should be persisted relative to the
212
- // currently stored session:
213
- // - inline: legacy whole-body storage (body keeps its `messages` array).
214
- // - body-only: split session whose messages are unchanged; body/metadata saved.
215
- // - 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).
216
192
  // - append: only the new tail rows are written (incremental).
217
193
  function messageStoragePlan(state, existing) {
218
194
  const incoming = state?.messages
219
- const isSplit = existing?.state?.messageStorage === MESSAGES_SPLIT_VALUE
220
- if (incoming === undefined) return isSplit ? { mode: 'body-only' } : { mode: 'inline' }
195
+ if (incoming === undefined) return { mode: 'body-only' }
221
196
  if (!Array.isArray(incoming)) throw new TypeError('state.messages must be an array')
222
- if (isSplit) {
223
- const storedCount = repository().messageCount({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId })
224
- if (incoming.length < storedCount) return { mode: 'replace', messages: incoming }
225
- const tail = incoming.slice(storedCount)
226
- if (tail.length === 0) {
227
- if (storedCount > 0) {
228
- const last = repository().readMessagesPage({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId, limit: 1, offset: storedCount - 1 })
229
- if (last.messages.length > 0 && last.messages[0].digest !== messageDigest(incoming[storedCount - 1])) {
230
- return { mode: 'replace', messages: incoming }
231
- }
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 }
232
215
  }
233
- return { mode: 'body-only' }
234
216
  }
235
- return { mode: 'append', messages: tail }
217
+ return { mode: 'body-only' }
236
218
  }
237
- if (incoming.length === 0) return { mode: 'inline' }
238
- if (incoming.length >= MESSAGES_SPLIT_THRESHOLD) return { mode: 'replace', messages: incoming }
239
- return { mode: 'inline' }
240
- }
241
-
242
- function scheduleSessionJsonMirrorDrain() {
243
- if (!sqliteReadable() || !mirrorAdapter || mirrorTimer) return
244
- mirrorTimer = setTimeout(() => {
245
- mirrorTimer = null
246
- void drainSessionJsonMirror().then(({ pending }) => {
247
- if (pending > 0) scheduleSessionJsonMirrorDrain()
248
- }).catch(() => {
249
- scheduleSessionJsonMirrorDrain()
250
- })
251
- }, MIRROR_DRAIN_INTERVAL_MS)
252
- mirrorTimer.unref?.()
253
- }
254
-
255
- export function requestSessionJsonMirrorDrain() {
256
- scheduleSessionJsonMirrorDrain()
257
- }
258
-
259
- export function stopSessionStateService() {
260
- if (mirrorTimer) clearTimeout(mirrorTimer)
261
- mirrorTimer = null
219
+ return { mode: 'append', messages: tail }
262
220
  }
263
221
 
264
222
  function savePair(state, metadata, options = {}) {
@@ -267,12 +225,7 @@ function savePair(state, metadata, options = {}) {
267
225
  const plan = messageStoragePlan(state, existing)
268
226
  const record = synchronize(state, metadata, sessionId, existing)
269
227
  const finalMetadata = { ...record.metadata }
270
- if (plan.mode === 'inline') {
271
- if (Array.isArray(state.messages)) {
272
- finalMetadata.messageCount = state.messages.length
273
- if (finalMetadata.preview === undefined) finalMetadata.preview = previewFromMessages(state.messages)
274
- }
275
- } else if (plan.mode === 'replace' || plan.mode === 'append') {
228
+ if (plan.mode === 'replace' || plan.mode === 'append') {
276
229
  const fullMessages = Array.isArray(state.messages) ? state.messages : plan.messages
277
230
  finalMetadata.messageCount = plan.mode === 'append' ? fullMessages.length : plan.messages.length
278
231
  if (finalMetadata.preview === undefined) finalMetadata.preview = previewFromMessages(fullMessages)
@@ -295,37 +248,25 @@ function savePair(state, metadata, options = {}) {
295
248
  expectedStateVersion: options.expectedStateVersion,
296
249
  })
297
250
  }
298
- requestSessionJsonMirrorDrain()
299
- // F9 Phase 3: surface the storage plan and the exact persisted message count
300
- // 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
301
253
  // re-reading the message table on every save.
302
- const totalMessageCount = plan.mode === 'inline'
303
- ? (Array.isArray(state.messages) ? state.messages.length : finalMetadata.messageCount)
304
- : 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 })
305
255
  return { ...saved, messageStoragePlan: plan.mode, messageCount: totalMessageCount }
306
256
  }
307
257
 
308
258
  /**
309
259
  * Current message representation of a stored session (conflict detection aid):
310
- * - `split`: messages live in `session_messages`; `count`/`tailDigest` describe
311
- * the stored rows (tail digest = row digest of the last message).
312
- * - non-split: messages are inline in the body; `count`/`tailDigest` are derived
313
- * from the body for parity, but agent-manager conflict detection relies on the
314
- * 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).
315
262
  */
316
263
  export function storedMessagesState(sessionId) {
317
- if (!sqliteReadable()) return { split: false, count: 0, tailDigest: '' }
318
264
  const record = repository().findBySessionId(sessionId)
319
265
  if (!record) return { split: false, count: 0, tailDigest: '' }
320
- if (record.state?.messageStorage !== MESSAGES_SPLIT_VALUE) {
321
- const messages = Array.isArray(record.state.messages) ? record.state.messages : []
322
- return { split: false, count: messages.length, tailDigest: messages.length ? messageDigest(messages[messages.length - 1]) : '' }
323
- }
324
266
  const count = repository().messageCount({ scope: record.scope, projectId: record.projectId, sessionId })
325
267
  let tailDigest = ''
326
268
  if (count > 0) {
327
- const last = repository().readMessagesPage({ scope: record.scope, projectId: record.projectId, sessionId, limit: 1, offset: count - 1 })
328
- tailDigest = last.messages[0]?.digest ?? ''
269
+ tailDigest = repository().readLastMessage({ scope: record.scope, projectId: record.projectId, sessionId })?.digest ?? ''
329
270
  }
330
271
  return { split: true, count, tailDigest }
331
272
  }
@@ -341,17 +282,14 @@ export function sessionMessagesTailDigest(messages) {
341
282
  }
342
283
 
343
284
  export function readSessionStateRecord(sessionId) {
344
- if (!sqliteReadable()) return requireJsonAdapter('readRecord')(sessionId)
345
285
  return repository().findBySessionId(sessionId)
346
286
  }
347
287
 
348
288
  export function readSessionStateValue(sessionId) {
349
- if (!sqliteReadable()) return requireJsonAdapter('readState')(sessionId)
350
289
  return assembleState(repository().findBySessionId(sessionId))
351
290
  }
352
291
 
353
292
  export function readSessionMetadataValue(sessionId) {
354
- if (!sqliteReadable()) return requireJsonAdapter('readMetadata')(sessionId)
355
293
  return repository().findBySessionId(sessionId)?.metadata ?? null
356
294
  }
357
295
 
@@ -378,8 +316,13 @@ function applyMetadataToState(existing, metadata) {
378
316
  }
379
317
 
380
318
  function metadataBucketChanges(scope, projectId, updateFn) {
381
- const snapshot = repository().exportSnapshot().records.filter((record) => !scope || (record.scope === scope && (scope !== 'project' || record.projectId === projectId)) )
382
- 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
+ }
383
326
  const updated = updateFn(structuredClone(current))
384
327
  if (!isPlainObject(updated)) throw new TypeError('Updated metadata bucket must be a plain object')
385
328
  const upserts = []
@@ -406,7 +349,6 @@ function metadataBucketChanges(scope, projectId, updateFn) {
406
349
  }
407
350
 
408
351
  export function updateSessionMetadataBucket(scope, projectId, updateFn) {
409
- if (!sqliteReadable()) return requireJsonAdapter('updateMetadataBucket')(scope, projectId, updateFn)
410
352
  const changes = metadataBucketChanges(scope, projectId, updateFn)
411
353
  if (changes.deletes.length > 0) {
412
354
  const error = new TypeError('Metadata bucket updates cannot delete session bodies; use full session delete')
@@ -416,13 +358,24 @@ export function updateSessionMetadataBucket(scope, projectId, updateFn) {
416
358
  }
417
359
  if (changes.upserts.length > 0) {
418
360
  repository().applyBatch({ upserts: changes.upserts })
419
- requestSessionJsonMirrorDrain()
420
361
  }
421
362
  return changes.updated
422
363
  }
423
364
 
424
365
  export function readSessionStateStore(storeName, { scope, projectId } = {}) {
425
- 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
+ }
426
379
  const records = repository().exportSnapshot().records.filter((record) => {
427
380
  if (!scope) return true
428
381
  if (record.scope !== scope) return false
@@ -431,14 +384,28 @@ export function readSessionStateStore(storeName, { scope, projectId } = {}) {
431
384
  return Object.fromEntries(records.map((record) => [record.sessionId, storeName === 'sessions' ? assembleState(record) : record.metadata]))
432
385
  }
433
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
+
434
403
  export function saveSessionStatePair({ state, metadata, expectedRevision = null, expectedStateVersion = null } = {}) {
435
- if (!sqliteReadable()) return requireJsonAdapter('savePair')({ state, metadata, expectedRevision, expectedStateVersion })
436
404
  return savePair(state, metadata ?? deriveMetadata(state), { expectedRevision, expectedStateVersion })
437
405
  }
438
406
 
439
407
  export function saveSessionBody(sessionId, value, { expectedRevision = null } = {}) {
440
408
  if (!isPlainObject(value)) throw new TypeError('Session body must be a plain object')
441
- if (!sqliteReadable()) return requireJsonAdapter('saveBody')(sessionId, value, { expectedRevision })
442
409
  const existing = repository().findBySessionId(sessionId)
443
410
  const state = { ...(existing?.state || {}), ...structuredClone(value), id: sessionId }
444
411
  const metadata = deriveMetadata(state, existing?.metadata || {})
@@ -447,7 +414,6 @@ export function saveSessionBody(sessionId, value, { expectedRevision = null } =
447
414
 
448
415
  export function saveSessionMetadata(sessionId, value, { expectedRevision = null } = {}) {
449
416
  if (!isPlainObject(value)) throw new TypeError('Session metadata must be a plain object')
450
- if (!sqliteReadable()) return requireJsonAdapter('saveMetadata')(sessionId, value, { expectedRevision })
451
417
  const existing = repository().findBySessionId(sessionId)
452
418
  if (!existing) {
453
419
  const error = new Error(`Session state does not exist: ${sessionId}`)
@@ -457,23 +423,17 @@ export function saveSessionMetadata(sessionId, value, { expectedRevision = null
457
423
  }
458
424
  const metadata = mergeMetadata(existing.metadata, value, sessionId)
459
425
  const synchronized = applyMetadataToState(existing, metadata)
460
- const saved = repository().save(synchronized, { expectedRevision: expectedRevision ?? existing.revision })
461
- requestSessionJsonMirrorDrain()
462
- return saved
426
+ return repository().save(synchronized, { expectedRevision: expectedRevision ?? existing.revision })
463
427
  }
464
428
 
465
429
  export function deleteSessionState(sessionId, { expectedRevision = null } = {}) {
466
- if (!sqliteReadable()) return requireJsonAdapter('delete')(sessionId, { expectedRevision })
467
430
  const existing = repository().findBySessionId(sessionId)
468
431
  if (!existing) return false
469
- const deleted = repository().deleteBySessionId(sessionId, { expectedRevision: expectedRevision ?? existing.revision })
470
- requestSessionJsonMirrorDrain()
471
- return deleted
432
+ return repository().deleteBySessionId(sessionId, { expectedRevision: expectedRevision ?? existing.revision })
472
433
  }
473
434
 
474
435
  export function replaceSessionStateStore(storeName, values) {
475
436
  if (!isPlainObject(values)) throw new TypeError('Session store must be a plain object')
476
- if (!sqliteReadable()) return requireJsonAdapter('replaceStore')(storeName, values)
477
437
  const current = new Map(repository().exportSnapshot().records.map((record) => [record.sessionId, record]))
478
438
  const records = []
479
439
  if (storeName === 'sessions') {
@@ -505,12 +465,10 @@ export function replaceSessionStateStore(storeName, values) {
505
465
  throw new TypeError(`Unsupported session state store: ${storeName}`)
506
466
  }
507
467
  repository().replaceAll(records)
508
- requestSessionJsonMirrorDrain()
509
468
  return values
510
469
  }
511
470
 
512
471
  export async function atomicSessionRecordUpdate(sessionId, updateFn, { maxRetries = 3 } = {}) {
513
- if (!sqliteReadable()) return requireJsonAdapter('atomicRecordUpdate')(sessionId, updateFn)
514
472
  for (let attempt = 0; attempt < maxRetries; attempt += 1) {
515
473
  const existing = repository().findBySessionId(sessionId)
516
474
  if (!existing) return null
@@ -537,7 +495,6 @@ export async function atomicSessionRecordUpdate(sessionId, updateFn, { maxRetrie
537
495
  }
538
496
 
539
497
  export async function atomicSessionStateUpdate(sessionId, updateFn, { maxRetries = 3 } = {}) {
540
- if (!sqliteReadable()) return requireJsonAdapter('atomicStateUpdate')(sessionId, updateFn)
541
498
  for (let attempt = 0; attempt < maxRetries; attempt += 1) {
542
499
  const existing = repository().findBySessionId(sessionId)
543
500
  if (!existing) return null
@@ -552,7 +509,6 @@ export async function atomicSessionStateUpdate(sessionId, updateFn, { maxRetries
552
509
  }
553
510
 
554
511
  export async function atomicSessionMetadataStateUpdate(scope, projectId, updateFn, { maxRetries = 3 } = {}) {
555
- if (!sqliteReadable()) return requireJsonAdapter('atomicMetadataUpdate')(scope, projectId, updateFn)
556
512
  for (let attempt = 0; attempt < maxRetries; attempt += 1) {
557
513
  try {
558
514
  return updateSessionMetadataBucket(scope, projectId, updateFn)
@@ -565,7 +521,15 @@ export async function atomicSessionMetadataStateUpdate(scope, projectId, updateF
565
521
 
566
522
  export function applySessionBatch(operations) {
567
523
  if (!Array.isArray(operations) || operations.length === 0) throw new TypeError('Session batch operations are required')
568
- 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
+ )
569
533
  const grouped = new Map()
570
534
  for (const operation of operations) {
571
535
  if (!['sessions', 'sessions-metadata'].includes(operation?.store)) throw new TypeError('Session batch only accepts sessions and sessions-metadata')
@@ -573,8 +537,11 @@ export function applySessionBatch(operations) {
573
537
  if (typeof operation.key !== 'string' || !operation.key) throw new TypeError('Session batch key is required')
574
538
  const entry = grouped.get(operation.key) || { sessionId: operation.key }
575
539
  if (operation.type === 'delete') {
576
- if (operation.store === 'sessions-metadata') throw new TypeError('Metadata-only delete is not allowed')
577
- 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
+ }
578
545
  } else if (operation.store === 'sessions') entry.state = operation.value
579
546
  else entry.metadata = operation.value
580
547
  if (operation.expectedRevision !== undefined) entry.expectedRevision = operation.expectedRevision
@@ -628,12 +595,10 @@ export function applySessionBatch(operations) {
628
595
  // succeeds instead of tripping the repository's empty-change guard.
629
596
  if (upserts.length === 0 && deletes.length === 0) return { saved: 0, deleted: 0, revisions: [] }
630
597
  const result = repository().applyBatch({ upserts, deletes })
631
- requestSessionJsonMirrorDrain()
632
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 })) }
633
599
  }
634
600
 
635
601
  export function exportSessionStateSnapshot() {
636
- if (!sqliteReadable()) return requireJsonAdapter('exportSnapshot')()
637
602
  const snapshot = repository().exportSnapshot()
638
603
  return {
639
604
  sessions: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, assembleState(record)])),
@@ -659,72 +624,25 @@ export function normalizeSessionSnapshotValues({ sessions, sessionsMetadata }) {
659
624
 
660
625
  export function replaceSessionStateSnapshot({ sessions, sessionsMetadata }, { merge = false } = {}) {
661
626
  if (!isPlainObject(sessions) || !isPlainObject(sessionsMetadata)) throw new TypeError('sessions and sessionsMetadata must be objects')
662
- if (!sqliteReadable()) return requireJsonAdapter('replaceSnapshot')({ sessions, sessionsMetadata }, { merge })
663
627
  const current = merge ? exportSessionStateSnapshot() : { sessions: {}, sessionsMetadata: {} }
664
628
  const targetSessions = { ...current.sessions, ...sessions }
665
629
  const targetMetadata = { ...current.sessionsMetadata, ...sessionsMetadata }
666
630
  const records = normalizeSessionSnapshotValues({ sessions: targetSessions, sessionsMetadata: targetMetadata })
667
631
  repository().replaceAll(records)
668
- requestSessionJsonMirrorDrain()
669
632
  return { sessions: records.length, sessionsMetadata: records.length }
670
633
  }
671
634
 
672
- export async function drainSessionJsonMirror() {
673
- if (drainPromise) return drainPromise
674
- drainPromise = (async () => {
675
- if (!mirrorAdapter) return { drained: 0, pending: repository().countMirrorQueue() }
676
- let drained = 0
677
- // Page through the outbox in small batches. A failed entry gets its
678
- // updated_at bumped by failMirror, so it sorts behind the remaining
679
- // entries and the next page drains others first; entries that keep
680
- // failing are retried by the scheduled drain. A batch with zero
681
- // acknowledgements stops the loop (no head-of-line livelock).
682
- for (;;) {
683
- const batch = repository().listMirrorQueue({ limit: MIRROR_DRAIN_BATCH_LIMIT })
684
- if (batch.length === 0) break
685
- let acknowledged = 0
686
- for (const entry of batch) {
687
- try {
688
- if (entry.operation === 'upsert') {
689
- const state = entry.state?.messageStorage === MESSAGES_SPLIT_VALUE
690
- ? { ...entry.state, messages: allMessages(entry) }
691
- : entry.state
692
- await mirrorAdapter.upsert({ ...entry, state })
693
- } else {
694
- await mirrorAdapter.delete(entry)
695
- }
696
- repository().acknowledgeMirror(entry)
697
- drained += 1
698
- acknowledged += 1
699
- } catch (error) {
700
- repository().failMirror(entry, error)
701
- }
702
- }
703
- if (acknowledged === 0) break
704
- }
705
- return { drained, pending: repository().countMirrorQueue() }
706
- })().finally(() => { drainPromise = null })
707
- const result = await drainPromise
708
- if (result.pending > 0) scheduleSessionJsonMirrorDrain()
709
- return result
710
- }
711
-
712
635
  export function getSessionStateDiagnostics() {
713
- const state = (() => {
714
- try { return readSessionStorageState() } catch { return { phase: cachedPhase } }
715
- })()
716
- if (!sqliteReadable()) return { ...state, authority: 'json', integrity: null, mirrorPending: null }
636
+ const state = readSessionStorageState()
717
637
  let integrity
718
- let mirrorPending = null
719
638
  try {
720
639
  // Lightweight (SQL-level) integrity: startup diagnostics on large stores
721
640
  // must not re-parse every stored body. The result carries
722
641
  // `lightweight: true`; full per-row verification stays on maintenance
723
642
  // entry points (backup export/restore, downgrade tooling).
724
643
  integrity = repository().verifyIntegrity({ quickCheck: true })
725
- mirrorPending = repository().countMirrorQueue()
726
644
  } catch (error) {
727
645
  integrity = { ok: false, error: error?.message || String(error) }
728
646
  }
729
- return { ...state, authority: 'sqlite', integrity, mirrorPending }
647
+ return { ...state, authority: 'sqlite', integrity }
730
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
  }