@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.
- package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-CApCpsDd.js} +1 -1
- package/dist/assets/ChatPanelHost-COTO7swS.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +1 -0
- package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-BIbIDfWY.js} +1 -1
- package/dist/assets/ScheduledTasksPage-BOqmBNCx.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-BCRtycRN.js} +304 -304
- package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +1 -0
- package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-DJa0M0El.js} +1 -1
- package/dist/assets/TerminalDock-Cqz_9mTC.js +2 -0
- package/dist/assets/WorkspaceInspector-C-GOK81D.js +36 -0
- package/dist/assets/{abnfDiagram-VRR7QNED-CMhVoYUh.js → abnfDiagram-VRR7QNED-DGklo3PO.js} +1 -1
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-C52qL6Hg.js → architectureDiagram-ZJ3FMSHR-CioWLVTp.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-CM3XbLol.js → blockDiagram-677ZJIJ3-BJWBq5e9.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-D7Bh1ui1.js → c4Diagram-LMCZKHZV-BacRvqFK.js} +1 -1
- package/dist/assets/channel-CeXY-wZo.js +1 -0
- package/dist/assets/{chunk-32BRIVSS-xgrtbT8e.js → chunk-32BRIVSS-B53BNE2C.js} +1 -1
- package/dist/assets/{chunk-52WLFC77-CDbaHw80.js → chunk-52WLFC77-CYq4hT4f.js} +1 -1
- package/dist/assets/{chunk-C7G6YPKG-ZMLUvfLy.js → chunk-C7G6YPKG-KFaGpP_Z.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-BcQhaqt7.js → chunk-EX3LRPZG-DqVd_ihG.js} +1 -1
- package/dist/assets/{chunk-FWX5IMBZ-DrnrdrSu.js → chunk-FWX5IMBZ-BxRjZkOP.js} +2 -2
- package/dist/assets/{chunk-HOUHSVGY-tdc-Z9eW.js → chunk-HOUHSVGY-C9QO8omv.js} +1 -1
- package/dist/assets/{chunk-ICXQ74PX-Cd7igVGt.js → chunk-ICXQ74PX-ClbGPgLV.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-CLTjV5ov.js → chunk-MOJQB5TN-Ba4wlF1J.js} +1 -1
- package/dist/assets/{chunk-OGEWGWER-TgvkXkFX.js → chunk-OGEWGWER-DczhQmcx.js} +1 -1
- package/dist/assets/{chunk-PUDLZKDR-C5GMVYup.js → chunk-PUDLZKDR-CPWpcKUF.js} +1 -1
- package/dist/assets/{chunk-Q4XR5HBZ-fo5-HsqY.js → chunk-Q4XR5HBZ-CiF6T--X.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-CEDazV6w.js → chunk-V7JOEXUC-Dxv0zwmw.js} +1 -1
- package/dist/assets/{chunk-VAUOI2AC-CWYjg8zE.js → chunk-VAUOI2AC-D3J5bw1P.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-C_-eEj-9.js → chunk-VR4S4FIN-BXQSwwII.js} +1 -1
- package/dist/assets/{chunk-WYO6CB5R-NF-MsJJR.js → chunk-WYO6CB5R-C0azl-fk.js} +2 -2
- package/dist/assets/{chunk-ZGVPDNZ5-CRrAYi4g.js → chunk-ZGVPDNZ5-D_8F4Akx.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-B8M6sf49.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-BY7fzBDH.js +1 -0
- package/dist/assets/{cynefinDiagram-TSTJHNR4-D4sZ7LYI.js → cynefinDiagram-TSTJHNR4-BFlsWz0B.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB-BlsnSh-Q.js → dagre-VKFMJZFB-DXhpjlEs.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-DW1yQiRO.js → diagram-FQU43EPY-DRVVOD9k.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-BTUAsFjb.js → diagram-G47NLZAW-BV9Gw1tL.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-BcaomqSd.js → diagram-NH7WQ7WH-CrUkXfRY.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-DDLX7Xwr.js → diagram-OA4YK3LP-CuILjn7v.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-CIave0An.js → diagram-WEI45ONY-nlNN3tWB.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-Cr1Y6lPu.js → ebnfDiagram-CCIWWBDH-BuDgZt9n.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-D3EgiVVH.js → erDiagram-Q63AITRT-MEwxjmtP.js} +1 -1
- package/dist/assets/flowDiagram-23GEKE2U-BiUP-Scq.js +1 -0
- package/dist/assets/{ganttDiagram-NO4QXBWP-B0oyDIqK.js → ganttDiagram-NO4QXBWP-DNw5qghE.js} +1 -1
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-uQtr6IqK.js → gitGraphDiagram-IHSO6WYX-CaTcakAt.js} +1 -1
- package/dist/assets/index-BWf6y3Kh.css +3 -0
- package/dist/assets/index-D3GWkXJR.js +66 -0
- package/dist/assets/{infoDiagram-FWYZ7A6U-B_GzlLIM.js → infoDiagram-FWYZ7A6U-DwfyP7dU.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CKQIsSdU.js → ishikawaDiagram-FXEZZL3T-CmrzbOCV.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-DrrvzZL1.js → journeyDiagram-5HDEW3XC-CYqe3sgr.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-CJQRTdXz.js → kanban-definition-HUTT4EX6-BMDO_mv0.js} +1 -1
- package/dist/assets/{line-B4DxX7tC.js → line-SbxJR8ZT.js} +1 -1
- package/dist/assets/lit-vendor-CinchGmI.js +2 -0
- package/dist/assets/local-tools-DY0yEU3T.js +350 -0
- package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-7Xwu2RVW.js} +2 -2
- package/dist/assets/{mermaid.core-DWpShXCs.js → mermaid.core-CwXTzYjs.js} +3 -3
- package/dist/assets/{mindmap-definition-LN4V7U3C-CEVwZD78.js → mindmap-definition-LN4V7U3C-CLyJ78mO.js} +1 -1
- package/dist/assets/{pegDiagram-2B236MQR-CBLkBdnQ.js → pegDiagram-2B236MQR-4qFznrSw.js} +1 -1
- package/dist/assets/{pi-web-ui-DV5jKjSg.js → pi-web-ui-BNuvqKDT.js} +289 -289
- package/dist/assets/{pieDiagram-ENE6RG2P-8tCnwtWS.js → pieDiagram-ENE6RG2P-141sGxVe.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-CWd28cHF.js → quadrantDiagram-ABIIQ3AL-SUI44pAX.js} +1 -1
- package/dist/assets/{railroadDiagram-RFXS5EU6-CI4ouOBs.js → railroadDiagram-RFXS5EU6-1oiZuYBB.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-DtFz7sw4.js → requirementDiagram-TGXJPOKE-CfVishLi.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB--VeOwwk9.js → sankeyDiagram-HTMAVEWB-DVu7J_Z9.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-B8AHk5Oe.js → sequenceDiagram-DBY2YBRQ-D-Ip2yiS.js} +1 -1
- package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-C_JvWbAa.js} +3 -3
- package/dist/assets/{stateDiagram-2N3HPSRC-etXnJ8X8.js → stateDiagram-2N3HPSRC-Cg0hWwrp.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DhmzNQd9.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-pabIKAmn.js → swimlanes-5IMT3BWC-Co5D-cGU.js} +1 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-BEvomQNa.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-C55MKm3L.js → timeline-definition-FHXFAJF6-CBk4c_Ua.js} +1 -1
- package/dist/assets/{vennDiagram-L72KCM5P-CgNf1Q9W.js → vennDiagram-L72KCM5P-BII7wdRx.js} +1 -1
- package/dist/assets/{wardleyDiagram-EHGQE667-Drt5ytec.js → wardleyDiagram-EHGQE667-CkyJJIXP.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-hhoEo_Ar.js → xychartDiagram-FW5EYKEG-B1A66Ks0.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +192 -48
- package/server/approval-store.mjs +1 -0
- package/server/ask-store.mjs +78 -0
- package/server/auto-archive.mjs +55 -8
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
- package/server/maintenance/export-session-state-v1.mjs +1 -11
- package/server/routes/agent.mjs +9 -0
- package/server/routes/backup.mjs +1 -1
- package/server/routes/storage.mjs +63 -49
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-state-backup.mjs +56 -5
- package/server/session-state-import.mjs +174 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +128 -210
- package/server/share-cutover.mjs +21 -4
- package/server/sqlite/database.mjs +142 -7
- package/server/sqlite/lan-access-repository.mjs +6 -4
- package/server/sqlite/migrations.mjs +109 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
- package/server/sqlite/session-index-repository.mjs +59 -208
- package/server/sqlite/session-state-repository.mjs +337 -387
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +218 -454
- package/server/tools/definitions.mjs +18 -0
- package/server/tools/index.mjs +14 -2
- package/server/utils/logger.mjs +15 -4
- package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
- package/dist/assets/CloudAccountSettingsPage-CDkKN1Sz.js +0 -1
- package/dist/assets/ScheduledTasksPage-C9lOTi75.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-CH_t33A3.js +0 -1
- package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
- package/dist/assets/WorkspaceInspector-DPXgLIum.js +0 -36
- package/dist/assets/channel-Duu1CuH9.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-Cc63fLWP.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-CoXU0qT6.js +0 -1
- package/dist/assets/flowDiagram-23GEKE2U-BS7KXcqn.js +0 -1
- package/dist/assets/index-DQBOm9gt.js +0 -66
- package/dist/assets/index-ps85guTs.css +0 -3
- package/dist/assets/lit-vendor-DvaQX_UQ.js +0 -2
- package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
- package/dist/assets/stateDiagram-v2-6OUMAXLB-Dms3pICj.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-B14aWjv6.js +0 -8
- 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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
23
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
|
95
|
-
stateCount
|
|
96
|
-
digest:
|
|
97
|
-
backupFile:
|
|
98
|
-
diagnostic:
|
|
99
|
-
updatedAt:
|
|
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
|
|
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
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// -
|
|
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
|
-
|
|
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 (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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: '
|
|
217
|
+
return { mode: 'body-only' }
|
|
236
218
|
}
|
|
237
|
-
|
|
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 === '
|
|
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
|
-
|
|
299
|
-
//
|
|
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 =
|
|
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
|
-
*
|
|
311
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
577
|
-
|
|
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
|
|
647
|
+
return { ...state, authority: 'sqlite', integrity }
|
|
730
648
|
}
|
package/server/share-cutover.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
}
|