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.
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/70bd10c7-55cf-4e49-94de-8aa1b9edf33a.json +1 -0
- package/.output/server/chunks/_/nitro.mjs +857 -668
- package/.output/server/chunks/_/nitro.mjs.map +1 -1
- package/.output/server/package.json +1 -1
- package/cli/core/context.mjs +8 -4
- package/package.json +1 -1
- package/scripts/_dbg-full-feature-e2e.mjs +1 -1
- package/scripts/_dbg-retention-test.ts +25 -0
- package/scripts/dev-guard.mjs +33 -0
- package/scripts/start.mjs +11 -0
- package/server/data/daqs.json +75 -75
- package/server/data/dcw-lines.json +14 -0
- package/server/data/dcw-products.json +14 -0
- package/server/data/dcw-rollback.json +222 -1
- package/server/data/dcw-runs.json +54 -36
- package/server/data/dcw-writes.json +96 -0
- package/server/data/dcws.json +42 -0
- package/server/data/line-runs.json +22 -22
- package/server/plugins/backup.ts +21 -11
- package/server/plugins/dev-stability-guard.ts +13 -1
- package/server/plugins/retention.ts +57 -0
- package/server/services/workshop/agents/node-bindings.repo.ts +2 -2
- package/server/services/workshop/daq/daq-controller.ts +4 -2
- package/server/services/workshop/db/retention.ts +50 -0
- package/server/services/workshop/dcw/dcw-controller.ts +4 -2
- package/shared/config/home.mjs +60 -15
- package/shared/config/single-instance.mjs +86 -0
- package/.output/public/_nuxt/builds/meta/eede1532-1366-486a-bdab-4d27fa03a1d8.json +0 -1
package/server/plugins/backup.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 备份定时插件(S6,production-readiness-plan)。
|
|
3
3
|
*
|
|
4
|
-
* 对 data/ 下三库(workshop/users/daq-timeseries)每日
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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(
|
|
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:
|
|
484
|
-
nodesOnline: this.
|
|
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:
|
|
224
|
-
nodesOnline: this.running ?
|
|
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
|
}
|
package/shared/config/home.mjs
CHANGED
|
@@ -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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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, // 当前生效配置根(
|
|
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
|
-
|
|
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(
|
|
69
|
-
dataDir: join(
|
|
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 环境变量 >
|
|
86
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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":[]}
|