@capyclaw/ppt-workbench 0.0.0-stage → 2.4.0

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -3
  3. package/THIRD_PARTY_NOTICES.md +21 -0
  4. package/contract/ppt-contract.json +1529 -0
  5. package/cordis.patch.yml +25 -0
  6. package/core/audit.js +500 -0
  7. package/core/chart.js +199 -0
  8. package/core/color.js +80 -0
  9. package/core/deck-layout.js +195 -0
  10. package/core/formula.js +80 -0
  11. package/core/hfmath/hershey.js +1763 -0
  12. package/core/hfmath/index.js +1176 -0
  13. package/core/hfmath/stretch.js +41 -0
  14. package/core/hfmath/symbols.js +408 -0
  15. package/core/ops/browser.js +28 -0
  16. package/core/ops/chart.js +64 -0
  17. package/core/ops/deck.js +21 -0
  18. package/core/ops/dispatcher.js +2 -0
  19. package/core/ops/element.js +55 -0
  20. package/core/ops/history.js +11 -0
  21. package/core/ops/import.js +95 -0
  22. package/core/ops/index.js +39 -0
  23. package/core/ops/playback.js +9 -0
  24. package/core/ops/recolor.js +33 -0
  25. package/core/ops/slide.js +66 -0
  26. package/core/ops/table.js +85 -0
  27. package/core/ops/text.js +35 -0
  28. package/core/ops/view.js +13 -0
  29. package/core/ops.js +12 -0
  30. package/core/quick.js +52 -0
  31. package/core/shape-library.js +122 -0
  32. package/core/table.js +178 -0
  33. package/core/text.js +200 -0
  34. package/editor/app.js +5366 -0
  35. package/editor/heartbeat-worker.js +116 -0
  36. package/editor/index.html +89 -0
  37. package/editor/ppt-full-app.js +376 -0
  38. package/lib/host.mjs +98 -0
  39. package/package.json +51 -4
  40. package/server/audit.mjs +2 -0
  41. package/server/bridge-client.mjs +174 -0
  42. package/server/constants.mjs +20 -0
  43. package/server/contract-snapshots.mjs +62 -0
  44. package/server/contract.mjs +24 -0
  45. package/server/deck-layout.mjs +2 -0
  46. package/server/http-utils.mjs +2 -0
  47. package/server/infra/http.mjs +113 -0
  48. package/server/infra/runtime/context.mjs +128 -0
  49. package/server/infra/store/blob-store.mjs +109 -0
  50. package/server/infra/tuning.mjs +82 -0
  51. package/server/infra/util/fs-lock.mjs +132 -0
  52. package/server/infra/workspace/manager.mjs +657 -0
  53. package/server/mcp/dispatch.mjs +2 -0
  54. package/server/mcp/handler.mjs +2 -0
  55. package/server/mcp/instructions.mjs +40 -0
  56. package/server/mcp-http.mjs +3 -0
  57. package/server/ppt-shim.mjs +156 -0
  58. package/server/relay.mjs +245 -0
  59. package/server/routes/assets.mjs +2 -0
  60. package/server/routes/bridge.mjs +47 -0
  61. package/server/routes/editor.mjs +9 -0
  62. package/server/routes/import.mjs +258 -0
  63. package/server/routes/mcp.mjs +17 -0
  64. package/server/runtime/recovery.mjs +9 -0
  65. package/server/runtime/watch.mjs +17 -0
  66. package/server/shared/SHARED_MANIFEST.json +47 -0
  67. package/server/shared/asset-service.mjs +217 -0
  68. package/server/shared/editor-host.mjs +125 -0
  69. package/server/shared/kit/editor/lease.mjs +139 -0
  70. package/server/shared/kit/service/api.mjs +45 -0
  71. package/server/shared/kit/service/http.mjs +113 -0
  72. package/server/shared/kit/service/mcp-http.mjs +142 -0
  73. package/server/shared/kit/service/single-instance.mjs +72 -0
  74. package/server/shared/kit/tools/validate.mjs +115 -0
  75. package/server/shared/kit/tuning.mjs +82 -0
  76. package/server/shared/kit/util/fs-lock.mjs +132 -0
  77. package/server/shared/service-host.mjs +511 -0
  78. package/server/shared/state-service.mjs +249 -0
  79. package/server/shared/tool-host.mjs +341 -0
  80. package/server/shared/tuning.mjs +63 -0
  81. package/server/single-instance.mjs +2 -0
  82. package/server/store/backends/file-backend.mjs +13 -0
  83. package/server/store/checkpoints.mjs +28 -0
  84. package/server/store/doc.mjs +52 -0
  85. package/server/store/index.mjs +34 -0
  86. package/server/store/journal.mjs +9 -0
  87. package/server/store/lease.mjs +2 -0
  88. package/server/store/sanitize.mjs +32 -0
  89. package/server/store.mjs +2 -0
  90. package/server/tools/capabilities/apply.mjs +33 -0
  91. package/server/tools/capabilities/audit.mjs +24 -0
  92. package/server/tools/capabilities/chart.mjs +83 -0
  93. package/server/tools/capabilities/compose.mjs +97 -0
  94. package/server/tools/capabilities/contract.mjs +34 -0
  95. package/server/tools/capabilities/deck.mjs +27 -0
  96. package/server/tools/capabilities/element.mjs +67 -0
  97. package/server/tools/capabilities/export.mjs +95 -0
  98. package/server/tools/capabilities/formula.mjs +72 -0
  99. package/server/tools/capabilities/history.mjs +29 -0
  100. package/server/tools/capabilities/image.mjs +152 -0
  101. package/server/tools/capabilities/imagesearch.mjs +152 -0
  102. package/server/tools/capabilities/import.mjs +180 -0
  103. package/server/tools/capabilities/index.mjs +58 -0
  104. package/server/tools/capabilities/line.mjs +58 -0
  105. package/server/tools/capabilities/notes.mjs +33 -0
  106. package/server/tools/capabilities/present.mjs +34 -0
  107. package/server/tools/capabilities/recolor.mjs +60 -0
  108. package/server/tools/capabilities/screenshot.mjs +86 -0
  109. package/server/tools/capabilities/shape.mjs +64 -0
  110. package/server/tools/capabilities/slide.mjs +81 -0
  111. package/server/tools/capabilities/style.mjs +48 -0
  112. package/server/tools/capabilities/table.mjs +127 -0
  113. package/server/tools/capabilities/task.mjs +52 -0
  114. package/server/tools/capabilities/text.mjs +82 -0
  115. package/server/tools/domains/color.mjs +4 -0
  116. package/server/tools/domains/image.mjs +49 -0
  117. package/server/tools/domains/index.mjs +7 -0
  118. package/server/tools/domains/pptx-export.mjs +566 -0
  119. package/server/tools/domains/recolor.mjs +25 -0
  120. package/server/tools/domains/remote-image.mjs +291 -0
  121. package/server/tools/domains/slide.mjs +21 -0
  122. package/server/tools/domains/text-style.mjs +11 -0
  123. package/server/tools/domains/tooling.mjs +13 -0
  124. package/server/tools/index.mjs +14 -0
  125. package/server/tools/kernel/define.mjs +5 -0
  126. package/server/tools/kernel/ports.mjs +42 -0
  127. package/server/tools/kernel/registry.mjs +17 -0
  128. package/server/tools.mjs +18 -0
  129. package/server/tuning.mjs +86 -0
  130. package/server/validate.mjs +2 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Runtime context:插件启动时读取本次 DSH runtime 的端口、目录、namespace。
3
+ *
4
+ * 优先级:
5
+ * 1. 显式 runtimeFile / config.runtimeFile
6
+ * 2. DSH_RUNTIME_FILE
7
+ * 3. DSH_RUNTIME_ID + DSH_STORE_ROOT + DSH_STORE_TOKEN 环境变量
8
+ *
9
+ * 插件不应再读全局插件目录或写死端口;所有持久化内容都走
10
+ * WorkspaceClient,确保只作用于当前 runtime 的 namespace。
11
+ */
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { WorkspaceManager, WorkspaceClient } from '../workspace/manager.mjs'
15
+
16
+ function readJson(file, fallback = null) {
17
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback }
18
+ }
19
+
20
+ export function runtimeFileFromEnv(env = process.env) {
21
+ return env.DSH_RUNTIME_FILE || ''
22
+ }
23
+
24
+ export function loadRuntimeContext({ runtimeFile, env = process.env } = {}) {
25
+ const file = runtimeFile || runtimeFileFromEnv(env)
26
+ if (file && fs.existsSync(file)) {
27
+ const runtime = readJson(file, null)
28
+ if (!runtime) throw new Error(`runtime 文件损坏: ${file}`)
29
+ return normalizeRuntime(runtime, { runtimeFile: path.resolve(file), env })
30
+ }
31
+ if (env.DSH_RUNTIME_ID && env.DSH_STORE_ROOT) {
32
+ return normalizeRuntime({
33
+ runtimeId: env.DSH_RUNTIME_ID,
34
+ storeRoot: env.DSH_STORE_ROOT,
35
+ ports: {
36
+ harness: Number(env.DSH_PROXY_PORT) || 0,
37
+ dsh: Number(env.DSH_UP_PORT) || 0,
38
+ content: Number(env.DSH_CONTENT_PORT) || 0,
39
+ },
40
+ paths: {
41
+ root: env.DSH_RUNTIME_ROOT || '',
42
+ dshHome: env.DSH_HOME || '',
43
+ workspace: env.DSH_WORKSPACE || '',
44
+ logs: env.DSH_LOG_DIR || '',
45
+ tmp: env.DSH_TMP_DIR || '',
46
+ contentState: env.DSH_CONTENT_STATE || '',
47
+ },
48
+ store: {
49
+ root: env.DSH_STORE_ROOT,
50
+ namespace: env.DSH_STORE_NAMESPACE || env.DSH_RUNTIME_ID,
51
+ token: env.DSH_STORE_TOKEN || '',
52
+ },
53
+ workId: env.DSH_WORK_ID || 'main',
54
+ profile: env.DSH_PROFILE || '',
55
+ domain: env.DSH_DOMAIN || '',
56
+ }, { runtimeFile: null, env })
57
+ }
58
+ return null
59
+ }
60
+
61
+ function normalizeRuntime(runtime, { runtimeFile = null, env = process.env } = {}) {
62
+ const store = runtime.store || {}
63
+ const paths = runtime.paths || {}
64
+ const ports = runtime.ports || {}
65
+ return {
66
+ ...runtime,
67
+ runtimeFile,
68
+ runtimeId: runtime.runtimeId || env.DSH_RUNTIME_ID || '',
69
+ storeRoot: runtime.storeRoot || store.root || env.DSH_STORE_ROOT || '',
70
+ store: {
71
+ root: store.root || runtime.storeRoot || env.DSH_STORE_ROOT || '',
72
+ namespace: store.namespace || runtime.runtimeId || env.DSH_RUNTIME_ID || '',
73
+ token: store.token || env.DSH_STORE_TOKEN || '',
74
+ },
75
+ ports: {
76
+ harness: Number(ports.harness) || Number(env.DSH_PROXY_PORT) || 0,
77
+ dsh: Number(ports.dsh) || Number(env.DSH_UP_PORT) || 0,
78
+ content: Number(ports.content) || Number(env.DSH_CONTENT_PORT) || 0,
79
+ ...ports,
80
+ },
81
+ paths: {
82
+ root: paths.root || '',
83
+ dshHome: paths.dshHome || env.DSH_HOME || '',
84
+ workspace: paths.workspace || env.DSH_WORKSPACE || '',
85
+ logs: paths.logs || env.DSH_LOG_DIR || '',
86
+ tmp: paths.tmp || env.DSH_TMP_DIR || '',
87
+ contentState: paths.contentState || env.DSH_CONTENT_STATE || '',
88
+ ...paths,
89
+ },
90
+ workId: runtime.workId || env.DSH_WORK_ID || 'main',
91
+ }
92
+ }
93
+
94
+ export function runtimeContextFromConfig(config = {}, options = {}) {
95
+ if (config && config.runtimeContext && typeof config.runtimeContext === 'object') {
96
+ return normalizeRuntime(config.runtimeContext, options)
97
+ }
98
+ if (config && config.runtimeFile) return loadRuntimeContext({ ...options, runtimeFile: config.runtimeFile })
99
+ return loadRuntimeContext(options)
100
+ }
101
+
102
+ export function createRuntimeStoreClient(context, { workId } = {}) {
103
+ if (!context || !context.store || !context.store.root || !context.store.token) {
104
+ return null
105
+ }
106
+ const store = new WorkspaceManager({ storeRoot: context.store.root })
107
+ return new WorkspaceClient({
108
+ manager: store,
109
+ runtimeId: context.store.namespace || context.runtimeId,
110
+ token: context.store.token,
111
+ workId: workId || context.workId || 'main',
112
+ })
113
+ }
114
+
115
+ export function runtimePaths(context) {
116
+ return (context && context.paths) || {}
117
+ }
118
+
119
+ export function isPathInside(root, target) {
120
+ if (!root || !target) return false
121
+ const rel = path.relative(path.resolve(root), path.resolve(target))
122
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
123
+ }
124
+
125
+ export function assertPathInside(root, target, label = 'path') {
126
+ if (!isPathInside(root, target)) throw new Error(`${label} 越出 runtime 根目录: ${target}`)
127
+ return path.resolve(target)
128
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 纯内容仓库:只负责按 sha256 存取不可变字节。
3
+ *
4
+ * 这里刻意不保存 manifest、不关心哪些文件属于同一个作品、不解释文件名。
5
+ * 文件关系、归属、版本和生命周期全部由 workspace/manager.mjs 的中间层负责。
6
+ */
7
+ import crypto from 'node:crypto'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+
11
+ export class BlobStore {
12
+ constructor({ root }) {
13
+ if (!root) throw new Error('BlobStore 需要 root')
14
+ this.root = path.resolve(root)
15
+ this.objectsRoot = path.join(this.root, 'objects')
16
+ }
17
+
18
+ init() {
19
+ fs.mkdirSync(this.objectsRoot, { recursive: true })
20
+ return this
21
+ }
22
+
23
+ blobPath(hash) {
24
+ if (!/^[a-f0-9]{64}$/.test(String(hash || ''))) throw new Error(`非法对象 hash: ${hash}`)
25
+ return path.join(this.objectsRoot, hash.slice(0, 2), hash)
26
+ }
27
+
28
+ put(data) {
29
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
30
+ const hash = crypto.createHash('sha256').update(buffer).digest('hex')
31
+ const file = this.blobPath(hash)
32
+ if (!fs.existsSync(file)) {
33
+ fs.mkdirSync(path.dirname(file), { recursive: true })
34
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`
35
+ fs.writeFileSync(tmp, buffer)
36
+ try {
37
+ fs.renameSync(tmp, file)
38
+ } catch (error) {
39
+ /* 并发写同一 hash 时目标可能已出现;目标有效就复用,绝不删除目标。 */
40
+ if (fs.existsSync(file)) {
41
+ try { fs.rmSync(tmp, { force: true }) } catch { /* 忽略 */ }
42
+ return { hash, size: buffer.length }
43
+ }
44
+ try { fs.rmSync(tmp, { force: true }) } catch { /* 忽略 */ }
45
+ throw error
46
+ }
47
+ }
48
+ return { hash, size: buffer.length }
49
+ }
50
+
51
+ get(hash) {
52
+ const data = fs.readFileSync(this.blobPath(hash))
53
+ /* 内容寻址仓库的读取必须复核 sha256:截断/篡改对象不能静默使用。 */
54
+ const actual = crypto.createHash('sha256').update(data).digest('hex')
55
+ if (actual !== String(hash)) {
56
+ throw new Error(`BlobStore 对象校验失败: ${hash} (actual ${actual})`)
57
+ }
58
+ return data
59
+ }
60
+
61
+ has(hash) {
62
+ try { return fs.existsSync(this.blobPath(hash)) } catch { return false }
63
+ }
64
+
65
+ delete(hash) {
66
+ const file = this.blobPath(hash)
67
+ if (!fs.existsSync(file)) return false
68
+ fs.rmSync(file, { force: true })
69
+ return true
70
+ }
71
+
72
+ list() {
73
+ const out = []
74
+ let prefixes = []
75
+ try {
76
+ prefixes = fs.readdirSync(this.objectsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory())
77
+ } catch {
78
+ return out
79
+ }
80
+ for (const prefix of prefixes) {
81
+ const dir = path.join(this.objectsRoot, prefix.name)
82
+ for (const name of fs.readdirSync(dir)) {
83
+ if (!/^[a-f0-9]{64}$/.test(name)) continue
84
+ const file = path.join(dir, name)
85
+ out.push({ hash: name, size: fs.statSync(file).size })
86
+ }
87
+ }
88
+ return out
89
+ }
90
+
91
+ /** 仓库只按中间层给的引用集合做物理清理,不自己判断文件关系。 */
92
+ gc(referencedHashes, { minAgeMs = 0 } = {}) {
93
+ const referenced = referencedHashes instanceof Set ? referencedHashes : new Set(referencedHashes || [])
94
+ const cutoff = Date.now() - Math.max(0, Number(minAgeMs) || 0)
95
+ let removed = 0
96
+ let skipped = 0
97
+ for (const { hash, size } of this.list()) {
98
+ if (referenced.has(hash)) continue
99
+ if (minAgeMs > 0) {
100
+ try {
101
+ if (fs.statSync(this.blobPath(hash)).mtimeMs > cutoff) { skipped++; continue }
102
+ } catch { continue }
103
+ }
104
+ this.delete(hash)
105
+ removed++
106
+ }
107
+ return { removed, skipped, referenced: referenced.size }
108
+ }
109
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * dsh-plugin-kit 运行时调优层。
3
+ *
4
+ * 默认值与进化搜索(evolution/ NSGA-II)的 founder 基线保持一致;
5
+ * 生产环境可用 DSH_TUNING_JSON 覆盖(按包名分组),测试/评估用 setTuning 注入。
6
+ *
7
+ * DSH_TUNING_JSON 形如:
8
+ * {"dsh-plugin-kit": {"fileLockPollMs": 25, "journalMaxBytes": 2097152}}
9
+ */
10
+
11
+ const DEFAULTS = {
12
+ /* fs-lock:元数据文件锁
13
+ * pollMs=10 由 NSGA-II 进化搜索固化(evolution/,2026-11-01):
14
+ * 24 路竞争延迟 2202→1400ms;10ms 为延迟与轮询 IO 的膝点。 */
15
+ fileLockSyncTimeoutMs: 10000,
16
+ fileLockSyncStaleMs: 30000,
17
+ fileLockAsyncTimeoutMs: 30000,
18
+ fileLockPollMs: 10,
19
+ fileLockFreshGraceMs: 2000,
20
+
21
+ /* 编辑器租约:wait() 轮询节奏 */
22
+ leaseWaitPollMs: 200,
23
+ leaseWaitTimeoutMs: 4000,
24
+
25
+ /* JournalStore:journal 轮转阈值与尾读默认行数
26
+ * 512KB 由进化搜索固化:尾读 p95 4.3→1.9ms、磁盘 -70%、追加吞吐 +8%。 */
27
+ journalMaxBytes: 512 * 1024,
28
+ journalTailLimit: 8,
29
+
30
+ /* WorkspaceManager catalog 写节流(降低全局 catalog 锁竞争) */
31
+ catalogThrottleMs: 5000,
32
+
33
+ /* WorkspaceClient token 校验缓存 */
34
+ tokenVerifyCacheMs: 30000,
35
+
36
+ /* HTTP 服务三级超时(keepAliveTimeoutMs 必须小于 headersTimeoutMs) */
37
+ requestTimeoutMs: 30000,
38
+ headersTimeoutMs: 10000,
39
+ keepAliveTimeoutMs: 5000,
40
+ }
41
+
42
+ let overrides = {}
43
+ let envLoaded = false
44
+
45
+ function loadEnv() {
46
+ if (envLoaded) return
47
+ envLoaded = true
48
+ try {
49
+ const raw = process.env.DSH_TUNING_JSON
50
+ if (!raw) return
51
+ const parsed = JSON.parse(raw)
52
+ if (!parsed || typeof parsed !== 'object') return
53
+ const own = parsed['dsh-plugin-kit'] && typeof parsed['dsh-plugin-kit'] === 'object'
54
+ ? parsed['dsh-plugin-kit']
55
+ : parsed
56
+ for (const key of Object.keys(DEFAULTS)) {
57
+ if (own[key] !== undefined) overrides[key] = own[key]
58
+ }
59
+ } catch { /* 非法 JSON 静默忽略:调优失败不能阻塞启动 */ }
60
+ }
61
+
62
+ /** 当前生效参数(默认值 + env + setTuning 覆盖)。 */
63
+ export function tuning() {
64
+ loadEnv()
65
+ return { ...DEFAULTS, ...overrides }
66
+ }
67
+
68
+ /** 测试/评估注入。只接受已知键,避免拼写错误静默生效。 */
69
+ export function setTuning(patch = {}) {
70
+ loadEnv()
71
+ for (const [key, value] of Object.entries(patch)) {
72
+ if (key in DEFAULTS) overrides[key] = value
73
+ }
74
+ }
75
+
76
+ /** 恢复到默认值 + env 覆盖。 */
77
+ export function resetTuning() {
78
+ overrides = {}
79
+ envLoaded = false
80
+ }
81
+
82
+ export const TUNING_DEFAULTS = { ...DEFAULTS }
@@ -0,0 +1,132 @@
1
+ /** 同步文件锁:给 registry / workspace manifest 这类元数据用。 */
2
+ import crypto from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { tuning } from '../tuning.mjs'
6
+
7
+ const sleepCell = new Int32Array(new SharedArrayBuffer(4))
8
+
9
+ export function sleepSync(ms) {
10
+ Atomics.wait(sleepCell, 0, 0, ms)
11
+ }
12
+
13
+ function pidDead(pid) {
14
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false
15
+ try { process.kill(pid, 0); return false } catch (error) { return error.code === 'ESRCH' }
16
+ }
17
+
18
+ function newToken() {
19
+ return crypto.randomBytes(12).toString('hex')
20
+ }
21
+
22
+ function tryCreate(lockFile, token) {
23
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true })
24
+ let fd
25
+ try {
26
+ fd = fs.openSync(lockFile, 'wx')
27
+ } catch (error) {
28
+ if (error.code === 'EEXIST') return false
29
+ throw error
30
+ }
31
+ try {
32
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token, ts: Date.now() }))
33
+ try { fs.fsyncSync(fd) } catch { /* 部分文件系统不支持 */ }
34
+ } finally {
35
+ fs.closeSync(fd)
36
+ }
37
+ return true
38
+ }
39
+
40
+ // Only a complete record whose owner is provably dead can be reclaimed. A
41
+ // record-specific claim serializes competing reclaimers, including across
42
+ // successive lock owners. An abandoned claim is intentionally not reclaimed.
43
+ function reclaimDead(lockFile, staleMs) {
44
+ let raw, info, stat
45
+ try {
46
+ raw = fs.readFileSync(lockFile, 'utf8')
47
+ info = JSON.parse(raw)
48
+ stat = fs.statSync(lockFile)
49
+ } catch { return false }
50
+ if (!/^[a-f0-9]{24}$/.test(info?.token || '') || !pidDead(info.pid) || Date.now() - stat.mtimeMs <= staleMs) return false
51
+ const claim = `${lockFile}.reclaim-${crypto.createHash('sha256').update(raw).digest('hex')}`
52
+ let fd
53
+ try { fd = fs.openSync(claim, 'wx') } catch { return false }
54
+ try {
55
+ try {
56
+ if (fs.readFileSync(lockFile, 'utf8') !== raw || !pidDead(info.pid)) return false
57
+ fs.rmSync(lockFile)
58
+ return true
59
+ } catch { return false }
60
+ } finally {
61
+ fs.closeSync(fd)
62
+ try { fs.rmSync(claim, { force: true }) } catch { /* 保留未知状态 */ }
63
+ }
64
+ }
65
+
66
+ function releaseOwned(lockFile, token) {
67
+ try {
68
+ const info = JSON.parse(fs.readFileSync(lockFile, 'utf8'))
69
+ if (info.token === token) fs.rmSync(lockFile, { force: true })
70
+ } catch { /* 已被删除或被接管 */ }
71
+ }
72
+
73
+ function acquireSync(lockFile, { timeoutMs, staleMs }) {
74
+ const token = newToken()
75
+ const deadline = Date.now() + timeoutMs
76
+ const { fileLockPollMs: pollMs } = tuning()
77
+ while (true) {
78
+ if (tryCreate(lockFile, token)) return token
79
+ if (reclaimDead(lockFile, staleMs)) continue
80
+ if (Date.now() >= deadline) throw new Error(`等待文件锁超时: ${lockFile}`)
81
+ sleepSync(Math.min(pollMs, Math.max(1, deadline - Date.now())))
82
+ }
83
+ }
84
+
85
+ async function acquireAsync(lockFile, { timeoutMs, staleMs }) {
86
+ const token = newToken()
87
+ const deadline = Date.now() + timeoutMs
88
+ const { fileLockPollMs: pollMs } = tuning()
89
+ while (true) {
90
+ if (tryCreate(lockFile, token)) return token
91
+ if (reclaimDead(lockFile, staleMs)) continue
92
+ if (Date.now() >= deadline) throw new Error(`等待文件锁超时: ${lockFile}`)
93
+ await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(1, deadline - Date.now()))))
94
+ }
95
+ }
96
+
97
+ export function withFileLock(lockFile, fn, opts = {}) {
98
+ const defaults = tuning()
99
+ const { timeoutMs = defaults.fileLockSyncTimeoutMs, staleMs = defaults.fileLockSyncStaleMs } = opts
100
+ const token = acquireSync(lockFile, { timeoutMs, staleMs })
101
+ try {
102
+ return fn()
103
+ } finally {
104
+ releaseOwned(lockFile, token)
105
+ }
106
+ }
107
+
108
+ /** 异步版本:用于分配端口这类需要 await 探测的场景。 */
109
+ export async function withAsyncFileLock(lockFile, fn, opts = {}) {
110
+ const defaults = tuning()
111
+ const { timeoutMs = defaults.fileLockAsyncTimeoutMs, staleMs = defaults.fileLockSyncStaleMs } = opts
112
+ const token = await acquireAsync(lockFile, { timeoutMs, staleMs })
113
+ try {
114
+ return await fn()
115
+ } finally {
116
+ releaseOwned(lockFile, token)
117
+ }
118
+ }
119
+
120
+ export function writeJsonAtomic(file, value) {
121
+ fs.mkdirSync(path.dirname(file), { recursive: true })
122
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
123
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n')
124
+ try { fs.renameSync(tmp, file) } catch {
125
+ try { fs.rmSync(file, { force: true }) } catch { /* 忽略 */ }
126
+ fs.renameSync(tmp, file)
127
+ }
128
+ }
129
+
130
+ export function readJson(file, fallback = null) {
131
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback }
132
+ }