@shawnstack/quickforge 1.7.9 → 1.7.11

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 (53) hide show
  1. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
  2. package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
  3. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
  4. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
  7. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
  9. package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
  11. package/dist/assets/index-BgMvQW1b.js +66 -0
  12. package/dist/assets/index-CB1g2D0f.css +3 -0
  13. package/dist/assets/local-tools-Dopjg9xq.js +270 -0
  14. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
  15. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +3 -6
  19. package/server/agent-manager.mjs +125 -43
  20. package/server/auto-archive.mjs +55 -8
  21. package/server/auto-compaction.mjs +0 -2
  22. package/server/context-usage.mjs +9 -7
  23. package/server/index.mjs +172 -51
  24. package/server/lan-access-cutover.mjs +21 -4
  25. package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
  26. package/server/maintenance/export-session-state-v1.mjs +5 -13
  27. package/server/routes/backup.mjs +8 -1
  28. package/server/routes/storage.mjs +85 -48
  29. package/server/scheduled-runs-cutover.mjs +15 -1
  30. package/server/session-index-service.mjs +57 -391
  31. package/server/session-persistence-lock.mjs +17 -6
  32. package/server/session-state-backup.mjs +60 -7
  33. package/server/session-state-import.mjs +157 -0
  34. package/server/session-state-maintenance.mjs +126 -0
  35. package/server/session-state-service.mjs +133 -194
  36. package/server/share-cutover.mjs +21 -4
  37. package/server/sqlite/database.mjs +142 -7
  38. package/server/sqlite/lan-access-repository.mjs +6 -4
  39. package/server/sqlite/migrations.mjs +109 -0
  40. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  41. package/server/sqlite/session-index-repository.mjs +59 -208
  42. package/server/sqlite/session-state-repository.mjs +359 -289
  43. package/server/sqlite/share-repository.mjs +56 -45
  44. package/server/startup-state.mjs +107 -0
  45. package/server/storage.mjs +240 -448
  46. package/server/utils/logger.mjs +15 -4
  47. package/server/utils/process-tree.mjs +14 -2
  48. package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
  49. package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
  50. package/dist/assets/index-D-J_8Smf.css +0 -3
  51. package/dist/assets/index-DFASc15l.js +0 -66
  52. package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
  53. package/server/session-state-cutover.mjs +0 -370
@@ -1,168 +1,18 @@
1
- import { createHash } from 'node:crypto'
2
1
  import { createSessionIndexRepository } from './sqlite/session-index-repository.mjs'
3
2
  import { getSqliteStorage } from './sqlite/database.mjs'
4
3
  import { logger } from './utils/logger.mjs'
5
4
 
6
- const DEFAULT_MAX_REBUILD_ATTEMPTS = 3
7
- const DEFAULT_VERIFY_TTL_MS = 5_000
5
+ // Storage v2 session index service. The JSON-metadata convergence machinery
6
+ // (shadow verification, verify TTL, scheduled rebuilds, incremental
7
+ // syncMetadataCommit) is retired together with the session_index projection
8
+ // table: queries read the authoritative `sessions` table directly, so there is
9
+ // no second data source that could drift. The service keeps the route-facing
10
+ // boundary (readiness/analysis/queryPage) and the diagnostics shape the
11
+ // /api/system status surface renders.
8
12
 
9
- let defaultReader = null
10
13
  let defaultRepository = null
11
14
  let activeService = null
12
15
 
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
16
  function safeError(error) {
167
17
  return {
168
18
  name: error instanceof Error ? error.name : 'Error',
@@ -170,148 +20,54 @@ function safeError(error) {
170
20
  }
171
21
  }
172
22
 
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') {
23
+ export function createSessionIndexService({ repository, log = logger } = {}) {
24
+ if (!repository || typeof repository.listPage !== 'function' || typeof repository.analyzeQuery !== 'function') {
183
25
  throw new TypeError('Session index service requires a repository')
184
26
  }
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
27
  const diagnostics = {
190
28
  status: 'uninitialized',
191
29
  initialized: false,
192
- degraded: true,
193
- dirty: true,
30
+ degraded: false,
31
+ dirty: false,
194
32
  sourceCount: 0,
195
- sourceDigest: emptyDigest,
196
33
  indexCount: 0,
197
- indexDigest: emptyDigest,
198
34
  count: 0,
199
- digest: emptyDigest,
200
35
  lastVerifiedAt: null,
201
36
  rebuildCount: 0,
202
37
  rebuildGeneration: 0,
203
- keyIdMismatchCount: 0,
204
38
  duplicateSessionIdCount: 0,
205
- queryCompatible: false,
206
- compatibilityIssues: null,
39
+ queryCompatible: true,
207
40
  lastFailure: null,
208
41
  }
209
42
 
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
43
+ function getDiagnostics() {
44
+ return { ...diagnostics }
290
45
  }
291
46
 
292
- function scheduleRebuild() {
293
- if (!rebuildPromise) {
294
- setTimeout(() => {
295
- if (!rebuildPromise && diagnostics.dirty) void rebuild().catch(() => {})
296
- }, 0).unref?.()
47
+ function readiness() {
48
+ return {
49
+ ready: diagnostics.initialized && !diagnostics.degraded,
50
+ reason: diagnostics.initialized ? null : 'uninitialized',
51
+ ...getDiagnostics(),
297
52
  }
298
53
  }
299
54
 
300
55
  async function initialize() {
301
56
  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)
57
+ diagnostics.count = repository.count()
58
+ diagnostics.sourceCount = diagnostics.count
59
+ diagnostics.indexCount = diagnostics.count
306
60
  diagnostics.initialized = true
307
- return { ok: !diagnostics.degraded, rebuilt: diagnostics.rebuildCount > 0, ...getDiagnostics() }
61
+ diagnostics.status = 'ready'
62
+ diagnostics.lastVerifiedAt = new Date().toISOString()
63
+ return { ok: true, rebuilt: false, ...getDiagnostics() }
308
64
  } catch (error) {
309
65
  diagnostics.initialized = true
310
- diagnostics.dirty = true
311
66
  diagnostics.degraded = true
67
+ diagnostics.dirty = true
312
68
  diagnostics.status = 'degraded'
313
69
  diagnostics.lastFailure = safeError(error)
314
- log.warn('Session index initialization degraded; JSON metadata remains authoritative.', {
70
+ log.warn('Session index initialization failed; queries will fall back.', {
315
71
  operation: 'session_index_initialize',
316
72
  error: diagnostics.lastFailure,
317
73
  })
@@ -319,147 +75,62 @@ export function createSessionIndexService({
319
75
  }
320
76
  }
321
77
 
322
- async function verifyIntegrity({ force = false } = {}) {
78
+ async function verifyIntegrity() {
323
79
  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
- }
80
+ return readiness()
348
81
  }
349
82
 
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
83
+ async function queryPage(options) {
84
+ if (!diagnostics.initialized) return { ok: false, reason: 'uninitialized', readiness: getDiagnostics() }
355
85
  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 }
86
+ // Duplicate session ids and complete sort-key ties make LIMIT/OFFSET
87
+ // pagination unstable; the route falls back to its legacy sorted read.
88
+ const analysis = repository.analyzeQuery(options)
89
+ if (analysis.duplicateSessionIdCount > 0) return { ok: false, reason: 'duplicate_session_id', analysis, readiness: readiness() }
90
+ if (analysis.fullSortKeyTieCount > 0) return { ok: false, reason: 'sort_key_tie', analysis, readiness: readiness() }
91
+ return { ok: true, page: repository.listPage(options), analysis, readiness: readiness() }
384
92
  } catch (error) {
385
93
  diagnostics.dirty = true
386
94
  diagnostics.degraded = true
387
95
  diagnostics.status = 'degraded'
388
96
  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',
97
+ log.warn('Session index query failed; route falls back.', {
98
+ operation: 'session_index_query',
394
99
  error: diagnostics.lastFailure,
395
100
  })
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() }
101
+ return { ok: false, reason: 'repository_error', error: diagnostics.lastFailure, readiness: getDiagnostics() }
430
102
  }
431
103
  }
432
104
 
433
- async function snapshot() {
434
- return readSnapshot()
105
+ // Storage v2: the index IS the sessions table, so metadata commits have
106
+ // nothing to mirror. Kept for the storage.mjs commit-hook wiring contract.
107
+ async function syncMetadataCommit() {
108
+ return { ok: true, skipped: true }
435
109
  }
436
110
 
437
- function getDiagnostics() {
438
- return { ...diagnostics, compatibilityIssues: diagnostics.compatibilityIssues ? { ...diagnostics.compatibilityIssues } : null }
111
+ function markQueryFailure() {
112
+ // No-op: there is no separate projection to mark dirty.
439
113
  }
440
114
 
441
115
  return Object.freeze({
442
- snapshot, initialize, rebuild, verifyIntegrity, queryPage, markQueryFailure,
443
- syncMetadataCommit, getDiagnostics,
116
+ initialize, verifyIntegrity, queryPage, syncMetadataCommit, markQueryFailure, getDiagnostics,
444
117
  })
445
118
  }
446
119
 
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
120
+ export function configureSessionIndex({ repository } = {}) {
121
+ if (repository !== undefined && repository !== null && typeof repository !== 'object') throw new TypeError('repository must be an object')
450
122
  defaultRepository = repository ?? defaultRepository
451
123
  }
452
124
 
453
125
  export async function initializeSessionIndex(options = {}) {
454
- const readBuckets = options.readBuckets ?? defaultReader
455
126
  const repository = options.repository ?? defaultRepository ?? createSessionIndexRepository(getSqliteStorage())
456
- activeService = createSessionIndexService({ ...options, repository, readBuckets })
127
+ activeService = createSessionIndexService({ ...options, repository })
457
128
  return activeService.initialize()
458
129
  }
459
130
 
460
- export async function syncSessionMetadataCommit(change) {
131
+ export async function syncSessionMetadataCommit() {
461
132
  if (!activeService) return { ok: true, skipped: true }
462
- return activeService.syncMetadataCommit(change)
133
+ return activeService.syncMetadataCommit()
463
134
  }
464
135
 
465
136
  export async function querySessionIndexPage(options) {
@@ -467,29 +138,24 @@ export async function querySessionIndexPage(options) {
467
138
  return activeService.queryPage(options)
468
139
  }
469
140
 
470
- export function markSessionIndexQueryFailure(reason, error) {
471
- activeService?.markQueryFailure(reason, error)
141
+ export function markSessionIndexQueryFailure() {
142
+ activeService?.markQueryFailure()
472
143
  }
473
144
 
474
145
  export function getSessionIndexDiagnostics() {
475
146
  return activeService?.getDiagnostics() ?? {
476
147
  status: 'uninitialized',
477
148
  initialized: false,
478
- degraded: true,
479
- dirty: true,
149
+ degraded: false,
150
+ dirty: false,
480
151
  sourceCount: 0,
481
- sourceDigest: sessionIndexAggregateDigest([]),
482
152
  indexCount: 0,
483
- indexDigest: sessionIndexAggregateDigest([]),
484
153
  count: 0,
485
- digest: sessionIndexAggregateDigest([]),
486
154
  lastVerifiedAt: null,
487
155
  rebuildCount: 0,
488
156
  rebuildGeneration: 0,
489
- keyIdMismatchCount: 0,
490
157
  duplicateSessionIdCount: 0,
491
- queryCompatible: false,
492
- compatibilityIssues: null,
158
+ queryCompatible: true,
493
159
  lastFailure: null,
494
160
  }
495
161
  }
@@ -1,9 +1,20 @@
1
- let persistenceQueue = Promise.resolve()
1
+ // Keyed FIFO persistence queues. Each key owns one promise chain, so
2
+ // operations under the same key run strictly in order while operations under
3
+ // different keys run independently. The default (global) key serializes
4
+ // everything; per-session keys are used once SQLite session state is
5
+ // authoritative, where correctness relies on per-row revision CAS instead of
6
+ // global mutual exclusion. JSON-mirror mode and auto-archive keep the global
7
+ // key for their bucket-wide read-modify-write atomicity.
8
+ const persistenceQueues = new Map()
2
9
 
3
- export function withSessionPersistenceLock(operation) {
4
- const result = persistenceQueue
5
- .catch(() => undefined)
6
- .then(operation)
7
- persistenceQueue = result.then(() => undefined, () => undefined)
10
+ export function withSessionPersistenceLock(operation, key = '') {
11
+ const previous = persistenceQueues.get(key) ?? Promise.resolve()
12
+ const result = previous.catch(() => undefined).then(operation)
13
+ const tail = result.then(() => undefined, () => undefined)
14
+ persistenceQueues.set(key, tail)
15
+ tail.then(() => {
16
+ // Drop the queue entry once it drains so idle keys do not linger.
17
+ if (persistenceQueues.get(key) === tail) persistenceQueues.delete(key)
18
+ })
8
19
  return result
9
20
  }