@shawnstack/quickforge 1.7.8 → 1.7.10

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 (35) hide show
  1. package/bin/quickforge.mjs +10 -1
  2. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Dw9orPNt.js} +1 -1
  3. package/dist/assets/{ChatPanelHost-cd80S4EX.js → ChatPanelHost-CW3-MQyL.js} +2 -2
  4. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-CDkKN1Sz.js} +1 -1
  5. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-Dj1bmsEf.js} +1 -1
  6. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-C9lOTi75.js} +1 -1
  7. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-CFuvSATi.js} +3 -3
  8. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-CH_t33A3.js} +1 -1
  9. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-DLo05j7c.js} +1 -1
  10. package/dist/assets/{TerminalDock-CczcCJJE.js → TerminalDock-CSOVub-m.js} +1 -1
  11. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-DPXgLIum.js} +1 -1
  12. package/dist/assets/index-DQBOm9gt.js +66 -0
  13. package/dist/assets/index-ps85guTs.css +3 -0
  14. package/dist/assets/{local-tools-Dn1Y9aPe.js → local-tools-BxfQeIUQ.js} +1 -1
  15. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-CwHp0K4q.js} +1 -1
  16. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-k9ycekIU.js} +1 -1
  17. package/dist/index.html +4 -4
  18. package/package.json +1 -1
  19. package/server/agent-manager.mjs +68 -2
  20. package/server/auto-compaction.mjs +0 -2
  21. package/server/context-usage.mjs +9 -7
  22. package/server/maintenance/downgrade-session-state-v1.mjs +4 -2
  23. package/server/maintenance/export-session-state-v1.mjs +4 -2
  24. package/server/public-api.mjs +21 -2
  25. package/server/routes/backup.mjs +7 -0
  26. package/server/routes/storage.mjs +23 -0
  27. package/server/session-persistence-lock.mjs +17 -6
  28. package/server/session-state-backup.mjs +4 -2
  29. package/server/session-state-cutover.mjs +284 -91
  30. package/server/session-state-service.mjs +38 -17
  31. package/server/sqlite/session-state-repository.mjs +145 -25
  32. package/server/storage.mjs +28 -0
  33. package/server/utils/process-tree.mjs +14 -2
  34. package/dist/assets/index-D-J_8Smf.css +0 -3
  35. package/dist/assets/index-DFASc15l.js +0 -66
@@ -294,9 +294,12 @@ function digestRows(database) {
294
294
  }))
295
295
  }
296
296
 
297
+ function digestFromLines(lines) {
298
+ return createHash('sha256').update([...lines].sort().join('\n')).digest('hex')
299
+ }
300
+
297
301
  function verificationDigest(rows) {
298
- const values = rows.map((row) => snapshotDigestLine(row.scope, row.project_id, row.session_id, row.state_digest, row.metadata_digest, row.messages_digest)).sort()
299
- return createHash('sha256').update(values.join('\n')).digest('hex')
302
+ return digestFromLines(rows.map((row) => snapshotDigestLine(row.scope, row.project_id, row.session_id, row.state_digest, row.metadata_digest, row.messages_digest)))
300
303
  }
301
304
 
302
305
  export function sessionStateSnapshotDigest(records) {
@@ -582,8 +585,9 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
582
585
  }, options).saved
583
586
  }
584
587
 
585
- function replaceAll(inputs, { beforeCommit, storageState, expectedCount, expectedDigest } = {}) {
588
+ function replaceAll(inputs, { beforeCommit, storageState, expectedCount, expectedDigest, mirrorDeletes = [] } = {}) {
586
589
  if (!Array.isArray(inputs)) throw new TypeError('records must be an array')
590
+ if (!Array.isArray(mirrorDeletes)) throw new TypeError('mirrorDeletes must be an array')
587
591
  const timestamp = now()
588
592
  const records = inputs.map((input) => {
589
593
  let messages = input.messages
@@ -597,6 +601,13 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
597
601
  if (ids.has(record.sessionId)) throw new TypeError(`Duplicate session id: ${record.sessionId}`)
598
602
  ids.add(record.sessionId)
599
603
  }
604
+ const deletes = mirrorDeletes.map((input) => ({ ...bucket(input.scope, input.projectId), sessionId: nonEmptyString(input.sessionId, 'sessionId') }))
605
+ const recordKeys = new Set(records.map((record) => JSON.stringify([record.scope, record.projectId, record.sessionId])))
606
+ for (const entry of deletes) {
607
+ if (recordKeys.has(JSON.stringify([entry.scope, entry.projectId, entry.sessionId]))) {
608
+ throw new TypeError(`mirrorDeletes entry duplicates a record key: ${entry.sessionId}`)
609
+ }
610
+ }
600
611
  return storage.transaction((database) => {
601
612
  database.exec('DELETE FROM session_states; DELETE FROM session_state_tombstones; DELETE FROM session_index; DELETE FROM session_json_mirror_queue; DELETE FROM session_messages;')
602
613
  for (const record of records) {
@@ -606,6 +617,12 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
606
617
  upsertIndex(database, record)
607
618
  enqueueMirror(database, record, 'upsert', 1)
608
619
  }
620
+ // Orphan deletes intentionally have no session_states/session_index rows
621
+ // (they were dropped as stale residue); enqueue mirror deletes so the
622
+ // drain physically clears leftover JSON files (idempotent when absent).
623
+ for (const entry of deletes) {
624
+ enqueueMirror(database, { ...entry, stateJson: null, metadataJson: null, now: timestamp }, 'delete', 1)
625
+ }
609
626
  const digest = verificationDigest(digestRows(database))
610
627
  if (expectedCount !== undefined && records.length !== expectedCount) throw new Error('Session state replace count verification failed')
611
628
  if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('Session state replace digest verification failed')
@@ -615,6 +632,72 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
615
632
  }, { mode: 'immediate' })
616
633
  }
617
634
 
635
+ // Streaming equivalent of replaceAll for sources that cannot be materialized
636
+ // in memory (large JSON cutover imports). Consumes a sync or async iterable
637
+ // record-by-record inside one immediate transaction; only the per-record
638
+ // digest lines accumulate. `storage.transaction` requires a synchronous
639
+ // callback, so the transaction is managed manually: any error — including
640
+ // one thrown by the iterator mid-stream — rolls everything back, preserving
641
+ // replaceAll's all-or-nothing semantics. Callers must hold the session
642
+ // state maintenance lock (a concurrent writer hitting the open transaction
643
+ // fails loudly instead of interleaving).
644
+ async function replaceAllStream(recordIterable, { beforeCommit, storageState, expectedCount, expectedDigest, mirrorDeletes = [] } = {}) {
645
+ if (!recordIterable || (typeof recordIterable[Symbol.asyncIterator] !== 'function' && typeof recordIterable[Symbol.iterator] !== 'function')) {
646
+ throw new TypeError('records must be a sync or async iterable')
647
+ }
648
+ if (!Array.isArray(mirrorDeletes)) throw new TypeError('mirrorDeletes must be an array')
649
+ const timestamp = now()
650
+ const deletes = mirrorDeletes.map((input) => ({ ...bucket(input.scope, input.projectId), sessionId: nonEmptyString(input.sessionId, 'sessionId') }))
651
+ // Record keys arrive incrementally while streaming, so the mirrorDeletes
652
+ // conflict check runs per record against the precomputed delete keys (the
653
+ // mirror image of replaceAll's post-hoc set comparison).
654
+ const deleteKeys = new Set(deletes.map((entry) => JSON.stringify([entry.scope, entry.projectId, entry.sessionId])))
655
+ storage.exec('BEGIN IMMEDIATE')
656
+ let count = 0
657
+ const digestLines = []
658
+ try {
659
+ storage.exec('DELETE FROM session_states; DELETE FROM session_state_tombstones; DELETE FROM session_index; DELETE FROM session_json_mirror_queue; DELETE FROM session_messages;')
660
+ const insert = storage.prepare(`INSERT INTO session_states (${STATE_COLUMNS}) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`)
661
+ const ids = new Set()
662
+ for await (const input of recordIterable) {
663
+ let messages = input.messages
664
+ if (messages === undefined && input.state?.messageStorage === 'split') {
665
+ messages = Array.isArray(input.state.messages) ? input.state.messages : []
666
+ }
667
+ const record = normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
668
+ if (ids.has(record.sessionId)) throw new TypeError(`Duplicate session id: ${record.sessionId}`)
669
+ ids.add(record.sessionId)
670
+ if (deleteKeys.has(JSON.stringify([record.scope, record.projectId, record.sessionId]))) {
671
+ throw new TypeError(`mirrorDeletes entry duplicates a record key: ${record.sessionId}`)
672
+ }
673
+ insert.run(record.scope, record.projectId, record.sessionId, record.stateVersion, record.stateJson, record.stateDigest, record.metadataJson, record.metadataDigest, timestamp, timestamp)
674
+ if (record.messages !== undefined) writeMessages(storage, record, 'replace', timestamp)
675
+ upsertIndex(storage, record)
676
+ enqueueMirror(storage, record, 'upsert', 1)
677
+ // Streamed imports are non-split (messages inline, none in
678
+ // session_messages), so the empty messages digest matches what
679
+ // digestRows computes for freshly imported non-split rows.
680
+ digestLines.push(snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, ''))
681
+ count += 1
682
+ }
683
+ // Orphan deletes intentionally have no session_states/session_index rows;
684
+ // see replaceAll for the mirror-drain rationale.
685
+ for (const entry of deletes) {
686
+ enqueueMirror(storage, { ...entry, stateJson: null, metadataJson: null, now: timestamp }, 'delete', 1)
687
+ }
688
+ const digest = digestFromLines(digestLines)
689
+ if (expectedCount !== undefined && count !== expectedCount) throw new Error('Session state replace count verification failed')
690
+ if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('Session state replace digest verification failed')
691
+ updateStorageState(storage, storageState ? { ...storageState, stateCount: count, digest, updatedAt: timestamp } : null)
692
+ beforeCommit?.(storage)
693
+ storage.exec('COMMIT')
694
+ return count
695
+ } catch (error) {
696
+ try { storage.exec('ROLLBACK') } catch { /* transaction already closed */ }
697
+ throw error
698
+ }
699
+ }
700
+
618
701
  function exportSnapshot() {
619
702
  return storage.transaction((database) => {
620
703
  const rows = database.prepare(`SELECT ${STATE_COLUMNS} FROM session_states ORDER BY scope, project_id, session_id`).all()
@@ -644,10 +727,55 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
644
727
  return verificationDigest(digestRows(storage))
645
728
  }
646
729
 
730
+ // SQL-only integrity counts shared by both verification modes (no row
731
+ // bodies are loaded; joins and aggregates run inside SQLite).
732
+ function sqlIntegrityCounts() {
733
+ return {
734
+ missingIndex: Number(storage.prepare(`
735
+ SELECT COUNT(*) AS count FROM session_states s
736
+ LEFT JOIN session_index i ON i.scope = s.scope AND i.project_id = s.project_id AND i.session_id = s.session_id
737
+ WHERE i.session_id IS NULL OR i.metadata_digest <> s.metadata_digest OR i.state_version <> s.state_version
738
+ `).get().count),
739
+ orphanIndex: Number(storage.prepare(`
740
+ SELECT COUNT(*) AS count FROM session_index i
741
+ LEFT JOIN session_states s ON s.scope = i.scope AND s.project_id = i.project_id AND s.session_id = i.session_id
742
+ WHERE s.session_id IS NULL
743
+ `).get().count),
744
+ duplicateIds: Number(storage.prepare('SELECT COUNT(*) AS count FROM (SELECT session_id FROM session_states GROUP BY session_id HAVING COUNT(*) > 1)').get().count),
745
+ activeTombstones: Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
746
+ JOIN session_states s ON s.scope = t.scope AND s.project_id = t.project_id AND s.session_id = t.session_id`).get().count),
747
+ staleTombstones: Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
748
+ JOIN session_states s ON s.session_id = t.session_id AND NOT (s.scope = t.scope AND s.project_id = t.project_id)`).get().count),
749
+ orphanMessages: Number(storage.prepare(`
750
+ SELECT COUNT(*) AS count FROM session_messages m
751
+ LEFT JOIN session_states s ON s.scope = m.scope AND s.project_id = m.project_id AND s.session_id = m.session_id
752
+ WHERE s.session_id IS NULL
753
+ `).get().count),
754
+ }
755
+ }
756
+
647
757
  function verifyIntegrity({ quickCheck = false } = {}) {
758
+ const counts = sqlIntegrityCounts()
648
759
  if (quickCheck) {
649
760
  const result = storage.prepare('PRAGMA quick_check').all().map((row) => row.quick_check)
650
761
  if (result.length !== 1 || result[0] !== 'ok') throw new Error(`SQLite quick_check failed: ${result.join(', ')}`)
762
+ // Lightweight mode: SQL-level checks only. Per-row JSON.parse and digest
763
+ // recomputation (invalidRecords/invalidDigests/invalidIndexDigests/
764
+ // invalidMessageDigests/invalidMessageRepresentations) and the snapshot
765
+ // digest are left to a full verification (maintenance entry points).
766
+ return {
767
+ ok: counts.missingIndex === 0 && counts.orphanIndex === 0 && counts.duplicateIds === 0
768
+ && counts.activeTombstones === 0 && counts.staleTombstones === 0 && counts.orphanMessages === 0,
769
+ count: Number(storage.prepare('SELECT COUNT(*) AS count FROM session_states').get().count),
770
+ digest: null,
771
+ ...counts,
772
+ invalidRecords: 0,
773
+ invalidDigests: 0,
774
+ invalidIndexDigests: 0,
775
+ invalidMessageDigests: 0,
776
+ invalidMessageRepresentations: 0,
777
+ lightweight: true,
778
+ }
651
779
  }
652
780
  const rows = storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states`).all()
653
781
  let invalidRecords = 0
@@ -675,11 +803,6 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
675
803
  invalidMessageDigests += 1
676
804
  }
677
805
  }
678
- const orphanMessages = Number(storage.prepare(`
679
- SELECT COUNT(*) AS count FROM session_messages m
680
- LEFT JOIN session_states s ON s.scope = m.scope AND s.project_id = m.project_id AND s.session_id = m.session_id
681
- WHERE s.session_id IS NULL
682
- `).get().count)
683
806
  const indexRows = storage.prepare('SELECT metadata_json, metadata_digest FROM session_index').all()
684
807
  let invalidIndexDigests = 0
685
808
  for (const row of indexRows) {
@@ -689,21 +812,7 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
689
812
  invalidIndexDigests += 1
690
813
  }
691
814
  }
692
- const missingIndex = Number(storage.prepare(`
693
- SELECT COUNT(*) AS count FROM session_states s
694
- LEFT JOIN session_index i ON i.scope = s.scope AND i.project_id = s.project_id AND i.session_id = s.session_id
695
- WHERE i.session_id IS NULL OR i.metadata_digest <> s.metadata_digest OR i.state_version <> s.state_version
696
- `).get().count)
697
- const orphanIndex = Number(storage.prepare(`
698
- SELECT COUNT(*) AS count FROM session_index i
699
- LEFT JOIN session_states s ON s.scope = i.scope AND s.project_id = i.project_id AND s.session_id = i.session_id
700
- WHERE s.session_id IS NULL
701
- `).get().count)
702
- const duplicateIds = Number(storage.prepare('SELECT COUNT(*) AS count FROM (SELECT session_id FROM session_states GROUP BY session_id HAVING COUNT(*) > 1)').get().count)
703
- const activeTombstones = Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
704
- JOIN session_states s ON s.scope = t.scope AND s.project_id = t.project_id AND s.session_id = t.session_id`).get().count)
705
- const staleTombstones = Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
706
- JOIN session_states s ON s.session_id = t.session_id AND NOT (s.scope = t.scope AND s.project_id = t.project_id)`).get().count)
815
+ const { missingIndex, orphanIndex, duplicateIds, activeTombstones, staleTombstones, orphanMessages } = counts
707
816
  return {
708
817
  ok: missingIndex === 0 && orphanIndex === 0 && duplicateIds === 0 && activeTombstones === 0 && staleTombstones === 0
709
818
  && invalidRecords === 0 && invalidDigests === 0 && invalidIndexDigests === 0
@@ -735,8 +844,13 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
735
844
  }, { mode: 'immediate' })
736
845
  }
737
846
 
738
- function listMirrorQueue() {
739
- return storage.prepare('SELECT * FROM session_json_mirror_queue ORDER BY updated_at, scope, project_id, session_id').all().map((row) => ({
847
+ function listMirrorQueue({ limit } = {}) {
848
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) throw new TypeError('limit must be a positive integer')
849
+ // The queue rows carry full state_json payloads, so callers draining large
850
+ // queues page through with `limit` instead of loading every row at once.
851
+ const rows = storage.prepare(`SELECT * FROM session_json_mirror_queue ORDER BY updated_at, scope, project_id, session_id${limit !== undefined ? ' LIMIT ?' : ''}`)
852
+ .all(...(limit !== undefined ? [limit] : []))
853
+ return rows.map((row) => ({
740
854
  scope: row.scope,
741
855
  projectId: row.scope === 'project' ? row.project_id : null,
742
856
  sessionId: row.session_id,
@@ -750,6 +864,10 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
750
864
  }))
751
865
  }
752
866
 
867
+ function countMirrorQueue() {
868
+ return Number(storage.prepare('SELECT COUNT(*) AS count FROM session_json_mirror_queue').get().count)
869
+ }
870
+
753
871
  function acknowledgeMirror(entry) {
754
872
  return Number(storage.prepare('DELETE FROM session_json_mirror_queue WHERE scope = ? AND project_id = ? AND session_id = ? AND revision = ?')
755
873
  .run(entry.scope, entry.scope === 'project' ? entry.projectId : '', entry.sessionId, entry.revision).changes) === 1
@@ -774,12 +892,14 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
774
892
  delete: deleteRecord,
775
893
  deleteBySessionId,
776
894
  replaceAll,
895
+ replaceAllStream,
777
896
  exportSnapshot,
778
897
  verifyIntegrity,
779
898
  count,
780
899
  digest,
781
900
  rebuildIndex,
782
901
  listMirrorQueue,
902
+ countMirrorQueue,
783
903
  acknowledgeMirror,
784
904
  failMirror,
785
905
  })
@@ -463,6 +463,34 @@ export async function readPhysicalSessionStateBuckets() {
463
463
  return result
464
464
  }
465
465
 
466
+ // Streaming filesystem adapter for the session-state JSON cutover: unlike
467
+ // readPhysicalSessionStateBuckets (which materializes every bucket into
468
+ // memory at once), the cutover consumes sessions one file at a time so a
469
+ // multi-GB library never needs to be fully resident. It reuses the same
470
+ // physical helpers, so the layout and read semantics stay identical:
471
+ // - listBuckets(): global bucket + one bucket per project directory
472
+ // - listSessionFiles(bucket): session ids from sessions/<id>.json file names
473
+ // - readSessionState(bucket, sessionId): parsed session body (null if absent)
474
+ // - readMetadataBucket(bucket): parsed sessions-metadata bucket (default {})
475
+ export function createPhysicalSessionStateFsAdapter() {
476
+ return {
477
+ async *listBuckets() {
478
+ await ensureStorage()
479
+ yield { scope: 'global', projectId: null }
480
+ for (const projectId of await listProjectIds()) yield { scope: 'project', projectId }
481
+ },
482
+ async *listSessionFiles(bucket) {
483
+ for (const file of await listSessionDataFiles(bucket)) yield path.basename(file, '.json')
484
+ },
485
+ readSessionState(bucket, sessionId) {
486
+ return readJsonFile(sessionDataFile(sessionId, bucket), null)
487
+ },
488
+ readMetadataBucket(bucket) {
489
+ return readJsonFile(sessionStoreFile('sessions-metadata', bucket), {})
490
+ },
491
+ }
492
+ }
493
+
466
494
  async function sessionStateFacade() {
467
495
  const service = await import('./session-state-service.mjs')
468
496
  return service.isSessionStateAuthoritative() ? service : null
@@ -3,6 +3,7 @@ import { once } from 'node:events'
3
3
 
4
4
  const DEFAULT_GRACE_MS = 1000
5
5
  const DEFAULT_FORCE_WAIT_MS = 1000
6
+ const TASKKILL_TIMEOUT_MS = 10_000
6
7
 
7
8
  function isRunning(child) {
8
9
  return Boolean(child?.pid) && child.exitCode == null && child.signalCode == null
@@ -36,8 +37,19 @@ function runTaskkill(pid, force, spawnImpl = spawn) {
36
37
  resolve(false)
37
38
  return
38
39
  }
39
- command.once?.('error', () => resolve(false))
40
- command.once?.('exit', (code) => resolve(code === 0))
40
+ let settled = false
41
+ const finish = (ok) => {
42
+ if (settled) return
43
+ settled = true
44
+ clearTimeout(timer)
45
+ resolve(ok)
46
+ }
47
+ // taskkill normally exits immediately, but a wedged taskkill.exe must not
48
+ // hang dispose()/destroyAgent forever on a dangling child process.
49
+ const timer = setTimeout(() => finish(false), TASKKILL_TIMEOUT_MS)
50
+ timer.unref?.()
51
+ command.once?.('error', () => finish(false))
52
+ command.once?.('exit', (code) => finish(code === 0))
41
53
  })
42
54
  }
43
55