@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.
- package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
- package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
- package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
- package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
- package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
- package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
- package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
- package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
- package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
- package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
- package/dist/assets/index-BgMvQW1b.js +66 -0
- package/dist/assets/index-CB1g2D0f.css +3 -0
- package/dist/assets/local-tools-Dopjg9xq.js +270 -0
- package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
- package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +125 -43
- package/server/auto-archive.mjs +55 -8
- package/server/auto-compaction.mjs +0 -2
- package/server/context-usage.mjs +9 -7
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
- package/server/maintenance/export-session-state-v1.mjs +5 -13
- package/server/routes/backup.mjs +8 -1
- package/server/routes/storage.mjs +85 -48
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-persistence-lock.mjs +17 -6
- package/server/session-state-backup.mjs +60 -7
- package/server/session-state-import.mjs +157 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +133 -194
- package/server/share-cutover.mjs +21 -4
- package/server/sqlite/database.mjs +142 -7
- package/server/sqlite/lan-access-repository.mjs +6 -4
- package/server/sqlite/migrations.mjs +109 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
- package/server/sqlite/session-index-repository.mjs +59 -208
- package/server/sqlite/session-state-repository.mjs +359 -289
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +240 -448
- package/server/utils/logger.mjs +15 -4
- package/server/utils/process-tree.mjs +14 -2
- package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
- package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
- package/dist/assets/index-D-J_8Smf.css +0 -3
- package/dist/assets/index-DFASc15l.js +0 -66
- package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
- package/server/session-state-cutover.mjs +0 -370
|
@@ -1,25 +1,15 @@
|
|
|
1
1
|
import { createSessionStateRepository, MESSAGES_PAGE_LIMIT_MAX, MESSAGES_SPLIT_VALUE, messageDigest } from './sqlite/session-state-repository.mjs'
|
|
2
2
|
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
4
|
+
// Storage v2 integration: SQLite is the single authoritative session store.
|
|
5
|
+
// The JSON→SQLite phase machine (cutover, mirror queue, JSON write barrier) is
|
|
6
|
+
// retired; the v2 `sessions`/`session_messages`/`session_tombstones` schema
|
|
7
|
+
// (migration v11) is the only representation, and legacy JSON session files are
|
|
8
|
+
// consumed once by importSessionStateFromJson during startup.
|
|
15
9
|
|
|
16
10
|
let repositoryInstance = null
|
|
17
|
-
let
|
|
18
|
-
let
|
|
19
|
-
let mirrorAdapter = null
|
|
20
|
-
let drainPromise = null
|
|
21
|
-
let mirrorTimer = null
|
|
22
|
-
const MIRROR_DRAIN_INTERVAL_MS = 1000
|
|
11
|
+
let repositoryInstanceHandle = null
|
|
12
|
+
let initializedAt = null
|
|
23
13
|
|
|
24
14
|
function isPlainObject(value) {
|
|
25
15
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
@@ -45,68 +35,58 @@ function storage() {
|
|
|
45
35
|
}
|
|
46
36
|
|
|
47
37
|
function repository() {
|
|
48
|
-
|
|
38
|
+
// Rebuild when the process-wide SQLite handle changed (close/reopen cycles
|
|
39
|
+
// in tests and restart flows): cached statements are bound to one handle.
|
|
40
|
+
const handle = storage()
|
|
41
|
+
if (!repositoryInstance || (repositoryInstanceHandle !== null && repositoryInstanceHandle !== handle)) {
|
|
42
|
+
repositoryInstance = createSessionStateRepository(handle)
|
|
43
|
+
repositoryInstanceHandle = handle
|
|
44
|
+
}
|
|
49
45
|
return repositoryInstance
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
48
|
+
// v2: only the repository override remains testable. The retired phase
|
|
49
|
+
// machine's `json`/`mirror`/`phase` options are accepted and ignored so old
|
|
50
|
+
// call sites keep working while they are cleaned up.
|
|
51
|
+
export function configureSessionStateService({ repository: configuredRepository } = {}) {
|
|
52
|
+
if (configuredRepository !== undefined) {
|
|
53
|
+
repositoryInstance = configuredRepository
|
|
54
|
+
repositoryInstanceHandle = null
|
|
72
55
|
}
|
|
73
56
|
}
|
|
74
57
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
58
|
+
// Constant authoritative state (startup-state/诊断展示用): there is no
|
|
59
|
+
// session_storage_state table any more — SQLite is authoritative by
|
|
60
|
+
// construction, and the live session count comes straight from the store.
|
|
84
61
|
export function readSessionStorageState() {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
62
|
+
let stateCount = null
|
|
63
|
+
try {
|
|
64
|
+
stateCount = repository().count()
|
|
65
|
+
} catch {
|
|
66
|
+
// SQLite not initialized yet (early startup diagnostics); the phase stays
|
|
67
|
+
// authoritative, the count is simply unknown until the store opens.
|
|
68
|
+
}
|
|
88
69
|
return {
|
|
89
|
-
phase:
|
|
90
|
-
stateCount
|
|
91
|
-
digest:
|
|
92
|
-
backupFile:
|
|
93
|
-
diagnostic:
|
|
94
|
-
updatedAt:
|
|
70
|
+
phase: 'authoritative',
|
|
71
|
+
stateCount,
|
|
72
|
+
digest: null,
|
|
73
|
+
backupFile: null,
|
|
74
|
+
diagnostic: null,
|
|
75
|
+
updatedAt: initializedAt,
|
|
95
76
|
}
|
|
96
77
|
}
|
|
97
78
|
|
|
98
79
|
export function initializeSessionStateService() {
|
|
80
|
+
initializedAt = new Date().toISOString()
|
|
99
81
|
return readSessionStorageState()
|
|
100
82
|
}
|
|
101
83
|
|
|
102
|
-
export function getSessionStoragePhase() {
|
|
103
|
-
return cachedPhase
|
|
104
|
-
}
|
|
105
|
-
|
|
106
84
|
export function isSessionStateAuthoritative() {
|
|
107
|
-
return
|
|
85
|
+
return true
|
|
108
86
|
}
|
|
109
87
|
|
|
88
|
+
export function stopSessionStateService() {}
|
|
89
|
+
|
|
110
90
|
function normalizeBucket(value, fallback = null) {
|
|
111
91
|
const scope = value?.scope ?? fallback?.scope ?? 'global'
|
|
112
92
|
if (scope === 'project') {
|
|
@@ -204,56 +184,39 @@ function assembleState(record) {
|
|
|
204
184
|
}
|
|
205
185
|
|
|
206
186
|
// Decide how an incoming full state should be persisted relative to the
|
|
207
|
-
// currently stored session
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// -
|
|
187
|
+
// currently stored session. Storage v2 keeps every session's messages in
|
|
188
|
+
// `session_messages` (the repository extracts any `messages` array on save),
|
|
189
|
+
// so only the incremental tail decision remains:
|
|
190
|
+
// - body-only: messages are unchanged (or absent); only the body is saved.
|
|
191
|
+
// - replace: full message rewrite (first save / truncation / in-place edit).
|
|
211
192
|
// - append: only the new tail rows are written (incremental).
|
|
212
193
|
function messageStoragePlan(state, existing) {
|
|
213
194
|
const incoming = state?.messages
|
|
214
|
-
|
|
215
|
-
if (incoming === undefined) return isSplit ? { mode: 'body-only' } : { mode: 'inline' }
|
|
195
|
+
if (incoming === undefined) return { mode: 'body-only' }
|
|
216
196
|
if (!Array.isArray(incoming)) throw new TypeError('state.messages must be an array')
|
|
217
|
-
if (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
197
|
+
if (!existing) return { mode: 'replace', messages: incoming }
|
|
198
|
+
if (incoming.length === 0) return { mode: 'replace', messages: incoming }
|
|
199
|
+
const storedCount = repository().messageCount({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId })
|
|
200
|
+
if (incoming.length < storedCount) return { mode: 'replace', messages: incoming }
|
|
201
|
+
const tail = incoming.slice(storedCount)
|
|
202
|
+
if (tail.length === 0) {
|
|
203
|
+
if (storedCount > 0) {
|
|
204
|
+
const last = repository().readLastMessage({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId })
|
|
205
|
+
if (last && last.digest !== messageDigest(incoming[storedCount - 1])) {
|
|
206
|
+
return { mode: 'replace', messages: incoming }
|
|
207
|
+
}
|
|
208
|
+
// Same-length in-place edits in the middle keep count and tail digest
|
|
209
|
+
// identical; probe one deterministic middle row so the edit is not
|
|
210
|
+
// silently dropped. A missing row also forces the full rewrite.
|
|
211
|
+
const midSeq = Math.floor(storedCount / 2)
|
|
212
|
+
if (midSeq !== storedCount - 1
|
|
213
|
+
&& repository().readMessageDigestAt({ scope: existing.scope, projectId: existing.projectId, sessionId: existing.sessionId, seq: midSeq }) !== messageDigest(incoming[midSeq])) {
|
|
214
|
+
return { mode: 'replace', messages: incoming }
|
|
227
215
|
}
|
|
228
|
-
return { mode: 'body-only' }
|
|
229
216
|
}
|
|
230
|
-
return { mode: '
|
|
217
|
+
return { mode: 'body-only' }
|
|
231
218
|
}
|
|
232
|
-
|
|
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
|
|
219
|
+
return { mode: 'append', messages: tail }
|
|
257
220
|
}
|
|
258
221
|
|
|
259
222
|
function savePair(state, metadata, options = {}) {
|
|
@@ -262,12 +225,7 @@ function savePair(state, metadata, options = {}) {
|
|
|
262
225
|
const plan = messageStoragePlan(state, existing)
|
|
263
226
|
const record = synchronize(state, metadata, sessionId, existing)
|
|
264
227
|
const finalMetadata = { ...record.metadata }
|
|
265
|
-
if (plan.mode === '
|
|
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') {
|
|
228
|
+
if (plan.mode === 'replace' || plan.mode === 'append') {
|
|
271
229
|
const fullMessages = Array.isArray(state.messages) ? state.messages : plan.messages
|
|
272
230
|
finalMetadata.messageCount = plan.mode === 'append' ? fullMessages.length : plan.messages.length
|
|
273
231
|
if (finalMetadata.preview === undefined) finalMetadata.preview = previewFromMessages(fullMessages)
|
|
@@ -290,37 +248,25 @@ function savePair(state, metadata, options = {}) {
|
|
|
290
248
|
expectedStateVersion: options.expectedStateVersion,
|
|
291
249
|
})
|
|
292
250
|
}
|
|
293
|
-
|
|
294
|
-
//
|
|
295
|
-
// so agent-manager can maintain its conflict-detection counters without
|
|
251
|
+
// Surface the storage plan and the exact persisted message count so
|
|
252
|
+
// agent-manager can maintain its conflict-detection counters without
|
|
296
253
|
// re-reading the message table on every save.
|
|
297
|
-
const totalMessageCount =
|
|
298
|
-
? (Array.isArray(state.messages) ? state.messages.length : finalMetadata.messageCount)
|
|
299
|
-
: repository().messageCount({ scope: saved.scope, projectId: saved.projectId, sessionId: saved.sessionId })
|
|
254
|
+
const totalMessageCount = repository().messageCount({ scope: saved.scope, projectId: saved.projectId, sessionId: saved.sessionId })
|
|
300
255
|
return { ...saved, messageStoragePlan: plan.mode, messageCount: totalMessageCount }
|
|
301
256
|
}
|
|
302
257
|
|
|
303
258
|
/**
|
|
304
259
|
* Current message representation of a stored session (conflict detection aid):
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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.
|
|
260
|
+
* every v2 session is split (`split: true`); `count`/`tailDigest` describe the
|
|
261
|
+
* stored rows (tail digest = row digest of the last message).
|
|
310
262
|
*/
|
|
311
263
|
export function storedMessagesState(sessionId) {
|
|
312
|
-
if (!sqliteReadable()) return { split: false, count: 0, tailDigest: '' }
|
|
313
264
|
const record = repository().findBySessionId(sessionId)
|
|
314
265
|
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
266
|
const count = repository().messageCount({ scope: record.scope, projectId: record.projectId, sessionId })
|
|
320
267
|
let tailDigest = ''
|
|
321
268
|
if (count > 0) {
|
|
322
|
-
|
|
323
|
-
tailDigest = last.messages[0]?.digest ?? ''
|
|
269
|
+
tailDigest = repository().readLastMessage({ scope: record.scope, projectId: record.projectId, sessionId })?.digest ?? ''
|
|
324
270
|
}
|
|
325
271
|
return { split: true, count, tailDigest }
|
|
326
272
|
}
|
|
@@ -336,17 +282,14 @@ export function sessionMessagesTailDigest(messages) {
|
|
|
336
282
|
}
|
|
337
283
|
|
|
338
284
|
export function readSessionStateRecord(sessionId) {
|
|
339
|
-
if (!sqliteReadable()) return requireJsonAdapter('readRecord')(sessionId)
|
|
340
285
|
return repository().findBySessionId(sessionId)
|
|
341
286
|
}
|
|
342
287
|
|
|
343
288
|
export function readSessionStateValue(sessionId) {
|
|
344
|
-
if (!sqliteReadable()) return requireJsonAdapter('readState')(sessionId)
|
|
345
289
|
return assembleState(repository().findBySessionId(sessionId))
|
|
346
290
|
}
|
|
347
291
|
|
|
348
292
|
export function readSessionMetadataValue(sessionId) {
|
|
349
|
-
if (!sqliteReadable()) return requireJsonAdapter('readMetadata')(sessionId)
|
|
350
293
|
return repository().findBySessionId(sessionId)?.metadata ?? null
|
|
351
294
|
}
|
|
352
295
|
|
|
@@ -373,8 +316,13 @@ function applyMetadataToState(existing, metadata) {
|
|
|
373
316
|
}
|
|
374
317
|
|
|
375
318
|
function metadataBucketChanges(scope, projectId, updateFn) {
|
|
376
|
-
|
|
377
|
-
|
|
319
|
+
// Metadata-only projection (readSessionMetadataBuckets shape): building the
|
|
320
|
+
// bucket's current map must never materialize state bodies or message rows.
|
|
321
|
+
const current = {}
|
|
322
|
+
for (const bucket of readSessionMetadataBuckets()) {
|
|
323
|
+
if (scope && (bucket.scope !== scope || (scope === 'project' && bucket.projectId !== projectId))) continue
|
|
324
|
+
Object.assign(current, bucket.metadata)
|
|
325
|
+
}
|
|
378
326
|
const updated = updateFn(structuredClone(current))
|
|
379
327
|
if (!isPlainObject(updated)) throw new TypeError('Updated metadata bucket must be a plain object')
|
|
380
328
|
const upserts = []
|
|
@@ -401,7 +349,6 @@ function metadataBucketChanges(scope, projectId, updateFn) {
|
|
|
401
349
|
}
|
|
402
350
|
|
|
403
351
|
export function updateSessionMetadataBucket(scope, projectId, updateFn) {
|
|
404
|
-
if (!sqliteReadable()) return requireJsonAdapter('updateMetadataBucket')(scope, projectId, updateFn)
|
|
405
352
|
const changes = metadataBucketChanges(scope, projectId, updateFn)
|
|
406
353
|
if (changes.deletes.length > 0) {
|
|
407
354
|
const error = new TypeError('Metadata bucket updates cannot delete session bodies; use full session delete')
|
|
@@ -411,13 +358,24 @@ export function updateSessionMetadataBucket(scope, projectId, updateFn) {
|
|
|
411
358
|
}
|
|
412
359
|
if (changes.upserts.length > 0) {
|
|
413
360
|
repository().applyBatch({ upserts: changes.upserts })
|
|
414
|
-
requestSessionJsonMirrorDrain()
|
|
415
361
|
}
|
|
416
362
|
return changes.updated
|
|
417
363
|
}
|
|
418
364
|
|
|
419
365
|
export function readSessionStateStore(storeName, { scope, projectId } = {}) {
|
|
420
|
-
|
|
366
|
+
// JSON-era provenance: 'sessions-metadata' has always been a metadata-only
|
|
367
|
+
// bucket store ({sessionId: metadata}). Loading it must never materialize
|
|
368
|
+
// state bodies or message rows — read the metadata-only projection straight
|
|
369
|
+
// from the sessions table; without a filter this returns every bucket's map
|
|
370
|
+
// merged, matching the JSON-era merged-store read contract.
|
|
371
|
+
if (storeName === 'sessions-metadata') {
|
|
372
|
+
const merged = {}
|
|
373
|
+
for (const bucket of readSessionMetadataBuckets()) {
|
|
374
|
+
if (scope && (bucket.scope !== scope || (scope === 'project' && bucket.projectId !== projectId))) continue
|
|
375
|
+
Object.assign(merged, bucket.metadata)
|
|
376
|
+
}
|
|
377
|
+
return merged
|
|
378
|
+
}
|
|
421
379
|
const records = repository().exportSnapshot().records.filter((record) => {
|
|
422
380
|
if (!scope) return true
|
|
423
381
|
if (record.scope !== scope) return false
|
|
@@ -426,14 +384,28 @@ export function readSessionStateStore(storeName, { scope, projectId } = {}) {
|
|
|
426
384
|
return Object.fromEntries(records.map((record) => [record.sessionId, storeName === 'sessions' ? assembleState(record) : record.metadata]))
|
|
427
385
|
}
|
|
428
386
|
|
|
387
|
+
// Metadata bucket summaries straight from the authoritative sessions table
|
|
388
|
+
// (meta_json projection only — state bodies and message rows are never
|
|
389
|
+
// materialized; the historical OOM lesson). Consumed by session-index
|
|
390
|
+
// wiring and the storage facade's per-bucket metadata updates.
|
|
391
|
+
export function readSessionMetadataBuckets() {
|
|
392
|
+
const rows = storage().prepare('SELECT scope, project_id, session_id, meta_json FROM sessions ORDER BY scope, project_id, session_id').all()
|
|
393
|
+
const buckets = new Map()
|
|
394
|
+
for (const row of rows) {
|
|
395
|
+
const projectId = row.scope === 'project' ? row.project_id : null
|
|
396
|
+
const key = `${row.scope}\0${projectId || ''}`
|
|
397
|
+
if (!buckets.has(key)) buckets.set(key, { scope: row.scope, projectId, metadata: {} })
|
|
398
|
+
buckets.get(key).metadata[row.session_id] = JSON.parse(row.meta_json)
|
|
399
|
+
}
|
|
400
|
+
return [...buckets.values()]
|
|
401
|
+
}
|
|
402
|
+
|
|
429
403
|
export function saveSessionStatePair({ state, metadata, expectedRevision = null, expectedStateVersion = null } = {}) {
|
|
430
|
-
if (!sqliteReadable()) return requireJsonAdapter('savePair')({ state, metadata, expectedRevision, expectedStateVersion })
|
|
431
404
|
return savePair(state, metadata ?? deriveMetadata(state), { expectedRevision, expectedStateVersion })
|
|
432
405
|
}
|
|
433
406
|
|
|
434
407
|
export function saveSessionBody(sessionId, value, { expectedRevision = null } = {}) {
|
|
435
408
|
if (!isPlainObject(value)) throw new TypeError('Session body must be a plain object')
|
|
436
|
-
if (!sqliteReadable()) return requireJsonAdapter('saveBody')(sessionId, value, { expectedRevision })
|
|
437
409
|
const existing = repository().findBySessionId(sessionId)
|
|
438
410
|
const state = { ...(existing?.state || {}), ...structuredClone(value), id: sessionId }
|
|
439
411
|
const metadata = deriveMetadata(state, existing?.metadata || {})
|
|
@@ -442,7 +414,6 @@ export function saveSessionBody(sessionId, value, { expectedRevision = null } =
|
|
|
442
414
|
|
|
443
415
|
export function saveSessionMetadata(sessionId, value, { expectedRevision = null } = {}) {
|
|
444
416
|
if (!isPlainObject(value)) throw new TypeError('Session metadata must be a plain object')
|
|
445
|
-
if (!sqliteReadable()) return requireJsonAdapter('saveMetadata')(sessionId, value, { expectedRevision })
|
|
446
417
|
const existing = repository().findBySessionId(sessionId)
|
|
447
418
|
if (!existing) {
|
|
448
419
|
const error = new Error(`Session state does not exist: ${sessionId}`)
|
|
@@ -452,23 +423,17 @@ export function saveSessionMetadata(sessionId, value, { expectedRevision = null
|
|
|
452
423
|
}
|
|
453
424
|
const metadata = mergeMetadata(existing.metadata, value, sessionId)
|
|
454
425
|
const synchronized = applyMetadataToState(existing, metadata)
|
|
455
|
-
|
|
456
|
-
requestSessionJsonMirrorDrain()
|
|
457
|
-
return saved
|
|
426
|
+
return repository().save(synchronized, { expectedRevision: expectedRevision ?? existing.revision })
|
|
458
427
|
}
|
|
459
428
|
|
|
460
429
|
export function deleteSessionState(sessionId, { expectedRevision = null } = {}) {
|
|
461
|
-
if (!sqliteReadable()) return requireJsonAdapter('delete')(sessionId, { expectedRevision })
|
|
462
430
|
const existing = repository().findBySessionId(sessionId)
|
|
463
431
|
if (!existing) return false
|
|
464
|
-
|
|
465
|
-
requestSessionJsonMirrorDrain()
|
|
466
|
-
return deleted
|
|
432
|
+
return repository().deleteBySessionId(sessionId, { expectedRevision: expectedRevision ?? existing.revision })
|
|
467
433
|
}
|
|
468
434
|
|
|
469
435
|
export function replaceSessionStateStore(storeName, values) {
|
|
470
436
|
if (!isPlainObject(values)) throw new TypeError('Session store must be a plain object')
|
|
471
|
-
if (!sqliteReadable()) return requireJsonAdapter('replaceStore')(storeName, values)
|
|
472
437
|
const current = new Map(repository().exportSnapshot().records.map((record) => [record.sessionId, record]))
|
|
473
438
|
const records = []
|
|
474
439
|
if (storeName === 'sessions') {
|
|
@@ -500,12 +465,10 @@ export function replaceSessionStateStore(storeName, values) {
|
|
|
500
465
|
throw new TypeError(`Unsupported session state store: ${storeName}`)
|
|
501
466
|
}
|
|
502
467
|
repository().replaceAll(records)
|
|
503
|
-
requestSessionJsonMirrorDrain()
|
|
504
468
|
return values
|
|
505
469
|
}
|
|
506
470
|
|
|
507
471
|
export async function atomicSessionRecordUpdate(sessionId, updateFn, { maxRetries = 3 } = {}) {
|
|
508
|
-
if (!sqliteReadable()) return requireJsonAdapter('atomicRecordUpdate')(sessionId, updateFn)
|
|
509
472
|
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
510
473
|
const existing = repository().findBySessionId(sessionId)
|
|
511
474
|
if (!existing) return null
|
|
@@ -532,7 +495,6 @@ export async function atomicSessionRecordUpdate(sessionId, updateFn, { maxRetrie
|
|
|
532
495
|
}
|
|
533
496
|
|
|
534
497
|
export async function atomicSessionStateUpdate(sessionId, updateFn, { maxRetries = 3 } = {}) {
|
|
535
|
-
if (!sqliteReadable()) return requireJsonAdapter('atomicStateUpdate')(sessionId, updateFn)
|
|
536
498
|
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
537
499
|
const existing = repository().findBySessionId(sessionId)
|
|
538
500
|
if (!existing) return null
|
|
@@ -547,7 +509,6 @@ export async function atomicSessionStateUpdate(sessionId, updateFn, { maxRetries
|
|
|
547
509
|
}
|
|
548
510
|
|
|
549
511
|
export async function atomicSessionMetadataStateUpdate(scope, projectId, updateFn, { maxRetries = 3 } = {}) {
|
|
550
|
-
if (!sqliteReadable()) return requireJsonAdapter('atomicMetadataUpdate')(scope, projectId, updateFn)
|
|
551
512
|
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
552
513
|
try {
|
|
553
514
|
return updateSessionMetadataBucket(scope, projectId, updateFn)
|
|
@@ -560,7 +521,15 @@ export async function atomicSessionMetadataStateUpdate(scope, projectId, updateF
|
|
|
560
521
|
|
|
561
522
|
export function applySessionBatch(operations) {
|
|
562
523
|
if (!Array.isArray(operations) || operations.length === 0) throw new TypeError('Session batch operations are required')
|
|
563
|
-
|
|
524
|
+
// pi-web-ui's SessionsStore.delete() emits a `sessions` delete AND a
|
|
525
|
+
// `sessions-metadata` delete for the same key in one transaction. The
|
|
526
|
+
// metadata delete is subsumed by the grouped full delete (idempotent no-op);
|
|
527
|
+
// only a metadata delete without a paired body delete stays rejected.
|
|
528
|
+
const fullDeleteKeys = new Set(
|
|
529
|
+
operations
|
|
530
|
+
.filter((operation) => operation?.type === 'delete' && operation?.store === 'sessions')
|
|
531
|
+
.map((operation) => operation.key),
|
|
532
|
+
)
|
|
564
533
|
const grouped = new Map()
|
|
565
534
|
for (const operation of operations) {
|
|
566
535
|
if (!['sessions', 'sessions-metadata'].includes(operation?.store)) throw new TypeError('Session batch only accepts sessions and sessions-metadata')
|
|
@@ -568,8 +537,11 @@ export function applySessionBatch(operations) {
|
|
|
568
537
|
if (typeof operation.key !== 'string' || !operation.key) throw new TypeError('Session batch key is required')
|
|
569
538
|
const entry = grouped.get(operation.key) || { sessionId: operation.key }
|
|
570
539
|
if (operation.type === 'delete') {
|
|
571
|
-
if (operation.store === 'sessions-metadata')
|
|
572
|
-
|
|
540
|
+
if (operation.store === 'sessions-metadata') {
|
|
541
|
+
if (!fullDeleteKeys.has(operation.key)) throw new TypeError('Metadata-only delete is not allowed')
|
|
542
|
+
} else {
|
|
543
|
+
entry.delete = true
|
|
544
|
+
}
|
|
573
545
|
} else if (operation.store === 'sessions') entry.state = operation.value
|
|
574
546
|
else entry.metadata = operation.value
|
|
575
547
|
if (operation.expectedRevision !== undefined) entry.expectedRevision = operation.expectedRevision
|
|
@@ -623,12 +595,10 @@ export function applySessionBatch(operations) {
|
|
|
623
595
|
// succeeds instead of tripping the repository's empty-change guard.
|
|
624
596
|
if (upserts.length === 0 && deletes.length === 0) return { saved: 0, deleted: 0, revisions: [] }
|
|
625
597
|
const result = repository().applyBatch({ upserts, deletes })
|
|
626
|
-
requestSessionJsonMirrorDrain()
|
|
627
598
|
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
599
|
}
|
|
629
600
|
|
|
630
601
|
export function exportSessionStateSnapshot() {
|
|
631
|
-
if (!sqliteReadable()) return requireJsonAdapter('exportSnapshot')()
|
|
632
602
|
const snapshot = repository().exportSnapshot()
|
|
633
603
|
return {
|
|
634
604
|
sessions: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, assembleState(record)])),
|
|
@@ -654,56 +624,25 @@ export function normalizeSessionSnapshotValues({ sessions, sessionsMetadata }) {
|
|
|
654
624
|
|
|
655
625
|
export function replaceSessionStateSnapshot({ sessions, sessionsMetadata }, { merge = false } = {}) {
|
|
656
626
|
if (!isPlainObject(sessions) || !isPlainObject(sessionsMetadata)) throw new TypeError('sessions and sessionsMetadata must be objects')
|
|
657
|
-
if (!sqliteReadable()) return requireJsonAdapter('replaceSnapshot')({ sessions, sessionsMetadata }, { merge })
|
|
658
627
|
const current = merge ? exportSessionStateSnapshot() : { sessions: {}, sessionsMetadata: {} }
|
|
659
628
|
const targetSessions = { ...current.sessions, ...sessions }
|
|
660
629
|
const targetMetadata = { ...current.sessionsMetadata, ...sessionsMetadata }
|
|
661
630
|
const records = normalizeSessionSnapshotValues({ sessions: targetSessions, sessionsMetadata: targetMetadata })
|
|
662
631
|
repository().replaceAll(records)
|
|
663
|
-
requestSessionJsonMirrorDrain()
|
|
664
632
|
return { sessions: records.length, sessionsMetadata: records.length }
|
|
665
633
|
}
|
|
666
634
|
|
|
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
635
|
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 }
|
|
636
|
+
const state = readSessionStorageState()
|
|
700
637
|
let integrity
|
|
701
|
-
let mirrorPending = null
|
|
702
638
|
try {
|
|
703
|
-
|
|
704
|
-
|
|
639
|
+
// Lightweight (SQL-level) integrity: startup diagnostics on large stores
|
|
640
|
+
// must not re-parse every stored body. The result carries
|
|
641
|
+
// `lightweight: true`; full per-row verification stays on maintenance
|
|
642
|
+
// entry points (backup export/restore, downgrade tooling).
|
|
643
|
+
integrity = repository().verifyIntegrity({ quickCheck: true })
|
|
705
644
|
} catch (error) {
|
|
706
645
|
integrity = { ok: false, error: error?.message || String(error) }
|
|
707
646
|
}
|
|
708
|
-
return { ...state, authority: 'sqlite', integrity
|
|
647
|
+
return { ...state, authority: 'sqlite', integrity }
|
|
709
648
|
}
|
package/server/share-cutover.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { readSharesJsonFile } from './share-json-file.mjs'
|
|
15
15
|
import { createDefaultShareMirror } from './share-service.mjs'
|
|
16
16
|
import { storageDir } from './storage.mjs'
|
|
17
|
+
import { logger } from './utils/logger.mjs'
|
|
17
18
|
|
|
18
19
|
const DEFAULT_LOCK_TTL_MS = 60_000
|
|
19
20
|
const DEFAULT_WAIT_TIMEOUT_MS = 65_000
|
|
@@ -213,23 +214,29 @@ export async function initializeShareCutover(options = {}) {
|
|
|
213
214
|
const storage = options.storage || getSqliteStorage()
|
|
214
215
|
const repository = options.repository || createShareRepository(storage)
|
|
215
216
|
configureShareService({ repository, mirror: options.mirror || createDefaultShareMirror() })
|
|
216
|
-
|
|
217
|
+
const log = options.logger || logger
|
|
218
|
+
const migrate = async () => {
|
|
217
219
|
const current = readShareStorageState()
|
|
218
220
|
if (current.phase === SHARE_STORAGE_PHASES.JSON_PENDING) {
|
|
219
221
|
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
220
222
|
if (!integrity.ok) throw new Error('Share state pending integrity verification failed')
|
|
221
223
|
const drained = await drainShareJsonMirror()
|
|
222
|
-
if (drained.pending === 0)
|
|
224
|
+
if (drained.pending === 0) {
|
|
225
|
+
setShareStoragePhase(SHARE_STORAGE_PHASES.AUTHORITATIVE, { shareCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
|
|
226
|
+
log.info('Share storage cutover promoted to authoritative', { domain: 'share', phase: 'authoritative', count: integrity.count })
|
|
227
|
+
}
|
|
223
228
|
return readShareStorageState()
|
|
224
229
|
}
|
|
225
230
|
if (current.phase === SHARE_STORAGE_PHASES.AUTHORITATIVE) {
|
|
226
231
|
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
227
232
|
if (!integrity.ok) throw new Error('Share state authoritative integrity verification failed')
|
|
228
233
|
await drainShareJsonMirror()
|
|
234
|
+
log.info('Share storage cutover startup check passed', { domain: 'share', phase: 'authoritative' })
|
|
229
235
|
return readShareStorageState()
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
if (current.phase === SHARE_STORAGE_PHASES.CUTOVER_RUNNING && current.backupFile) {
|
|
239
|
+
log.warn('Share storage cutover recovery: rerunning the JSON migration', { domain: 'share', phase: 'cutover_running', backupFile: current.backupFile })
|
|
233
240
|
setShareStoragePhase(SHARE_STORAGE_PHASES.JSON_AUTHORITATIVE, {
|
|
234
241
|
backupFile: current.backupFile,
|
|
235
242
|
diagnostic: { operation: 'cutover_recovery', recoveredFrom: SHARE_STORAGE_PHASES.CUTOVER_RUNNING },
|
|
@@ -244,6 +251,7 @@ export async function initializeShareCutover(options = {}) {
|
|
|
244
251
|
if (first.count !== second.count || first.digest !== second.digest) {
|
|
245
252
|
throw new Error('Share JSON source changed during cutover double read')
|
|
246
253
|
}
|
|
254
|
+
log.info('Share storage cutover migration started', { domain: 'share', count: first.count })
|
|
247
255
|
if (!backupFile) backupFile = await writeShareCutoverBackup(first, options.backupDirectory)
|
|
248
256
|
const third = buildShareJsonSnapshot(await readJson())
|
|
249
257
|
if (first.count !== third.count || first.digest !== third.digest) {
|
|
@@ -280,7 +288,9 @@ export async function initializeShareCutover(options = {}) {
|
|
|
280
288
|
diagnostic: first.diagnostics,
|
|
281
289
|
})
|
|
282
290
|
}
|
|
283
|
-
|
|
291
|
+
const result = readShareStorageState()
|
|
292
|
+
log.info('Share storage cutover migration complete', { domain: 'share', phase: result.phase, count: first.count })
|
|
293
|
+
return result
|
|
284
294
|
} catch (error) {
|
|
285
295
|
const state = readShareStorageState()
|
|
286
296
|
if (![SHARE_STORAGE_PHASES.JSON_PENDING, SHARE_STORAGE_PHASES.AUTHORITATIVE].includes(state.phase)) {
|
|
@@ -288,9 +298,16 @@ export async function initializeShareCutover(options = {}) {
|
|
|
288
298
|
backupFile,
|
|
289
299
|
diagnostic: { operation: 'cutover', errorName: error?.name || 'Error', error: error?.message || String(error) },
|
|
290
300
|
})
|
|
301
|
+
log.warn('Share storage cutover failed; keeping the JSON store path', { domain: 'share', phase: 'json_authoritative', errorName: error?.name || 'Error', errorMessage: error?.message })
|
|
291
302
|
return readShareStorageState()
|
|
292
303
|
}
|
|
293
304
|
throw error
|
|
294
305
|
}
|
|
295
|
-
}
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
return await runShareMaintenance(migrate, { ...options, storage, operation: 'share-cutover' })
|
|
309
|
+
} catch (error) {
|
|
310
|
+
log.error('Share storage cutover failed and blocked startup', { domain: 'share', errorName: error?.name || 'Error', errorMessage: error?.message })
|
|
311
|
+
throw error
|
|
312
|
+
}
|
|
296
313
|
}
|