@shawnstack/quickforge 1.7.10 → 1.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
  2. package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
  3. package/dist/assets/{CloudAccountSettingsPage-CDkKN1Sz.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
  4. package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-mLvwvTmL.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-C9lOTi75.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
  7. package/dist/assets/{ShareLinksSettingsPage-CH_t33A3.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-C7CDEoX9.js} +1 -1
  9. package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-DPXgLIum.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
  11. package/dist/assets/{index-DQBOm9gt.js → index-BgMvQW1b.js} +13 -13
  12. package/dist/assets/index-CB1g2D0f.css +3 -0
  13. package/dist/assets/local-tools-Dopjg9xq.js +270 -0
  14. package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
  15. package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-DYp35JM3.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +3 -6
  19. package/server/agent-manager.mjs +62 -46
  20. package/server/auto-archive.mjs +55 -8
  21. package/server/index.mjs +172 -51
  22. package/server/lan-access-cutover.mjs +21 -4
  23. package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
  24. package/server/maintenance/export-session-state-v1.mjs +1 -11
  25. package/server/routes/backup.mjs +1 -1
  26. package/server/routes/storage.mjs +63 -49
  27. package/server/scheduled-runs-cutover.mjs +15 -1
  28. package/server/session-index-service.mjs +57 -391
  29. package/server/session-state-backup.mjs +56 -5
  30. package/server/session-state-import.mjs +157 -0
  31. package/server/session-state-maintenance.mjs +126 -0
  32. package/server/session-state-service.mjs +128 -210
  33. package/server/share-cutover.mjs +21 -4
  34. package/server/sqlite/database.mjs +142 -7
  35. package/server/sqlite/lan-access-repository.mjs +6 -4
  36. package/server/sqlite/migrations.mjs +109 -0
  37. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  38. package/server/sqlite/session-index-repository.mjs +59 -208
  39. package/server/sqlite/session-state-repository.mjs +337 -387
  40. package/server/sqlite/share-repository.mjs +56 -45
  41. package/server/startup-state.mjs +107 -0
  42. package/server/storage.mjs +218 -454
  43. package/server/utils/logger.mjs +15 -4
  44. package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
  45. package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
  46. package/dist/assets/index-ps85guTs.css +0 -3
  47. package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
  48. package/server/session-state-cutover.mjs +0 -563
@@ -0,0 +1,157 @@
1
+ import { getSqliteStorage } from './sqlite/database.mjs'
2
+ import { createSessionStateRepository } from './sqlite/session-state-repository.mjs'
3
+ import { logger as defaultLogger } from './utils/logger.mjs'
4
+
5
+ function isPlainObject(value) {
6
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
7
+ }
8
+
9
+ // Bucket validation mirrors session-state-cutover.mjs's normalizeBucket: the
10
+ // importer walks the same physical JSON tree the cutover source did.
11
+ function normalizeBucket(raw) {
12
+ if (raw.scope === 'global') {
13
+ if (raw.projectId !== undefined && raw.projectId !== null && raw.projectId !== '') throw new TypeError('Global session bucket cannot have projectId')
14
+ return { scope: 'global', projectId: null }
15
+ }
16
+ if (raw.scope !== 'project' || typeof raw.projectId !== 'string' || !raw.projectId.trim() || raw.projectId === '.' || raw.projectId === '..' || /[\\/]/.test(raw.projectId)) {
17
+ throw new TypeError('Invalid session bucket')
18
+ }
19
+ return { scope: 'project', projectId: raw.projectId }
20
+ }
21
+
22
+ // Metadata derivation for body-only files (no sessions-metadata entry) —
23
+ // identical shaping to the cutover's deriveMetadata.
24
+ function deriveMetadata(sessionId, state, bucket) {
25
+ const createdAt = typeof state.createdAt === 'string'
26
+ ? state.createdAt
27
+ : (typeof state.lastModified === 'string' ? state.lastModified : '1970-01-01T00:00:00.000Z')
28
+ return {
29
+ id: sessionId,
30
+ title: typeof state.title === 'string' ? state.title : 'New chat',
31
+ titleSource: state.titleSource,
32
+ createdAt,
33
+ lastModified: typeof state.lastModified === 'string' ? state.lastModified : createdAt,
34
+ messageCount: state.messages.length,
35
+ stateVersion: Number.isInteger(state.stateVersion) && state.stateVersion >= 0 ? state.stateVersion : 0,
36
+ thinkingLevel: typeof state.thinkingLevel === 'string' ? state.thinkingLevel : 'off',
37
+ scope: bucket.scope,
38
+ ...(bucket.scope === 'project' ? { projectId: bucket.projectId } : {}),
39
+ taskStatus: state.taskStatus || 'idle',
40
+ taskStartedAt: state.taskStartedAt ?? null,
41
+ taskFinishedAt: state.taskFinishedAt ?? null,
42
+ }
43
+ }
44
+
45
+ // Per-session normalization with the cutover's normalizeSessionEntry
46
+ // validation semantics: id/scope/projectId consistency between bucket, body
47
+ // and metadata; orphan metadata-only entries are dropped (diagnosed by the
48
+ // caller); body-only files derive their metadata. Returns the repository
49
+ // record, or null for a metadata-only orphan.
50
+ function normalizeSessionEntry(bucket, sessionId, rawState, rawMetadata, diagnostics) {
51
+ if (!rawState && rawMetadata) {
52
+ diagnostics.push({ kind: 'metadata-only-orphan', scope: bucket.scope, projectId: bucket.projectId, sessionId })
53
+ return null
54
+ }
55
+ if (!isPlainObject(rawState)) throw new TypeError(`Invalid session state: ${sessionId}`)
56
+ if (!Array.isArray(rawState.messages)) throw new TypeError(`Session messages must be an array: ${sessionId}`)
57
+ if (rawState.id !== undefined && rawState.id !== sessionId) throw new TypeError(`Session body id mismatch: ${sessionId}`)
58
+ if (rawState.scope !== undefined && rawState.scope !== bucket.scope) throw new TypeError(`Session body scope mismatch: ${sessionId}`)
59
+ if (bucket.scope === 'project' && rawState.projectId !== undefined && rawState.projectId !== bucket.projectId) throw new TypeError(`Session body project mismatch: ${sessionId}`)
60
+ if (bucket.scope === 'global' && rawState.projectId !== undefined && rawState.projectId !== null) throw new TypeError(`Global session body project mismatch: ${sessionId}`)
61
+ let metadata
62
+ if (rawMetadata === undefined) {
63
+ diagnostics.push({ kind: 'body-only', scope: bucket.scope, projectId: bucket.projectId, sessionId })
64
+ metadata = deriveMetadata(sessionId, rawState, bucket)
65
+ } else {
66
+ if (!isPlainObject(rawMetadata)) throw new TypeError(`Invalid session metadata: ${sessionId}`)
67
+ if (rawMetadata.id !== undefined && rawMetadata.id !== sessionId) throw new TypeError(`Session metadata id mismatch: ${sessionId}`)
68
+ if (rawMetadata.scope !== undefined && rawMetadata.scope !== bucket.scope) throw new TypeError(`Session metadata scope mismatch: ${sessionId}`)
69
+ if (bucket.scope === 'project' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== bucket.projectId) throw new TypeError(`Session metadata project mismatch: ${sessionId}`)
70
+ if (bucket.scope === 'global' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== null) throw new TypeError(`Global session metadata project mismatch: ${sessionId}`)
71
+ metadata = structuredClone(rawMetadata)
72
+ }
73
+ const stateVersion = rawState.stateVersion ?? metadata.stateVersion ?? 0
74
+ if (!Number.isInteger(stateVersion) || stateVersion < 0) throw new TypeError(`Invalid session stateVersion: ${sessionId}`)
75
+ const state = { ...structuredClone(rawState), id: sessionId, scope: bucket.scope, stateVersion }
76
+ metadata = { ...metadata, id: sessionId, scope: bucket.scope, stateVersion }
77
+ if (bucket.scope === 'project') {
78
+ state.projectId = bucket.projectId
79
+ metadata.projectId = bucket.projectId
80
+ } else {
81
+ delete state.projectId
82
+ delete metadata.projectId
83
+ }
84
+ for (const field of ['pinnedAt', 'archivedAt']) {
85
+ if (metadata[field] !== undefined) state[field] = metadata[field]
86
+ }
87
+ return { scope: bucket.scope, projectId: bucket.projectId ?? null, sessionId, state, metadata }
88
+ }
89
+
90
+ // Schema v11 JSON importer: streams the physical session-state JSON tree
91
+ // (the same layout createPhysicalSessionStateFsAdapter walks for the old
92
+ // cutover) and re-imports every session into the v2 SQLite store, one
93
+ // transaction per session — repository.save extracts the messages (inline or
94
+ // legacy split-marked bodies alike), so the import is idempotent and can be
95
+ // re-run after any interruption. JSON files are read-only here: nothing is
96
+ // written back, no mirror, no phase state.
97
+ //
98
+ // Resilience: a failing entry (unreadable file, validation mismatch,
99
+ // cross-bucket duplicate) never aborts the run — it is counted in `skipped`
100
+ // and described in `diagnostics` so the operator can inspect and fix the
101
+ // source tree, then simply re-run.
102
+ export async function importSessionStateFromJson({ storage = null, logger = defaultLogger } = {}) {
103
+ const storageModule = storage ?? await import('./storage.mjs')
104
+ const fsAdapter = storageModule.createPhysicalSessionStateFsAdapter()
105
+ const repository = createSessionStateRepository(getSqliteStorage())
106
+ const diagnostics = []
107
+ let imported = 0
108
+ let skipped = 0
109
+
110
+ for await (const rawBucket of fsAdapter.listBuckets()) {
111
+ const bucket = normalizeBucket(rawBucket)
112
+ const metadata = await fsAdapter.readMetadataBucket(rawBucket)
113
+ if (!isPlainObject(metadata)) throw new TypeError('Session bucket stores must be objects')
114
+ const fileIds = new Set()
115
+ for await (const sessionId of fsAdapter.listSessionFiles(rawBucket)) fileIds.add(sessionId)
116
+ const ids = [...new Set([...fileIds, ...Object.keys(metadata)])].sort((left, right) => left.localeCompare(right))
117
+ for (const sessionId of ids) {
118
+ let record
119
+ try {
120
+ const rawState = fileIds.has(sessionId) ? await fsAdapter.readSessionState(rawBucket, sessionId) : undefined
121
+ record = normalizeSessionEntry(bucket, sessionId, rawState, metadata[sessionId], diagnostics)
122
+ } catch (error) {
123
+ skipped += 1
124
+ diagnostics.push({ kind: 'invalid-entry', scope: bucket.scope, projectId: bucket.projectId, sessionId, message: error instanceof Error ? error.message : String(error) })
125
+ continue
126
+ }
127
+ if (!record) {
128
+ skipped += 1
129
+ continue
130
+ }
131
+ try {
132
+ repository.save(record)
133
+ imported += 1
134
+ } catch (error) {
135
+ skipped += 1
136
+ diagnostics.push({
137
+ kind: 'save-error',
138
+ scope: record.scope,
139
+ projectId: record.projectId,
140
+ sessionId,
141
+ errorCode: error?.errorCode ?? null,
142
+ message: error instanceof Error ? error.message : String(error),
143
+ })
144
+ }
145
+ }
146
+ }
147
+
148
+ try {
149
+ repository.checkpointWal()
150
+ } catch (error) {
151
+ logger.warn('Session state import WAL checkpoint failed', {
152
+ error: error instanceof Error ? error.message : String(error),
153
+ })
154
+ }
155
+ logger.info('Session state JSON import finished', { imported, skipped })
156
+ return { imported, skipped, diagnostics }
157
+ }
@@ -0,0 +1,126 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { getSqliteStorage } from './sqlite/database.mjs'
4
+
5
+ // Session-domain maintenance lock (extracted from the retired
6
+ // session-state-cutover.mjs when the JSON→SQLite phase machine was removed):
7
+ // restore/verify operations serialize across processes through the
8
+ // `session_state_maintenance_lock` table (created by SQLite migration v6 and
9
+ // kept by schema v11), with owner liveness, fencing tokens and a TTL renewed
10
+ // by heartbeat while an operation runs.
11
+
12
+ const DEFAULT_LOCK_TTL_MS = 60_000
13
+ const DEFAULT_WAIT_TIMEOUT_MS = 65_000
14
+ const maintenanceContext = new AsyncLocalStorage()
15
+ let maintenanceCount = 0
16
+
17
+ function isPlainObject(value) {
18
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
19
+ }
20
+
21
+ function ownerPid(current) {
22
+ const value = Number(current.owner_pid)
23
+ return Number.isInteger(value) && value > 0 ? value : null
24
+ }
25
+
26
+ function pidAlive(pid) {
27
+ try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
28
+ }
29
+
30
+ export function acquireSessionStateMaintenanceLock(storage, options = {}) {
31
+ const nowMs = Number(options.now?.() ?? Date.now())
32
+ const acquiredAt = new Date(nowMs).toISOString()
33
+ const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
34
+ const identity = isPlainObject(options.owner)
35
+ ? { id: options.owner.id || `${options.owner.pid || process.pid}:${randomUUID()}`, pid: options.owner.pid || process.pid }
36
+ : { id: options.owner || `${process.pid}:${randomUUID()}`, pid: process.pid }
37
+ const isPidAlive = options.pidAlive || pidAlive
38
+ return storage.transaction((database) => {
39
+ const current = database.prepare('SELECT * FROM session_state_maintenance_lock WHERE singleton = 1').get()
40
+ if (current && current.owner === identity.id) {
41
+ database.prepare(`UPDATE session_state_maintenance_lock SET owner_pid = ?, operation = ?, heartbeat_at = ?, expires_at = ?
42
+ WHERE singleton = 1 AND owner = ? AND fencing = ?`)
43
+ .run(identity.pid, options.operation || current.operation, acquiredAt, expiresAt, identity.id, current.fencing)
44
+ return { owner: identity.id, ownerPid: identity.pid, fencing: Number(current.fencing), expiresAt }
45
+ }
46
+ if (current) {
47
+ const expired = Date.parse(current.expires_at) <= nowMs
48
+ const pid = ownerPid(current)
49
+ if (!expired || pid === null || isPidAlive(pid)) return null
50
+ }
51
+ const fencing = Number(current?.fencing || 0) + 1
52
+ database.prepare(`INSERT INTO session_state_maintenance_lock
53
+ (singleton, owner, owner_pid, fencing, operation, acquired_at, heartbeat_at, expires_at)
54
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?)
55
+ ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner, owner_pid=excluded.owner_pid, fencing=excluded.fencing,
56
+ operation=excluded.operation, acquired_at=excluded.acquired_at, heartbeat_at=excluded.heartbeat_at, expires_at=excluded.expires_at`)
57
+ .run(identity.id, identity.pid, fencing, options.operation || 'session-state-maintenance', acquiredAt, acquiredAt, expiresAt)
58
+ return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
59
+ }, { mode: 'immediate' })
60
+ }
61
+
62
+ export function renewSessionStateMaintenanceLock(storage, lease, options = {}) {
63
+ const nowMs = Number(options.now?.() ?? Date.now())
64
+ const heartbeatAt = new Date(nowMs).toISOString()
65
+ const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
66
+ const result = storage.prepare(`UPDATE session_state_maintenance_lock SET heartbeat_at = ?, expires_at = ? WHERE singleton = 1 AND owner = ? AND fencing = ?`)
67
+ .run(heartbeatAt, expiresAt, lease.owner, lease.fencing)
68
+ lease.expiresAt = expiresAt
69
+ return Number(result.changes) === 1
70
+ }
71
+
72
+ export function releaseSessionStateMaintenanceLock(storage, lease) {
73
+ return Number(storage.prepare('DELETE FROM session_state_maintenance_lock WHERE singleton = 1 AND owner = ? AND fencing = ?').run(lease.owner, lease.fencing).changes) === 1
74
+ }
75
+
76
+ async function waitForLock(storage, options) {
77
+ const deadline = Date.now() + (options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS)
78
+ while (Date.now() < deadline) {
79
+ const lease = acquireSessionStateMaintenanceLock(storage, options)
80
+ if (lease) return lease
81
+ await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 100))
82
+ }
83
+ throw new Error('Timed out waiting for session state maintenance lock')
84
+ }
85
+
86
+ export async function runSessionStateMaintenance(operation, options = {}) {
87
+ const existing = maintenanceContext.getStore()
88
+ if (existing) return operation(existing)
89
+ const storage = options.storage || getSqliteStorage()
90
+ const lease = await waitForLock(storage, options)
91
+ const interval = options.heartbeatIntervalMs ?? Math.max(10, Math.floor((options.ttlMs ?? DEFAULT_LOCK_TTL_MS) / 3))
92
+ const timer = setInterval(() => {
93
+ try {
94
+ if (!renewSessionStateMaintenanceLock(storage, lease, options)) throw new Error('Session state maintenance lock fencing was lost')
95
+ } catch (error) {
96
+ heartbeatError = error
97
+ rejectHeartbeat(error)
98
+ }
99
+ }, interval)
100
+ timer.unref?.()
101
+ let rejectHeartbeat
102
+ let heartbeatError = null
103
+ const heartbeatFailure = new Promise((_, reject) => { rejectHeartbeat = reject })
104
+ maintenanceCount += 1
105
+ try {
106
+ const context = { storage, lease }
107
+ const result = await Promise.race([maintenanceContext.run(context, () => Promise.resolve().then(() => operation(context))), heartbeatFailure])
108
+ if (heartbeatError) throw heartbeatError
109
+ return result
110
+ } finally {
111
+ clearInterval(timer)
112
+ releaseSessionStateMaintenanceLock(storage, lease)
113
+ maintenanceCount = Math.max(0, maintenanceCount - 1)
114
+ }
115
+ }
116
+
117
+ export function isSessionStateMaintenanceActive(storage) {
118
+ if (!storage) {
119
+ try { storage = getSqliteStorage() } catch { return false }
120
+ }
121
+ return maintenanceCount > 0 || Boolean(storage.prepare('SELECT 1 AS active FROM session_state_maintenance_lock WHERE singleton = 1').get())
122
+ }
123
+
124
+ export function currentSessionStateMaintenanceContext() {
125
+ return maintenanceContext.getStore() || null
126
+ }