@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.
- package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
- package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
- package/dist/assets/{CloudAccountSettingsPage-CDkKN1Sz.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
- package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-mLvwvTmL.js} +1 -1
- package/dist/assets/{ScheduledTasksPage-C9lOTi75.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
- package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
- package/dist/assets/{ShareLinksSettingsPage-CH_t33A3.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
- package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-C7CDEoX9.js} +1 -1
- package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
- package/dist/assets/{WorkspaceInspector-DPXgLIum.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
- package/dist/assets/{index-DQBOm9gt.js → index-BgMvQW1b.js} +13 -13
- package/dist/assets/index-CB1g2D0f.css +3 -0
- package/dist/assets/local-tools-Dopjg9xq.js +270 -0
- package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
- package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-DYp35JM3.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +62 -46
- package/server/auto-archive.mjs +55 -8
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
- package/server/maintenance/export-session-state-v1.mjs +1 -11
- package/server/routes/backup.mjs +1 -1
- package/server/routes/storage.mjs +63 -49
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-state-backup.mjs +56 -5
- package/server/session-state-import.mjs +157 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +128 -210
- package/server/share-cutover.mjs +21 -4
- package/server/sqlite/database.mjs +142 -7
- package/server/sqlite/lan-access-repository.mjs +6 -4
- package/server/sqlite/migrations.mjs +109 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
- package/server/sqlite/session-index-repository.mjs +59 -208
- package/server/sqlite/session-state-repository.mjs +337 -387
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +218 -454
- package/server/utils/logger.mjs +15 -4
- package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
- package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
- package/dist/assets/index-ps85guTs.css +0 -3
- package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
- 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
|
+
}
|