@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.
- package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
- package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
- package/dist/assets/{PluginsPage-BDKYfLna.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-DSy66Tuw.js → SettingsWorkspacePage-7lQ0Wl63.js} +391 -391
- package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
- package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
- package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
- package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
- package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
- package/dist/assets/index-D-J_8Smf.css +3 -0
- package/dist/assets/index-DFASc15l.js +66 -0
- package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
- package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-BEV4_TXC.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
- package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
- package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
- package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
- package/dist/assets/{skills-dialog-BTJ7mzk_.js → skills-dialog-DIzZvQqW.js} +3 -3
- package/dist/index.html +8 -8
- package/package.json +1 -1
- package/server/acp/server.mjs +17 -1
- package/server/agent-manager.mjs +251 -31
- package/server/ai-http-logger.mjs +162 -19
- package/server/ai-provider-options.mjs +4 -1
- package/server/auto-archive.mjs +42 -29
- package/server/auto-compaction.mjs +43 -1
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/config.mjs +8 -16
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/context-usage.mjs +11 -4
- package/server/index.mjs +69 -13
- package/server/lan-access-backup.mjs +256 -0
- package/server/lan-access-cutover.mjs +306 -0
- package/server/lan-access-json-file.mjs +75 -0
- package/server/lan-access-service.mjs +155 -0
- package/server/lan-access-store.mjs +110 -9
- package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
- package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
- package/server/maintenance/downgrade-share-v1.mjs +101 -0
- package/server/maintenance/export-lan-access-v1.mjs +80 -0
- package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
- package/server/maintenance/export-session-state-v1.mjs +67 -0
- package/server/maintenance/export-share-v1.mjs +77 -0
- package/server/routes/agent.mjs +37 -4
- package/server/routes/backup.mjs +221 -39
- package/server/routes/cloud.mjs +28 -1
- package/server/routes/scheduled-tasks.mjs +347 -134
- package/server/routes/shares.mjs +4 -2
- package/server/routes/storage.mjs +138 -8
- package/server/routes/workspace.mjs +265 -4
- package/server/scheduled-runs-backup.mjs +235 -0
- package/server/scheduled-runs-cutover.mjs +497 -0
- package/server/scheduled-task-runs-service.mjs +279 -0
- package/server/session-index-service.mjs +495 -0
- package/server/session-state-backup.mjs +277 -0
- package/server/session-state-cutover.mjs +370 -0
- package/server/session-state-service.mjs +709 -0
- package/server/share-backup.mjs +255 -0
- package/server/share-cutover.mjs +296 -0
- package/server/share-json-file.mjs +60 -0
- package/server/share-service.mjs +163 -0
- package/server/share-store.mjs +196 -19
- package/server/skills.mjs +0 -1
- package/server/sqlite/database.mjs +233 -0
- package/server/sqlite/lan-access-repository.mjs +652 -0
- package/server/sqlite/migrations.mjs +577 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
- package/server/sqlite/session-index-repository.mjs +297 -0
- package/server/sqlite/session-state-repository.mjs +786 -0
- package/server/sqlite/share-repository.mjs +854 -0
- package/server/storage.mjs +317 -1
- package/dist/assets/AgentProfilesPage-Dl5Peqov.js +0 -1
- package/dist/assets/ChatPanelHost-D3987TUJ.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +0 -1
- package/dist/assets/ScheduledTasksPage--GAki3lB.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +0 -1
- package/dist/assets/SharedConversationPage-B9BUVCri.js +0 -1
- package/dist/assets/TerminalDock-CT1nYo7P.js +0 -2
- package/dist/assets/WorkspaceInspector-BnfDLf9a.js +0 -36
- package/dist/assets/index-1ei6nhs-.css +0 -3
- package/dist/assets/index-BivLuZvX.js +0 -65
- package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
- /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
|
@@ -0,0 +1,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
|
+
}
|