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