@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createShareRepository } from './sqlite/share-repository.mjs'
|
|
2
|
+
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
3
|
+
import { materializeShareJsonEntry } from './share-json-file.mjs'
|
|
4
|
+
|
|
5
|
+
// F10 share storage phases — the same state machine as F8 session state but in
|
|
6
|
+
// its own storage domain (`share_storage_state`, independent maintenance lock).
|
|
7
|
+
export const SHARE_STORAGE_PHASES = Object.freeze({
|
|
8
|
+
JSON_AUTHORITATIVE: 'json_authoritative',
|
|
9
|
+
CUTOVER_RUNNING: 'cutover_running',
|
|
10
|
+
JSON_PENDING: 'sqlite_authoritative_json_pending',
|
|
11
|
+
AUTHORITATIVE: 'authoritative',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
let repositoryInstance = null
|
|
15
|
+
let cachedPhase = SHARE_STORAGE_PHASES.JSON_AUTHORITATIVE
|
|
16
|
+
let jsonAdapter = null
|
|
17
|
+
let mirrorAdapter = null
|
|
18
|
+
let drainPromise = null
|
|
19
|
+
let mirrorTimer = null
|
|
20
|
+
const MIRROR_DRAIN_INTERVAL_MS = 1000
|
|
21
|
+
|
|
22
|
+
function storage() {
|
|
23
|
+
return getSqliteStorage()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function repository() {
|
|
27
|
+
if (!repositoryInstance) repositoryInstance = createShareRepository(storage())
|
|
28
|
+
return repositoryInstance
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// JSON-authoritative read/write adapter slot (same pattern as
|
|
32
|
+
// session-state-service). share-store keeps its legacy JSON path, so this is a
|
|
33
|
+
// public extension point rather than an internal call site.
|
|
34
|
+
export function requireShareJsonAdapter(method) {
|
|
35
|
+
if (typeof jsonAdapter?.[method] !== 'function') throw new Error(`JSON authoritative share adapter does not implement ${method}`)
|
|
36
|
+
return jsonAdapter[method].bind(jsonAdapter)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sqliteReadable() {
|
|
40
|
+
return cachedPhase === SHARE_STORAGE_PHASES.JSON_PENDING || cachedPhase === SHARE_STORAGE_PHASES.AUTHORITATIVE
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createDefaultShareMirror() {
|
|
44
|
+
return {
|
|
45
|
+
async upsert(record) {
|
|
46
|
+
await materializeShareJsonEntry({ operation: 'upsert', record })
|
|
47
|
+
},
|
|
48
|
+
async delete(shareId) {
|
|
49
|
+
await materializeShareJsonEntry({ operation: 'delete', shareId })
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function configureShareService({ repository: configuredRepository, mirror, phase, json } = {}) {
|
|
55
|
+
if (configuredRepository !== undefined) repositoryInstance = configuredRepository
|
|
56
|
+
if (json !== undefined) jsonAdapter = json
|
|
57
|
+
if (mirror !== undefined) mirrorAdapter = mirror
|
|
58
|
+
if (phase !== undefined) {
|
|
59
|
+
if (!Object.values(SHARE_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid share storage phase: ${phase}`)
|
|
60
|
+
cachedPhase = phase
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The active share repository (lazily created from the SQLite storage handle).
|
|
66
|
+
* Only callable while the share storage is SQLite-readable (pending or
|
|
67
|
+
* authoritative); JSON-authoritative phases keep the legacy JSON store path.
|
|
68
|
+
*/
|
|
69
|
+
export function getShareRepository() {
|
|
70
|
+
return repository()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function setShareStoragePhase(phase, values = {}) {
|
|
74
|
+
if (!Object.values(SHARE_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid share storage phase: ${phase}`)
|
|
75
|
+
const updatedAt = new Date().toISOString()
|
|
76
|
+
storage().prepare(`UPDATE share_storage_state SET phase = ?, share_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ? WHERE singleton = 1`)
|
|
77
|
+
.run(phase, values.shareCount ?? null, values.digest ?? null, values.backupFile ?? null, values.diagnostic ? JSON.stringify(values.diagnostic) : null, updatedAt)
|
|
78
|
+
cachedPhase = phase
|
|
79
|
+
return readShareStorageState()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readShareStorageState() {
|
|
83
|
+
const row = storage().prepare('SELECT * FROM share_storage_state WHERE singleton = 1').get()
|
|
84
|
+
if (!row) throw new Error('Share storage state is missing')
|
|
85
|
+
cachedPhase = row.phase
|
|
86
|
+
return {
|
|
87
|
+
phase: row.phase,
|
|
88
|
+
shareCount: row.share_count === null ? null : Number(row.share_count),
|
|
89
|
+
digest: row.digest,
|
|
90
|
+
backupFile: row.backup_file,
|
|
91
|
+
diagnostic: row.diagnostic_json ? JSON.parse(row.diagnostic_json) : null,
|
|
92
|
+
updatedAt: row.updated_at,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function initializeShareService() {
|
|
97
|
+
return readShareStorageState()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getShareStoragePhase() {
|
|
101
|
+
return cachedPhase
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isShareStorageAuthoritative() {
|
|
105
|
+
return sqliteReadable()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function scheduleShareJsonMirrorDrain() {
|
|
109
|
+
if (!sqliteReadable() || !mirrorAdapter || mirrorTimer) return
|
|
110
|
+
mirrorTimer = setTimeout(() => {
|
|
111
|
+
mirrorTimer = null
|
|
112
|
+
void drainShareJsonMirror().then(({ pending }) => {
|
|
113
|
+
if (pending > 0) scheduleShareJsonMirrorDrain()
|
|
114
|
+
}).catch(() => {
|
|
115
|
+
scheduleShareJsonMirrorDrain()
|
|
116
|
+
})
|
|
117
|
+
}, MIRROR_DRAIN_INTERVAL_MS)
|
|
118
|
+
mirrorTimer.unref?.()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function requestShareJsonMirrorDrain() {
|
|
122
|
+
scheduleShareJsonMirrorDrain()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function stopShareService() {
|
|
126
|
+
if (mirrorTimer) clearTimeout(mirrorTimer)
|
|
127
|
+
mirrorTimer = null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function drainShareJsonMirror() {
|
|
131
|
+
if (!sqliteReadable() || !mirrorAdapter) return { pending: 0, drained: 0, failed: 0 }
|
|
132
|
+
if (drainPromise) return drainPromise
|
|
133
|
+
drainPromise = (async () => {
|
|
134
|
+
let drained = 0
|
|
135
|
+
let failed = 0
|
|
136
|
+
for (;;) {
|
|
137
|
+
const entries = repository().listMirrorQueue()
|
|
138
|
+
if (entries.length === 0) break
|
|
139
|
+
let progressed = false
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
try {
|
|
142
|
+
if (entry.operation === 'upsert') await mirrorAdapter.upsert(entry.record)
|
|
143
|
+
else await mirrorAdapter.delete(entry.shareId)
|
|
144
|
+
repository().acknowledgeMirror(entry)
|
|
145
|
+
drained += 1
|
|
146
|
+
progressed = true
|
|
147
|
+
} catch (error) {
|
|
148
|
+
repository().failMirror(entry, error)
|
|
149
|
+
failed += 1
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!progressed) break
|
|
153
|
+
}
|
|
154
|
+
return { pending: repository().listMirrorQueue().length, drained, failed }
|
|
155
|
+
})().finally(() => {
|
|
156
|
+
drainPromise = null
|
|
157
|
+
})
|
|
158
|
+
return drainPromise
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function listShareMirrorQueue() {
|
|
162
|
+
return repository().listMirrorQueue()
|
|
163
|
+
}
|
package/server/share-store.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { promises as fs } from 'node:fs'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { ensureStorage, storageDir } from './storage.mjs'
|
|
6
6
|
import { hashPassword, safeHashEqual, sha256Base64Url, verifyPassword } from './utils/password-auth.mjs'
|
|
7
|
+
import { getShareRepository, isShareStorageAuthoritative, requestShareJsonMirrorDrain } from './share-service.mjs'
|
|
8
|
+
import { isShareMaintenanceActive } from './share-cutover.mjs'
|
|
7
9
|
const SHARE_ID_PREFIX = 'qfs_'
|
|
8
10
|
const SHARE_TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
9
11
|
const MAX_SHARE_TOKENS = 50
|
|
@@ -23,6 +25,24 @@ function emitConversationShareInvalidated(shareId, reason) {
|
|
|
23
25
|
shareLifecycleEvents.emit('invalidated', { shareId, reason })
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
// F10 Phase 2: share writes are SQLite-authoritative once the share storage is
|
|
29
|
+
// pending/authoritative. The JSON file degrades to a best-effort mirror fed by
|
|
30
|
+
// the share mirror queue; json_authoritative/cutover_running keep the legacy
|
|
31
|
+
// JSON read/write path below.
|
|
32
|
+
function repositoryActive() {
|
|
33
|
+
return isShareStorageAuthoritative()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertShareWritesAllowed() {
|
|
37
|
+
if (!repositoryActive()) return
|
|
38
|
+
if (isShareMaintenanceActive()) {
|
|
39
|
+
const error = new Error('Share storage is under maintenance')
|
|
40
|
+
error.statusCode = 423
|
|
41
|
+
error.errorCode = 'SHARE_MAINTENANCE_ACTIVE'
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
26
46
|
function clearShareTokens(record) {
|
|
27
47
|
record.authVersion = (record.authVersion || 1) + 1
|
|
28
48
|
record.tokens = []
|
|
@@ -137,7 +157,7 @@ export function createShareToken(shareId) {
|
|
|
137
157
|
}
|
|
138
158
|
}
|
|
139
159
|
|
|
140
|
-
function
|
|
160
|
+
function pruneTokenRecords(tokens, now = Date.now()) {
|
|
141
161
|
return (Array.isArray(tokens) ? tokens : [])
|
|
142
162
|
.filter((tokenRecord) => {
|
|
143
163
|
if (!tokenRecord?.tokenHash) return false
|
|
@@ -149,11 +169,12 @@ function pruneShareTokens(tokens, now = Date.now()) {
|
|
|
149
169
|
|
|
150
170
|
export function verifyShareToken(record, token) {
|
|
151
171
|
if (!record || !token || typeof token !== 'string') return false
|
|
172
|
+
if (repositoryActive()) return getShareRepository().verifyToken(record, token)
|
|
152
173
|
const [tokenShareId, secret] = token.split('.')
|
|
153
174
|
if (tokenShareId !== record.id || !secret) return false
|
|
154
175
|
const actualHash = sha256Base64Url(secret)
|
|
155
176
|
const authVersion = record.authVersion || 1
|
|
156
|
-
const tokenRecords =
|
|
177
|
+
const tokenRecords = pruneTokenRecords(record.tokens)
|
|
157
178
|
|
|
158
179
|
if (record.tokenHash) {
|
|
159
180
|
tokenRecords.push({ tokenHash: record.tokenHash, authVersion: record.authVersion || 1 })
|
|
@@ -238,6 +259,59 @@ export async function createConversationShare({
|
|
|
238
259
|
const normalizedPassword = passwordProvided ? password.trim() : undefined
|
|
239
260
|
const passwordInfo = passwordProvided ? await hashSharePassword(normalizedPassword) : {}
|
|
240
261
|
return enqueueWrite(writeQueueName, async () => {
|
|
262
|
+
assertShareWritesAllowed()
|
|
263
|
+
if (repositoryActive()) {
|
|
264
|
+
const repository = getShareRepository()
|
|
265
|
+
const existingShares = repository.list({ sessionId, includeRevoked: true })
|
|
266
|
+
const current = existingShares.find((record) => !record.supersededAt)
|
|
267
|
+
|
|
268
|
+
if (permission === 'operate') {
|
|
269
|
+
const willHavePassword = passwordProvided ? Boolean(normalizedPassword) : Boolean(current?.passwordHash)
|
|
270
|
+
if (!willHavePassword) {
|
|
271
|
+
const error = new Error('Editable shares require a non-empty password')
|
|
272
|
+
error.statusCode = 400
|
|
273
|
+
throw error
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const timestamp = new Date().toISOString()
|
|
278
|
+
// Preserve the existing password when the client did not send a new one
|
|
279
|
+
// (matching the legacy JSON create), and keep access counters intact.
|
|
280
|
+
const existingPassword = current?.passwordHash
|
|
281
|
+
? { passwordHash: current.passwordHash, passwordSalt: current.passwordSalt, passwordVersion: current.passwordVersion }
|
|
282
|
+
: {}
|
|
283
|
+
const created = repository.create({
|
|
284
|
+
id: current?.id || generateShareId(),
|
|
285
|
+
sessionId,
|
|
286
|
+
permission,
|
|
287
|
+
titleSnapshot: titleSnapshot || current?.titleSnapshot || 'New chat',
|
|
288
|
+
scope: scope === 'project' ? 'project' : 'global',
|
|
289
|
+
projectId: scope === 'project' ? projectId : undefined,
|
|
290
|
+
allowCloudUsage: permission === 'operate' && allowCloudUsage === true,
|
|
291
|
+
expiresAt: expiresAt || undefined,
|
|
292
|
+
createdAt: current?.createdAt || timestamp,
|
|
293
|
+
updatedAt: timestamp,
|
|
294
|
+
accessCount: current?.accessCount ?? 0,
|
|
295
|
+
lastAccessedAt: current?.lastAccessedAt,
|
|
296
|
+
createdFromHost,
|
|
297
|
+
lastUpdatedFromHost: createdFromHost,
|
|
298
|
+
...(passwordProvided ? passwordInfo : existingPassword),
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
for (const record of existingShares) {
|
|
302
|
+
if (record.id !== current?.id && !record.supersededAt) emitConversationShareInvalidated(record.id, 'superseded')
|
|
303
|
+
}
|
|
304
|
+
if (current) {
|
|
305
|
+
const lifecycleChanged = Boolean(current.revokedAt)
|
|
306
|
+
|| String(current.expiresAt || '') !== String(created.expiresAt || '')
|
|
307
|
+
|| current.allowCloudUsage === true !== (created.allowCloudUsage === true)
|
|
308
|
+
|| (passwordProvided && (current.authVersion || 1) !== (created.authVersion || 1))
|
|
309
|
+
if (lifecycleChanged) emitConversationShareInvalidated(current.id, 'updated')
|
|
310
|
+
}
|
|
311
|
+
requestShareJsonMirrorDrain()
|
|
312
|
+
return publicShareRecord(created)
|
|
313
|
+
}
|
|
314
|
+
|
|
241
315
|
const data = await readShareStoreFile()
|
|
242
316
|
const now = new Date().toISOString()
|
|
243
317
|
const existingRecords = Object.values(data)
|
|
@@ -336,11 +410,16 @@ export async function createConversationShare({
|
|
|
336
410
|
|
|
337
411
|
export async function readConversationShare(shareId) {
|
|
338
412
|
assertSafeShareId(shareId)
|
|
413
|
+
if (repositoryActive()) return getShareRepository().get(shareId)
|
|
339
414
|
const data = await readShareStoreFile()
|
|
340
415
|
return data[shareId] || null
|
|
341
416
|
}
|
|
342
417
|
|
|
343
418
|
export async function listConversationShares(sessionId) {
|
|
419
|
+
if (repositoryActive()) {
|
|
420
|
+
const records = getShareRepository().list({ sessionId: sessionId || undefined, includeRevoked: true })
|
|
421
|
+
return records.map(publicShareRecord)
|
|
422
|
+
}
|
|
344
423
|
const data = await readShareStoreFile()
|
|
345
424
|
return Object.values(data)
|
|
346
425
|
.filter((record) => !sessionId || record.sessionId === sessionId)
|
|
@@ -352,6 +431,21 @@ export async function listConversationShares(sessionId) {
|
|
|
352
431
|
export async function revokeConversationShare(shareId) {
|
|
353
432
|
assertSafeShareId(shareId)
|
|
354
433
|
return enqueueWrite(writeQueueName, async () => {
|
|
434
|
+
assertShareWritesAllowed()
|
|
435
|
+
if (repositoryActive()) {
|
|
436
|
+
const repository = getShareRepository()
|
|
437
|
+
const record = repository.get(shareId)
|
|
438
|
+
if (!record) {
|
|
439
|
+
const error = new Error('Share not found')
|
|
440
|
+
error.statusCode = 404
|
|
441
|
+
throw error
|
|
442
|
+
}
|
|
443
|
+
const wasActive = !record.revokedAt
|
|
444
|
+
const revoked = repository.revoke(shareId, {})
|
|
445
|
+
requestShareJsonMirrorDrain()
|
|
446
|
+
if (wasActive) emitConversationShareInvalidated(shareId, 'revoked')
|
|
447
|
+
return publicShareRecord(revoked)
|
|
448
|
+
}
|
|
355
449
|
const data = await readShareStoreFile()
|
|
356
450
|
const record = data[shareId]
|
|
357
451
|
if (!record) {
|
|
@@ -378,6 +472,12 @@ export async function restoreConversationShare(shareId, expiresAt) {
|
|
|
378
472
|
throw error
|
|
379
473
|
}
|
|
380
474
|
return enqueueWrite(writeQueueName, async () => {
|
|
475
|
+
assertShareWritesAllowed()
|
|
476
|
+
if (repositoryActive()) {
|
|
477
|
+
const restored = getShareRepository().restore(shareId, { expiresAt })
|
|
478
|
+
requestShareJsonMirrorDrain()
|
|
479
|
+
return publicShareRecord(restored)
|
|
480
|
+
}
|
|
381
481
|
const data = await readShareStoreFile()
|
|
382
482
|
const record = data[shareId]
|
|
383
483
|
if (!record) {
|
|
@@ -408,6 +508,13 @@ export async function updateConversationShareExpiration(shareId, expiresAt) {
|
|
|
408
508
|
throw error
|
|
409
509
|
}
|
|
410
510
|
return enqueueWrite(writeQueueName, async () => {
|
|
511
|
+
assertShareWritesAllowed()
|
|
512
|
+
if (repositoryActive()) {
|
|
513
|
+
const updated = getShareRepository().update(shareId, { expiresAt: expiresAt || null })
|
|
514
|
+
requestShareJsonMirrorDrain()
|
|
515
|
+
emitConversationShareInvalidated(shareId, 'updated')
|
|
516
|
+
return publicShareRecord(updated)
|
|
517
|
+
}
|
|
411
518
|
const data = await readShareStoreFile()
|
|
412
519
|
const record = data[shareId]
|
|
413
520
|
if (!record) {
|
|
@@ -441,6 +548,29 @@ export async function updateConversationShare(shareId, { permission, password, e
|
|
|
441
548
|
}
|
|
442
549
|
const passwordInfo = passwordProvided ? await hashSharePassword(normalizedPassword) : {}
|
|
443
550
|
return enqueueWrite(writeQueueName, async () => {
|
|
551
|
+
assertShareWritesAllowed()
|
|
552
|
+
if (repositoryActive()) {
|
|
553
|
+
const repository = getShareRepository()
|
|
554
|
+
const existing = repository.get(shareId)
|
|
555
|
+
if (!existing) {
|
|
556
|
+
const error = new Error('Share not found')
|
|
557
|
+
error.statusCode = 404
|
|
558
|
+
throw error
|
|
559
|
+
}
|
|
560
|
+
const updated = repository.update(shareId, {
|
|
561
|
+
permission,
|
|
562
|
+
allowCloudUsage,
|
|
563
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
564
|
+
...passwordInfo,
|
|
565
|
+
})
|
|
566
|
+
requestShareJsonMirrorDrain()
|
|
567
|
+
const lifecycleChanged = (permission !== undefined && permission !== existing.permission)
|
|
568
|
+
|| (expiresAt !== undefined && String(existing.expiresAt || '') !== String(expiresAt || ''))
|
|
569
|
+
|| (allowCloudUsage !== undefined && (existing.allowCloudUsage === true) !== (allowCloudUsage === true))
|
|
570
|
+
|| passwordProvided
|
|
571
|
+
if (lifecycleChanged) emitConversationShareInvalidated(shareId, 'updated')
|
|
572
|
+
return publicShareRecord(updated)
|
|
573
|
+
}
|
|
444
574
|
const data = await readShareStoreFile()
|
|
445
575
|
const record = data[shareId]
|
|
446
576
|
if (!record) {
|
|
@@ -488,6 +618,20 @@ export async function updateConversationShare(shareId, { permission, password, e
|
|
|
488
618
|
export async function deleteConversationShare(shareId) {
|
|
489
619
|
assertSafeShareId(shareId)
|
|
490
620
|
return enqueueWrite(writeQueueName, async () => {
|
|
621
|
+
assertShareWritesAllowed()
|
|
622
|
+
if (repositoryActive()) {
|
|
623
|
+
const repository = getShareRepository()
|
|
624
|
+
const record = repository.get(shareId)
|
|
625
|
+
if (!record) {
|
|
626
|
+
const error = new Error('Share not found')
|
|
627
|
+
error.statusCode = 404
|
|
628
|
+
throw error
|
|
629
|
+
}
|
|
630
|
+
repository.delete(shareId, {})
|
|
631
|
+
requestShareJsonMirrorDrain()
|
|
632
|
+
emitConversationShareInvalidated(shareId, 'deleted')
|
|
633
|
+
return publicShareRecord(record)
|
|
634
|
+
}
|
|
491
635
|
const data = await readShareStoreFile()
|
|
492
636
|
const record = data[shareId]
|
|
493
637
|
if (!record) {
|
|
@@ -505,13 +649,19 @@ export async function deleteConversationShare(shareId) {
|
|
|
505
649
|
export async function issueConversationShareToken(shareId) {
|
|
506
650
|
assertSafeShareId(shareId)
|
|
507
651
|
return enqueueWrite(writeQueueName, async () => {
|
|
652
|
+
assertShareWritesAllowed()
|
|
653
|
+
if (repositoryActive()) {
|
|
654
|
+
const result = getShareRepository().issueToken(shareId, {})
|
|
655
|
+
requestShareJsonMirrorDrain()
|
|
656
|
+
return { token: result.token, share: publicShareRecord(result.share) }
|
|
657
|
+
}
|
|
508
658
|
const data = await readShareStoreFile()
|
|
509
659
|
const record = data[shareId]
|
|
510
660
|
assertShareActive(record)
|
|
511
661
|
const { token, tokenHash } = createShareToken(shareId)
|
|
512
662
|
const issuedAt = new Date().toISOString()
|
|
513
663
|
const expiresAt = new Date(Date.now() + SHARE_TOKEN_MAX_AGE_MS).toISOString()
|
|
514
|
-
record.tokens =
|
|
664
|
+
record.tokens = pruneTokenRecords(record.tokens)
|
|
515
665
|
record.tokens.push({ tokenHash, issuedAt, expiresAt, authVersion: record.authVersion || 1 })
|
|
516
666
|
record.tokens = record.tokens.slice(-MAX_SHARE_TOKENS)
|
|
517
667
|
record.tokenHash = undefined
|
|
@@ -525,6 +675,35 @@ export async function issueConversationShareToken(shareId) {
|
|
|
525
675
|
})
|
|
526
676
|
}
|
|
527
677
|
|
|
678
|
+
// Prune expired share tokens. Authoritative: a repository transaction that
|
|
679
|
+
// deletes expired rows and bumps the share revision/mirror when anything was
|
|
680
|
+
// removed. JSON: best-effort in-place prune of the legacy store.
|
|
681
|
+
export async function pruneShareTokens(shareId) {
|
|
682
|
+
assertSafeShareId(shareId)
|
|
683
|
+
if (repositoryActive()) {
|
|
684
|
+
return enqueueWrite(writeQueueName, async () => {
|
|
685
|
+
assertShareWritesAllowed()
|
|
686
|
+
const removed = getShareRepository().pruneTokens(shareId, {})
|
|
687
|
+
requestShareJsonMirrorDrain()
|
|
688
|
+
return removed
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
return enqueueWrite(writeQueueName, async () => {
|
|
692
|
+
const data = await readShareStoreFile()
|
|
693
|
+
const record = data[shareId]
|
|
694
|
+
if (!record) return 0
|
|
695
|
+
const pruned = pruneTokenRecords(record.tokens)
|
|
696
|
+
const removed = (Array.isArray(record.tokens) ? record.tokens.length : 0) - pruned.length
|
|
697
|
+
if (removed > 0) {
|
|
698
|
+
record.tokens = pruned
|
|
699
|
+
record.updatedAt = new Date().toISOString()
|
|
700
|
+
data[shareId] = record
|
|
701
|
+
await writeShareStoreFile(data)
|
|
702
|
+
}
|
|
703
|
+
return removed
|
|
704
|
+
})
|
|
705
|
+
}
|
|
706
|
+
|
|
528
707
|
function messageTimestampMs(message) {
|
|
529
708
|
const timestamp = message?.timestamp
|
|
530
709
|
if (typeof timestamp === 'number' && Number.isFinite(timestamp)) return timestamp
|
|
@@ -549,7 +728,7 @@ function lastModifiedFromMessages(messages, fallback) {
|
|
|
549
728
|
}
|
|
550
729
|
|
|
551
730
|
export async function rollbackSharedSessionMessages(record, rollbackMessageIndex) {
|
|
552
|
-
const { readSessionValue,
|
|
731
|
+
const { readSessionValue, atomicSessionRecordUpdate } = await import('./storage.mjs')
|
|
553
732
|
const { rollbackSessionMessages, rollbackStartIndexFromMessage } = await import('./agent-manager.mjs')
|
|
554
733
|
const session = await readSessionValue(record.sessionId)
|
|
555
734
|
if (!session) {
|
|
@@ -573,23 +752,21 @@ export async function rollbackSharedSessionMessages(record, rollbackMessageIndex
|
|
|
573
752
|
|
|
574
753
|
const nextMessages = messages.slice(0, rollbackIndex)
|
|
575
754
|
const lastModified = lastModifiedFromMessages(nextMessages, session.createdAt || session.lastModified)
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
metadata[record.sessionId] = {
|
|
585
|
-
...existing,
|
|
586
|
-
messageCount: nextMessages.length,
|
|
587
|
-
lastModified,
|
|
588
|
-
preview: previewFromMessages(nextMessages),
|
|
589
|
-
}
|
|
755
|
+
// Body + metadata roll back as one atomic session record update (single
|
|
756
|
+
// SQLite transaction when authoritative), so the shared page never observes
|
|
757
|
+
// a body rolled back while metadata still counts the removed messages.
|
|
758
|
+
const updated = await atomicSessionRecordUpdate(record.sessionId, ({ state, metadata }) => {
|
|
759
|
+
if (state.archivedAt) return null
|
|
760
|
+
return {
|
|
761
|
+
state: { ...state, messages: nextMessages, lastModified },
|
|
762
|
+
metadata: { ...metadata, messageCount: nextMessages.length, lastModified, preview: previewFromMessages(nextMessages) },
|
|
590
763
|
}
|
|
591
|
-
return metadata
|
|
592
764
|
})
|
|
765
|
+
if (!updated) {
|
|
766
|
+
const error = new Error('Session not found')
|
|
767
|
+
error.statusCode = 404
|
|
768
|
+
throw error
|
|
769
|
+
}
|
|
593
770
|
return { session: { ...session, messages: nextMessages, lastModified }, rollbackIndex }
|
|
594
771
|
}
|
|
595
772
|
|
package/server/skills.mjs
CHANGED
|
@@ -256,7 +256,6 @@ function skillFromStandardMarkdown(rootDir, source, text) {
|
|
|
256
256
|
const name = normalizeSkillName(rawManifest.name)
|
|
257
257
|
const description = normalizeString(rawManifest.description)
|
|
258
258
|
if (!name || !isValidSkillName(name)) return null
|
|
259
|
-
if (name !== normalizeSkillName(path.basename(rootDir))) return null
|
|
260
259
|
if (!description || description.length > 1024) return null
|
|
261
260
|
|
|
262
261
|
const metadata = normalizeMetadata(rawManifest.metadata)
|