@shawnstack/quickforge 1.7.7 → 1.7.8
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-BSM5w3bn.js +1 -0
- package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
- package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
- package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
- package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
- package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
- package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
- package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
- package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
- package/dist/assets/index-DFASc15l.js +66 -0
- package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
- package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
- package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
- package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
- package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
- package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
- package/dist/index.html +8 -8
- package/package.json +1 -1
- package/server/acp/server.mjs +17 -1
- package/server/agent-manager.mjs +251 -31
- package/server/ai-http-logger.mjs +162 -19
- package/server/ai-provider-options.mjs +4 -1
- package/server/auto-archive.mjs +42 -29
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/index.mjs +69 -13
- package/server/lan-access-backup.mjs +256 -0
- package/server/lan-access-cutover.mjs +306 -0
- package/server/lan-access-json-file.mjs +75 -0
- package/server/lan-access-service.mjs +155 -0
- package/server/lan-access-store.mjs +110 -9
- package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
- package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
- package/server/maintenance/downgrade-share-v1.mjs +101 -0
- package/server/maintenance/export-lan-access-v1.mjs +80 -0
- package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
- package/server/maintenance/export-session-state-v1.mjs +67 -0
- package/server/maintenance/export-share-v1.mjs +77 -0
- package/server/routes/agent.mjs +37 -4
- package/server/routes/backup.mjs +221 -39
- package/server/routes/cloud.mjs +28 -1
- package/server/routes/scheduled-tasks.mjs +347 -134
- package/server/routes/shares.mjs +4 -2
- package/server/routes/storage.mjs +138 -8
- package/server/routes/workspace.mjs +265 -4
- package/server/scheduled-runs-backup.mjs +235 -0
- package/server/scheduled-runs-cutover.mjs +497 -0
- package/server/scheduled-task-runs-service.mjs +279 -0
- package/server/session-index-service.mjs +495 -0
- package/server/session-state-backup.mjs +277 -0
- package/server/session-state-cutover.mjs +370 -0
- package/server/session-state-service.mjs +709 -0
- package/server/share-backup.mjs +255 -0
- package/server/share-cutover.mjs +296 -0
- package/server/share-json-file.mjs +60 -0
- package/server/share-service.mjs +163 -0
- package/server/share-store.mjs +196 -19
- package/server/skills.mjs +0 -1
- package/server/sqlite/database.mjs +233 -0
- package/server/sqlite/lan-access-repository.mjs +652 -0
- package/server/sqlite/migrations.mjs +577 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
- package/server/sqlite/session-index-repository.mjs +297 -0
- package/server/sqlite/session-state-repository.mjs +786 -0
- package/server/sqlite/share-repository.mjs +854 -0
- package/server/storage.mjs +317 -1
- package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
- package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
- package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
- package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
- package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
- package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
- package/dist/assets/index-BTPzyq7d.js +0 -65
- package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
- /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { createSessionStateRepository, MESSAGES_PAGE_LIMIT_MAX, MESSAGES_SPLIT_VALUE, messageDigest } from './sqlite/session-state-repository.mjs'
|
|
2
|
+
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
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
|
+
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
|
|
23
|
+
|
|
24
|
+
function isPlainObject(value) {
|
|
25
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function previewFromMessages(messages) {
|
|
29
|
+
if (!Array.isArray(messages)) return undefined
|
|
30
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
31
|
+
const message = messages[index]
|
|
32
|
+
if (message?.role !== 'assistant') continue
|
|
33
|
+
const content = message.content
|
|
34
|
+
if (Array.isArray(content)) {
|
|
35
|
+
return content.filter((block) => block?.type === 'text').map((block) => block.text ?? '').join(' ').slice(0, 200)
|
|
36
|
+
}
|
|
37
|
+
if (typeof content === 'string') return content.slice(0, 200)
|
|
38
|
+
return ''
|
|
39
|
+
}
|
|
40
|
+
return ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function storage() {
|
|
44
|
+
return getSqliteStorage()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function repository() {
|
|
48
|
+
if (!repositoryInstance) repositoryInstance = createSessionStateRepository(storage())
|
|
49
|
+
return repositoryInstance
|
|
50
|
+
}
|
|
51
|
+
|
|
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
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
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
|
+
|
|
84
|
+
export function readSessionStorageState() {
|
|
85
|
+
const row = stateRow()
|
|
86
|
+
if (!row) throw new Error('Session storage state is missing')
|
|
87
|
+
cachedPhase = row.phase
|
|
88
|
+
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,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function initializeSessionStateService() {
|
|
99
|
+
return readSessionStorageState()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getSessionStoragePhase() {
|
|
103
|
+
return cachedPhase
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isSessionStateAuthoritative() {
|
|
107
|
+
return sqliteReadable()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeBucket(value, fallback = null) {
|
|
111
|
+
const scope = value?.scope ?? fallback?.scope ?? 'global'
|
|
112
|
+
if (scope === 'project') {
|
|
113
|
+
const projectId = value?.projectId ?? fallback?.projectId
|
|
114
|
+
if (typeof projectId !== 'string' || !projectId.trim()) throw new TypeError('projectId is required for project sessions')
|
|
115
|
+
return { scope, projectId }
|
|
116
|
+
}
|
|
117
|
+
if (scope !== 'global') throw new TypeError('scope must be global or project')
|
|
118
|
+
return { scope: 'global', projectId: null }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function stateVersion(state, metadata, fallback = 0) {
|
|
122
|
+
const value = state?.stateVersion ?? metadata?.stateVersion ?? fallback
|
|
123
|
+
if (!Number.isInteger(value) || value < 0) throw new TypeError('stateVersion must be a non-negative integer')
|
|
124
|
+
return value
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function deriveMetadata(state, existing = {}) {
|
|
128
|
+
const version = stateVersion(state, existing)
|
|
129
|
+
const bucket = normalizeBucket(state, existing)
|
|
130
|
+
const id = state.id
|
|
131
|
+
const next = {
|
|
132
|
+
...existing,
|
|
133
|
+
id,
|
|
134
|
+
title: state.title ?? existing.title ?? 'New chat',
|
|
135
|
+
titleSource: state.titleSource ?? existing.titleSource,
|
|
136
|
+
createdAt: state.createdAt ?? existing.createdAt ?? new Date().toISOString(),
|
|
137
|
+
lastModified: state.lastModified ?? existing.lastModified ?? state.createdAt ?? new Date().toISOString(),
|
|
138
|
+
messageCount: Array.isArray(state.messages) ? state.messages.length : (existing.messageCount ?? 0),
|
|
139
|
+
preview: Array.isArray(state.messages) ? previewFromMessages(state.messages) : existing.preview,
|
|
140
|
+
stateVersion: version,
|
|
141
|
+
thinkingLevel: state.thinkingLevel ?? existing.thinkingLevel ?? 'off',
|
|
142
|
+
harness: state.harness ?? existing.harness,
|
|
143
|
+
harnessSessionId: state.harnessSessionId ?? existing.harnessSessionId,
|
|
144
|
+
accessMode: state.accessMode ?? existing.accessMode,
|
|
145
|
+
yoloMode: state.yoloMode ?? existing.yoloMode,
|
|
146
|
+
scope: bucket.scope,
|
|
147
|
+
taskStatus: state.taskStatus ?? existing.taskStatus ?? 'idle',
|
|
148
|
+
taskStartedAt: state.taskStartedAt ?? existing.taskStartedAt ?? null,
|
|
149
|
+
taskFinishedAt: state.taskFinishedAt ?? existing.taskFinishedAt ?? null,
|
|
150
|
+
contextCompaction: state.contextCompaction ?? existing.contextCompaction,
|
|
151
|
+
idleRetention: state.idleRetention ?? existing.idleRetention,
|
|
152
|
+
archivedAt: existing.archivedAt ?? state.archivedAt,
|
|
153
|
+
pinnedAt: existing.pinnedAt ?? state.pinnedAt,
|
|
154
|
+
}
|
|
155
|
+
if (bucket.scope === 'project') next.projectId = bucket.projectId
|
|
156
|
+
else delete next.projectId
|
|
157
|
+
return Object.fromEntries(Object.entries(next).filter(([, value]) => value !== undefined))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function synchronize(stateValue, metadataValue, sessionId, fallback = null) {
|
|
161
|
+
if (!isPlainObject(stateValue) || !isPlainObject(metadataValue)) throw new TypeError('Session state and metadata must be plain objects')
|
|
162
|
+
const state = structuredClone(stateValue)
|
|
163
|
+
const metadata = structuredClone(metadataValue)
|
|
164
|
+
state.id = sessionId
|
|
165
|
+
metadata.id = sessionId
|
|
166
|
+
const bucket = normalizeBucket(state, normalizeBucket(metadata, fallback))
|
|
167
|
+
const version = stateVersion(state, metadata, fallback?.stateVersion ?? 0)
|
|
168
|
+
state.scope = bucket.scope
|
|
169
|
+
metadata.scope = bucket.scope
|
|
170
|
+
state.stateVersion = version
|
|
171
|
+
metadata.stateVersion = version
|
|
172
|
+
if (bucket.scope === 'project') {
|
|
173
|
+
state.projectId = bucket.projectId
|
|
174
|
+
metadata.projectId = bucket.projectId
|
|
175
|
+
} else {
|
|
176
|
+
delete state.projectId
|
|
177
|
+
delete metadata.projectId
|
|
178
|
+
}
|
|
179
|
+
for (const field of ['pinnedAt', 'archivedAt']) {
|
|
180
|
+
if (metadata[field] !== undefined && metadata[field] !== null) state[field] = metadata[field]
|
|
181
|
+
else delete state[field]
|
|
182
|
+
}
|
|
183
|
+
return { state, metadata, ...bucket, stateVersion: version, sessionId }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function allMessages(record) {
|
|
187
|
+
if (!record) return []
|
|
188
|
+
const messages = []
|
|
189
|
+
let offset = 0
|
|
190
|
+
for (;;) {
|
|
191
|
+
const page = repository().readMessagesPage({ scope: record.scope, projectId: record.projectId, sessionId: record.sessionId, limit: MESSAGES_PAGE_LIMIT_MAX, offset })
|
|
192
|
+
messages.push(...page.messages.map((row) => row.message))
|
|
193
|
+
if (!page.hasMore || page.messages.length === 0) break
|
|
194
|
+
offset = page.nextOffset
|
|
195
|
+
}
|
|
196
|
+
return messages
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function assembleState(record) {
|
|
200
|
+
if (!record) return null
|
|
201
|
+
if (record.state?.messageStorage !== MESSAGES_SPLIT_VALUE) return record.state
|
|
202
|
+
const messages = Array.isArray(record.messages) ? record.messages : allMessages(record)
|
|
203
|
+
return { ...record.state, messages }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 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).
|
|
211
|
+
// - append: only the new tail rows are written (incremental).
|
|
212
|
+
function messageStoragePlan(state, existing) {
|
|
213
|
+
const incoming = state?.messages
|
|
214
|
+
const isSplit = existing?.state?.messageStorage === MESSAGES_SPLIT_VALUE
|
|
215
|
+
if (incoming === undefined) return isSplit ? { mode: 'body-only' } : { mode: 'inline' }
|
|
216
|
+
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
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { mode: 'body-only' }
|
|
229
|
+
}
|
|
230
|
+
return { mode: 'append', messages: tail }
|
|
231
|
+
}
|
|
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
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function savePair(state, metadata, options = {}) {
|
|
260
|
+
const sessionId = options.sessionId ?? state?.id ?? metadata?.id
|
|
261
|
+
const existing = options.fallback ?? (sessionId ? repository().findBySessionId(sessionId) : null)
|
|
262
|
+
const plan = messageStoragePlan(state, existing)
|
|
263
|
+
const record = synchronize(state, metadata, sessionId, existing)
|
|
264
|
+
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') {
|
|
271
|
+
const fullMessages = Array.isArray(state.messages) ? state.messages : plan.messages
|
|
272
|
+
finalMetadata.messageCount = plan.mode === 'append' ? fullMessages.length : plan.messages.length
|
|
273
|
+
if (finalMetadata.preview === undefined) finalMetadata.preview = previewFromMessages(fullMessages)
|
|
274
|
+
}
|
|
275
|
+
const finalRecord = { ...record, metadata: finalMetadata }
|
|
276
|
+
let saved
|
|
277
|
+
if (plan.mode === 'replace') {
|
|
278
|
+
saved = repository().replaceMessages(finalRecord, plan.messages, {
|
|
279
|
+
expectedRevision: options.expectedRevision,
|
|
280
|
+
expectedStateVersion: options.expectedStateVersion,
|
|
281
|
+
})
|
|
282
|
+
} else if (plan.mode === 'append') {
|
|
283
|
+
saved = repository().appendMessages(finalRecord, plan.messages, {
|
|
284
|
+
expectedRevision: options.expectedRevision,
|
|
285
|
+
expectedStateVersion: options.expectedStateVersion,
|
|
286
|
+
})
|
|
287
|
+
} else {
|
|
288
|
+
saved = repository().save(finalRecord, {
|
|
289
|
+
expectedRevision: options.expectedRevision,
|
|
290
|
+
expectedStateVersion: options.expectedStateVersion,
|
|
291
|
+
})
|
|
292
|
+
}
|
|
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
|
|
296
|
+
// 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 })
|
|
300
|
+
return { ...saved, messageStoragePlan: plan.mode, messageCount: totalMessageCount }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 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.
|
|
310
|
+
*/
|
|
311
|
+
export function storedMessagesState(sessionId) {
|
|
312
|
+
if (!sqliteReadable()) return { split: false, count: 0, tailDigest: '' }
|
|
313
|
+
const record = repository().findBySessionId(sessionId)
|
|
314
|
+
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
|
+
const count = repository().messageCount({ scope: record.scope, projectId: record.projectId, sessionId })
|
|
320
|
+
let tailDigest = ''
|
|
321
|
+
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 ?? ''
|
|
324
|
+
}
|
|
325
|
+
return { split: true, count, tailDigest }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Canonical digest of the last message of an array — matches the row-level
|
|
330
|
+
* `message_digest` written by the repository, so agent-manager can cheaply
|
|
331
|
+
* verify the boundary message on reconnect/conflict checks.
|
|
332
|
+
*/
|
|
333
|
+
export function sessionMessagesTailDigest(messages) {
|
|
334
|
+
if (!Array.isArray(messages) || messages.length === 0) return ''
|
|
335
|
+
return messageDigest(messages[messages.length - 1])
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function readSessionStateRecord(sessionId) {
|
|
339
|
+
if (!sqliteReadable()) return requireJsonAdapter('readRecord')(sessionId)
|
|
340
|
+
return repository().findBySessionId(sessionId)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function readSessionStateValue(sessionId) {
|
|
344
|
+
if (!sqliteReadable()) return requireJsonAdapter('readState')(sessionId)
|
|
345
|
+
return assembleState(repository().findBySessionId(sessionId))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function readSessionMetadataValue(sessionId) {
|
|
349
|
+
if (!sqliteReadable()) return requireJsonAdapter('readMetadata')(sessionId)
|
|
350
|
+
return repository().findBySessionId(sessionId)?.metadata ?? null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function mergeMetadata(existing, update, sessionId) {
|
|
354
|
+
const metadata = { ...existing, ...structuredClone(update), id: sessionId }
|
|
355
|
+
for (const field of ['pinnedAt', 'archivedAt']) {
|
|
356
|
+
if (Object.prototype.hasOwnProperty.call(update, field) && (update[field] === null || update[field] === undefined)) delete metadata[field]
|
|
357
|
+
}
|
|
358
|
+
return metadata
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function applyMetadataToState(existing, metadata) {
|
|
362
|
+
const state = { ...existing.state }
|
|
363
|
+
for (const field of [
|
|
364
|
+
'title', 'titleSource', 'lastModified', 'stateVersion', 'thinkingLevel', 'harness', 'harnessSessionId',
|
|
365
|
+
'accessMode', 'yoloMode', 'source', 'channelId', 'channelName', 'taskStatus', 'taskStartedAt',
|
|
366
|
+
'taskFinishedAt', 'contextCompaction', 'idleRetention', 'pinnedAt', 'archivedAt',
|
|
367
|
+
]) {
|
|
368
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, field)) continue
|
|
369
|
+
if ((field === 'pinnedAt' || field === 'archivedAt') && (metadata[field] === undefined || metadata[field] === null)) delete state[field]
|
|
370
|
+
else state[field] = metadata[field]
|
|
371
|
+
}
|
|
372
|
+
return synchronize(state, metadata, existing.sessionId, existing)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
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)]))
|
|
378
|
+
const updated = updateFn(structuredClone(current))
|
|
379
|
+
if (!isPlainObject(updated)) throw new TypeError('Updated metadata bucket must be a plain object')
|
|
380
|
+
const upserts = []
|
|
381
|
+
const deletes = []
|
|
382
|
+
for (const sessionId of new Set([...Object.keys(current), ...Object.keys(updated)])) {
|
|
383
|
+
const before = current[sessionId]
|
|
384
|
+
const after = updated[sessionId]
|
|
385
|
+
if (JSON.stringify(before) === JSON.stringify(after)) continue
|
|
386
|
+
const existing = repository().findBySessionId(sessionId)
|
|
387
|
+
if (after === undefined) {
|
|
388
|
+
if (existing) deletes.push(existing)
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
if (!existing) {
|
|
392
|
+
const error = new TypeError(`Metadata-only orphan is not allowed: ${sessionId}`)
|
|
393
|
+
error.statusCode = 409
|
|
394
|
+
error.errorCode = 'SESSION_STATE_REQUIRED'
|
|
395
|
+
throw error
|
|
396
|
+
}
|
|
397
|
+
const metadata = mergeMetadata(existing.metadata, after, sessionId)
|
|
398
|
+
upserts.push({ record: applyMetadataToState(existing, metadata), expectedRevision: existing.revision })
|
|
399
|
+
}
|
|
400
|
+
return { updated, upserts, deletes }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function updateSessionMetadataBucket(scope, projectId, updateFn) {
|
|
404
|
+
if (!sqliteReadable()) return requireJsonAdapter('updateMetadataBucket')(scope, projectId, updateFn)
|
|
405
|
+
const changes = metadataBucketChanges(scope, projectId, updateFn)
|
|
406
|
+
if (changes.deletes.length > 0) {
|
|
407
|
+
const error = new TypeError('Metadata bucket updates cannot delete session bodies; use full session delete')
|
|
408
|
+
error.errorCode = 'SESSION_FULL_DELETE_REQUIRED'
|
|
409
|
+
error.statusCode = 409
|
|
410
|
+
throw error
|
|
411
|
+
}
|
|
412
|
+
if (changes.upserts.length > 0) {
|
|
413
|
+
repository().applyBatch({ upserts: changes.upserts })
|
|
414
|
+
requestSessionJsonMirrorDrain()
|
|
415
|
+
}
|
|
416
|
+
return changes.updated
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function readSessionStateStore(storeName, { scope, projectId } = {}) {
|
|
420
|
+
if (!sqliteReadable()) return requireJsonAdapter('readStore')(storeName, { scope, projectId })
|
|
421
|
+
const records = repository().exportSnapshot().records.filter((record) => {
|
|
422
|
+
if (!scope) return true
|
|
423
|
+
if (record.scope !== scope) return false
|
|
424
|
+
return scope !== 'project' || record.projectId === projectId
|
|
425
|
+
})
|
|
426
|
+
return Object.fromEntries(records.map((record) => [record.sessionId, storeName === 'sessions' ? assembleState(record) : record.metadata]))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function saveSessionStatePair({ state, metadata, expectedRevision = null, expectedStateVersion = null } = {}) {
|
|
430
|
+
if (!sqliteReadable()) return requireJsonAdapter('savePair')({ state, metadata, expectedRevision, expectedStateVersion })
|
|
431
|
+
return savePair(state, metadata ?? deriveMetadata(state), { expectedRevision, expectedStateVersion })
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function saveSessionBody(sessionId, value, { expectedRevision = null } = {}) {
|
|
435
|
+
if (!isPlainObject(value)) throw new TypeError('Session body must be a plain object')
|
|
436
|
+
if (!sqliteReadable()) return requireJsonAdapter('saveBody')(sessionId, value, { expectedRevision })
|
|
437
|
+
const existing = repository().findBySessionId(sessionId)
|
|
438
|
+
const state = { ...(existing?.state || {}), ...structuredClone(value), id: sessionId }
|
|
439
|
+
const metadata = deriveMetadata(state, existing?.metadata || {})
|
|
440
|
+
return savePair(state, metadata, { sessionId, expectedRevision: expectedRevision ?? existing?.revision ?? 0, fallback: existing })
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function saveSessionMetadata(sessionId, value, { expectedRevision = null } = {}) {
|
|
444
|
+
if (!isPlainObject(value)) throw new TypeError('Session metadata must be a plain object')
|
|
445
|
+
if (!sqliteReadable()) return requireJsonAdapter('saveMetadata')(sessionId, value, { expectedRevision })
|
|
446
|
+
const existing = repository().findBySessionId(sessionId)
|
|
447
|
+
if (!existing) {
|
|
448
|
+
const error = new Error(`Session state does not exist: ${sessionId}`)
|
|
449
|
+
error.statusCode = 409
|
|
450
|
+
error.errorCode = 'SESSION_STATE_REQUIRED'
|
|
451
|
+
throw error
|
|
452
|
+
}
|
|
453
|
+
const metadata = mergeMetadata(existing.metadata, value, sessionId)
|
|
454
|
+
const synchronized = applyMetadataToState(existing, metadata)
|
|
455
|
+
const saved = repository().save(synchronized, { expectedRevision: expectedRevision ?? existing.revision })
|
|
456
|
+
requestSessionJsonMirrorDrain()
|
|
457
|
+
return saved
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function deleteSessionState(sessionId, { expectedRevision = null } = {}) {
|
|
461
|
+
if (!sqliteReadable()) return requireJsonAdapter('delete')(sessionId, { expectedRevision })
|
|
462
|
+
const existing = repository().findBySessionId(sessionId)
|
|
463
|
+
if (!existing) return false
|
|
464
|
+
const deleted = repository().deleteBySessionId(sessionId, { expectedRevision: expectedRevision ?? existing.revision })
|
|
465
|
+
requestSessionJsonMirrorDrain()
|
|
466
|
+
return deleted
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function replaceSessionStateStore(storeName, values) {
|
|
470
|
+
if (!isPlainObject(values)) throw new TypeError('Session store must be a plain object')
|
|
471
|
+
if (!sqliteReadable()) return requireJsonAdapter('replaceStore')(storeName, values)
|
|
472
|
+
const current = new Map(repository().exportSnapshot().records.map((record) => [record.sessionId, record]))
|
|
473
|
+
const records = []
|
|
474
|
+
if (storeName === 'sessions') {
|
|
475
|
+
for (const [sessionId, state] of Object.entries(values)) {
|
|
476
|
+
if (!isPlainObject(state)) throw new TypeError(`Invalid session body: ${sessionId}`)
|
|
477
|
+
const existing = current.get(sessionId)
|
|
478
|
+
const nextState = { ...(existing?.state || {}), ...state, id: sessionId }
|
|
479
|
+
records.push(synchronize(nextState, deriveMetadata(nextState, existing?.metadata || {}), sessionId, existing))
|
|
480
|
+
}
|
|
481
|
+
} else if (storeName === 'sessions-metadata') {
|
|
482
|
+
if (Object.keys(values).length !== current.size) {
|
|
483
|
+
const error = new TypeError('Metadata replacement cannot create or remove session bodies')
|
|
484
|
+
error.errorCode = 'SESSION_FULL_DELETE_REQUIRED'
|
|
485
|
+
error.statusCode = 409
|
|
486
|
+
throw error
|
|
487
|
+
}
|
|
488
|
+
for (const [sessionId, metadataValue] of Object.entries(values)) {
|
|
489
|
+
const existing = current.get(sessionId)
|
|
490
|
+
if (!existing || !isPlainObject(metadataValue)) {
|
|
491
|
+
const error = new TypeError(`Metadata-only orphan is not allowed: ${sessionId}`)
|
|
492
|
+
error.statusCode = 409
|
|
493
|
+
error.errorCode = 'SESSION_STATE_REQUIRED'
|
|
494
|
+
throw error
|
|
495
|
+
}
|
|
496
|
+
const metadata = mergeMetadata(existing.metadata, metadataValue, sessionId)
|
|
497
|
+
records.push(applyMetadataToState(existing, metadata))
|
|
498
|
+
}
|
|
499
|
+
} else {
|
|
500
|
+
throw new TypeError(`Unsupported session state store: ${storeName}`)
|
|
501
|
+
}
|
|
502
|
+
repository().replaceAll(records)
|
|
503
|
+
requestSessionJsonMirrorDrain()
|
|
504
|
+
return values
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export async function atomicSessionRecordUpdate(sessionId, updateFn, { maxRetries = 3 } = {}) {
|
|
508
|
+
if (!sqliteReadable()) return requireJsonAdapter('atomicRecordUpdate')(sessionId, updateFn)
|
|
509
|
+
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
510
|
+
const existing = repository().findBySessionId(sessionId)
|
|
511
|
+
if (!existing) return null
|
|
512
|
+
const updated = await updateFn({
|
|
513
|
+
state: structuredClone(assembleState(existing)),
|
|
514
|
+
metadata: structuredClone(existing.metadata),
|
|
515
|
+
revision: existing.revision,
|
|
516
|
+
stateVersion: existing.stateVersion,
|
|
517
|
+
})
|
|
518
|
+
if (!updated) return existing
|
|
519
|
+
const state = updated.state ?? existing.state
|
|
520
|
+
const metadata = updated.metadata ?? deriveMetadata(state, existing.metadata)
|
|
521
|
+
try {
|
|
522
|
+
return savePair(state, metadata, {
|
|
523
|
+
sessionId,
|
|
524
|
+
expectedRevision: existing.revision,
|
|
525
|
+
fallback: existing,
|
|
526
|
+
})
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error?.errorCode !== 'SESSION_STATE_CONFLICT' || attempt === maxRetries - 1) throw error
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return null
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export async function atomicSessionStateUpdate(sessionId, updateFn, { maxRetries = 3 } = {}) {
|
|
535
|
+
if (!sqliteReadable()) return requireJsonAdapter('atomicStateUpdate')(sessionId, updateFn)
|
|
536
|
+
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
537
|
+
const existing = repository().findBySessionId(sessionId)
|
|
538
|
+
if (!existing) return null
|
|
539
|
+
const updated = await updateFn(structuredClone(assembleState(existing)))
|
|
540
|
+
try {
|
|
541
|
+
return savePair(updated, deriveMetadata(updated, existing.metadata), { sessionId, expectedRevision: existing.revision, fallback: existing }).state
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (error?.errorCode !== 'SESSION_STATE_CONFLICT' || attempt === maxRetries - 1) throw error
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return null
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function atomicSessionMetadataStateUpdate(scope, projectId, updateFn, { maxRetries = 3 } = {}) {
|
|
550
|
+
if (!sqliteReadable()) return requireJsonAdapter('atomicMetadataUpdate')(scope, projectId, updateFn)
|
|
551
|
+
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
552
|
+
try {
|
|
553
|
+
return updateSessionMetadataBucket(scope, projectId, updateFn)
|
|
554
|
+
} catch (error) {
|
|
555
|
+
if (error?.errorCode !== 'SESSION_STATE_CONFLICT' || attempt === maxRetries - 1) throw error
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return null
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function applySessionBatch(operations) {
|
|
562
|
+
if (!Array.isArray(operations) || operations.length === 0) throw new TypeError('Session batch operations are required')
|
|
563
|
+
if (!sqliteReadable()) return requireJsonAdapter('applyBatch')(operations)
|
|
564
|
+
const grouped = new Map()
|
|
565
|
+
for (const operation of operations) {
|
|
566
|
+
if (!['sessions', 'sessions-metadata'].includes(operation?.store)) throw new TypeError('Session batch only accepts sessions and sessions-metadata')
|
|
567
|
+
if (!['set', 'delete'].includes(operation?.type)) throw new TypeError('Invalid session batch operation')
|
|
568
|
+
if (typeof operation.key !== 'string' || !operation.key) throw new TypeError('Session batch key is required')
|
|
569
|
+
const entry = grouped.get(operation.key) || { sessionId: operation.key }
|
|
570
|
+
if (operation.type === 'delete') {
|
|
571
|
+
if (operation.store === 'sessions-metadata') throw new TypeError('Metadata-only delete is not allowed')
|
|
572
|
+
entry.delete = true
|
|
573
|
+
} else if (operation.store === 'sessions') entry.state = operation.value
|
|
574
|
+
else entry.metadata = operation.value
|
|
575
|
+
if (operation.expectedRevision !== undefined) entry.expectedRevision = operation.expectedRevision
|
|
576
|
+
if (operation.expectedStateVersion !== undefined) entry.expectedStateVersion = operation.expectedStateVersion
|
|
577
|
+
grouped.set(operation.key, entry)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const upserts = []
|
|
581
|
+
const deletes = []
|
|
582
|
+
for (const entry of grouped.values()) {
|
|
583
|
+
const existing = repository().findBySessionId(entry.sessionId)
|
|
584
|
+
if (entry.delete) {
|
|
585
|
+
if (existing) deletes.push({ ...existing, expectedRevision: entry.expectedRevision ?? existing.revision })
|
|
586
|
+
continue
|
|
587
|
+
}
|
|
588
|
+
if (!entry.state && !existing) throw new TypeError(`Session body is required for a new session: ${entry.sessionId}`)
|
|
589
|
+
if (entry.state !== undefined && !isPlainObject(entry.state)) throw new TypeError(`Invalid session body: ${entry.sessionId}`)
|
|
590
|
+
if (entry.metadata !== undefined && !isPlainObject(entry.metadata)) throw new TypeError(`Invalid session metadata: ${entry.sessionId}`)
|
|
591
|
+
const state = { ...(existing?.state || {}), ...(entry.state || {}), id: entry.sessionId }
|
|
592
|
+
const plan = messageStoragePlan(state, existing)
|
|
593
|
+
const derivedMetadata = deriveMetadata(state, existing?.metadata || {})
|
|
594
|
+
const metadata = entry.metadata
|
|
595
|
+
? mergeMetadata(derivedMetadata, entry.metadata, entry.sessionId)
|
|
596
|
+
: derivedMetadata
|
|
597
|
+
if (plan.mode === 'replace' || plan.mode === 'append') {
|
|
598
|
+
metadata.messageCount = plan.mode === 'append' ? state.messages.length : plan.messages.length
|
|
599
|
+
if (metadata.preview === undefined) metadata.preview = previewFromMessages(state.messages)
|
|
600
|
+
}
|
|
601
|
+
const record = entry.metadata
|
|
602
|
+
? applyMetadataToState({
|
|
603
|
+
...(existing || {}),
|
|
604
|
+
state,
|
|
605
|
+
metadata: existing?.metadata || {},
|
|
606
|
+
sessionId: entry.sessionId,
|
|
607
|
+
stateVersion: existing?.stateVersion ?? state.stateVersion ?? metadata.stateVersion ?? 0,
|
|
608
|
+
...normalizeBucket(state, metadata),
|
|
609
|
+
}, metadata)
|
|
610
|
+
: synchronize(state, metadata, entry.sessionId, existing)
|
|
611
|
+
const upsert = {
|
|
612
|
+
record,
|
|
613
|
+
expectedRevision: entry.expectedRevision ?? existing?.revision ?? 0,
|
|
614
|
+
expectedStateVersion: entry.expectedStateVersion,
|
|
615
|
+
}
|
|
616
|
+
if (plan.mode === 'replace' || plan.mode === 'append') {
|
|
617
|
+
upsert.messages = plan.messages
|
|
618
|
+
upsert.messagesMode = plan.mode
|
|
619
|
+
}
|
|
620
|
+
upserts.push(upsert)
|
|
621
|
+
}
|
|
622
|
+
// An idempotent batch (e.g. deleting sessions that are already gone) still
|
|
623
|
+
// succeeds instead of tripping the repository's empty-change guard.
|
|
624
|
+
if (upserts.length === 0 && deletes.length === 0) return { saved: 0, deleted: 0, revisions: [] }
|
|
625
|
+
const result = repository().applyBatch({ upserts, deletes })
|
|
626
|
+
requestSessionJsonMirrorDrain()
|
|
627
|
+
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
|
+
}
|
|
629
|
+
|
|
630
|
+
export function exportSessionStateSnapshot() {
|
|
631
|
+
if (!sqliteReadable()) return requireJsonAdapter('exportSnapshot')()
|
|
632
|
+
const snapshot = repository().exportSnapshot()
|
|
633
|
+
return {
|
|
634
|
+
sessions: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, assembleState(record)])),
|
|
635
|
+
sessionsMetadata: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.metadata])),
|
|
636
|
+
count: snapshot.count,
|
|
637
|
+
digest: snapshot.digest,
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function normalizeSessionSnapshotValues({ sessions, sessionsMetadata }) {
|
|
642
|
+
if (!isPlainObject(sessions) || !isPlainObject(sessionsMetadata)) throw new TypeError('sessions and sessionsMetadata must be objects')
|
|
643
|
+
const records = []
|
|
644
|
+
for (const [sessionId, state] of Object.entries(sessions)) {
|
|
645
|
+
if (!isPlainObject(state)) throw new TypeError(`Invalid session body: ${sessionId}`)
|
|
646
|
+
const metadata = sessionsMetadata[sessionId]
|
|
647
|
+
records.push(synchronize(state, isPlainObject(metadata) ? metadata : deriveMetadata({ ...state, id: sessionId }), sessionId))
|
|
648
|
+
}
|
|
649
|
+
for (const sessionId of Object.keys(sessionsMetadata)) {
|
|
650
|
+
if (!Object.prototype.hasOwnProperty.call(sessions, sessionId)) throw new TypeError(`Metadata-only orphan is not allowed: ${sessionId}`)
|
|
651
|
+
}
|
|
652
|
+
return records
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function replaceSessionStateSnapshot({ sessions, sessionsMetadata }, { merge = false } = {}) {
|
|
656
|
+
if (!isPlainObject(sessions) || !isPlainObject(sessionsMetadata)) throw new TypeError('sessions and sessionsMetadata must be objects')
|
|
657
|
+
if (!sqliteReadable()) return requireJsonAdapter('replaceSnapshot')({ sessions, sessionsMetadata }, { merge })
|
|
658
|
+
const current = merge ? exportSessionStateSnapshot() : { sessions: {}, sessionsMetadata: {} }
|
|
659
|
+
const targetSessions = { ...current.sessions, ...sessions }
|
|
660
|
+
const targetMetadata = { ...current.sessionsMetadata, ...sessionsMetadata }
|
|
661
|
+
const records = normalizeSessionSnapshotValues({ sessions: targetSessions, sessionsMetadata: targetMetadata })
|
|
662
|
+
repository().replaceAll(records)
|
|
663
|
+
requestSessionJsonMirrorDrain()
|
|
664
|
+
return { sessions: records.length, sessionsMetadata: records.length }
|
|
665
|
+
}
|
|
666
|
+
|
|
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
|
+
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 }
|
|
700
|
+
let integrity
|
|
701
|
+
let mirrorPending = null
|
|
702
|
+
try {
|
|
703
|
+
integrity = repository().verifyIntegrity()
|
|
704
|
+
mirrorPending = repository().listMirrorQueue().length
|
|
705
|
+
} catch (error) {
|
|
706
|
+
integrity = { ok: false, error: error?.message || String(error) }
|
|
707
|
+
}
|
|
708
|
+
return { ...state, authority: 'sqlite', integrity, mirrorPending }
|
|
709
|
+
}
|