@shawnstack/quickforge 1.7.7 → 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.
Files changed (83) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/cloud/auto-approval.mjs +155 -0
  29. package/server/cloud/client.mjs +5 -0
  30. package/server/cloud/qf-agent-process.mjs +163 -3
  31. package/server/cloud/service-config.mjs +1 -1
  32. package/server/index.mjs +69 -13
  33. package/server/lan-access-backup.mjs +256 -0
  34. package/server/lan-access-cutover.mjs +306 -0
  35. package/server/lan-access-json-file.mjs +75 -0
  36. package/server/lan-access-service.mjs +155 -0
  37. package/server/lan-access-store.mjs +110 -9
  38. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  39. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  40. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  41. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  42. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  43. package/server/maintenance/export-session-state-v1.mjs +67 -0
  44. package/server/maintenance/export-share-v1.mjs +77 -0
  45. package/server/routes/agent.mjs +37 -4
  46. package/server/routes/backup.mjs +221 -39
  47. package/server/routes/cloud.mjs +28 -1
  48. package/server/routes/scheduled-tasks.mjs +347 -134
  49. package/server/routes/shares.mjs +4 -2
  50. package/server/routes/storage.mjs +138 -8
  51. package/server/routes/workspace.mjs +265 -4
  52. package/server/scheduled-runs-backup.mjs +235 -0
  53. package/server/scheduled-runs-cutover.mjs +497 -0
  54. package/server/scheduled-task-runs-service.mjs +279 -0
  55. package/server/session-index-service.mjs +495 -0
  56. package/server/session-state-backup.mjs +277 -0
  57. package/server/session-state-cutover.mjs +370 -0
  58. package/server/session-state-service.mjs +709 -0
  59. package/server/share-backup.mjs +255 -0
  60. package/server/share-cutover.mjs +296 -0
  61. package/server/share-json-file.mjs +60 -0
  62. package/server/share-service.mjs +163 -0
  63. package/server/share-store.mjs +196 -19
  64. package/server/skills.mjs +0 -1
  65. package/server/sqlite/database.mjs +233 -0
  66. package/server/sqlite/lan-access-repository.mjs +652 -0
  67. package/server/sqlite/migrations.mjs +577 -0
  68. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  69. package/server/sqlite/session-index-repository.mjs +297 -0
  70. package/server/sqlite/session-state-repository.mjs +786 -0
  71. package/server/sqlite/share-repository.mjs +854 -0
  72. package/server/storage.mjs +317 -1
  73. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  74. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  75. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  76. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  77. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  78. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  79. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  80. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  81. package/dist/assets/index-BTPzyq7d.js +0 -65
  82. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  83. /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, writeSessionValue, deleteSessionValue, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
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 direction = url.searchParams.get('direction') === 'desc' ? 'desc' : 'asc'
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
- await writeSessionValue(key, body?.value)
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 deleteSessionValue(key)
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
- async function readWorkspaceTextFile(context, relativePath) {
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
- const buffer = await fs.readFile(file)
719
- return { content: buffer.toString('utf8'), size: stat.size, path: toWorkspaceRelative(file, context) }
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
- 'cache-control': 'no-store',
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