@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,34 +1,28 @@
1
1
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
2
  import { readStore, writeStore, atomicUpdate, getComparable, getStoreRevision, readSessionStoreScoped, readSessionValue, writeSessionValueWithMetadata, deleteSessionWithMetadata, applySessionBatch, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
3
3
  import { AUTO_ARCHIVE_SETTINGS_KEY, archiveInactiveSessions, normalizeAutoArchiveSettings } from '../auto-archive.mjs'
4
+ import { refreshAllSessionModels, destroyAgent } from '../agent-manager.mjs'
5
+ import { logger } from '../utils/logger.mjs'
4
6
  import { directorySize } from '../utils/workspace.mjs'
5
7
  import { isAuthenticatedAppClient } from '../access-policy.mjs'
6
- import { getSessionIndexDiagnostics, markSessionIndexQueryFailure, querySessionIndexPage } from '../session-index-service.mjs'
7
- import { isSessionStateMaintenanceActive } from '../session-state-cutover.mjs'
8
+ import { querySessionIndexPage } from '../session-index-service.mjs'
9
+ import { isSessionStateMaintenanceActive } from '../session-state-maintenance.mjs'
10
+ import { isSessionStateAuthoritative } from '../session-state-service.mjs'
11
+ import { verifySessionStateIntegrityForMaintenance } from '../session-state-backup.mjs'
8
12
 
9
13
  const metadataIndexCache = new Map()
10
14
  const MAX_METADATA_INDEX_CACHE_ENTRIES = 50
11
15
  const METADATA_INDEX_CACHE_TTL_MS = 1000
12
16
  const SESSION_QUERY_INDEXES = new Set(['createdAt', 'lastModified', 'pinnedAt'])
13
- const shadowGenerationByShape = new Map()
14
- let shadowSampler = () => Math.random() < 0.01
15
17
 
16
- export function configureSessionIndexQueryShadow({ sample } = {}) {
17
- if (sample !== undefined && typeof sample !== 'function') throw new TypeError('sample must be a function')
18
- shadowSampler = sample ?? (() => Math.random() < 0.01)
19
- shadowGenerationByShape.clear()
20
- }
21
-
22
- function canonicalJson(value) {
23
- if (Array.isArray(value)) return value.map(canonicalJson)
24
- if (!value || typeof value !== 'object') return value
25
- return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalJson(value[key])]))
26
- }
18
+ // In-memory agent disposal used when sessions are deleted from storage.
19
+ // destroyAgent performs a final persist, so deletions must destroy the agent
20
+ // BEFORE removing persisted state otherwise the final persist resurrects
21
+ // the deleted row. Tests inject a spy through configureStorageSessionAgentDisposal.
22
+ let destroyAgentForSession = destroyAgent
27
23
 
28
- function sameShadowPage(sqlPage, jsonValues, limit, offset) {
29
- const expected = jsonValues.slice(offset, offset + limit)
30
- if (sqlPage.total !== jsonValues.length || sqlPage.values.length !== expected.length) return false
31
- return JSON.stringify(sqlPage.values.map(canonicalJson)) === JSON.stringify(expected.map(canonicalJson))
24
+ export function configureStorageSessionAgentDisposal({ destroy } = {}) {
25
+ destroyAgentForSession = destroy ?? destroyAgent
32
26
  }
33
27
 
34
28
  function parseSqlPagination(limitParam, offsetParam) {
@@ -65,19 +59,6 @@ function sqlSessionQueryOptions({ store, indexName, directionParam, scope, proje
65
59
  }
66
60
  }
67
61
 
68
- function shadowShape(options) {
69
- return JSON.stringify({
70
- scopeMode: options.scopeMode,
71
- hasProjectId: options.scopeMode === 'project',
72
- archive: options.archive,
73
- pinnedOnly: options.pinnedOnly,
74
- sort: options.sort,
75
- direction: options.direction,
76
- limit: options.limit,
77
- offset: options.offset,
78
- })
79
- }
80
-
81
62
  function metadataIndexCacheKey({ scope, projectId, indexName, direction, archived, pinned }) {
82
63
  return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction, archived: archived || '', pinned: pinned || '' })
83
64
  }
@@ -213,6 +194,17 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
213
194
  throw error
214
195
  }
215
196
  try {
197
+ // Destroy in-memory agents for deleted sessions BEFORE removing the
198
+ // persisted state: destroyAgent's final persist would otherwise
199
+ // resurrect the deleted row right after the batch commit.
200
+ for (const operation of operations) {
201
+ if (operation?.type !== 'delete' || operation?.store !== 'sessions') continue
202
+ try {
203
+ await destroyAgentForSession(operation.key)
204
+ } catch (error) {
205
+ logger.warn(`Failed to destroy in-memory agent for deleted session ${operation.key}:`, error?.message || error)
206
+ }
207
+ }
216
208
  const result = await applySessionBatch(operations)
217
209
  sendJson(res, 200, { ok: true, saved: result?.saved ?? 0, deleted: result?.deleted ?? 0 })
218
210
  } catch (error) {
@@ -230,6 +222,34 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
230
222
  return
231
223
  }
232
224
 
225
+ // Manual session state integrity verification (design review suggestion
226
+ // 9): startup only runs the lightweight SQL-level check, so per-row digest
227
+ // rot would stay invisible until an offline full verification. POST with
228
+ // { full: true } runs the complete per-row digest recomputation under the
229
+ // session state maintenance lock (same pattern as the authoritative backup
230
+ // export); the response carries only summary counters and the elapsed
231
+ // time, never row payloads.
232
+ if (req.method === 'POST' && parts.length === 4 && store === 'maintenance' && parts[3] === 'verify-session-integrity') {
233
+ if (!isSessionStateAuthoritative()) {
234
+ const error = new Error('Session state integrity verification requires authoritative SQLite storage')
235
+ error.statusCode = 409
236
+ error.errorCode = 'SESSION_STATE_NOT_AUTHORITATIVE'
237
+ throw error
238
+ }
239
+ if (isSessionStateMaintenanceActive()) {
240
+ const error = new Error('Session storage maintenance is in progress')
241
+ error.statusCode = 423
242
+ error.errorCode = 'SESSION_STORAGE_MAINTENANCE'
243
+ throw error
244
+ }
245
+ const body = await readJsonBody(req)
246
+ // forceQuickCheck: the manual endpoint always runs a real quick_check scan
247
+ // (never the startup process-cache/marker shortcut).
248
+ const result = await verifySessionStateIntegrityForMaintenance({ full: body?.full === true, forceQuickCheck: true, maintenance: true })
249
+ sendJson(res, 200, result)
250
+ return
251
+ }
252
+
233
253
  if (req.method === 'GET' && parts[3] === 'keys') {
234
254
  const prefix = url.searchParams.get('prefix') || ''
235
255
  const data = await readStore(store)
@@ -254,25 +274,14 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
254
274
  const sqlOptions = sqlSessionQueryOptions({
255
275
  store, indexName, directionParam, scope, projectId, archived, pinned, limitParam, offsetParam,
256
276
  })
277
+ // Storage v2: sessions-metadata queries eligible for SQL LIMIT/OFFSET are
278
+ // served straight from the authoritative sessions table (no JSON shadow —
279
+ // the SQLite projection IS the store). Everything else keeps the legacy
280
+ // read+sort path (which also reads from SQLite through the facade).
257
281
  if (sqlOptions) {
258
282
  const sqlResult = await querySessionIndexPage(sqlOptions)
259
283
  if (sqlResult.ok) {
260
- const diagnostics = getSessionIndexDiagnostics()
261
- const shape = shadowShape(sqlOptions)
262
- const lastGeneration = shadowGenerationByShape.get(shape)
263
- const forced = lastGeneration !== diagnostics.rebuildGeneration
264
- if (!forced && !shadowSampler({ shape, generation: diagnostics.rebuildGeneration })) {
265
- sendJson(res, 200, { values: sqlResult.page.values, total: sqlResult.page.total })
266
- return
267
- }
268
- const jsonValues = await readIndexedValues(store, indexName, direction, scope, projectId, archived, pinned)
269
- shadowGenerationByShape.set(shape, diagnostics.rebuildGeneration)
270
- if (sameShadowPage(sqlResult.page, jsonValues, sqlOptions.limit, sqlOptions.offset)) {
271
- sendJson(res, 200, { values: sqlResult.page.values, total: sqlResult.page.total })
272
- return
273
- }
274
- markSessionIndexQueryFailure('shadow_mismatch')
275
- sendJson(res, 200, { values: jsonValues.slice(sqlOptions.offset, sqlOptions.offset + sqlOptions.limit), total: jsonValues.length })
284
+ sendJson(res, 200, { values: sqlResult.page.values, total: sqlResult.page.total })
276
285
  return
277
286
  }
278
287
  }
@@ -293,6 +302,13 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
293
302
 
294
303
  if (req.method === 'DELETE' && parts.length === 3) {
295
304
  await writeStore(store, {})
305
+ if (store === 'custom-providers') {
306
+ try {
307
+ await refreshAllSessionModels()
308
+ } catch (error) {
309
+ logger.error('Failed to refresh session models after clearing custom-providers:', error)
310
+ }
311
+ }
296
312
  sendJson(res, 200, { ok: true })
297
313
  return
298
314
  }
@@ -340,12 +356,26 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
340
356
  if (store === 'settings' && key === AUTO_ARCHIVE_SETTINGS_KEY && normalizeAutoArchiveSettings(body?.value).enabled) {
341
357
  await archiveInactiveSessions()
342
358
  }
359
+ if (store === 'custom-providers') {
360
+ try {
361
+ await refreshAllSessionModels()
362
+ } catch (error) {
363
+ logger.error('Failed to refresh session models after custom-providers update:', error)
364
+ }
365
+ }
343
366
  sendJson(res, 200, { ok: true })
344
367
  return
345
368
  }
346
369
 
347
370
  if (req.method === 'DELETE') {
348
371
  if (store === 'sessions') {
372
+ // Destroy the in-memory agent first: its final persist would
373
+ // otherwise resurrect the row right after the delete.
374
+ try {
375
+ await destroyAgentForSession(key)
376
+ } catch (error) {
377
+ logger.warn(`Failed to destroy in-memory agent for deleted session ${key}:`, error?.message || error)
378
+ }
349
379
  await deleteSessionWithMetadata(key)
350
380
  sendJson(res, 200, { ok: true })
351
381
  return
@@ -355,6 +385,13 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
355
385
  delete data[key]
356
386
  return data
357
387
  })
388
+ if (store === 'custom-providers') {
389
+ try {
390
+ await refreshAllSessionModels()
391
+ } catch (error) {
392
+ logger.error('Failed to refresh session models after custom-providers key deletion:', error)
393
+ }
394
+ }
358
395
  sendJson(res, 200, { ok: true })
359
396
  return
360
397
  }
@@ -241,8 +241,9 @@ export function acquireScheduledRunsMaintenanceLock(storage, {
241
241
  return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
242
242
  }
243
243
  if (current) {
244
+ const expired = Date.parse(current.expires_at) <= nowMs
244
245
  const stalePid = ownerPidFromRow(current)
245
- if (stalePid === null || pidAlive(stalePid)) return null
246
+ if (!expired || stalePid === null || pidAlive(stalePid)) return null
246
247
  }
247
248
  const fencing = Number(current?.fencing || 0) + 1
248
249
  database.prepare(`
@@ -341,6 +342,10 @@ export async function runScheduledRunsMaintenance(operation, options = {}) {
341
342
  throw error
342
343
  } finally {
343
344
  if (retainedMaintenance?.lease !== lease) {
345
+ // Reset any stale retained flag from an earlier retain: this branch
346
+ // releases the lock normally, so retainedMaintenance must only stay set
347
+ // while a retained lease is genuinely still held.
348
+ retainedMaintenance = null
344
349
  clearInterval(timer)
345
350
  releaseScheduledRunsMaintenanceLock(storage, lease)
346
351
  localMaintenanceCount = Math.max(0, localMaintenanceCount - 1)
@@ -424,9 +429,18 @@ export async function initializeScheduledRunsCutover(options = {}) {
424
429
  }
425
430
 
426
431
  if (state.phase === SCHEDULED_RUNS_PHASES.AUTHORITATIVE) {
432
+ // The JSON file is only a non-authoritative mirror in this phase: a
433
+ // corrupt or unslimmable mirror degrades (diagnostic + warn) without
434
+ // blocking startup, while the authoritative SQLite health check stays
435
+ // fail-closed.
427
436
  try {
428
437
  const snapshot = splitScheduledTasksRuns(await readTasks())
429
438
  if (snapshot.count > 0) await slimJson(readTasks, writeTasks, snapshot.metadata)
439
+ } catch (error) {
440
+ recordScheduledRunsDiagnostic('authoritative_validation', error, {}, storage)
441
+ log.warn('Scheduled runs authoritative JSON mirror is invalid; continuing startup', { errorName: error?.name || 'Error' })
442
+ }
443
+ try {
430
444
  storage.health({ quickCheck: true })
431
445
  } catch (error) {
432
446
  recordScheduledRunsDiagnostic('authoritative_validation', error, {}, storage)