agentworkshop 0.5.2 → 0.5.4

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.
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * 备份定时插件(S6,production-readiness-plan)。
3
3
  *
4
- * 对 data/ 下三库(workshop/users/daq-timeseries)每日 VACUUM INTO 快照到
4
+ * 对 data/ 下三库(workshop/users/daq-timeseries)每日 serialize 镜像快照到
5
5
  * data/backups/,按 BACKUP_KEEP(默认 7)轮转;调度模式复用 ws.ts 保留期清理的
6
6
  * setInterval + globalThis key 防 HMR 重复 + unref()。dev 与生产均生效
7
7
  * (备份无副作用,始终开启;BACKUP_DISABLED=1 可关)。
8
8
  */
9
9
  import { DatabaseSync } from 'node:sqlite'
10
- import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
10
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
11
11
  import { resolve } from 'node:path'
12
12
  import { ensureDataDir } from '@/shared/config/home.mjs'
13
13
 
@@ -15,7 +15,23 @@ const g = globalThis as typeof globalThis & { __awBackupTimer?: NodeJS.Timeout,
15
15
 
16
16
  const DB_FILES = ['workshop.sqlite', 'users.sqlite', 'daq-timeseries.sqlite'] as const
17
17
 
18
- function backupOnce(dataDir: string): void {
18
+ /**
19
+ * 单库在线快照(hardening ST-2):node:sqlite serialize() 一致性数据库镜像 → 落盘。
20
+ * 纯读操作,不与主写者抢锁(VACUUM INTO 会与写事务竞争 SQLITE_BUSY,已实测);
21
+ * 产物为标准 SQLite 文件,与历史 .bak 结构兼容。失败只记录不抛出。
22
+ */
23
+ async function backupOne(src: string, target: string): Promise<void> {
24
+ const db = new DatabaseSync(src)
25
+ try {
26
+ const image = db.serialize()
27
+ writeFileSync(target, image)
28
+ }
29
+ finally {
30
+ db.close()
31
+ }
32
+ }
33
+
34
+ async function backupOnce(dataDir: string): Promise<void> {
19
35
  const backupDir = resolve(dataDir, 'backups')
20
36
  mkdirSync(backupDir, { recursive: true })
21
37
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
@@ -24,14 +40,8 @@ function backupOnce(dataDir: string): void {
24
40
  if (!existsSync(src)) continue
25
41
  const target = resolve(backupDir, `${file}.${stamp}.bak`)
26
42
  try {
27
- const db = new DatabaseSync(src)
28
- try {
29
- // VACUUM INTO:在线一致性快照(WAL 下安全),不阻塞长事务
30
- db.exec(`VACUUM INTO '${target.replace(/\\/g, '/')}'`)
31
- }
32
- finally {
33
- db.close()
34
- }
43
+ await backupOne(src, target)
44
+ console.log('[backup]', file, '快照完成(serialize)')
35
45
  }
36
46
  catch (err) {
37
47
  console.error(`[backup] ${file} 快照失败:`, err instanceof Error ? err.message : err)
@@ -7,6 +7,9 @@
7
7
  * 依赖异常中止进程,绝不能被护栏吞掉。
8
8
  */
9
9
  const SOCKET_NOISE = /ECONNRESET|EPIPE|ECONNABORTED|ETIMEDOUT|ERR_STREAM_PREMATURE_CLOSE/
10
+ // SQLite 忙/锁(hardening ST-1):多写竞争的瞬态错误,记录并继续;
11
+ // 真正的库损坏/磁盘错误不经此路径,仍走致命语义。
12
+ const SQLITE_BUSY = /SQLITE_BUSY|SQLITE_LOCKED|database is locked/i
10
13
 
11
14
  function isSocketNoise(reason: unknown): boolean {
12
15
  if (reason == null) return false
@@ -22,11 +25,16 @@ function describe(reason: unknown): string {
22
25
  }
23
26
 
24
27
  export default defineNitroPlugin(() => {
28
+ const isTransientDbBusy = (reason: unknown): boolean => SQLITE_BUSY.test(describe(reason))
25
29
  process.on('unhandledRejection', (reason) => {
26
30
  if (isSocketNoise(reason)) {
27
31
  console.warn(`[stability-guard] socket noise suppressed: ${describe(reason).slice(0, 120)}`)
28
32
  return
29
33
  }
34
+ if (isTransientDbBusy(reason)) {
35
+ console.warn(`[stability-guard] sqlite busy(瞬态,不退出): ${describe(reason).slice(0, 120)}`)
36
+ return
37
+ }
30
38
  console.error(`[stability-guard] fatal unhandledRejection, exiting:\n${describe(reason)}`)
31
39
  process.exit(1)
32
40
  })
@@ -35,8 +43,12 @@ export default defineNitroPlugin(() => {
35
43
  console.warn(`[stability-guard] socket noise suppressed: ${describe(err).slice(0, 120)}`)
36
44
  return
37
45
  }
46
+ if (isTransientDbBusy(err)) {
47
+ console.warn(`[stability-guard] sqlite busy(瞬态,不退出): ${describe(err).slice(0, 120)}`)
48
+ return
49
+ }
38
50
  console.error(`[stability-guard] fatal uncaughtException, exiting:\n${describe(err)}`)
39
51
  process.exit(1)
40
52
  })
41
- console.warn('[stability-guard] armed (socket disconnects → log & continue; real errors → exit 1)')
53
+ console.warn('[stability-guard] armed (socket 断连与 sqlite 瞬态忙 → 记录继续;真实错误 → exit 1)')
42
54
  })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 保留期定时插件(hardening GROW-1)。
3
+ *
4
+ * 每 24h 对 workshop.sqlite 的 messages/audit_log/approval_history 做分批保留
5
+ * 清理(策略见 db/retention.ts;RETENTION_DISABLED=1 可整体关闭)。
6
+ * 调度模式同 backup.ts:setInterval + globalThis key 防 HMR 重复 + unref()。
7
+ * 启动 90s 后首跑(避开启动风暴,且错峰于 backup 的 30s 首备)。
8
+ */
9
+ import { DatabaseSync } from 'node:sqlite'
10
+ import { existsSync } from 'node:fs'
11
+ import { resolve } from 'node:path'
12
+ import { runRetentionSweep } from '@/server/services/workshop/db/retention'
13
+ import { ensureDataDir } from '@/shared/config/home.mjs'
14
+
15
+ const g = globalThis as typeof globalThis & { __awRetentionTimer?: NodeJS.Timeout }
16
+
17
+ function sweepOnce(dataDir: string): void {
18
+ const dbPath = resolve(dataDir, 'workshop.sqlite')
19
+ if (!existsSync(dbPath)) return
20
+ const db = new DatabaseSync(dbPath)
21
+ try {
22
+ const results = runRetentionSweep(db)
23
+ for (const r of results) {
24
+ if (r.removed > 0) console.log(`[retention] ${r.table} 清理 ${r.removed} 行(>${r.days}d)`)
25
+ }
26
+ }
27
+ finally {
28
+ db.close()
29
+ }
30
+ }
31
+
32
+ export default function retentionPlugin() {
33
+ if (process.env.RETENTION_DISABLED === '1' || process.env.AW_RETENTION_DISABLED === '1') return
34
+ if (g.__awRetentionTimer) return
35
+ const dataDir = ensureDataDir()
36
+
37
+ const first = setTimeout(() => {
38
+ try {
39
+ sweepOnce(dataDir)
40
+ }
41
+ catch (err) {
42
+ console.error('[retention] 首轮清理失败:', err instanceof Error ? err.message : err)
43
+ }
44
+ }, 90_000)
45
+ first.unref?.()
46
+
47
+ const timer = setInterval(() => {
48
+ try {
49
+ sweepOnce(dataDir)
50
+ }
51
+ catch (err) {
52
+ console.error('[retention] 定时清理失败:', err instanceof Error ? err.message : err)
53
+ }
54
+ }, 24 * 3_600_000)
55
+ timer.unref?.()
56
+ g.__awRetentionTimer = timer
57
+ }
@@ -10,7 +10,7 @@
10
10
  import { createLogger } from '../logger'
11
11
  import { randomUUID } from 'node:crypto'
12
12
  import fs from 'node:fs'
13
- import { join } from 'node:path'
13
+ import { dirname, join } from 'node:path'
14
14
  import { ensureDataDir } from '@/shared/config/home.mjs'
15
15
  import { AppError, ErrorCodes } from '../../../utils/errors'
16
16
 
@@ -148,7 +148,7 @@ export class AgentNodeBindingRepo {
148
148
 
149
149
  private flush(): void {
150
150
  try {
151
- fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
151
+ fs.mkdirSync(dirname(DB_PATH), { recursive: true })
152
152
  fs.writeFileSync(DB_PATH, JSON.stringify(this.list, null, 2), 'utf-8')
153
153
  }
154
154
  catch (err) {
@@ -476,12 +476,14 @@ class DaqController {
476
476
  controllerState(): AepDaqControllerState & { produced?: number, consumed?: number, dropped?: number, samplesStored?: number, tsdbDropped?: number } {
477
477
  // 丢弃 = 队列层真实丢弃(inproc 拥塞/mqtt 断连)+ 消费侧乱序迟到帧(诚实可见)
478
478
  const queueLost = g_queueLost()
479
+ // 单次遍历(hardening PERF-1):status 高频路径不再对全表多次 all()
480
+ const nodes = this.repo.all()
479
481
  return {
480
482
  running: this.running,
481
483
  defaultIntervalMs: this.defaultIntervalMs,
482
484
  defaultPublishIntervalMs: this.defaultPublishIntervalMs,
483
- nodesTotal: this.repo.all().length,
484
- nodesOnline: this.onlineCount(),
485
+ nodesTotal: nodes.length,
486
+ nodesOnline: this.running ? nodes.filter(n => n.enabled).length : 0,
485
487
  produced: this.producedCount,
486
488
  consumed: this.consumedCount,
487
489
  dropped: Math.max(0, this.producedCount - this.consumedCount) + queueLost + this.lateDropped,
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 增长治理保留策略(hardening GROW-1)。
3
+ *
4
+ * 为无保留机制的只增表做分批清理(每批 ≤5000,循环到 0,避免长事务持锁):
5
+ * messages 默认 30d (AW_MESSAGES_RETENTION_D)
6
+ * audit_log 默认 90d (AW_AUDIT_RETENTION_D;合规观察期,从严保留)
7
+ * approval_history 默认 180d (AW_APPROVAL_RETENTION_D;历史裁决主张不可变,放宽)
8
+ * AW_RETENTION_DISABLED=1 整体逃生门。时间列均为 TEXT ISO,可字典序比较。
9
+ * channel_events 与 daq_samples 已有各自保留机制,此处不重复覆盖。
10
+ */
11
+ type DatabaseSyncLike = {
12
+ prepare: (sql: string) => { run: (...args: unknown[]) => { changes: number | bigint } }
13
+ }
14
+
15
+ const DAY_MS = 86_400_000
16
+ const BATCH = 5000
17
+
18
+ const TARGETS = [
19
+ { table: 'messages', column: 'created_at', days: 30, envKey: 'AW_MESSAGES_RETENTION_D' },
20
+ { table: 'audit_log', column: 'at', days: 90, envKey: 'AW_AUDIT_RETENTION_D' },
21
+ { table: 'approval_history', column: 'created_at', days: 180, envKey: 'AW_APPROVAL_RETENTION_D' },
22
+ ] as const
23
+
24
+ function retentionDays(envKey: string, fallback: number): number {
25
+ const n = Number(process.env[envKey])
26
+ return Number.isFinite(n) && n > 0 ? n : fallback
27
+ }
28
+
29
+ export interface RetentionResult { table: string, removed: number, days: number }
30
+
31
+ /** 对单库执行一轮保留清理;返回各表删除行数(调用方负责 try/catch 与日志)。 */
32
+ export function runRetentionSweep(db: DatabaseSyncLike): RetentionResult[] {
33
+ if (process.env.AW_RETENTION_DISABLED === '1') return []
34
+ const out: RetentionResult[] = []
35
+ for (const t of TARGETS) {
36
+ const days = retentionDays(t.envKey, t.days)
37
+ const before = new Date(Date.now() - days * DAY_MS).toISOString()
38
+ let removed = 0
39
+ for (;;) {
40
+ const res = db.prepare(
41
+ `DELETE FROM ${t.table} WHERE rowid IN (SELECT rowid FROM ${t.table} WHERE ${t.column} < ? LIMIT ${BATCH})`,
42
+ ).run(before)
43
+ const n = Number(res.changes)
44
+ removed += n
45
+ if (n < BATCH) break
46
+ }
47
+ out.push({ table: t.table, removed, days })
48
+ }
49
+ return out
50
+ }
@@ -218,10 +218,12 @@ class DcwController {
218
218
  }
219
219
 
220
220
  controllerState() {
221
+ // 单次遍历(hardening PERF-1):status 高频路径不再对全表多次 all()
222
+ const nodes = this.repo.all()
221
223
  return {
222
224
  running: this.running,
223
- nodesTotal: this.repo.all().length,
224
- nodesOnline: this.running ? this.repo.all().filter(n => n.enabled).length : 0,
225
+ nodesTotal: nodes.length,
226
+ nodesOnline: this.running ? nodes.filter(n => n.enabled).length : 0,
225
227
  writesTotal: this.writesTotal,
226
228
  writesFailed: this.writesFailed,
227
229
  }
@@ -38,16 +38,34 @@ export function findRepoRoot(startDir = process.cwd()) {
38
38
  }
39
39
  }
40
40
 
41
+ /** 从 cwd 向上找项目级配置根(<dir>/.AgentWorkShop 目录真实存在);找不到返回 null。
42
+ * 向上不超过用户主目录——~/.AgentWorkShop 是回退根,不能被误判为某项目的运行时根 */
43
+ export function findLocalConfigRoot(startDir = process.cwd()) {
44
+ const home = homedir()
45
+ let dir = resolve(startDir)
46
+ for (;;) {
47
+ const candidate = join(dir, HOME_DIRNAME)
48
+ if (dir !== home && existsSync(candidate) && statSync(candidate).isDirectory()) return candidate
49
+ const parent = dirname(dir)
50
+ if (parent === dir || dir === home) return null
51
+ dir = parent
52
+ }
53
+ }
54
+
41
55
  /**
42
56
  * 解析当前运行模式与全部路径。
43
- * 优先级:env.AW_MODE === 'home' 显式强制 home 模式(启动器对全局安装的
44
- * 应用载荷注入;否则包目录自身含 config.yml+nuxt.config.ts 会被误判为检出)
45
- * > cwd 向上找检出 > home 模式。
57
+ * 配置根优先级(用户规约:项目 ./.AgentWorkShop > ~/.AgentWorkShop):
58
+ * ① env.AW_MODE === 'home' 显式强制 home 模式(启动器对全局安装的应用载荷注入;
59
+ * 否则包目录自身含 config.yml+nuxt.config.ts 会被误判为检出)
60
+ * ② cwd 向上找到真实存在的 ./.AgentWorkShop 目录 → 项目运行时根(检出或纯工作区皆可)
61
+ * ③ cwd 向上找到项目检出(config.yml+nuxt.config.ts)但无 ./.AgentWorkShop
62
+ * → 按规约回退 ~/.AgentWorkShop 作为运行时根(工厂默认 config.yml 仍取检出根)
63
+ * ④ 其余任意目录 → home 模式(~/.AgentWorkShop,AW_HOME 可重定向)
46
64
  * @returns {{
47
65
  * mode: 'repo'|'home',
48
66
  * root: string, // repo=检出根; home=应用载荷根(本包)
49
67
  * home: string, // 用户级根 ~/.AgentWorkShop(AW_HOME 可重定向;用户级指令/全局配置)
50
- * configRoot: string, // 当前生效配置根(repo=<repo>/.AgentWorkShop; home=~/.AgentWorkShop)
68
+ * configRoot: string, // 当前生效配置根(项目运行时=<项目>/.AgentWorkShop; 否则 ~/.AgentWorkShop)
51
69
  * configPath: string, // 生效的 config.yml(工厂默认;repo 在检出根,home 在配置根)
52
70
  * settingsPath: string, // 生效的 runtime-settings.json(两种模式都在配置根)
53
71
  * dataDir: string, // 运行数据目录(两种模式都在配置根)
@@ -55,20 +73,44 @@ export function findRepoRoot(startDir = process.cwd()) {
55
73
  */
56
74
  export function resolveRunMode({ cwd, packageRoot, env = process.env } = {}) {
57
75
  const home = awHome(env)
76
+ const startDir = cwd ?? process.cwd()
58
77
  const forceHome = env.AW_MODE === 'home'
59
- const repoRoot = forceHome ? null : findRepoRoot(cwd ?? process.cwd())
78
+
79
+ // ② 项目级 ./.AgentWorkShop 显式存在 → 它就是运行时根(最高优先)
80
+ if (!forceHome) {
81
+ const localConfigRoot = findLocalConfigRoot(startDir)
82
+ if (localConfigRoot) {
83
+ const projectRoot = dirname(localConfigRoot)
84
+ const repoRoot = isRepoRoot(projectRoot) ? projectRoot : null
85
+ return {
86
+ mode: 'repo',
87
+ root: repoRoot ?? projectRoot,
88
+ home,
89
+ configRoot: localConfigRoot,
90
+ configPath: repoRoot
91
+ ? join(repoRoot, 'config.yml')
92
+ : (existsSync(join(localConfigRoot, 'config.yml')) ? join(localConfigRoot, 'config.yml') : join(packageRoot ?? '', 'config.yml')),
93
+ settingsPath: join(localConfigRoot, 'runtime-settings.json'),
94
+ dataDir: join(localConfigRoot, 'data'),
95
+ }
96
+ }
97
+ }
98
+
99
+ // ③ 检出内运行但无 ./.AgentWorkShop → 回退 ~/.AgentWorkShop(工厂默认配置仍在检出根)
100
+ const repoRoot = forceHome ? null : findRepoRoot(startDir)
60
101
  if (repoRoot) {
61
- const configRoot = join(repoRoot, HOME_DIRNAME)
62
102
  return {
63
103
  mode: 'repo',
64
104
  root: repoRoot,
65
105
  home,
66
- configRoot,
106
+ configRoot: home,
67
107
  configPath: join(repoRoot, 'config.yml'),
68
- settingsPath: join(configRoot, 'runtime-settings.json'),
69
- dataDir: join(configRoot, 'data'),
108
+ settingsPath: join(home, 'runtime-settings.json'),
109
+ dataDir: join(home, 'data'),
70
110
  }
71
111
  }
112
+
113
+ // ④ home 模式
72
114
  return {
73
115
  mode: 'home',
74
116
  root: packageRoot ?? repoRoot,
@@ -82,16 +124,16 @@ export function resolveRunMode({ cwd, packageRoot, env = process.env } = {}) {
82
124
 
83
125
  /**
84
126
  * 运行数据目录(服务端/CLI 通用):
85
- * AW_DATA_DIR 环境变量 > home 模式(AW_MODE=home) 配置根/data
86
- * > repo 模式(cwd 有 config.yml) cwd/.AgentWorkShop/data > cwd/data 兜底。
127
+ * AW_DATA_DIR 环境变量 > resolveRunMode 的配置根/data
128
+ * (项目 ./.AgentWorkShop 显式存在 > 检出内无 .AgentWorkShop 回退 ~/.AgentWorkShop
129
+ * > home 模式 ~/.AgentWorkShop/data > cwd/data 兜底)。
87
130
  * 启动器(scripts/start.mjs / dev-guard.mjs)会经 resolveRunMode 注入 AW_DATA_DIR;
88
131
  * 本函数为其兜底(直接 node .output/server/index.mjs 等无启动器场景)。
89
132
  */
90
133
  export function dataDirFor(cwd = process.cwd(), env = process.env) {
91
134
  if (env.AW_DATA_DIR && String(env.AW_DATA_DIR).trim()) return resolve(String(env.AW_DATA_DIR).trim())
92
- if (env.AW_MODE === 'home') return join(awHome(env), 'data')
93
- if (existsSync(join(cwd, 'config.yml'))) return join(cwd, HOME_DIRNAME, 'data')
94
- return join(cwd, 'data')
135
+ const rm = resolveRunMode({ cwd, env })
136
+ return rm.dataDir
95
137
  }
96
138
 
97
139
  /**
@@ -102,7 +144,9 @@ export function dataDirFor(cwd = process.cwd(), env = process.env) {
102
144
  export function ensureDataDir(cwd = process.cwd(), env = process.env) {
103
145
  const dir = dataDirFor(cwd, env)
104
146
  mkdirSync(dir, { recursive: true })
105
- if (env.AW_MODE === 'home') return dir
147
+ // 旧位置迁移仅当配置根就在 cwd 内(本地 ./.AgentWorkShop);回退 ~/.AgentWorkShop
148
+ // 时不得把检出仓库的旧数据搬进用户全局根(跨项目污染)
149
+ if (env.AW_MODE === 'home' || !resolve(dir).startsWith(resolve(cwd))) return dir
106
150
  const legacyDirs = [join(cwd, 'data'), join(cwd, 'server', 'data')]
107
151
  for (const legacy of legacyDirs) {
108
152
  if (resolve(legacy) === resolve(dir) || !existsSync(legacy)) continue
@@ -140,6 +184,7 @@ export default {
140
184
  awHome,
141
185
  isRepoRoot,
142
186
  findRepoRoot,
187
+ findLocalConfigRoot,
143
188
  resolveRunMode,
144
189
  dataDirFor,
145
190
  ensureDataDir,
@@ -0,0 +1,86 @@
1
+ // ============================================================
2
+ // 单实例互斥(hardening ST-1)—— 零依赖,CLI / 启动脚本通用。
3
+ // 锁文件 <configRoot>/.runtime/aw.lock 原子创建(wx):
4
+ // { pid, startedAt, mode, port }
5
+ // 已存在且 PID 存活 → 视为已有实例(返回冲突信息,调用方以退出码 2 退出);
6
+ // PID 已死(崩溃/强杀残留) → 自动接管重写。进程退出时 releaseLock 兜底清理。
7
+ // ============================================================
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ function pidAlive(pid) {
12
+ if (!Number.isInteger(pid) || pid <= 0) return false
13
+ try {
14
+ // signal 0 探活:进程存在但不发信号;ESRCH=不存在,EPERM=存在(他人进程)
15
+ process.kill(pid, 0)
16
+ return true
17
+ }
18
+ catch (err) {
19
+ return err?.code === 'EPERM'
20
+ }
21
+ }
22
+
23
+ /**
24
+ * 尝试获取单实例锁。
25
+ * @returns {{ ok: true, lockPath: string, release: () => void }
26
+ * | { ok: false, lockPath: string, holder: { pid: number, startedAt: string, mode: string, port: number | null } }}
27
+ */
28
+ export function acquireLock(configRoot, { mode = 'prod', port = null } = {}) {
29
+ const runtimeDir = join(configRoot, '.runtime')
30
+ const lockPath = join(runtimeDir, 'aw.lock')
31
+ mkdirSync(runtimeDir, { recursive: true })
32
+ if (existsSync(lockPath)) {
33
+ let holder = null
34
+ try {
35
+ holder = JSON.parse(readFileSync(lockPath, 'utf8'))
36
+ }
37
+ catch { /* 损坏锁文件按残留处理 */ }
38
+ if (holder && pidAlive(holder.pid)) {
39
+ return { ok: false, lockPath, holder: { ...holder, port: holder.port ?? null } }
40
+ }
41
+ try { rmSync(lockPath) } catch { /* 抢不掉则下方 wx 再失败 */ }
42
+ }
43
+ const me = { pid: process.pid, startedAt: new Date().toISOString(), mode, port }
44
+ try {
45
+ writeFileSync(lockPath, JSON.stringify(me, null, 2), { flag: 'wx' })
46
+ }
47
+ catch (err) {
48
+ // 与并发启动者竞态:按冲突处理
49
+ let holder = null
50
+ try { holder = JSON.parse(readFileSync(lockPath, 'utf8')) } catch { /* ignore */ }
51
+ if (holder && pidAlive(holder.pid)) {
52
+ return { ok: false, lockPath, holder: { ...holder, port: holder.port ?? null } }
53
+ }
54
+ throw err
55
+ }
56
+ let released = false
57
+ const release = () => {
58
+ if (released) return
59
+ released = true
60
+ try {
61
+ const cur = JSON.parse(readFileSync(lockPath, 'utf8'))
62
+ if (cur?.pid !== process.pid) return // 锁已被他人接管,不误删
63
+ rmSync(lockPath)
64
+ }
65
+ catch { /* 已不存在/损坏,忽略 */ }
66
+ }
67
+ process.once('exit', release)
68
+ process.once('SIGINT', () => { release(); process.exit(130) })
69
+ process.once('SIGTERM', () => { release(); process.exit(143) })
70
+ return { ok: true, lockPath, release }
71
+ }
72
+
73
+ /** 端口占用探测:返回占用进程 PID(未占用返回 null)。TCP try-listen 后立刻释放。 */
74
+ export async function checkPort(host, port) {
75
+ const net = await import('node:net')
76
+ return new Promise((resolveProbe) => {
77
+ const srv = net.createServer()
78
+ srv.once('error', (err) => {
79
+ resolveProbe(err?.code === 'EADDRINUSE' ? port : null)
80
+ srv.close()
81
+ })
82
+ srv.listen(port, host, () => {
83
+ srv.close(() => resolveProbe(null))
84
+ })
85
+ })
86
+ }
@@ -1 +0,0 @@
1
- {"id":"eede1532-1366-486a-bdab-4d27fa03a1d8","timestamp":1788336798647,"prerendered":[]}