@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.
Files changed (83) 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-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  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-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  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-GY7sdfIr.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-C1fJ1Jl7.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/cloud/auto-approval.mjs +155 -0
  29. package/server/cloud/client.mjs +5 -0
  30. package/server/cloud/qf-agent-process.mjs +163 -3
  31. package/server/cloud/service-config.mjs +1 -1
  32. package/server/index.mjs +69 -13
  33. package/server/lan-access-backup.mjs +256 -0
  34. package/server/lan-access-cutover.mjs +306 -0
  35. package/server/lan-access-json-file.mjs +75 -0
  36. package/server/lan-access-service.mjs +155 -0
  37. package/server/lan-access-store.mjs +110 -9
  38. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  39. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  40. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  41. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  42. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  43. package/server/maintenance/export-session-state-v1.mjs +67 -0
  44. package/server/maintenance/export-share-v1.mjs +77 -0
  45. package/server/routes/agent.mjs +37 -4
  46. package/server/routes/backup.mjs +221 -39
  47. package/server/routes/cloud.mjs +28 -1
  48. package/server/routes/scheduled-tasks.mjs +347 -134
  49. package/server/routes/shares.mjs +4 -2
  50. package/server/routes/storage.mjs +138 -8
  51. package/server/routes/workspace.mjs +265 -4
  52. package/server/scheduled-runs-backup.mjs +235 -0
  53. package/server/scheduled-runs-cutover.mjs +497 -0
  54. package/server/scheduled-task-runs-service.mjs +279 -0
  55. package/server/session-index-service.mjs +495 -0
  56. package/server/session-state-backup.mjs +277 -0
  57. package/server/session-state-cutover.mjs +370 -0
  58. package/server/session-state-service.mjs +709 -0
  59. package/server/share-backup.mjs +255 -0
  60. package/server/share-cutover.mjs +296 -0
  61. package/server/share-json-file.mjs +60 -0
  62. package/server/share-service.mjs +163 -0
  63. package/server/share-store.mjs +196 -19
  64. package/server/skills.mjs +0 -1
  65. package/server/sqlite/database.mjs +233 -0
  66. package/server/sqlite/lan-access-repository.mjs +652 -0
  67. package/server/sqlite/migrations.mjs +577 -0
  68. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  69. package/server/sqlite/session-index-repository.mjs +297 -0
  70. package/server/sqlite/session-state-repository.mjs +786 -0
  71. package/server/sqlite/share-repository.mjs +854 -0
  72. package/server/storage.mjs +317 -1
  73. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  74. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  75. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  76. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  77. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  78. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  79. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  80. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  81. package/dist/assets/index-BTPzyq7d.js +0 -65
  82. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  83. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -0,0 +1,495 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createSessionIndexRepository } from './sqlite/session-index-repository.mjs'
3
+ import { getSqliteStorage } from './sqlite/database.mjs'
4
+ import { logger } from './utils/logger.mjs'
5
+
6
+ const DEFAULT_MAX_REBUILD_ATTEMPTS = 3
7
+ const DEFAULT_VERIFY_TTL_MS = 5_000
8
+
9
+ let defaultReader = null
10
+ let defaultRepository = null
11
+ let activeService = null
12
+
13
+ function isPlainObject(value) {
14
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
15
+ }
16
+
17
+ function canonicalValue(value) {
18
+ if (Array.isArray(value)) return value.map(canonicalValue)
19
+ if (!isPlainObject(value)) return value
20
+ const result = {}
21
+ for (const key of Object.keys(value).sort()) {
22
+ if (value[key] !== undefined) result[key] = canonicalValue(value[key])
23
+ }
24
+ return result
25
+ }
26
+
27
+ export function canonicalSessionMetadata(metadata, { scope, projectId, sessionId }) {
28
+ if (!isPlainObject(metadata)) throw new TypeError('Session metadata must be a plain object')
29
+ const canonical = canonicalValue(metadata)
30
+ canonical.id = sessionId
31
+ canonical.scope = scope
32
+ if (scope === 'project') canonical.projectId = projectId
33
+ else delete canonical.projectId
34
+ return canonicalValue(canonical)
35
+ }
36
+
37
+ function sha256(value) {
38
+ return createHash('sha256').update(value).digest('hex')
39
+ }
40
+
41
+ export function sessionMetadataDigest(metadata) {
42
+ return sha256(JSON.stringify(canonicalValue(metadata)))
43
+ }
44
+
45
+ export function sessionIndexAggregateDigest(entries) {
46
+ const normalized = [...entries]
47
+ .map((entry) => `${entry.scope}\0${entry.projectId || ''}\0${entry.sessionId}\0${entry.metadataDigest}`)
48
+ .sort()
49
+ return sha256(normalized.join('\n'))
50
+ }
51
+
52
+ function normalizeBucket(bucket) {
53
+ if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) throw new TypeError('Session metadata bucket must be an object')
54
+ if (bucket.scope === 'global') return { scope: 'global', projectId: null }
55
+ if (bucket.scope !== 'project' || typeof bucket.projectId !== 'string' || !bucket.projectId.trim()) {
56
+ throw new TypeError('Session metadata bucket scope/projectId is invalid')
57
+ }
58
+ return { scope: 'project', projectId: bucket.projectId }
59
+ }
60
+
61
+ function nullableString(value) {
62
+ return typeof value === 'string' ? value : null
63
+ }
64
+
65
+ function nullableInteger(value) {
66
+ return Number.isInteger(value) && value >= 0 ? value : null
67
+ }
68
+
69
+ function isCanonicalIso(value) {
70
+ if (typeof value !== 'string' || !value) return false
71
+ const timestamp = Date.parse(value)
72
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value
73
+ }
74
+
75
+ function fieldCompatibility(metadata, field, { iso = false } = {}) {
76
+ if (metadata[field] === undefined) return true
77
+ if (typeof metadata[field] !== 'string') return false
78
+ return !iso || isCanonicalIso(metadata[field])
79
+ }
80
+
81
+ function metadataCompatibility(bucket, sessionId, metadata, canonical) {
82
+ const issues = {
83
+ keyIdMismatch: metadata.id !== undefined && metadata.id !== sessionId,
84
+ scopeConflict: (metadata.scope !== undefined && metadata.scope !== bucket.scope)
85
+ || (bucket.scope === 'project'
86
+ ? metadata.projectId !== undefined && metadata.projectId !== bucket.projectId
87
+ : Object.prototype.hasOwnProperty.call(metadata, 'projectId')),
88
+ invalidCreatedAt: !fieldCompatibility(metadata, 'createdAt', { iso: true }),
89
+ invalidLastModified: !fieldCompatibility(metadata, 'lastModified', { iso: true }),
90
+ invalidPinnedAt: !fieldCompatibility(metadata, 'pinnedAt', { iso: true }),
91
+ invalidArchivedAt: !fieldCompatibility(metadata, 'archivedAt'),
92
+ canonicalMismatch: JSON.stringify(canonicalValue(metadata)) !== JSON.stringify(canonical),
93
+ }
94
+ return { compatible: !Object.values(issues).some(Boolean), issues }
95
+ }
96
+
97
+ function buildRow(bucket, sessionId, metadata, indexedAt) {
98
+ const canonical = canonicalSessionMetadata(metadata, { ...bucket, sessionId })
99
+ return {
100
+ ...bucket,
101
+ sessionId,
102
+ createdAt: nullableString(canonical.createdAt),
103
+ lastModified: nullableString(canonical.lastModified),
104
+ messageCount: nullableInteger(canonical.messageCount),
105
+ pinnedAt: nullableString(canonical.pinnedAt),
106
+ archivedAt: nullableString(canonical.archivedAt),
107
+ stateVersion: nullableInteger(canonical.stateVersion),
108
+ metadata: canonical,
109
+ metadataDigest: sessionMetadataDigest(canonical),
110
+ indexedAt,
111
+ }
112
+ }
113
+
114
+ function snapshotFromBuckets(buckets, indexedAt) {
115
+ if (!Array.isArray(buckets)) throw new TypeError('Session metadata reader must return an array')
116
+ const rows = []
117
+ const keys = new Set()
118
+ const sessionBuckets = new Map()
119
+ const compatibilityIssues = {
120
+ keyIdMismatch: 0,
121
+ scopeConflict: 0,
122
+ invalidCreatedAt: 0,
123
+ invalidLastModified: 0,
124
+ invalidPinnedAt: 0,
125
+ invalidArchivedAt: 0,
126
+ canonicalMismatch: 0,
127
+ }
128
+ let duplicateSessionIdCount = 0
129
+
130
+ for (const rawBucket of buckets) {
131
+ const bucket = normalizeBucket(rawBucket)
132
+ if (!isPlainObject(rawBucket.metadata)) throw new TypeError('Session metadata bucket content must be a plain object')
133
+ for (const [sessionId, metadata] of Object.entries(rawBucket.metadata)) {
134
+ if (!sessionId || !isPlainObject(metadata)) throw new TypeError('Session metadata entries must be keyed plain objects')
135
+ const compositeKey = `${bucket.scope}\0${bucket.projectId || ''}\0${sessionId}`
136
+ if (keys.has(compositeKey)) throw new TypeError('Duplicate session metadata bucket composite key')
137
+ keys.add(compositeKey)
138
+ const seenBuckets = sessionBuckets.get(sessionId) || 0
139
+ if (seenBuckets === 1) duplicateSessionIdCount += 1
140
+ sessionBuckets.set(sessionId, seenBuckets + 1)
141
+ const row = buildRow(bucket, sessionId, metadata, indexedAt)
142
+ const compatibility = metadataCompatibility(bucket, sessionId, metadata, row.metadata)
143
+ for (const [issue, present] of Object.entries(compatibility.issues)) {
144
+ if (present) compatibilityIssues[issue] += 1
145
+ }
146
+ rows.push(row)
147
+ }
148
+ }
149
+
150
+ rows.sort((left, right) => {
151
+ const leftKey = `${left.scope}\0${left.projectId || ''}\0${left.sessionId}`
152
+ const rightKey = `${right.scope}\0${right.projectId || ''}\0${right.sessionId}`
153
+ return leftKey.localeCompare(rightKey)
154
+ })
155
+ return {
156
+ rows,
157
+ count: rows.length,
158
+ digest: sessionIndexAggregateDigest(rows),
159
+ keyIdMismatchCount: compatibilityIssues.keyIdMismatch,
160
+ duplicateSessionIdCount,
161
+ compatibilityIssues,
162
+ queryCompatible: Object.values(compatibilityIssues).every((count) => count === 0),
163
+ }
164
+ }
165
+
166
+ function safeError(error) {
167
+ return {
168
+ name: error instanceof Error ? error.name : 'Error',
169
+ code: typeof error?.code === 'string' ? error.code : null,
170
+ }
171
+ }
172
+
173
+ export function createSessionIndexService({
174
+ repository,
175
+ readBuckets,
176
+ now = () => new Date().toISOString(),
177
+ nowMs = () => Date.now(),
178
+ verifyTtlMs = DEFAULT_VERIFY_TTL_MS,
179
+ maxRebuildAttempts = DEFAULT_MAX_REBUILD_ATTEMPTS,
180
+ log = logger,
181
+ } = {}) {
182
+ if (!repository || typeof repository.replaceAll !== 'function' || typeof repository.applyChanges !== 'function') {
183
+ throw new TypeError('Session index service requires a repository')
184
+ }
185
+ if (typeof readBuckets !== 'function') throw new TypeError('Session index service requires a bucket reader')
186
+
187
+ let rebuildPromise = null
188
+ const emptyDigest = sessionIndexAggregateDigest([])
189
+ const diagnostics = {
190
+ status: 'uninitialized',
191
+ initialized: false,
192
+ degraded: true,
193
+ dirty: true,
194
+ sourceCount: 0,
195
+ sourceDigest: emptyDigest,
196
+ indexCount: 0,
197
+ indexDigest: emptyDigest,
198
+ count: 0,
199
+ digest: emptyDigest,
200
+ lastVerifiedAt: null,
201
+ rebuildCount: 0,
202
+ rebuildGeneration: 0,
203
+ keyIdMismatchCount: 0,
204
+ duplicateSessionIdCount: 0,
205
+ queryCompatible: false,
206
+ compatibilityIssues: null,
207
+ lastFailure: null,
208
+ }
209
+
210
+ async function readSnapshot() {
211
+ return snapshotFromBuckets(await readBuckets(), now())
212
+ }
213
+
214
+ function indexedVerification() {
215
+ const entries = repository.listVerification()
216
+ return { count: entries.length, digest: sessionIndexAggregateDigest(entries) }
217
+ }
218
+
219
+ function projectionIntegrity() {
220
+ if (typeof repository.listIntegrity !== 'function') return true
221
+ return repository.listIntegrity().every((row) => {
222
+ const metadata = row.metadata
223
+ if (!isPlainObject(metadata)) return false
224
+ if (row.isArchived !== Boolean(metadata.archivedAt)) return false
225
+ if (row.isPinned !== Boolean(metadata.pinnedAt)) return false
226
+ if (row.createdAt !== nullableString(metadata.createdAt)) return false
227
+ if (row.lastModified !== nullableString(metadata.lastModified)) return false
228
+ if (row.pinnedAt !== nullableString(metadata.pinnedAt)) return false
229
+ if (row.archivedAt !== nullableString(metadata.archivedAt)) return false
230
+ if (row.messageCount !== nullableInteger(metadata.messageCount)) return false
231
+ return true
232
+ })
233
+ }
234
+
235
+ function setVerification(snapshot, verification, { dirty = false } = {}) {
236
+ diagnostics.sourceCount = snapshot.count
237
+ diagnostics.sourceDigest = snapshot.digest
238
+ diagnostics.indexCount = verification.count
239
+ diagnostics.indexDigest = verification.digest
240
+ diagnostics.count = snapshot.count
241
+ diagnostics.digest = snapshot.digest
242
+ diagnostics.keyIdMismatchCount = snapshot.keyIdMismatchCount
243
+ diagnostics.duplicateSessionIdCount = snapshot.duplicateSessionIdCount
244
+ diagnostics.queryCompatible = snapshot.queryCompatible
245
+ diagnostics.compatibilityIssues = { ...snapshot.compatibilityIssues }
246
+ diagnostics.dirty = dirty
247
+ diagnostics.degraded = dirty || !snapshot.queryCompatible
248
+ diagnostics.status = diagnostics.degraded ? 'degraded' : 'ready'
249
+ diagnostics.lastVerifiedAt = now()
250
+ if (!diagnostics.degraded) diagnostics.lastFailure = null
251
+ }
252
+
253
+ async function rebuildInternal(initialSnapshot = null) {
254
+ let snapshot = initialSnapshot
255
+ for (let attempt = 1; attempt <= maxRebuildAttempts; attempt += 1) {
256
+ snapshot ||= await readSnapshot()
257
+ repository.replaceAll(snapshot.rows)
258
+ const afterSource = await readSnapshot()
259
+ if (afterSource.count !== snapshot.count || afterSource.digest !== snapshot.digest) {
260
+ snapshot = afterSource
261
+ continue
262
+ }
263
+ const verification = indexedVerification()
264
+ if (verification.count !== snapshot.count || verification.digest !== snapshot.digest) {
265
+ snapshot = afterSource
266
+ continue
267
+ }
268
+ diagnostics.rebuildCount += 1
269
+ diagnostics.rebuildGeneration += 1
270
+ setVerification(snapshot, verification)
271
+ return { ok: true, rebuilt: true, attempts: attempt, ...getDiagnostics() }
272
+ }
273
+ throw new Error(`Session index source changed during ${maxRebuildAttempts} rebuild attempts`)
274
+ }
275
+
276
+ function rebuild(initialSnapshot = null) {
277
+ if (rebuildPromise) return rebuildPromise
278
+ rebuildPromise = rebuildInternal(initialSnapshot).catch((error) => {
279
+ diagnostics.dirty = true
280
+ diagnostics.degraded = true
281
+ diagnostics.status = 'degraded'
282
+ diagnostics.lastFailure = safeError(error)
283
+ log.warn('Session index rebuild failed; JSON metadata remains authoritative.', {
284
+ operation: 'session_index_rebuild',
285
+ error: diagnostics.lastFailure,
286
+ })
287
+ throw error
288
+ }).finally(() => { rebuildPromise = null })
289
+ return rebuildPromise
290
+ }
291
+
292
+ function scheduleRebuild() {
293
+ if (!rebuildPromise) {
294
+ setTimeout(() => {
295
+ if (!rebuildPromise && diagnostics.dirty) void rebuild().catch(() => {})
296
+ }, 0).unref?.()
297
+ }
298
+ }
299
+
300
+ async function initialize() {
301
+ try {
302
+ const snapshot = await readSnapshot()
303
+ const verification = indexedVerification()
304
+ if (verification.count !== snapshot.count || verification.digest !== snapshot.digest) await rebuild(snapshot)
305
+ else setVerification(snapshot, verification)
306
+ diagnostics.initialized = true
307
+ return { ok: !diagnostics.degraded, rebuilt: diagnostics.rebuildCount > 0, ...getDiagnostics() }
308
+ } catch (error) {
309
+ diagnostics.initialized = true
310
+ diagnostics.dirty = true
311
+ diagnostics.degraded = true
312
+ diagnostics.status = 'degraded'
313
+ diagnostics.lastFailure = safeError(error)
314
+ log.warn('Session index initialization degraded; JSON metadata remains authoritative.', {
315
+ operation: 'session_index_initialize',
316
+ error: diagnostics.lastFailure,
317
+ })
318
+ return { ok: false, degraded: true, ...getDiagnostics() }
319
+ }
320
+ }
321
+
322
+ async function verifyIntegrity({ force = false } = {}) {
323
+ if (!diagnostics.initialized) return { ready: false, reason: 'uninitialized', ...getDiagnostics() }
324
+ const lastVerifiedMs = diagnostics.lastVerifiedAt ? Date.parse(diagnostics.lastVerifiedAt) : Number.NaN
325
+ if (!force && Number.isFinite(lastVerifiedMs) && nowMs() - lastVerifiedMs < verifyTtlMs) {
326
+ return { ready: !diagnostics.dirty && !diagnostics.degraded && diagnostics.queryCompatible, ...getDiagnostics() }
327
+ }
328
+ try {
329
+ const snapshot = await readSnapshot()
330
+ const verification = indexedVerification()
331
+ const projectionValid = projectionIntegrity()
332
+ const matches = verification.count === snapshot.count && verification.digest === snapshot.digest && projectionValid
333
+ setVerification(snapshot, verification, { dirty: !matches })
334
+ if (!matches) scheduleRebuild()
335
+ return {
336
+ ready: matches && snapshot.queryCompatible,
337
+ reason: !projectionValid ? 'projection_mismatch' : !matches ? 'digest_mismatch' : snapshot.queryCompatible ? null : 'source_incompatible',
338
+ ...getDiagnostics(),
339
+ }
340
+ } catch (error) {
341
+ diagnostics.dirty = true
342
+ diagnostics.degraded = true
343
+ diagnostics.status = 'degraded'
344
+ diagnostics.lastFailure = safeError(error)
345
+ scheduleRebuild()
346
+ return { ready: false, reason: 'verify_failed', ...getDiagnostics() }
347
+ }
348
+ }
349
+
350
+ async function syncMetadataCommit({ scope, projectId, previous, next }) {
351
+ const bucket = normalizeBucket({ scope, projectId })
352
+ if (!isPlainObject(previous) || !isPlainObject(next)) throw new TypeError('previous and next metadata buckets must be plain objects')
353
+ if (!diagnostics.initialized) return { ok: true, skipped: true }
354
+ diagnostics.lastVerifiedAt = null
355
+ try {
356
+ const indexedAt = now()
357
+ const upserts = []
358
+ const deletes = []
359
+ const ids = new Set([...Object.keys(previous), ...Object.keys(next)])
360
+ for (const sessionId of ids) {
361
+ const previousMetadata = previous[sessionId]
362
+ const nextMetadata = next[sessionId]
363
+ if (nextMetadata === undefined) {
364
+ deletes.push({ ...bucket, sessionId })
365
+ continue
366
+ }
367
+ const nextRow = buildRow(bucket, sessionId, nextMetadata, indexedAt)
368
+ if (previousMetadata !== undefined) {
369
+ const previousRow = buildRow(bucket, sessionId, previousMetadata, indexedAt)
370
+ if (previousRow.metadataDigest === nextRow.metadataDigest) continue
371
+ }
372
+ upserts.push(nextRow)
373
+ }
374
+ repository.applyChanges({ upserts, deletes })
375
+ const verification = indexedVerification()
376
+ diagnostics.indexCount = verification.count
377
+ diagnostics.indexDigest = verification.digest
378
+ diagnostics.count = verification.count
379
+ diagnostics.digest = verification.digest
380
+ diagnostics.sourceCount = verification.count
381
+ diagnostics.sourceDigest = verification.digest
382
+ if (!diagnostics.dirty) diagnostics.lastFailure = null
383
+ return { ok: true, upserted: upserts.length, deleted: deletes.length, dirty: diagnostics.dirty }
384
+ } catch (error) {
385
+ diagnostics.dirty = true
386
+ diagnostics.degraded = true
387
+ diagnostics.status = 'degraded'
388
+ diagnostics.lastFailure = safeError(error)
389
+ scheduleRebuild()
390
+ log.warn('Session index incremental sync failed; JSON metadata commit remains successful.', {
391
+ operation: 'session_index_sync',
392
+ scope: bucket.scope,
393
+ hasProjectId: bucket.scope === 'project',
394
+ error: diagnostics.lastFailure,
395
+ })
396
+ return { ok: false, degraded: true }
397
+ }
398
+ }
399
+
400
+ function markQueryFailure(reason, error = null) {
401
+ diagnostics.dirty = true
402
+ diagnostics.degraded = true
403
+ diagnostics.status = 'degraded'
404
+ diagnostics.lastFailure = error ? safeError(error) : { name: 'SessionIndexQueryMismatch', code: reason }
405
+ log.warn('Session index query degraded; JSON metadata remains authoritative.', {
406
+ operation: 'session_index_query',
407
+ reason,
408
+ error: error ? diagnostics.lastFailure : null,
409
+ })
410
+ scheduleRebuild()
411
+ }
412
+
413
+ async function queryPage(options) {
414
+ const readiness = await verifyIntegrity()
415
+ if (!readiness.ready) {
416
+ return {
417
+ ok: false,
418
+ reason: readiness.reason || (readiness.queryCompatible === false ? 'source_incompatible' : 'not_ready'),
419
+ readiness,
420
+ }
421
+ }
422
+ try {
423
+ const analysis = repository.analyzeQuery(options)
424
+ if (analysis.duplicateSessionIdCount > 0) return { ok: false, reason: 'duplicate_session_id', analysis, readiness }
425
+ if (analysis.fullSortKeyTieCount > 0) return { ok: false, reason: 'sort_key_tie', analysis, readiness }
426
+ return { ok: true, page: repository.listPage(options), analysis, readiness }
427
+ } catch (error) {
428
+ markQueryFailure('repository_error', error)
429
+ return { ok: false, reason: 'repository_error', error: safeError(error), readiness: getDiagnostics() }
430
+ }
431
+ }
432
+
433
+ async function snapshot() {
434
+ return readSnapshot()
435
+ }
436
+
437
+ function getDiagnostics() {
438
+ return { ...diagnostics, compatibilityIssues: diagnostics.compatibilityIssues ? { ...diagnostics.compatibilityIssues } : null }
439
+ }
440
+
441
+ return Object.freeze({
442
+ snapshot, initialize, rebuild, verifyIntegrity, queryPage, markQueryFailure,
443
+ syncMetadataCommit, getDiagnostics,
444
+ })
445
+ }
446
+
447
+ export function configureSessionIndex({ readBuckets, repository } = {}) {
448
+ if (readBuckets !== undefined && typeof readBuckets !== 'function') throw new TypeError('readBuckets must be a function')
449
+ defaultReader = readBuckets ?? defaultReader
450
+ defaultRepository = repository ?? defaultRepository
451
+ }
452
+
453
+ export async function initializeSessionIndex(options = {}) {
454
+ const readBuckets = options.readBuckets ?? defaultReader
455
+ const repository = options.repository ?? defaultRepository ?? createSessionIndexRepository(getSqliteStorage())
456
+ activeService = createSessionIndexService({ ...options, repository, readBuckets })
457
+ return activeService.initialize()
458
+ }
459
+
460
+ export async function syncSessionMetadataCommit(change) {
461
+ if (!activeService) return { ok: true, skipped: true }
462
+ return activeService.syncMetadataCommit(change)
463
+ }
464
+
465
+ export async function querySessionIndexPage(options) {
466
+ if (!activeService) return { ok: false, reason: 'uninitialized', readiness: getSessionIndexDiagnostics() }
467
+ return activeService.queryPage(options)
468
+ }
469
+
470
+ export function markSessionIndexQueryFailure(reason, error) {
471
+ activeService?.markQueryFailure(reason, error)
472
+ }
473
+
474
+ export function getSessionIndexDiagnostics() {
475
+ return activeService?.getDiagnostics() ?? {
476
+ status: 'uninitialized',
477
+ initialized: false,
478
+ degraded: true,
479
+ dirty: true,
480
+ sourceCount: 0,
481
+ sourceDigest: sessionIndexAggregateDigest([]),
482
+ indexCount: 0,
483
+ indexDigest: sessionIndexAggregateDigest([]),
484
+ count: 0,
485
+ digest: sessionIndexAggregateDigest([]),
486
+ lastVerifiedAt: null,
487
+ rebuildCount: 0,
488
+ rebuildGeneration: 0,
489
+ keyIdMismatchCount: 0,
490
+ duplicateSessionIdCount: 0,
491
+ queryCompatible: false,
492
+ compatibilityIssues: null,
493
+ lastFailure: null,
494
+ }
495
+ }