@shawnstack/quickforge 1.7.6 → 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.
Files changed (87) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-BDKYfLna.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-DSy66Tuw.js → SettingsWorkspacePage-7lQ0Wl63.js} +391 -391
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/index-D-J_8Smf.css +3 -0
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-BEV4_TXC.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-BTJ7mzk_.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/auto-compaction.mjs +43 -1
  29. package/server/cloud/auto-approval.mjs +155 -0
  30. package/server/cloud/client.mjs +5 -0
  31. package/server/cloud/config.mjs +8 -16
  32. package/server/cloud/qf-agent-process.mjs +163 -3
  33. package/server/cloud/service-config.mjs +1 -1
  34. package/server/context-usage.mjs +11 -4
  35. package/server/index.mjs +69 -13
  36. package/server/lan-access-backup.mjs +256 -0
  37. package/server/lan-access-cutover.mjs +306 -0
  38. package/server/lan-access-json-file.mjs +75 -0
  39. package/server/lan-access-service.mjs +155 -0
  40. package/server/lan-access-store.mjs +110 -9
  41. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  42. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  43. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  44. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  45. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  46. package/server/maintenance/export-session-state-v1.mjs +67 -0
  47. package/server/maintenance/export-share-v1.mjs +77 -0
  48. package/server/routes/agent.mjs +37 -4
  49. package/server/routes/backup.mjs +221 -39
  50. package/server/routes/cloud.mjs +28 -1
  51. package/server/routes/scheduled-tasks.mjs +347 -134
  52. package/server/routes/shares.mjs +4 -2
  53. package/server/routes/storage.mjs +138 -8
  54. package/server/routes/workspace.mjs +265 -4
  55. package/server/scheduled-runs-backup.mjs +235 -0
  56. package/server/scheduled-runs-cutover.mjs +497 -0
  57. package/server/scheduled-task-runs-service.mjs +279 -0
  58. package/server/session-index-service.mjs +495 -0
  59. package/server/session-state-backup.mjs +277 -0
  60. package/server/session-state-cutover.mjs +370 -0
  61. package/server/session-state-service.mjs +709 -0
  62. package/server/share-backup.mjs +255 -0
  63. package/server/share-cutover.mjs +296 -0
  64. package/server/share-json-file.mjs +60 -0
  65. package/server/share-service.mjs +163 -0
  66. package/server/share-store.mjs +196 -19
  67. package/server/skills.mjs +0 -1
  68. package/server/sqlite/database.mjs +233 -0
  69. package/server/sqlite/lan-access-repository.mjs +652 -0
  70. package/server/sqlite/migrations.mjs +577 -0
  71. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  72. package/server/sqlite/session-index-repository.mjs +297 -0
  73. package/server/sqlite/session-state-repository.mjs +786 -0
  74. package/server/sqlite/share-repository.mjs +854 -0
  75. package/server/storage.mjs +317 -1
  76. package/dist/assets/AgentProfilesPage-Dl5Peqov.js +0 -1
  77. package/dist/assets/ChatPanelHost-D3987TUJ.js +0 -307
  78. package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +0 -1
  79. package/dist/assets/ScheduledTasksPage--GAki3lB.js +0 -2
  80. package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +0 -1
  81. package/dist/assets/SharedConversationPage-B9BUVCri.js +0 -1
  82. package/dist/assets/TerminalDock-CT1nYo7P.js +0 -2
  83. package/dist/assets/WorkspaceInspector-BnfDLf9a.js +0 -36
  84. package/dist/assets/index-1ei6nhs-.css +0 -3
  85. package/dist/assets/index-BivLuZvX.js +0 -65
  86. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  87. /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
+ }