@shawnstack/quickforge 1.7.7 → 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.
Files changed (83) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/cloud/auto-approval.mjs +155 -0
  29. package/server/cloud/client.mjs +5 -0
  30. package/server/cloud/qf-agent-process.mjs +163 -3
  31. package/server/cloud/service-config.mjs +1 -1
  32. package/server/index.mjs +69 -13
  33. package/server/lan-access-backup.mjs +256 -0
  34. package/server/lan-access-cutover.mjs +306 -0
  35. package/server/lan-access-json-file.mjs +75 -0
  36. package/server/lan-access-service.mjs +155 -0
  37. package/server/lan-access-store.mjs +110 -9
  38. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  39. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  40. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  41. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  42. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  43. package/server/maintenance/export-session-state-v1.mjs +67 -0
  44. package/server/maintenance/export-share-v1.mjs +77 -0
  45. package/server/routes/agent.mjs +37 -4
  46. package/server/routes/backup.mjs +221 -39
  47. package/server/routes/cloud.mjs +28 -1
  48. package/server/routes/scheduled-tasks.mjs +347 -134
  49. package/server/routes/shares.mjs +4 -2
  50. package/server/routes/storage.mjs +138 -8
  51. package/server/routes/workspace.mjs +265 -4
  52. package/server/scheduled-runs-backup.mjs +235 -0
  53. package/server/scheduled-runs-cutover.mjs +497 -0
  54. package/server/scheduled-task-runs-service.mjs +279 -0
  55. package/server/session-index-service.mjs +495 -0
  56. package/server/session-state-backup.mjs +277 -0
  57. package/server/session-state-cutover.mjs +370 -0
  58. package/server/session-state-service.mjs +709 -0
  59. package/server/share-backup.mjs +255 -0
  60. package/server/share-cutover.mjs +296 -0
  61. package/server/share-json-file.mjs +60 -0
  62. package/server/share-service.mjs +163 -0
  63. package/server/share-store.mjs +196 -19
  64. package/server/skills.mjs +0 -1
  65. package/server/sqlite/database.mjs +233 -0
  66. package/server/sqlite/lan-access-repository.mjs +652 -0
  67. package/server/sqlite/migrations.mjs +577 -0
  68. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  69. package/server/sqlite/session-index-repository.mjs +297 -0
  70. package/server/sqlite/session-state-repository.mjs +786 -0
  71. package/server/sqlite/share-repository.mjs +854 -0
  72. package/server/storage.mjs +317 -1
  73. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  74. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  75. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  76. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  77. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  78. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  79. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  80. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  81. package/dist/assets/index-BTPzyq7d.js +0 -65
  82. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  83. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -2,6 +2,8 @@ import { promises as fs } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { ensureStorage, storageDir } from './storage.mjs'
4
4
  import { createRandomToken, hashPassword, safeHashEqual, sha256Base64Url, verifyPassword } from './utils/password-auth.mjs'
5
+ import { getLanAccessRepository, isLanAccessStorageAuthoritative, requestLanAccessJsonMirrorDrain } from './lan-access-service.mjs'
6
+ import { isLanAccessMaintenanceActive } from './lan-access-cutover.mjs'
5
7
 
6
8
  const LAN_ACCESS_DIR = path.join(storageDir, 'security')
7
9
  const LAN_ACCESS_FILE = path.join(LAN_ACCESS_DIR, 'lan-access.json')
@@ -20,6 +22,40 @@ function enqueueWrite(queueName, operation) {
20
22
  return next
21
23
  }
22
24
 
25
+ // F11 Phase 2: once the LAN access storage is SQLite-readable
26
+ // (sqlite_authoritative_json_pending/authoritative) every read/write routes
27
+ // through the repository and the JSON file degrades to a best-effort mirror.
28
+ // json_authoritative/cutover_running keep the legacy JSON path below.
29
+ function repositoryActive() {
30
+ return isLanAccessStorageAuthoritative()
31
+ }
32
+
33
+ // Maintenance writes are only blocked while the SQLite store is authoritative;
34
+ // the legacy JSON path keeps working during cutover/maintenance.
35
+ function assertLanAccessWritesAllowed() {
36
+ if (!repositoryActive()) return
37
+ if (isLanAccessMaintenanceActive()) {
38
+ const error = new Error('LAN access storage is under maintenance')
39
+ error.statusCode = 423
40
+ error.errorCode = 'LAN_ACCESS_MAINTENANCE_ACTIVE'
41
+ throw error
42
+ }
43
+ }
44
+
45
+ // Fail closed on a missing config record: pending/authoritative states are only
46
+ // entered after cutover integrity verification, so a missing row means the
47
+ // store is damaged and must not be silently treated as default-disabled.
48
+ function requireLanAccessRepositoryConfig() {
49
+ const config = getLanAccessRepository().getConfig()
50
+ if (!config) {
51
+ const error = new Error('LAN access state is unavailable')
52
+ error.statusCode = 503
53
+ error.errorCode = 'LAN_ACCESS_STATE_UNAVAILABLE'
54
+ throw error
55
+ }
56
+ return config
57
+ }
58
+
23
59
  function defaultLanAccessConfig() {
24
60
  return {
25
61
  enabled: false,
@@ -144,15 +180,30 @@ function assertPasswordAllowed(password) {
144
180
  }
145
181
 
146
182
  export async function readLanAccessStatus() {
183
+ if (repositoryActive()) return publicStatus(requireLanAccessRepositoryConfig())
147
184
  return publicStatus(await readLanAccessFile())
148
185
  }
149
186
 
150
187
  export async function readLanAccessConfig() {
188
+ if (repositoryActive()) return requireLanAccessRepositoryConfig()
151
189
  return readLanAccessFile()
152
190
  }
153
191
 
154
192
  export async function updateLanAccessSettings({ enabled, password, sessionTtlHours }) {
155
193
  return enqueueWrite(writeQueueName, async () => {
194
+ assertLanAccessWritesAllowed()
195
+ if (repositoryActive()) {
196
+ const passwordProvided = typeof password === 'string' && password.length > 0
197
+ if (passwordProvided) assertPasswordAllowed(password)
198
+ const passwordInfo = passwordProvided ? await hashPassword(password.trim()) : {}
199
+ const updated = getLanAccessRepository().updateSettings({
200
+ enabled,
201
+ sessionTtlHours,
202
+ ...passwordInfo,
203
+ })
204
+ requestLanAccessJsonMirrorDrain()
205
+ return publicStatus(updated.config)
206
+ }
156
207
  const current = await readLanAccessFile()
157
208
  const passwordProvided = typeof password === 'string' && password.length > 0
158
209
  const nextEnabled = Boolean(enabled)
@@ -184,6 +235,12 @@ export async function updateLanAccessSettings({ enabled, password, sessionTtlHou
184
235
 
185
236
  export async function revokeLanAccessTokens() {
186
237
  return enqueueWrite(writeQueueName, async () => {
238
+ assertLanAccessWritesAllowed()
239
+ if (repositoryActive()) {
240
+ const updated = getLanAccessRepository().revokeAll()
241
+ requestLanAccessJsonMirrorDrain()
242
+ return publicStatus(updated.config)
243
+ }
187
244
  const current = await readLanAccessFile()
188
245
  const next = normalizeConfig({
189
246
  ...current,
@@ -204,6 +261,12 @@ export async function revokeLanAccessTokenById(id) {
204
261
  throw error
205
262
  }
206
263
  return enqueueWrite(writeQueueName, async () => {
264
+ assertLanAccessWritesAllowed()
265
+ if (repositoryActive()) {
266
+ const updated = getLanAccessRepository().revokeTokenById(normalizedId)
267
+ requestLanAccessJsonMirrorDrain()
268
+ return publicStatus(updated.config)
269
+ }
207
270
  const current = await readLanAccessFile()
208
271
  const tokens = pruneTokens(current.tokens)
209
272
  const nextTokens = tokens.filter((tokenRecord) => tokenRecordId(tokenRecord) !== normalizedId)
@@ -228,6 +291,12 @@ export async function revokeLanAccessToken(token) {
228
291
  if (!secret) return false
229
292
  const actualHash = sha256Base64Url(secret)
230
293
  return enqueueWrite(writeQueueName, async () => {
294
+ assertLanAccessWritesAllowed()
295
+ if (repositoryActive()) {
296
+ const revoked = getLanAccessRepository().revokeToken(token)
297
+ requestLanAccessJsonMirrorDrain()
298
+ return revoked
299
+ }
231
300
  const current = await readLanAccessFile()
232
301
  if (Number(versionText) !== (current.authVersion || 1)) return false
233
302
  const tokens = pruneTokens(current.tokens)
@@ -249,6 +318,31 @@ export function lanAccessCookieName() {
249
318
 
250
319
  export async function issueLanAccessToken(password, metadata = {}) {
251
320
  return enqueueWrite(writeQueueName, async () => {
321
+ assertLanAccessWritesAllowed()
322
+ if (repositoryActive()) {
323
+ const repository = getLanAccessRepository()
324
+ const current = repository.getConfig()
325
+ if (!current?.enabled || !current.passwordHash) {
326
+ const error = new Error('LAN access is not enabled.')
327
+ error.statusCode = 403
328
+ throw error
329
+ }
330
+ if (!(await verifyPassword(current, typeof password === 'string' ? password.trim() : ''))) {
331
+ const error = new Error('Invalid LAN access password')
332
+ error.statusCode = 401
333
+ throw error
334
+ }
335
+ const result = repository.issueToken({
336
+ remoteAddress: metadata.remoteAddress,
337
+ userAgent: metadata.userAgent,
338
+ })
339
+ requestLanAccessJsonMirrorDrain()
340
+ return {
341
+ token: result.token,
342
+ expiresAt: result.expiresAt,
343
+ maxAge: result.maxAge,
344
+ }
345
+ }
252
346
  const current = await readLanAccessFile()
253
347
  if (!current.enabled || !current.passwordHash) {
254
348
  const error = new Error('LAN access is not enabled.')
@@ -293,13 +387,20 @@ export async function issueLanAccessToken(password, metadata = {}) {
293
387
  }
294
388
 
295
389
  export async function verifyLanAccessToken(token) {
296
- const current = await readLanAccessFile()
297
- if (!current.enabled || !current.passwordHash || !token || typeof token !== 'string') return false
298
- const [versionText, secret] = token.split('.')
299
- if (Number(versionText) !== (current.authVersion || 1) || !secret) return false
300
- const actualHash = sha256Base64Url(secret)
301
- return pruneTokens(current.tokens).some((tokenRecord) => {
302
- if ((tokenRecord.authVersion || 1) !== (current.authVersion || 1)) return false
303
- return safeHashEqual(tokenRecord.tokenHash, actualHash)
304
- })
390
+ try {
391
+ if (repositoryActive()) return getLanAccessRepository().verifyToken(token)
392
+ const current = await readLanAccessFile()
393
+ if (!current.enabled || !current.passwordHash || !token || typeof token !== 'string') return false
394
+ const [versionText, secret] = token.split('.')
395
+ if (Number(versionText) !== (current.authVersion || 1) || !secret) return false
396
+ const actualHash = sha256Base64Url(secret)
397
+ return pruneTokens(current.tokens).some((tokenRecord) => {
398
+ if ((tokenRecord.authVersion || 1) !== (current.authVersion || 1)) return false
399
+ return safeHashEqual(tokenRecord.tokenHash, actualHash)
400
+ })
401
+ } catch {
402
+ // Fail closed on any storage error or malformed input: LAN access is
403
+ // denied rather than accidentally allowed.
404
+ return false
405
+ }
305
406
  }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path'
3
+ import { closeSqliteStorage, initializeSqliteStorage } from '../sqlite/database.mjs'
4
+ import { createLanAccessRepository } from '../sqlite/lan-access-repository.mjs'
5
+ import { buildLanAccessJsonSnapshot } from '../lan-access-cutover.mjs'
6
+ import {
7
+ LAN_ACCESS_STORAGE_PHASES,
8
+ configureLanAccessService,
9
+ createDefaultLanAccessMirror,
10
+ drainLanAccessJsonMirror,
11
+ readLanAccessStorageState,
12
+ setLanAccessStoragePhase,
13
+ } from '../lan-access-service.mjs'
14
+ import { readLanAccessJsonFile } from '../lan-access-json-file.mjs'
15
+ import { dataDir } from '../storage.mjs'
16
+
17
+ // F11 Phase 3 offline LAN access downgrade (shutdown-time only). Mirrors
18
+ // downgrade-share-v1.mjs in the independent lan-access storage domain:
19
+ // --dry-run reports without writing anything or changing the phase; the default
20
+ // run drains the lan-access JSON mirror (materializing the whole config into
21
+ // security/lan-access.json) and verifies the on-disk JSON against the
22
+ // authoritative SQLite snapshot with an exact count/digest comparison; --commit
23
+ // then flips authority back to json_authoritative. Any failure leaves no
24
+ // partial output or phase change.
25
+ const AUTHORITATIVE_PHASES = new Set(['sqlite_authoritative_json_pending', 'authoritative'])
26
+
27
+ const args = new Set(process.argv.slice(2).filter((arg) => arg.startsWith('--')))
28
+ const dryRun = args.has('--dry-run')
29
+ const commit = args.has('--commit')
30
+
31
+ function exitError(message) {
32
+ process.stderr.write(`${message}\n`)
33
+ process.exitCode = 1
34
+ }
35
+
36
+ try {
37
+ const storage = await initializeSqliteStorage({ dataDir })
38
+ storage.health({ quickCheck: true })
39
+ const repository = createLanAccessRepository(storage)
40
+ configureLanAccessService({ repository, mirror: createDefaultLanAccessMirror() })
41
+ const state = readLanAccessStorageState()
42
+ if (state.phase === 'cutover_running') {
43
+ throw new Error('LAN access cutover is still running; stop all QuickForge processes and retry')
44
+ }
45
+ if (state.phase === LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE) {
46
+ throw new Error('LAN access storage is already JSON authoritative; nothing to downgrade')
47
+ }
48
+ if (!AUTHORITATIVE_PHASES.has(state.phase)) {
49
+ throw new Error(`LAN access storage is ${state.phase}; downgrade requires pending or authoritative phase`)
50
+ }
51
+ const integrity = repository.verifyIntegrity({ quickCheck: true })
52
+ if (!integrity.ok) throw new Error('LAN access storage integrity verification failed')
53
+ const snapshot = repository.exportSnapshot()
54
+ if (snapshot.tokenCount !== integrity.count || snapshot.digest !== integrity.digest) {
55
+ throw new Error('LAN access storage snapshot count/digest verification failed')
56
+ }
57
+ const report = {
58
+ ok: true,
59
+ phase: state.phase,
60
+ count: snapshot.tokenCount,
61
+ digest: snapshot.digest,
62
+ dryRun,
63
+ commit,
64
+ lanAccessFile: path.join(dataDir, 'storage', 'security', 'lan-access.json'),
65
+ }
66
+
67
+ if (dryRun) {
68
+ // Read-only: report what a real run would do without writing anything or
69
+ // changing the phase.
70
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
71
+ } else {
72
+ const drained = await drainLanAccessJsonMirror()
73
+ if (drained.pending !== 0) {
74
+ throw new Error(`LAN access JSON mirror drain failed: ${drained.pending} pending`)
75
+ }
76
+ // Verify the on-disk JSON store exactly matches the authoritative SQLite
77
+ // snapshot before allowing any phase change.
78
+ const mirror = buildLanAccessJsonSnapshot(await readLanAccessJsonFile())
79
+ if (mirror.tokenCount !== snapshot.tokenCount || mirror.digest !== snapshot.digest) {
80
+ throw new Error(`LAN access JSON mirror verification failed: SQLite ${snapshot.tokenCount}/${snapshot.digest} vs JSON ${mirror.tokenCount}/${mirror.digest}`)
81
+ }
82
+ report.materialized = drained.drained
83
+ if (commit) {
84
+ setLanAccessStoragePhase(LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE, {
85
+ lanTokenCount: snapshot.tokenCount,
86
+ digest: snapshot.digest,
87
+ diagnostic: { operation: 'downgrade', materialized: drained.drained },
88
+ })
89
+ report.phase = LAN_ACCESS_STORAGE_PHASES.JSON_AUTHORITATIVE
90
+ report.phaseChanged = true
91
+ } else {
92
+ report.phaseChanged = false
93
+ report.message = 'LAN access JSON mirror materialized; run with --commit after stopping all QuickForge processes to switch authority back to JSON'
94
+ }
95
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
96
+ }
97
+ } catch (error) {
98
+ exitError(error?.message || String(error))
99
+ } finally {
100
+ await closeSqliteStorage().catch(() => {})
101
+ }
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto'
3
+ import path from 'node:path'
4
+ import { closeSqliteStorage, initializeSqliteStorage } from '../sqlite/database.mjs'
5
+ import { createSessionStateRepository, snapshotDigestLine } from '../sqlite/session-state-repository.mjs'
6
+ import { buildSessionJsonSnapshot } from '../session-state-cutover.mjs'
7
+ import {
8
+ SESSION_STORAGE_PHASES,
9
+ configureSessionStateService,
10
+ drainSessionJsonMirror,
11
+ readSessionStorageState,
12
+ setSessionStoragePhase,
13
+ } from '../session-state-service.mjs'
14
+ import {
15
+ dataDir,
16
+ materializeSessionJsonMirrorEntry,
17
+ readPhysicalSessionStateBuckets,
18
+ } from '../storage.mjs'
19
+
20
+ const AUTHORITATIVE_PHASES = new Set(['sqlite_authoritative_json_pending', 'authoritative'])
21
+
22
+ const args = new Set(process.argv.slice(2).filter((arg) => arg.startsWith('--')))
23
+ const dryRun = args.has('--dry-run')
24
+ const commit = args.has('--commit')
25
+
26
+ function mirrorAdapter() {
27
+ async function materialize(entry) {
28
+ await materializeSessionJsonMirrorEntry(entry)
29
+ }
30
+ return { upsert: materialize, delete: materialize }
31
+ }
32
+
33
+ function exitError(message) {
34
+ process.stderr.write(`${message}\n`)
35
+ process.exitCode = 1
36
+ }
37
+
38
+ function isPlainObject(value) {
39
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
40
+ }
41
+
42
+ function canonicalize(value) {
43
+ if (Array.isArray(value)) return value.map(canonicalize)
44
+ if (!isPlainObject(value)) return value
45
+ return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalize(value[key])]))
46
+ }
47
+
48
+ function digestJson(value) {
49
+ return createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex')
50
+ }
51
+
52
+ // F9 split sessions: the downgrade materializes the JSON mirror as full bodies
53
+ // (split marker + messages inline) via drainSessionJsonMirror, so the SQLite
54
+ // side must be digested over the ASSEMBLED representation — not the stored
55
+ // stripped body + messages digest — to compare against buildSessionJsonSnapshot.
56
+ function assembledSnapshotDigest(snapshot) {
57
+ const rows = snapshot.records.map((record) => {
58
+ const state = record.state?.messageStorage === 'split'
59
+ ? { ...record.state, messages: record.messages ?? [] }
60
+ : record.state
61
+ return snapshotDigestLine(record.scope, record.projectId || '', record.sessionId, digestJson(state), digestJson(record.metadata), '')
62
+ })
63
+ return createHash('sha256').update(rows.sort().join('\n')).digest('hex')
64
+ }
65
+
66
+ try {
67
+ const storage = await initializeSqliteStorage({ dataDir })
68
+ storage.health({ quickCheck: true })
69
+ const repository = createSessionStateRepository(storage)
70
+ configureSessionStateService({ repository, mirror: mirrorAdapter() })
71
+ const state = readSessionStorageState()
72
+ if (state.phase === 'cutover_running') {
73
+ throw new Error('Session state cutover is still running; stop all QuickForge processes and retry')
74
+ }
75
+ if (state.phase === SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE) {
76
+ throw new Error('Session state is already JSON authoritative; nothing to downgrade')
77
+ }
78
+ if (!AUTHORITATIVE_PHASES.has(state.phase)) {
79
+ throw new Error(`Session state is ${state.phase}; downgrade requires pending or authoritative phase`)
80
+ }
81
+ const integrity = repository.verifyIntegrity({ quickCheck: true })
82
+ if (!integrity.ok) throw new Error('Session state integrity verification failed')
83
+ const snapshot = repository.exportSnapshot()
84
+ if (snapshot.count !== integrity.count || snapshot.digest !== integrity.digest) {
85
+ throw new Error('Session state snapshot count/digest verification failed')
86
+ }
87
+ const report = {
88
+ ok: true,
89
+ phase: state.phase,
90
+ count: snapshot.count,
91
+ digest: snapshot.digest,
92
+ dryRun,
93
+ commit,
94
+ sessionsDirectory: path.join(dataDir, 'storage', 'conversations'),
95
+ }
96
+
97
+ if (dryRun) {
98
+ // Read-only: report what a real run would materialize without writing
99
+ // anything or changing the phase.
100
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
101
+ } else {
102
+ await drainSessionJsonMirror()
103
+ // Verify the on-disk JSON mirror exactly matches the authoritative SQLite
104
+ // snapshot before allowing any phase change. Split sessions are compared
105
+ // over their assembled representation (full body with messages), which is
106
+ // what the JSON mirror materializes.
107
+ const mirror = buildSessionJsonSnapshot(await readPhysicalSessionStateBuckets())
108
+ const targetDigest = assembledSnapshotDigest(snapshot)
109
+ if (mirror.count !== snapshot.count || mirror.digest !== targetDigest) {
110
+ throw new Error(`JSON mirror verification failed: SQLite ${snapshot.count}/${targetDigest} vs JSON ${mirror.count}/${mirror.digest}`)
111
+ }
112
+ if (commit) {
113
+ setSessionStoragePhase(SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE, {
114
+ stateCount: snapshot.count,
115
+ digest: snapshot.digest,
116
+ diagnostic: { operation: 'downgrade', materialized: mirror.count },
117
+ })
118
+ report.phase = SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE
119
+ report.phaseChanged = true
120
+ } else {
121
+ report.phaseChanged = false
122
+ report.message = 'JSON mirror materialized; run with --commit after stopping all QuickForge processes to switch authority back to JSON'
123
+ }
124
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
125
+ }
126
+ } catch (error) {
127
+ exitError(error?.message || String(error))
128
+ } finally {
129
+ await closeSqliteStorage().catch(() => {})
130
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path'
3
+ import { closeSqliteStorage, initializeSqliteStorage } from '../sqlite/database.mjs'
4
+ import { createShareRepository } from '../sqlite/share-repository.mjs'
5
+ import { buildShareJsonSnapshot } from '../share-cutover.mjs'
6
+ import {
7
+ SHARE_STORAGE_PHASES,
8
+ configureShareService,
9
+ createDefaultShareMirror,
10
+ drainShareJsonMirror,
11
+ readShareStorageState,
12
+ setShareStoragePhase,
13
+ } from '../share-service.mjs'
14
+ import { readSharesJsonFile } from '../share-json-file.mjs'
15
+ import { dataDir } from '../storage.mjs'
16
+
17
+ // F10 Phase 3 offline share downgrade (shutdown-time only). Mirrors
18
+ // downgrade-session-state-v1.mjs in the independent share storage domain:
19
+ // --dry-run reports without writing anything or changing the phase; the default
20
+ // run drains the share JSON mirror (materializing the whole conversation-shares
21
+ // store) and verifies the on-disk JSON against the authoritative SQLite
22
+ // snapshot with an exact count/digest comparison; --commit then flips authority
23
+ // back to json_authoritative. Any failure leaves no partial output or phase
24
+ // change.
25
+ const AUTHORITATIVE_PHASES = new Set(['sqlite_authoritative_json_pending', 'authoritative'])
26
+
27
+ const args = new Set(process.argv.slice(2).filter((arg) => arg.startsWith('--')))
28
+ const dryRun = args.has('--dry-run')
29
+ const commit = args.has('--commit')
30
+
31
+ function exitError(message) {
32
+ process.stderr.write(`${message}\n`)
33
+ process.exitCode = 1
34
+ }
35
+
36
+ try {
37
+ const storage = await initializeSqliteStorage({ dataDir })
38
+ storage.health({ quickCheck: true })
39
+ const repository = createShareRepository(storage)
40
+ configureShareService({ repository, mirror: createDefaultShareMirror() })
41
+ const state = readShareStorageState()
42
+ if (state.phase === 'cutover_running') {
43
+ throw new Error('Share cutover is still running; stop all QuickForge processes and retry')
44
+ }
45
+ if (state.phase === SHARE_STORAGE_PHASES.JSON_AUTHORITATIVE) {
46
+ throw new Error('Share storage is already JSON authoritative; nothing to downgrade')
47
+ }
48
+ if (!AUTHORITATIVE_PHASES.has(state.phase)) {
49
+ throw new Error(`Share storage is ${state.phase}; downgrade requires pending or authoritative phase`)
50
+ }
51
+ const integrity = repository.verifyIntegrity({ quickCheck: true })
52
+ if (!integrity.ok) throw new Error('Share storage integrity verification failed')
53
+ const snapshot = repository.exportSnapshot()
54
+ if (snapshot.count !== integrity.count || snapshot.digest !== integrity.digest) {
55
+ throw new Error('Share storage snapshot count/digest verification failed')
56
+ }
57
+ const report = {
58
+ ok: true,
59
+ phase: state.phase,
60
+ count: snapshot.count,
61
+ digest: snapshot.digest,
62
+ dryRun,
63
+ commit,
64
+ sharesFile: path.join(dataDir, 'storage', 'shares', 'conversation-shares.json'),
65
+ }
66
+
67
+ if (dryRun) {
68
+ // Read-only: report what a real run would do without writing anything or
69
+ // changing the phase.
70
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
71
+ } else {
72
+ const drained = await drainShareJsonMirror()
73
+ if (drained.pending !== 0) {
74
+ throw new Error(`Share JSON mirror drain failed: ${drained.pending} pending`)
75
+ }
76
+ // Verify the on-disk JSON store exactly matches the authoritative SQLite
77
+ // snapshot before allowing any phase change.
78
+ const mirror = buildShareJsonSnapshot(await readSharesJsonFile())
79
+ if (mirror.count !== snapshot.count || mirror.digest !== snapshot.digest) {
80
+ throw new Error(`Share JSON mirror verification failed: SQLite ${snapshot.count}/${snapshot.digest} vs JSON ${mirror.count}/${mirror.digest}`)
81
+ }
82
+ report.materialized = drained.drained
83
+ if (commit) {
84
+ setShareStoragePhase(SHARE_STORAGE_PHASES.JSON_AUTHORITATIVE, {
85
+ shareCount: snapshot.count,
86
+ digest: snapshot.digest,
87
+ diagnostic: { operation: 'downgrade', materialized: drained.drained },
88
+ })
89
+ report.phase = SHARE_STORAGE_PHASES.JSON_AUTHORITATIVE
90
+ report.phaseChanged = true
91
+ } else {
92
+ report.phaseChanged = false
93
+ report.message = 'Share JSON mirror materialized; run with --commit after stopping all QuickForge processes to switch authority back to JSON'
94
+ }
95
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
96
+ }
97
+ } catch (error) {
98
+ exitError(error?.message || String(error))
99
+ } finally {
100
+ await closeSqliteStorage().catch(() => {})
101
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from 'node:crypto'
3
+ import { promises as fs } from 'node:fs'
4
+ import path from 'node:path'
5
+ import { closeSqliteStorage, initializeSqliteStorage } from '../sqlite/database.mjs'
6
+ import { createLanAccessRepository } from '../sqlite/lan-access-repository.mjs'
7
+ import { buildLanAccessJsonSnapshot } from '../lan-access-cutover.mjs'
8
+ import { readLanAccessStorageState } from '../lan-access-service.mjs'
9
+ import { dataDir } from '../storage.mjs'
10
+
11
+ // F11 Phase 3 offline authoritative LAN access export (shutdown-time only).
12
+ // Mirrors export-share-v1.mjs in the independent lan-access storage domain:
13
+ // refuses cutover_running and json_authoritative, runs quick_check +
14
+ // verifyIntegrity + exportSnapshot with fail-closed count/digest checks, and
15
+ // only renames the temporary file into place after re-reading and re-verifying
16
+ // what was written. The exported config carries token hashes only (never the
17
+ // raw secrets) and strips the repository-internal revision.
18
+ const AUTHORITATIVE_PHASES = new Set(['sqlite_authoritative_json_pending', 'authoritative'])
19
+
20
+ function outputPath() {
21
+ const argument = process.argv[2]
22
+ if (argument) return path.resolve(argument)
23
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
24
+ return path.join(process.cwd(), `quickforge-lan-access-${stamp}.json`)
25
+ }
26
+
27
+ const finalPath = outputPath()
28
+ const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
29
+
30
+ function publicConfig(config) {
31
+ const rest = { ...config }
32
+ delete rest.revision
33
+ return rest
34
+ }
35
+
36
+ try {
37
+ const storage = await initializeSqliteStorage({ dataDir })
38
+ storage.health({ quickCheck: true })
39
+ const repository = createLanAccessRepository(storage)
40
+ const state = readLanAccessStorageState()
41
+ if (state.phase === 'cutover_running') {
42
+ throw new Error('LAN access cutover is still running; stop all QuickForge processes and retry')
43
+ }
44
+ if (!AUTHORITATIVE_PHASES.has(state.phase)) {
45
+ throw new Error(`LAN access storage is ${state.phase}; authoritative export requires the SQLite cutover to be complete`)
46
+ }
47
+ const integrity = repository.verifyIntegrity({ quickCheck: true })
48
+ if (!integrity.ok) throw new Error('LAN access storage integrity verification failed')
49
+ const snapshot = repository.exportSnapshot()
50
+ if (snapshot.tokenCount !== integrity.count || snapshot.digest !== integrity.digest) {
51
+ throw new Error('LAN access backup count/digest verification failed')
52
+ }
53
+ const backup = {
54
+ app: 'quickforge',
55
+ version: 1,
56
+ exportedAt: new Date().toISOString(),
57
+ scope: 'lan-access',
58
+ includeSecrets: false,
59
+ lanAccessState: { phase: state.phase, count: snapshot.tokenCount, digest: snapshot.digest },
60
+ data: {
61
+ lanAccess: publicConfig(snapshot.config),
62
+ },
63
+ }
64
+ await fs.mkdir(path.dirname(finalPath), { recursive: true })
65
+ await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
66
+ const verified = JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
67
+ const recheck = buildLanAccessJsonSnapshot(verified.data?.lanAccess)
68
+ if (verified.lanAccessState?.count !== snapshot.tokenCount || verified.lanAccessState?.digest !== snapshot.digest
69
+ || recheck.tokenCount !== snapshot.tokenCount || recheck.digest !== snapshot.digest) {
70
+ throw new Error('LAN access export verification failed')
71
+ }
72
+ await fs.rename(temporaryPath, finalPath)
73
+ process.stdout.write(`${finalPath}\n`)
74
+ } catch (error) {
75
+ await fs.rm(temporaryPath, { force: true }).catch(() => {})
76
+ process.stderr.write(`${error?.message || error}\n`)
77
+ process.exitCode = 1
78
+ } finally {
79
+ await closeSqliteStorage().catch(() => {})
80
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from 'node:crypto'
3
+ import { promises as fs } from 'node:fs'
4
+ import path from 'node:path'
5
+ import { closeSqliteStorage, initializeSqliteStorage } from '../sqlite/database.mjs'
6
+ import { readScheduledTasksForBackup } from '../scheduled-runs-backup.mjs'
7
+ import { readScheduledRunsState } from '../scheduled-runs-cutover.mjs'
8
+ import { dataDir } from '../storage.mjs'
9
+
10
+ function outputPath() {
11
+ const argument = process.argv[2]
12
+ if (argument) return path.resolve(argument)
13
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
14
+ return path.join(process.cwd(), `quickforge-scheduled-runs-${stamp}.json`)
15
+ }
16
+
17
+ const finalPath = outputPath()
18
+ const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
19
+
20
+ try {
21
+ const storage = await initializeSqliteStorage({ dataDir })
22
+ storage.health({ quickCheck: true })
23
+ const state = readScheduledRunsState(storage)
24
+ if (state.phase === 'cutover_running') throw new Error('Scheduled runs cutover is still running; stop all QuickForge processes and retry')
25
+ const scheduledTasks = await readScheduledTasksForBackup()
26
+ const backup = {
27
+ app: 'quickforge',
28
+ version: 1,
29
+ exportedAt: new Date().toISOString(),
30
+ scope: 'config',
31
+ includeSecrets: false,
32
+ data: { scheduledTasks },
33
+ }
34
+ await fs.mkdir(path.dirname(finalPath), { recursive: true })
35
+ await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
36
+ JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
37
+ await fs.rename(temporaryPath, finalPath)
38
+ process.stdout.write(`${finalPath}\n`)
39
+ } catch (error) {
40
+ await fs.rm(temporaryPath, { force: true }).catch(() => {})
41
+ process.stderr.write(`${error?.message || error}\n`)
42
+ process.exitCode = 1
43
+ } finally {
44
+ await closeSqliteStorage().catch(() => {})
45
+ }