@shawnstack/quickforge 1.7.7 → 1.7.9
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/bin/quickforge.mjs +10 -1
- 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-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
- 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-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
- 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-GY7sdfIr.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-C1fJ1Jl7.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/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- 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/public-api.mjs +21 -2
- 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-BfA_sQ3A.js +0 -1
- package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
- package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
- package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
- package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
- package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
- package/dist/assets/index-BTPzyq7d.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
|
@@ -1,12 +1,82 @@
|
|
|
1
1
|
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
2
|
-
import { readStore, writeStore, atomicUpdate, getComparable, getStoreRevision, readSessionStoreScoped, readSessionValue,
|
|
2
|
+
import { readStore, writeStore, atomicUpdate, getComparable, getStoreRevision, readSessionStoreScoped, readSessionValue, writeSessionValueWithMetadata, deleteSessionWithMetadata, applySessionBatch, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
|
|
3
3
|
import { AUTO_ARCHIVE_SETTINGS_KEY, archiveInactiveSessions, normalizeAutoArchiveSettings } from '../auto-archive.mjs'
|
|
4
4
|
import { directorySize } from '../utils/workspace.mjs'
|
|
5
5
|
import { isAuthenticatedAppClient } from '../access-policy.mjs'
|
|
6
|
+
import { getSessionIndexDiagnostics, markSessionIndexQueryFailure, querySessionIndexPage } from '../session-index-service.mjs'
|
|
7
|
+
import { isSessionStateMaintenanceActive } from '../session-state-cutover.mjs'
|
|
6
8
|
|
|
7
9
|
const metadataIndexCache = new Map()
|
|
8
10
|
const MAX_METADATA_INDEX_CACHE_ENTRIES = 50
|
|
9
11
|
const METADATA_INDEX_CACHE_TTL_MS = 1000
|
|
12
|
+
const SESSION_QUERY_INDEXES = new Set(['createdAt', 'lastModified', 'pinnedAt'])
|
|
13
|
+
const shadowGenerationByShape = new Map()
|
|
14
|
+
let shadowSampler = () => Math.random() < 0.01
|
|
15
|
+
|
|
16
|
+
export function configureSessionIndexQueryShadow({ sample } = {}) {
|
|
17
|
+
if (sample !== undefined && typeof sample !== 'function') throw new TypeError('sample must be a function')
|
|
18
|
+
shadowSampler = sample ?? (() => Math.random() < 0.01)
|
|
19
|
+
shadowGenerationByShape.clear()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function canonicalJson(value) {
|
|
23
|
+
if (Array.isArray(value)) return value.map(canonicalJson)
|
|
24
|
+
if (!value || typeof value !== 'object') return value
|
|
25
|
+
return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalJson(value[key])]))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sameShadowPage(sqlPage, jsonValues, limit, offset) {
|
|
29
|
+
const expected = jsonValues.slice(offset, offset + limit)
|
|
30
|
+
if (sqlPage.total !== jsonValues.length || sqlPage.values.length !== expected.length) return false
|
|
31
|
+
return JSON.stringify(sqlPage.values.map(canonicalJson)) === JSON.stringify(expected.map(canonicalJson))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseSqlPagination(limitParam, offsetParam) {
|
|
35
|
+
if (!limitParam || !/^[1-9]\d*$/.test(limitParam)) return null
|
|
36
|
+
if (offsetParam !== null && !/^(?:0|[1-9]\d*)$/.test(offsetParam)) return null
|
|
37
|
+
const limit = Number(limitParam)
|
|
38
|
+
const offset = offsetParam === null ? 0 : Number(offsetParam)
|
|
39
|
+
if (!Number.isSafeInteger(limit) || !Number.isSafeInteger(offset)) return null
|
|
40
|
+
return { limit, offset }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sqlSessionQueryOptions({ store, indexName, directionParam, scope, projectId, archived, pinned, limitParam, offsetParam }) {
|
|
44
|
+
if (store !== 'sessions-metadata' || !SESSION_QUERY_INDEXES.has(indexName)) return null
|
|
45
|
+
if (directionParam !== null && directionParam !== 'asc' && directionParam !== 'desc') return null
|
|
46
|
+
if (archived !== null && archived !== 'only' && archived !== 'include') return null
|
|
47
|
+
if (pinned !== null && pinned !== 'only') return null
|
|
48
|
+
const pagination = parseSqlPagination(limitParam, offsetParam)
|
|
49
|
+
if (!pagination) return null
|
|
50
|
+
|
|
51
|
+
let scopeMode = 'all'
|
|
52
|
+
if (scope === 'global' && !projectId) scopeMode = 'global'
|
|
53
|
+
else if (scope === 'project' && typeof projectId === 'string' && projectId.length > 0) scopeMode = 'project'
|
|
54
|
+
else if ((scope === 'projects' || scope === 'project-all') && !projectId) scopeMode = 'projects'
|
|
55
|
+
else if (scope !== null || projectId !== null) return null
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
...pagination,
|
|
59
|
+
scopeMode,
|
|
60
|
+
projectId: scopeMode === 'project' ? projectId : null,
|
|
61
|
+
archive: archived === 'only' ? 'only' : archived === 'include' ? 'include' : 'exclude',
|
|
62
|
+
pinnedOnly: pinned === 'only',
|
|
63
|
+
sort: indexName,
|
|
64
|
+
direction: directionParam === 'desc' ? 'desc' : 'asc',
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shadowShape(options) {
|
|
69
|
+
return JSON.stringify({
|
|
70
|
+
scopeMode: options.scopeMode,
|
|
71
|
+
hasProjectId: options.scopeMode === 'project',
|
|
72
|
+
archive: options.archive,
|
|
73
|
+
pinnedOnly: options.pinnedOnly,
|
|
74
|
+
sort: options.sort,
|
|
75
|
+
direction: options.direction,
|
|
76
|
+
limit: options.limit,
|
|
77
|
+
offset: options.offset,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
10
80
|
|
|
11
81
|
function metadataIndexCacheKey({ scope, projectId, indexName, direction, archived, pinned }) {
|
|
12
82
|
return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction, archived: archived || '', pinned: pinned || '' })
|
|
@@ -125,6 +195,41 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
|
|
|
125
195
|
|
|
126
196
|
const store = decodeSegment(parts[2])
|
|
127
197
|
|
|
198
|
+
// Session batch transaction endpoint: one request commits a set/delete mix
|
|
199
|
+
// across `sessions` and `sessions-metadata` as a single SQLite transaction
|
|
200
|
+
// (authoritative). expectedStateVersion is optional per operation.
|
|
201
|
+
if (req.method === 'POST' && parts.length === 3 && store === 'batch') {
|
|
202
|
+
if (isSessionStateMaintenanceActive()) {
|
|
203
|
+
const error = new Error('Session storage maintenance is in progress')
|
|
204
|
+
error.statusCode = 423
|
|
205
|
+
error.errorCode = 'SESSION_STORAGE_MAINTENANCE'
|
|
206
|
+
throw error
|
|
207
|
+
}
|
|
208
|
+
const body = await readJsonBody(req)
|
|
209
|
+
const operations = body?.operations
|
|
210
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
211
|
+
const error = new Error('Session batch operations are required')
|
|
212
|
+
error.statusCode = 400
|
|
213
|
+
throw error
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const result = await applySessionBatch(operations)
|
|
217
|
+
sendJson(res, 200, { ok: true, saved: result?.saved ?? 0, deleted: result?.deleted ?? 0 })
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (['SESSION_STATE_CONFLICT', 'SESSION_STATE_DUPLICATE_ID', 'SESSION_STATE_REQUIRED', 'SESSION_FULL_DELETE_REQUIRED'].includes(error?.errorCode)) {
|
|
220
|
+
error.statusCode = 409
|
|
221
|
+
throw error
|
|
222
|
+
}
|
|
223
|
+
if (error instanceof TypeError) {
|
|
224
|
+
const mapped = new Error(error.message)
|
|
225
|
+
mapped.statusCode = 400
|
|
226
|
+
throw mapped
|
|
227
|
+
}
|
|
228
|
+
throw error
|
|
229
|
+
}
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
128
233
|
if (req.method === 'GET' && parts[3] === 'keys') {
|
|
129
234
|
const prefix = url.searchParams.get('prefix') || ''
|
|
130
235
|
const data = await readStore(store)
|
|
@@ -135,7 +240,8 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
|
|
|
135
240
|
|
|
136
241
|
if (req.method === 'GET' && parts[3] === 'index') {
|
|
137
242
|
const indexName = decodeSegment(parts[4])
|
|
138
|
-
const
|
|
243
|
+
const directionParam = url.searchParams.get('direction')
|
|
244
|
+
const direction = directionParam === 'desc' ? 'desc' : 'asc'
|
|
139
245
|
const scope = url.searchParams.get('scope')
|
|
140
246
|
const projectId = url.searchParams.get('projectId')
|
|
141
247
|
const limitParam = url.searchParams.get('limit')
|
|
@@ -145,6 +251,32 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
|
|
|
145
251
|
|
|
146
252
|
await ensureStorage()
|
|
147
253
|
|
|
254
|
+
const sqlOptions = sqlSessionQueryOptions({
|
|
255
|
+
store, indexName, directionParam, scope, projectId, archived, pinned, limitParam, offsetParam,
|
|
256
|
+
})
|
|
257
|
+
if (sqlOptions) {
|
|
258
|
+
const sqlResult = await querySessionIndexPage(sqlOptions)
|
|
259
|
+
if (sqlResult.ok) {
|
|
260
|
+
const diagnostics = getSessionIndexDiagnostics()
|
|
261
|
+
const shape = shadowShape(sqlOptions)
|
|
262
|
+
const lastGeneration = shadowGenerationByShape.get(shape)
|
|
263
|
+
const forced = lastGeneration !== diagnostics.rebuildGeneration
|
|
264
|
+
if (!forced && !shadowSampler({ shape, generation: diagnostics.rebuildGeneration })) {
|
|
265
|
+
sendJson(res, 200, { values: sqlResult.page.values, total: sqlResult.page.total })
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
const jsonValues = await readIndexedValues(store, indexName, direction, scope, projectId, archived, pinned)
|
|
269
|
+
shadowGenerationByShape.set(shape, diagnostics.rebuildGeneration)
|
|
270
|
+
if (sameShadowPage(sqlResult.page, jsonValues, sqlOptions.limit, sqlOptions.offset)) {
|
|
271
|
+
sendJson(res, 200, { values: sqlResult.page.values, total: sqlResult.page.total })
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
markSessionIndexQueryFailure('shadow_mismatch')
|
|
275
|
+
sendJson(res, 200, { values: jsonValues.slice(sqlOptions.offset, sqlOptions.offset + sqlOptions.limit), total: jsonValues.length })
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
148
280
|
const values = await readIndexedValues(store, indexName, direction, scope, projectId, archived, pinned)
|
|
149
281
|
|
|
150
282
|
const total = values.length
|
|
@@ -194,7 +326,9 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
|
|
|
194
326
|
if (req.method === 'PUT') {
|
|
195
327
|
const body = await readJsonBody(req)
|
|
196
328
|
if (store === 'sessions') {
|
|
197
|
-
|
|
329
|
+
// Single-session PUT writes the body and derives/merges metadata so it
|
|
330
|
+
// never leaves a body orphaned (one SQLite transaction when authoritative).
|
|
331
|
+
await writeSessionValueWithMetadata(key, body?.value)
|
|
198
332
|
sendJson(res, 200, { ok: true })
|
|
199
333
|
return
|
|
200
334
|
}
|
|
@@ -212,11 +346,7 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
|
|
|
212
346
|
|
|
213
347
|
if (req.method === 'DELETE') {
|
|
214
348
|
if (store === 'sessions') {
|
|
215
|
-
await
|
|
216
|
-
await atomicUpdate('sessions-metadata', (data) => {
|
|
217
|
-
delete data[key]
|
|
218
|
-
return data
|
|
219
|
-
})
|
|
349
|
+
await deleteSessionWithMetadata(key)
|
|
220
350
|
sendJson(res, 200, { ok: true })
|
|
221
351
|
return
|
|
222
352
|
}
|
|
@@ -25,6 +25,11 @@ const MAX_GIT_LINE_COUNT_TOTAL_BYTES = 10 * 1024 * 1024
|
|
|
25
25
|
const GIT_LINE_COUNT_CONCURRENCY = 6
|
|
26
26
|
const PREVIEW_ALLOWED_EXTENSIONS = new Set(['.html', '.htm', '.css', '.js', '.mjs', '.json', '.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico', '.txt', '.md'])
|
|
27
27
|
const MAX_TREE_NODES = 50000
|
|
28
|
+
const DEFAULT_CHILDREN_LIMIT = 200
|
|
29
|
+
const MAX_CHILDREN_LIMIT = 500
|
|
30
|
+
const DEFAULT_SEARCH_LIMIT = 100
|
|
31
|
+
const MAX_SEARCH_LIMIT = 200
|
|
32
|
+
const MAX_SEARCH_VISITED_ENTRIES = 50000
|
|
28
33
|
const SKIP_DIRS = new Set(['.git', 'node_modules'])
|
|
29
34
|
|
|
30
35
|
const extensionLanguageMap = new Map([
|
|
@@ -701,7 +706,9 @@ async function readGitFile(workspaceRoot, ref, relativePath) {
|
|
|
701
706
|
return result.code === 0 ? result.stdout.toString('utf8') : ''
|
|
702
707
|
}
|
|
703
708
|
|
|
704
|
-
|
|
709
|
+
// 安全校验 + stat(不读内容):普通读取与 meta 轻量探测共用,保证两条路径
|
|
710
|
+
// 的 400/413/ENOENT 错误语义完全一致。
|
|
711
|
+
async function statWorkspaceTextFile(context, relativePath) {
|
|
705
712
|
const file = resolveWorkspacePath(relativePath, context)
|
|
706
713
|
await assertSafeWorkspacePath(file, context, { allowSensitive: true })
|
|
707
714
|
const stat = await fs.stat(file)
|
|
@@ -715,8 +722,13 @@ async function readWorkspaceTextFile(context, relativePath) {
|
|
|
715
722
|
error.statusCode = 413
|
|
716
723
|
throw error
|
|
717
724
|
}
|
|
718
|
-
|
|
719
|
-
|
|
725
|
+
return { file, size: stat.size, mtimeMs: stat.mtimeMs, path: toWorkspaceRelative(file, context) }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function readWorkspaceTextFile(context, relativePath) {
|
|
729
|
+
const info = await statWorkspaceTextFile(context, relativePath)
|
|
730
|
+
const buffer = await fs.readFile(info.file)
|
|
731
|
+
return { content: buffer.toString('utf8'), size: info.size, mtimeMs: info.mtimeMs, path: info.path }
|
|
720
732
|
}
|
|
721
733
|
|
|
722
734
|
async function buildTreeForDirectory(dir, context, counter, validateWorkspacePath) {
|
|
@@ -765,6 +777,202 @@ async function handleWorkspaceTree(req, res, url) {
|
|
|
765
777
|
sendJson(res, 200, { root: context.project.name, tree })
|
|
766
778
|
}
|
|
767
779
|
|
|
780
|
+
function parseBoundedLimit(rawValue, defaultValue, maxValue) {
|
|
781
|
+
if (rawValue === null || rawValue === '') return defaultValue
|
|
782
|
+
const value = Number(rawValue)
|
|
783
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
784
|
+
const error = new Error('limit must be a positive integer')
|
|
785
|
+
error.statusCode = 400
|
|
786
|
+
throw error
|
|
787
|
+
}
|
|
788
|
+
return Math.min(value, maxValue)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function encodeWorkspaceCursor(offset) {
|
|
792
|
+
return Buffer.from(String(offset), 'utf8').toString('base64url')
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function decodeWorkspaceCursor(rawCursor) {
|
|
796
|
+
if (!rawCursor) return 0
|
|
797
|
+
let decoded
|
|
798
|
+
try {
|
|
799
|
+
decoded = Buffer.from(rawCursor, 'base64url').toString('utf8')
|
|
800
|
+
} catch {
|
|
801
|
+
const error = new Error('cursor is invalid')
|
|
802
|
+
error.statusCode = 400
|
|
803
|
+
throw error
|
|
804
|
+
}
|
|
805
|
+
if (!/^\d+$/.test(decoded)) {
|
|
806
|
+
const error = new Error('cursor is invalid')
|
|
807
|
+
error.statusCode = 400
|
|
808
|
+
throw error
|
|
809
|
+
}
|
|
810
|
+
const value = Number(decoded)
|
|
811
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
812
|
+
const error = new Error('cursor is invalid')
|
|
813
|
+
error.statusCode = 400
|
|
814
|
+
throw error
|
|
815
|
+
}
|
|
816
|
+
return value
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function compareWorkspaceEntries(left, right) {
|
|
820
|
+
if (left.type !== right.type) return left.type === 'directory' ? -1 : 1
|
|
821
|
+
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })
|
|
822
|
+
|| left.name.localeCompare(right.name, undefined, { sensitivity: 'variant' })
|
|
823
|
+
|| (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function workspaceEntryFromDirent(dir, entry, context, validateWorkspacePath) {
|
|
827
|
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) return null
|
|
828
|
+
const fullPath = path.join(dir, entry.name)
|
|
829
|
+
const relativePath = toWorkspaceRelative(fullPath, context)
|
|
830
|
+
if (entry.isDirectory()) return { name: entry.name, path: relativePath, type: 'directory' }
|
|
831
|
+
if (entry.isFile()) return { name: entry.name, path: relativePath, type: 'file' }
|
|
832
|
+
if (!entry.isSymbolicLink()) return null
|
|
833
|
+
|
|
834
|
+
try {
|
|
835
|
+
await validateWorkspacePath(fullPath, { allowSensitive: true })
|
|
836
|
+
const stat = await fs.stat(fullPath)
|
|
837
|
+
if (stat.isDirectory()) {
|
|
838
|
+
if (SKIP_DIRS.has(entry.name)) return null
|
|
839
|
+
return { name: entry.name, path: relativePath, type: 'directory' }
|
|
840
|
+
}
|
|
841
|
+
if (stat.isFile()) return { name: entry.name, path: relativePath, type: 'file' }
|
|
842
|
+
} catch {
|
|
843
|
+
// External, broken, or inaccessible symbolic links are omitted.
|
|
844
|
+
}
|
|
845
|
+
return null
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function readWorkspaceDirectory(context, relativePath) {
|
|
849
|
+
const requestedPath = relativePath?.trim() || '.'
|
|
850
|
+
const directory = resolveWorkspacePath(requestedPath, context)
|
|
851
|
+
const validateWorkspacePath = await createWorkspacePathValidator(context)
|
|
852
|
+
await validateWorkspacePath(directory, { allowSensitive: true })
|
|
853
|
+
const stat = await fs.stat(directory)
|
|
854
|
+
if (!stat.isDirectory()) {
|
|
855
|
+
const error = new Error('Path is not a directory')
|
|
856
|
+
error.statusCode = 400
|
|
857
|
+
throw error
|
|
858
|
+
}
|
|
859
|
+
return { directory, requestedPath: toWorkspaceRelative(directory, context), validateWorkspacePath }
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export async function listWorkspaceChildren(context, relativePath = '.', options = {}) {
|
|
863
|
+
const limit = parseBoundedLimit(options.limit ?? null, DEFAULT_CHILDREN_LIMIT, MAX_CHILDREN_LIMIT)
|
|
864
|
+
const offset = decodeWorkspaceCursor(options.cursor || '')
|
|
865
|
+
const { directory, requestedPath, validateWorkspacePath } = await readWorkspaceDirectory(context, relativePath)
|
|
866
|
+
const dirents = await fs.readdir(directory, { withFileTypes: true })
|
|
867
|
+
const entries = []
|
|
868
|
+
for (const dirent of dirents) {
|
|
869
|
+
const entry = await workspaceEntryFromDirent(directory, dirent, context, validateWorkspacePath)
|
|
870
|
+
if (entry) entries.push(entry)
|
|
871
|
+
}
|
|
872
|
+
entries.sort(compareWorkspaceEntries)
|
|
873
|
+
if (offset > entries.length) {
|
|
874
|
+
const error = new Error('cursor is out of range')
|
|
875
|
+
error.statusCode = 400
|
|
876
|
+
throw error
|
|
877
|
+
}
|
|
878
|
+
const page = entries.slice(offset, offset + limit)
|
|
879
|
+
const nextOffset = offset + page.length
|
|
880
|
+
return {
|
|
881
|
+
root: context.project.name,
|
|
882
|
+
path: requestedPath,
|
|
883
|
+
entries: page,
|
|
884
|
+
nextCursor: nextOffset < entries.length ? encodeWorkspaceCursor(nextOffset) : null,
|
|
885
|
+
truncated: nextOffset < entries.length,
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function handleWorkspaceChildren(req, res, url) {
|
|
890
|
+
const context = await projectContextFromUrl(url)
|
|
891
|
+
sendJson(res, 200, await listWorkspaceChildren(context, url.searchParams.get('path') || '.', {
|
|
892
|
+
limit: url.searchParams.get('limit'),
|
|
893
|
+
cursor: url.searchParams.get('cursor') || '',
|
|
894
|
+
}))
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export async function readValidatedWorkspaceSearchDirectory(directory, validateWorkspacePath, visitedDirectories, io = {}) {
|
|
898
|
+
const realpath = io.realpath || fs.realpath
|
|
899
|
+
const readdir = io.readdir || fs.readdir
|
|
900
|
+
await validateWorkspacePath(directory, { allowSensitive: true })
|
|
901
|
+
const directoryReal = await realpath(directory)
|
|
902
|
+
const directoryKey = process.platform === 'win32' ? directoryReal.toLocaleLowerCase() : directoryReal
|
|
903
|
+
if (visitedDirectories.has(directoryKey)) return null
|
|
904
|
+
visitedDirectories.add(directoryKey)
|
|
905
|
+
const dirents = await readdir(directory, { withFileTypes: true })
|
|
906
|
+
return { directoryReal, dirents }
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export async function searchWorkspace(context, rawQuery, options = {}) {
|
|
910
|
+
const query = typeof rawQuery === 'string' ? rawQuery.trim() : ''
|
|
911
|
+
if (query.length < 2) {
|
|
912
|
+
const error = new Error('query must contain at least 2 characters')
|
|
913
|
+
error.statusCode = 400
|
|
914
|
+
throw error
|
|
915
|
+
}
|
|
916
|
+
const limit = parseBoundedLimit(options.limit ?? null, DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT)
|
|
917
|
+
const validateWorkspacePath = await createWorkspacePathValidator(context)
|
|
918
|
+
await validateWorkspacePath(context.workspaceRoot, { allowSensitive: true })
|
|
919
|
+
const normalizedQuery = query.toLocaleLowerCase()
|
|
920
|
+
const results = []
|
|
921
|
+
const pending = [context.workspaceRoot]
|
|
922
|
+
const visitedDirectories = new Set()
|
|
923
|
+
let visited = 0
|
|
924
|
+
let traversalTruncated = false
|
|
925
|
+
let hasAdditionalMatch = false
|
|
926
|
+
|
|
927
|
+
while (pending.length > 0 && !traversalTruncated && !hasAdditionalMatch) {
|
|
928
|
+
const directory = pending.pop()
|
|
929
|
+
let validated
|
|
930
|
+
try {
|
|
931
|
+
validated = await readValidatedWorkspaceSearchDirectory(directory, validateWorkspacePath, visitedDirectories)
|
|
932
|
+
} catch {
|
|
933
|
+
continue
|
|
934
|
+
}
|
|
935
|
+
if (!validated) continue
|
|
936
|
+
const { dirents } = validated
|
|
937
|
+
dirents.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })
|
|
938
|
+
|| left.name.localeCompare(right.name, undefined, { sensitivity: 'variant' })
|
|
939
|
+
|| (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
|
|
940
|
+
const childDirectories = []
|
|
941
|
+
for (const dirent of dirents) {
|
|
942
|
+
if (visited >= MAX_SEARCH_VISITED_ENTRIES) {
|
|
943
|
+
traversalTruncated = true
|
|
944
|
+
break
|
|
945
|
+
}
|
|
946
|
+
visited += 1
|
|
947
|
+
const entry = await workspaceEntryFromDirent(directory, dirent, context, validateWorkspacePath)
|
|
948
|
+
if (!entry) continue
|
|
949
|
+
if (entry.path.toLocaleLowerCase().includes(normalizedQuery) || entry.name.toLocaleLowerCase().includes(normalizedQuery)) {
|
|
950
|
+
results.push(entry)
|
|
951
|
+
if (results.length > limit) {
|
|
952
|
+
hasAdditionalMatch = true
|
|
953
|
+
break
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (entry.type === 'directory') childDirectories.push(path.join(directory, dirent.name))
|
|
957
|
+
}
|
|
958
|
+
for (let index = childDirectories.length - 1; index >= 0; index -= 1) pending.push(childDirectories[index])
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
results.sort(compareWorkspaceEntries)
|
|
962
|
+
return {
|
|
963
|
+
root: context.project.name,
|
|
964
|
+
query,
|
|
965
|
+
entries: results.slice(0, limit),
|
|
966
|
+
truncated: traversalTruncated || hasAdditionalMatch,
|
|
967
|
+
}}
|
|
968
|
+
|
|
969
|
+
async function handleWorkspaceSearch(req, res, url) {
|
|
970
|
+
const context = await projectContextFromUrl(url)
|
|
971
|
+
sendJson(res, 200, await searchWorkspace(context, url.searchParams.get('query') || '', {
|
|
972
|
+
limit: url.searchParams.get('limit'),
|
|
973
|
+
}))
|
|
974
|
+
}
|
|
975
|
+
|
|
768
976
|
async function handleWorkspaceFile(req, res, url) {
|
|
769
977
|
const context = await projectContextFromUrl(url)
|
|
770
978
|
const relativePath = url.searchParams.get('path') || ''
|
|
@@ -773,6 +981,19 @@ async function handleWorkspaceFile(req, res, url) {
|
|
|
773
981
|
error.statusCode = 400
|
|
774
982
|
throw error
|
|
775
983
|
}
|
|
984
|
+
// meta=1:轻量元信息探测(与 preview 路由的 `__quickforge_check=1` 探测风格一致)。
|
|
985
|
+
// 走同一安全校验与 stat,但绝不读取文件内容,供客户端校验本地文件缓存快照。
|
|
986
|
+
if (url.searchParams.get('meta') === '1') {
|
|
987
|
+
const info = await statWorkspaceTextFile(context, relativePath)
|
|
988
|
+
sendJson(res, 200, {
|
|
989
|
+
path: info.path,
|
|
990
|
+
size: info.size,
|
|
991
|
+
mtimeMs: info.mtimeMs,
|
|
992
|
+
language: languageFromPath(info.path),
|
|
993
|
+
readonly: true,
|
|
994
|
+
})
|
|
995
|
+
return
|
|
996
|
+
}
|
|
776
997
|
const file = await readWorkspaceTextFile(context, relativePath)
|
|
777
998
|
sendJson(res, 200, {
|
|
778
999
|
...file,
|
|
@@ -840,6 +1061,21 @@ export async function inspectWorkspacePreviewFile(context, relativePath) {
|
|
|
840
1061
|
}
|
|
841
1062
|
}
|
|
842
1063
|
|
|
1064
|
+
// ETag 源=stat(零额外 IO):mtimeMs+size 足以标识本地文件版本
|
|
1065
|
+
export function buildPreviewEtag(stat) {
|
|
1066
|
+
return `"${stat.mtimeMs}-${stat.size}"`
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// If-None-Match 支持精确值、`*` 与逗号分隔列表(每项 trim 后比较)
|
|
1070
|
+
function ifNoneMatchSatisfied(headerValue, etag) {
|
|
1071
|
+
return String(headerValue)
|
|
1072
|
+
.split(',')
|
|
1073
|
+
.some((candidate) => {
|
|
1074
|
+
const value = candidate.trim()
|
|
1075
|
+
return value === etag || value === '*'
|
|
1076
|
+
})
|
|
1077
|
+
}
|
|
1078
|
+
|
|
843
1079
|
async function handleWorkspacePreview(req, res, url) {
|
|
844
1080
|
let relativePath = ''
|
|
845
1081
|
try {
|
|
@@ -858,19 +1094,36 @@ async function handleWorkspacePreview(req, res, url) {
|
|
|
858
1094
|
|
|
859
1095
|
const context = await projectContextFromId(projectId)
|
|
860
1096
|
const preview = await inspectWorkspacePreviewFile(context, relativePath)
|
|
1097
|
+
const etag = buildPreviewEtag(preview.stat)
|
|
861
1098
|
if (url.searchParams.get('__quickforge_check') === '1') {
|
|
862
1099
|
sendJson(res, 200, {
|
|
863
1100
|
ok: true,
|
|
864
1101
|
path: relativePath,
|
|
865
1102
|
size: preview.stat.size,
|
|
1103
|
+
mtimeMs: preview.stat.mtimeMs,
|
|
866
1104
|
contentType: preview.contentType,
|
|
867
1105
|
})
|
|
868
1106
|
return
|
|
869
1107
|
}
|
|
870
1108
|
|
|
1109
|
+
const ifNoneMatch = req.headers?.['if-none-match']
|
|
1110
|
+
if (typeof ifNoneMatch === 'string' && ifNoneMatchSatisfied(ifNoneMatch, etag)) {
|
|
1111
|
+
// 304=未变化零传输:命中协商缓存,不读文件体直接结束
|
|
1112
|
+
res.writeHead(304, {
|
|
1113
|
+
etag,
|
|
1114
|
+
'cache-control': 'private, no-cache',
|
|
1115
|
+
'x-content-type-options': 'nosniff',
|
|
1116
|
+
})
|
|
1117
|
+
res.end()
|
|
1118
|
+
return
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// no-cache=每次协商:浏览器每次回源校验,文件变化立即生效
|
|
871
1122
|
res.writeHead(200, {
|
|
872
1123
|
'content-type': preview.contentType,
|
|
873
|
-
'
|
|
1124
|
+
'content-length': String(preview.stat.size),
|
|
1125
|
+
'cache-control': 'private, no-cache',
|
|
1126
|
+
etag,
|
|
874
1127
|
'x-content-type-options': 'nosniff',
|
|
875
1128
|
})
|
|
876
1129
|
const buffer = await fs.readFile(preview.file)
|
|
@@ -1153,6 +1406,14 @@ export async function handleWorkspaceApi(req, res, url, requestContext = {}) {
|
|
|
1153
1406
|
await handleWorkspaceTree(req, res, url)
|
|
1154
1407
|
return
|
|
1155
1408
|
}
|
|
1409
|
+
if (req.method === 'GET' && url.pathname === '/api/workspace/children') {
|
|
1410
|
+
await handleWorkspaceChildren(req, res, url)
|
|
1411
|
+
return
|
|
1412
|
+
}
|
|
1413
|
+
if (req.method === 'GET' && url.pathname === '/api/workspace/search') {
|
|
1414
|
+
await handleWorkspaceSearch(req, res, url)
|
|
1415
|
+
return
|
|
1416
|
+
}
|
|
1156
1417
|
if (req.method === 'GET' && url.pathname === '/api/workspace/file') {
|
|
1157
1418
|
await handleWorkspaceFile(req, res, url)
|
|
1158
1419
|
return
|