agentworkshop 0.7.5 → 0.7.6
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/cli/commands/stop.mjs +108 -108
- package/cli/infra.mjs +108 -109
- package/config.yml +1 -1
- package/package.json +1 -1
- package/scripts/start.mjs +31 -8
- package/shared/config/single-instance.mjs +129 -86
- package/tui/lib/config.mjs +62 -58
package/cli/commands/stop.mjs
CHANGED
|
@@ -1,108 +1,108 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// 指令:stop —— 终止当前配置根下运行中的 aw 实例(读单实例锁 → 终止进程树)。
|
|
3
|
-
// 用法:aw stop [--home] (--home 强制以 home 配置根为目标;默认随 cwd 解析)
|
|
4
|
-
// ============================================================
|
|
5
|
-
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
6
|
-
import { join } from 'node:path'
|
|
7
|
-
import { spawnSync } from 'node:child_process'
|
|
8
|
-
import { color } from '../core/logger.mjs'
|
|
9
|
-
|
|
10
|
-
export const meta = {
|
|
11
|
-
name: 'stop',
|
|
12
|
-
aliases: [],
|
|
13
|
-
group: '服务',
|
|
14
|
-
summary: '终止运行中的 aw 服务实例(读单实例锁)',
|
|
15
|
-
usage: 'aw stop [--home]',
|
|
16
|
-
needsProject: false,
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function pidAlive(pid) {
|
|
20
|
-
try {
|
|
21
|
-
process.kill(pid, 0)
|
|
22
|
-
return true
|
|
23
|
-
}
|
|
24
|
-
catch (err) {
|
|
25
|
-
return err?.code === 'EPERM'
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** 终止进程树:Windows taskkill /T /F;POSIX 先 SIGTERM 宽限 5s 再 SIGKILL */
|
|
30
|
-
function killTree(pid) {
|
|
31
|
-
if (process.platform === 'win32') {
|
|
32
|
-
const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf8' })
|
|
33
|
-
if (r.status !== 0) throw new Error((r.stderr || r.stdout || 'taskkill 失败').trim().split(/\r?\n/)[0])
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
try {
|
|
37
|
-
process.kill(pid, 'SIGTERM')
|
|
38
|
-
}
|
|
39
|
-
catch { /* 已死 */ }
|
|
40
|
-
const deadline = Date.now() + 5000
|
|
41
|
-
while (Date.now() < deadline && pidAlive(pid)) {
|
|
42
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
|
|
43
|
-
}
|
|
44
|
-
if (pidAlive(pid)) {
|
|
45
|
-
try {
|
|
46
|
-
process.kill(pid, 'SIGKILL')
|
|
47
|
-
}
|
|
48
|
-
catch { /* 已死 */ }
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function run(argv, ctx) {
|
|
53
|
-
// argv 为 parseArgs 结果({ flags, positionals, unknown });兼容裸数组调用
|
|
54
|
-
const flags = argv?.flags ?? {}
|
|
55
|
-
const positionals = Array.isArray(argv?.positionals) ? argv.positionals : (Array.isArray(argv) ? argv : [])
|
|
56
|
-
const forceHome = flags.home === true || positionals.includes('--home')
|
|
57
|
-
|
|
58
|
-
const configRoot = forceHome
|
|
59
|
-
? ctx.home
|
|
60
|
-
: (ctx.mode === 'repo' ? join(ctx.root, '.AgentWorkShop') : ctx.home)
|
|
61
|
-
const lockPath = join(configRoot, '.runtime', 'aw.lock')
|
|
62
|
-
|
|
63
|
-
if (!existsSync(lockPath)) {
|
|
64
|
-
console.log(`${color.blue('ℹ')} 未发现运行中的实例(无锁文件:${lockPath})`)
|
|
65
|
-
return 0
|
|
66
|
-
}
|
|
67
|
-
let holder
|
|
68
|
-
try {
|
|
69
|
-
holder = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
rmSync(lockPath, { force: true })
|
|
73
|
-
console.log(`${color.blue('ℹ')} 锁文件损坏,已清除:${lockPath}`)
|
|
74
|
-
return 0
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const pid = Number(holder?.pid)
|
|
78
|
-
if (!Number.isInteger(pid) || pid <= 0) {
|
|
79
|
-
rmSync(lockPath, { force: true })
|
|
80
|
-
console.log(`${color.blue('ℹ')} 锁内 PID 无效,已清除锁文件`)
|
|
81
|
-
return 0
|
|
82
|
-
}
|
|
83
|
-
if (!pidAlive(pid)) {
|
|
84
|
-
rmSync(lockPath, { force: true })
|
|
85
|
-
console.log(`${color.blue('ℹ')} 持锁进程已不存在(pid=${pid},残留锁),已清除锁文件`)
|
|
86
|
-
return 0
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
console.log(`› 终止实例 pid=${pid}(mode=${holder.mode ?? '?'}${holder.port ? `,端口=${holder.port}` : ''},启动于 ${holder.startedAt ?? '?'})`)
|
|
90
|
-
try {
|
|
91
|
-
killTree(pid)
|
|
92
|
-
}
|
|
93
|
-
catch (err) {
|
|
94
|
-
console.error(`${color.red('✖')} 终止失败:${err.message}`)
|
|
95
|
-
console.error(` 可手动执行:taskkill /PID ${pid} /T /F(Windows)`)
|
|
96
|
-
return 1
|
|
97
|
-
}
|
|
98
|
-
// 锁文件由退出钩子自清;残留则手动兜底
|
|
99
|
-
for (let i = 0; i < 20 && existsSync(lockPath); i++) {
|
|
100
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
|
|
101
|
-
}
|
|
102
|
-
try {
|
|
103
|
-
if (existsSync(lockPath)) rmSync(lockPath, { force: true })
|
|
104
|
-
}
|
|
105
|
-
catch { /* ignore */ }
|
|
106
|
-
console.log(`${color.green('✔')} 实例已停止(pid=${pid})`)
|
|
107
|
-
return 0
|
|
108
|
-
}
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 指令:stop —— 终止当前配置根下运行中的 aw 实例(读单实例锁 → 终止进程树)。
|
|
3
|
+
// 用法:aw stop [--home] (--home 强制以 home 配置根为目标;默认随 cwd 解析)
|
|
4
|
+
// ============================================================
|
|
5
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { color } from '../core/logger.mjs'
|
|
9
|
+
|
|
10
|
+
export const meta = {
|
|
11
|
+
name: 'stop',
|
|
12
|
+
aliases: [],
|
|
13
|
+
group: '服务',
|
|
14
|
+
summary: '终止运行中的 aw 服务实例(读单实例锁)',
|
|
15
|
+
usage: 'aw stop [--home]',
|
|
16
|
+
needsProject: false,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pidAlive(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0)
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return err?.code === 'EPERM'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 终止进程树:Windows taskkill /T /F;POSIX 先 SIGTERM 宽限 5s 再 SIGKILL */
|
|
30
|
+
function killTree(pid) {
|
|
31
|
+
if (process.platform === 'win32') {
|
|
32
|
+
const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf8' })
|
|
33
|
+
if (r.status !== 0) throw new Error((r.stderr || r.stdout || 'taskkill 失败').trim().split(/\r?\n/)[0])
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 'SIGTERM')
|
|
38
|
+
}
|
|
39
|
+
catch { /* 已死 */ }
|
|
40
|
+
const deadline = Date.now() + 5000
|
|
41
|
+
while (Date.now() < deadline && pidAlive(pid)) {
|
|
42
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
|
|
43
|
+
}
|
|
44
|
+
if (pidAlive(pid)) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 'SIGKILL')
|
|
47
|
+
}
|
|
48
|
+
catch { /* 已死 */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function run(argv, ctx) {
|
|
53
|
+
// argv 为 parseArgs 结果({ flags, positionals, unknown });兼容裸数组调用
|
|
54
|
+
const flags = argv?.flags ?? {}
|
|
55
|
+
const positionals = Array.isArray(argv?.positionals) ? argv.positionals : (Array.isArray(argv) ? argv : [])
|
|
56
|
+
const forceHome = flags.home === true || positionals.includes('--home')
|
|
57
|
+
|
|
58
|
+
const configRoot = forceHome
|
|
59
|
+
? ctx.home
|
|
60
|
+
: (ctx.mode === 'repo' ? join(ctx.root, '.AgentWorkShop') : ctx.home)
|
|
61
|
+
const lockPath = join(configRoot, '.runtime', 'aw.lock')
|
|
62
|
+
|
|
63
|
+
if (!existsSync(lockPath)) {
|
|
64
|
+
console.log(`${color.blue('ℹ')} 未发现运行中的实例(无锁文件:${lockPath})`)
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
let holder
|
|
68
|
+
try {
|
|
69
|
+
holder = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
rmSync(lockPath, { force: true })
|
|
73
|
+
console.log(`${color.blue('ℹ')} 锁文件损坏,已清除:${lockPath}`)
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const pid = Number(holder?.pid)
|
|
78
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
79
|
+
rmSync(lockPath, { force: true })
|
|
80
|
+
console.log(`${color.blue('ℹ')} 锁内 PID 无效,已清除锁文件`)
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
if (!pidAlive(pid)) {
|
|
84
|
+
rmSync(lockPath, { force: true })
|
|
85
|
+
console.log(`${color.blue('ℹ')} 持锁进程已不存在(pid=${pid},残留锁),已清除锁文件`)
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(`› 终止实例 pid=${pid}(mode=${holder.mode ?? '?'}${holder.port ? `,端口=${holder.port}` : ''},启动于 ${holder.startedAt ?? '?'})`)
|
|
90
|
+
try {
|
|
91
|
+
killTree(pid)
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.error(`${color.red('✖')} 终止失败:${err.message}`)
|
|
95
|
+
console.error(` 可手动执行:taskkill /PID ${pid} /T /F(Windows)`)
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
98
|
+
// 锁文件由退出钩子自清;残留则手动兜底
|
|
99
|
+
for (let i = 0; i < 20 && existsSync(lockPath); i++) {
|
|
100
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
if (existsSync(lockPath)) rmSync(lockPath, { force: true })
|
|
104
|
+
}
|
|
105
|
+
catch { /* ignore */ }
|
|
106
|
+
console.log(`${color.green('✔')} 实例已停止(pid=${pid})`)
|
|
107
|
+
return 0
|
|
108
|
+
}
|
package/cli/infra.mjs
CHANGED
|
@@ -1,109 +1,108 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// AgentWorkShop CLI — 数采基础设施前置检查(Docker 编排)
|
|
3
|
-
// ------------------------------------------------------------
|
|
4
|
-
// aw start 启动服务前的引导层:
|
|
5
|
-
// 1. 探测 MQTT / TimescaleDB 端口连通(按有效配置的 host/port)
|
|
6
|
-
// 2. 已连通 → 依赖就绪,直接通过
|
|
7
|
-
// 3. 未连通且本机 Docker 可用且策略允许 → docker compose up -d 拉起并等待端口
|
|
8
|
-
// 4. 无 Docker → 打印回退引导:安装 Docker,或在 config.yml 按需配置外部依赖地址
|
|
9
|
-
// 真正的容器编排与降级重连在服务进程内(server/plugins/daq.ts → ensureDaqInfrastructure);
|
|
10
|
-
// 本模块只做启动前置探测与引导,失败不阻断服务启动(平台降级可用)。
|
|
11
|
-
// ============================================================
|
|
12
|
-
import { spawnSync } from 'node:child_process'
|
|
13
|
-
import { existsSync } from 'node:fs'
|
|
14
|
-
import { connect } from 'node:net'
|
|
15
|
-
import { join } from 'node:path'
|
|
16
|
-
import { color } from './core/logger.mjs'
|
|
17
|
-
|
|
18
|
-
/** 本机 Docker 可用性(docker -v) */
|
|
19
|
-
export function detectDocker() {
|
|
20
|
-
const r = spawnSync('docker', ['-v'], { encoding: 'utf8'
|
|
21
|
-
if (r.status === 0) return { ok: true, version: (r.stdout ?? '').trim().replace(/^Docker version\s*/i, '') }
|
|
22
|
-
return { ok: false, version: null }
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** TCP 端口连通探测(毫秒级超时;resolve('online'|'offline')) */
|
|
26
|
-
export function probeTcp(port, host = '127.0.0.1', timeoutMs = 1200) {
|
|
27
|
-
return new Promise((resolveState) => {
|
|
28
|
-
const sock = connect({ port: Number(port), host })
|
|
29
|
-
const done = (state) => {
|
|
30
|
-
sock.destroy()
|
|
31
|
-
resolveState(state)
|
|
32
|
-
}
|
|
33
|
-
sock.setTimeout(timeoutMs)
|
|
34
|
-
sock.on('connect', () => done('online'))
|
|
35
|
-
sock.on('timeout', () => done('offline'))
|
|
36
|
-
sock.on('error', () => done('offline'))
|
|
37
|
-
})
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** 在 compose 目录执行 docker compose up -d(阻塞至命令结束) */
|
|
41
|
-
export function composeUp(composeDir) {
|
|
42
|
-
const r = spawnSync('docker', ['compose', 'up', '-d'], {
|
|
43
|
-
cwd: composeDir,
|
|
44
|
-
stdio: 'inherit',
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
* @
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
result.
|
|
91
|
-
result
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
console.log('')
|
|
101
|
-
console.log(color.
|
|
102
|
-
console.log(color.
|
|
103
|
-
console.log(` ${color.cyan('
|
|
104
|
-
console.log(`
|
|
105
|
-
console.log(color.dim(` daq.
|
|
106
|
-
console.log(color.dim(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
1
|
+
// ============================================================
|
|
2
|
+
// AgentWorkShop CLI — 数采基础设施前置检查(Docker 编排)
|
|
3
|
+
// ------------------------------------------------------------
|
|
4
|
+
// aw start 启动服务前的引导层:
|
|
5
|
+
// 1. 探测 MQTT / TimescaleDB 端口连通(按有效配置的 host/port)
|
|
6
|
+
// 2. 已连通 → 依赖就绪,直接通过
|
|
7
|
+
// 3. 未连通且本机 Docker 可用且策略允许 → docker compose up -d 拉起并等待端口
|
|
8
|
+
// 4. 无 Docker → 打印回退引导:安装 Docker,或在 config.yml 按需配置外部依赖地址
|
|
9
|
+
// 真正的容器编排与降级重连在服务进程内(server/plugins/daq.ts → ensureDaqInfrastructure);
|
|
10
|
+
// 本模块只做启动前置探测与引导,失败不阻断服务启动(平台降级可用)。
|
|
11
|
+
// ============================================================
|
|
12
|
+
import { spawnSync } from 'node:child_process'
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { connect } from 'node:net'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { color } from './core/logger.mjs'
|
|
17
|
+
|
|
18
|
+
/** 本机 Docker 可用性(docker -v) */
|
|
19
|
+
export function detectDocker() {
|
|
20
|
+
const r = spawnSync('docker', ['-v'], { encoding: 'utf8' })
|
|
21
|
+
if (r.status === 0) return { ok: true, version: (r.stdout ?? '').trim().replace(/^Docker version\s*/i, '') }
|
|
22
|
+
return { ok: false, version: null }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** TCP 端口连通探测(毫秒级超时;resolve('online'|'offline')) */
|
|
26
|
+
export function probeTcp(port, host = '127.0.0.1', timeoutMs = 1200) {
|
|
27
|
+
return new Promise((resolveState) => {
|
|
28
|
+
const sock = connect({ port: Number(port), host })
|
|
29
|
+
const done = (state) => {
|
|
30
|
+
sock.destroy()
|
|
31
|
+
resolveState(state)
|
|
32
|
+
}
|
|
33
|
+
sock.setTimeout(timeoutMs)
|
|
34
|
+
sock.on('connect', () => done('online'))
|
|
35
|
+
sock.on('timeout', () => done('offline'))
|
|
36
|
+
sock.on('error', () => done('offline'))
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 在 compose 目录执行 docker compose up -d(阻塞至命令结束) */
|
|
41
|
+
export function composeUp(composeDir) {
|
|
42
|
+
const r = spawnSync('docker', ['compose', 'up', '-d'], {
|
|
43
|
+
cwd: composeDir,
|
|
44
|
+
stdio: 'inherit',
|
|
45
|
+
})
|
|
46
|
+
return r.status === 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 数采基础设施前置检查。
|
|
51
|
+
* @param {{ daq: any, configRoot: string }} opts ctx.config 片段与配置根(daQ compose 种子所在)
|
|
52
|
+
* @returns {{ ok: boolean, mqtt: object, timescale: object, docker: object }}
|
|
53
|
+
*/
|
|
54
|
+
export async function daqPreflight({ daq, configRoot }) {
|
|
55
|
+
const mqtt = {
|
|
56
|
+
host: String(daq?.['daq.mqtt.host'] ?? '127.0.0.1'),
|
|
57
|
+
port: Number(daq?.['daq.mqtt.port'] ?? 1883),
|
|
58
|
+
online: null,
|
|
59
|
+
}
|
|
60
|
+
const timescale = {
|
|
61
|
+
host: String(daq?.['daq.timescale.host'] ?? '127.0.0.1'),
|
|
62
|
+
port: Number(daq?.['daq.timescale.port'] ?? 5432),
|
|
63
|
+
online: null,
|
|
64
|
+
}
|
|
65
|
+
const policy = String(daq?.['daq.startInfrastructure'] ?? 'auto')
|
|
66
|
+
|
|
67
|
+
const docker = detectDocker()
|
|
68
|
+
mqtt.online = (await probeTcp(mqtt.port, mqtt.host)) === 'online'
|
|
69
|
+
timescale.online = (await probeTcp(timescale.port, timescale.host)) === 'online'
|
|
70
|
+
const allOnline = mqtt.online && timescale.online
|
|
71
|
+
const composeDir = existsSync(join(configRoot, 'docker-compose.yml')) ? configRoot : null
|
|
72
|
+
|
|
73
|
+
const result = { ok: allOnline, mqtt, timescale, docker, policy, composeDir }
|
|
74
|
+
|
|
75
|
+
if (allOnline) return result
|
|
76
|
+
|
|
77
|
+
// 未全连通:
|
|
78
|
+
// · Docker 可用 + 策略允许(auto/always)+ 本地有 compose 定义 → 拉起并等待端口上线
|
|
79
|
+
if (docker.ok && policy !== 'never' && composeDir) {
|
|
80
|
+
console.log(`${color.cyan('›')} 数采依赖未就绪(MQTT ${mqtt.online ? '✓' : '✗'} / Timescale ${timescale.online ? '✓' : '✗'})——docker compose up -d 拉起 ...`)
|
|
81
|
+
const up = composeUp(composeDir)
|
|
82
|
+
// 等待端口上线(最长 30s;Timescale 首启初始化较慢)
|
|
83
|
+
for (let i = 0; i < 15; i++) {
|
|
84
|
+
await new Promise(r => setTimeout(r, 2000))
|
|
85
|
+
mqtt.online = (await probeTcp(mqtt.port, mqtt.host)) === 'online'
|
|
86
|
+
timescale.online = (await probeTcp(timescale.port, timescale.host)) === 'online'
|
|
87
|
+
if (mqtt.online && timescale.online) break
|
|
88
|
+
}
|
|
89
|
+
result.ok = mqtt.online && timescale.online
|
|
90
|
+
result.pulled = up
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 打印回退引导(无 Docker 且依赖不可达时) */
|
|
98
|
+
export function printInfraGuidance({ mqtt, timescale, docker }) {
|
|
99
|
+
console.log('')
|
|
100
|
+
console.log(color.yellow('⚠ 数采基础设施不可达(MQTT/TimescaleDB)——服务将继续启动,数采功能降级并每 30s 自动重连。'))
|
|
101
|
+
console.log(color.dim(' 解决方式(二选一):'))
|
|
102
|
+
console.log(` ${color.cyan('1.')} 安装 Docker Desktop 后重启本命令,依赖容器将自动拉起`)
|
|
103
|
+
console.log(` ${color.cyan('2.')} 在 config.yml 的 daq 段按需配置外部依赖:`)
|
|
104
|
+
console.log(color.dim(` daq.mqtt.host/port (当前 ${mqtt.host}:${mqtt.port} ✗)`))
|
|
105
|
+
console.log(color.dim(` daq.timescale.host/port (当前 ${timescale.host}:${timescale.port} ✗)`))
|
|
106
|
+
if (!docker.ok) console.log(color.dim(' (本机未检测到 Docker)'))
|
|
107
|
+
console.log('')
|
|
108
|
+
}
|
package/config.yml
CHANGED
package/package.json
CHANGED
package/scripts/start.mjs
CHANGED
|
@@ -53,21 +53,44 @@ const prodPort = eff.effective['server.prod.port'] ?? 3000
|
|
|
53
53
|
const portSource = eff.sources['server.prod.port']
|
|
54
54
|
|
|
55
55
|
// ---- 单实例互斥(hardening ST-1):同配置根双开直接退出码 2,防 SQLite 锁崩溃 ----
|
|
56
|
-
|
|
57
|
-
const
|
|
56
|
+
// ---- 单实例互斥 + 自动顶替:同配置根已有实例 → 自动停止后重启新实例(防 SQLite 锁崩溃) ----
|
|
57
|
+
const { acquireLock, checkPort, terminatePid } = await import('../shared/config/single-instance.mjs')
|
|
58
|
+
let requestedPort = argPort ? Number(argPort) : prodPort
|
|
59
|
+
let lock = acquireLock(rm.configRoot, { mode: `prod:${rm.mode}`, port: requestedPort })
|
|
58
60
|
if (!lock.ok) {
|
|
59
61
|
const h = lock.holder
|
|
60
|
-
console.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
console.log(`› 发现已运行实例(pid=${h.pid}${h.port ? `,端口=${h.port}` : ''},启动于 ${h.startedAt ?? '未知'})— 自动停止后重启 ...`)
|
|
63
|
+
const stopped = await terminatePid(h.pid).catch(() => false)
|
|
64
|
+
if (!stopped) {
|
|
65
|
+
console.error(`✖ 旧实例(pid=${h.pid})自动停止失败,请手动执行 aw stop 或 taskkill /PID ${h.pid} /T /F`)
|
|
66
|
+
process.exit(2)
|
|
67
|
+
}
|
|
68
|
+
lock = acquireLock(rm.configRoot, { mode: `prod:${rm.mode}`, port: requestedPort })
|
|
69
|
+
if (!lock.ok) {
|
|
70
|
+
console.error(`✖ 旧实例已停止但锁仍被占用(可能并发启动):pid=${lock.holder?.pid ?? '?'}`)
|
|
71
|
+
process.exit(2)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- 端口顺延:配置端口被任意进程占用(含其他配置根的实例)→ 逐个 +1(最多 10 次) ----
|
|
76
|
+
let port = requestedPort
|
|
77
|
+
let bumped = 0
|
|
78
|
+
while (bumped < 10 && await checkPort(host, port)) {
|
|
79
|
+
console.log(`› 端口 ${port} 被占用,顺延至 ${port + 1} ...`)
|
|
80
|
+
port += 1
|
|
81
|
+
bumped += 1
|
|
82
|
+
}
|
|
83
|
+
if (bumped >= 10) {
|
|
84
|
+
console.error(`✖ ${host} 上 ${requestedPort}-${port} 全部被占用,无法启动`)
|
|
85
|
+
process.exit(1)
|
|
64
86
|
}
|
|
87
|
+
if (bumped > 0) console.log(`✔ 使用顺延端口 ${port}(配置端口 ${requestedPort} 被占用)`)
|
|
65
88
|
|
|
66
89
|
process.env.HOST = process.env.HOST || process.env.NITRO_HOST || String(host)
|
|
67
|
-
process.env.PORT = process.env.PORT || process.env.NITRO_PORT || String(
|
|
90
|
+
process.env.PORT = process.env.PORT || process.env.NITRO_PORT || String(port)
|
|
68
91
|
process.env.NITRO_HOST = process.env.HOST
|
|
69
92
|
process.env.NITRO_PORT = process.env.PORT
|
|
70
93
|
|
|
71
|
-
console.log(`[config] 生产服务启动 -> http://${process.env.HOST}:${process.env.PORT} (模式: ${rm.mode}, port source: ${portSource})`)
|
|
94
|
+
console.log(`[config] 生产服务启动 -> http://${process.env.HOST}:${process.env.PORT} (模式: ${rm.mode}, port source: ${portSource}${bumped > 0 ? ' + 顺延' : ''})`)
|
|
72
95
|
|
|
73
96
|
await import('../.output/server/index.mjs')
|
|
@@ -1,86 +1,129 @@
|
|
|
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
|
-
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
const waitSync = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 终止进程树并等待其死亡(供 `aw stop` 与 start 的"顶掉旧实例"共用)。
|
|
27
|
+
* Windows:taskkill /T /F;POSIX:SIGTERM 宽限 5s → SIGKILL。
|
|
28
|
+
* @returns {Promise<boolean>} 是否已确认死亡
|
|
29
|
+
*/
|
|
30
|
+
export async function terminatePid(pid) {
|
|
31
|
+
if (!pidAlive(pid)) return true
|
|
32
|
+
if (process.platform === 'win32') {
|
|
33
|
+
const { spawnSync } = await import('node:child_process')
|
|
34
|
+
const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf8' })
|
|
35
|
+
if (r.status !== 0) return !pidAlive(pid)
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 'SIGTERM')
|
|
40
|
+
}
|
|
41
|
+
catch { /* 已死 */ }
|
|
42
|
+
const deadline = Date.now() + 5000
|
|
43
|
+
while (Date.now() < deadline && pidAlive(pid)) waitSync(200)
|
|
44
|
+
if (pidAlive(pid)) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 'SIGKILL')
|
|
47
|
+
}
|
|
48
|
+
catch { /* 已死 */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const deadline = Date.now() + 5000
|
|
52
|
+
while (Date.now() < deadline && pidAlive(pid)) waitSync(200)
|
|
53
|
+
return !pidAlive(pid)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 尝试获取单实例锁。
|
|
58
|
+
* @returns {{ ok: true, lockPath: string, release: () => void }
|
|
59
|
+
* | { ok: false, lockPath: string, holder: { pid: number, startedAt: string, mode: string, port: number | null } }}
|
|
60
|
+
*/
|
|
61
|
+
export function acquireLock(configRoot, { mode = 'prod', port = null } = {}) {
|
|
62
|
+
const runtimeDir = join(configRoot, '.runtime')
|
|
63
|
+
const lockPath = join(runtimeDir, 'aw.lock')
|
|
64
|
+
mkdirSync(runtimeDir, { recursive: true })
|
|
65
|
+
if (existsSync(lockPath)) {
|
|
66
|
+
let holder = null
|
|
67
|
+
try {
|
|
68
|
+
holder = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
69
|
+
}
|
|
70
|
+
catch { /* 损坏锁文件按残留处理 */ }
|
|
71
|
+
if (holder && pidAlive(holder.pid)) {
|
|
72
|
+
return { ok: false, lockPath, holder: { ...holder, port: holder.port ?? null } }
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
rmSync(lockPath)
|
|
76
|
+
}
|
|
77
|
+
catch { /* 抢不掉则下方 wx 再失败 */ }
|
|
78
|
+
}
|
|
79
|
+
const me = { pid: process.pid, startedAt: new Date().toISOString(), mode, port }
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(lockPath, JSON.stringify(me, null, 2), { flag: 'wx' })
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
// 与并发启动者竞态:按冲突处理
|
|
85
|
+
let holder = null
|
|
86
|
+
try {
|
|
87
|
+
holder = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
88
|
+
}
|
|
89
|
+
catch { /* ignore */ }
|
|
90
|
+
if (holder && pidAlive(holder.pid)) {
|
|
91
|
+
return { ok: false, lockPath, holder: { ...holder, port: holder.port ?? null } }
|
|
92
|
+
}
|
|
93
|
+
throw err
|
|
94
|
+
}
|
|
95
|
+
let released = false
|
|
96
|
+
const release = () => {
|
|
97
|
+
if (released) return
|
|
98
|
+
released = true
|
|
99
|
+
try {
|
|
100
|
+
const cur = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
101
|
+
if (cur?.pid !== process.pid) return // 锁已被他人接管,不误删
|
|
102
|
+
rmSync(lockPath)
|
|
103
|
+
}
|
|
104
|
+
catch { /* 已不存在/损坏,忽略 */ }
|
|
105
|
+
}
|
|
106
|
+
const releaseAndExit = (code) => {
|
|
107
|
+
release()
|
|
108
|
+
process.exit(code)
|
|
109
|
+
}
|
|
110
|
+
process.once('exit', release)
|
|
111
|
+
process.once('SIGINT', () => releaseAndExit(130))
|
|
112
|
+
process.once('SIGTERM', () => releaseAndExit(143))
|
|
113
|
+
return { ok: true, lockPath, release }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 端口占用探测:返回占用进程 PID(未占用返回 null)。TCP try-listen 后立刻释放。 */
|
|
117
|
+
export async function checkPort(host, port) {
|
|
118
|
+
const net = await import('node:net')
|
|
119
|
+
return new Promise((resolveProbe) => {
|
|
120
|
+
const srv = net.createServer()
|
|
121
|
+
srv.once('error', (err) => {
|
|
122
|
+
resolveProbe(err?.code === 'EADDRINUSE' ? port : null)
|
|
123
|
+
srv.close()
|
|
124
|
+
})
|
|
125
|
+
srv.listen(port, host, () => {
|
|
126
|
+
srv.close(() => resolveProbe(null))
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
}
|
package/tui/lib/config.mjs
CHANGED
|
@@ -1,58 +1,62 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// TUI 认证/连接配置 —— 落配置根 .AgentWorkShop/tui-auth.json(0600)。
|
|
3
|
-
// 解析口径与服务端一致:cwd 向上找 config.yml → <repo>/.AgentWorkShop,
|
|
4
|
-
// 找不到(全局安装任意 cwd)→ ~/.AgentWorkShop。
|
|
5
|
-
// ============================================================
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'
|
|
7
|
-
import { homedir } from 'node:os'
|
|
8
|
-
import { join, resolve, dirname } from 'node:path'
|
|
9
|
-
|
|
10
|
-
/** 向上查找文件(repo 模式判定) */
|
|
11
|
-
function findUp(startDir, filename) {
|
|
12
|
-
let dir = resolve(startDir)
|
|
13
|
-
for (;;) {
|
|
14
|
-
const candidate = join(dir, filename)
|
|
15
|
-
if (existsSync(candidate)) return candidate
|
|
16
|
-
const parent = dirname(dir)
|
|
17
|
-
if (parent === dir) return null
|
|
18
|
-
dir = parent
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** 配置根(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
// ============================================================
|
|
2
|
+
// TUI 认证/连接配置 —— 落配置根 .AgentWorkShop/tui-auth.json(0600)。
|
|
3
|
+
// 解析口径与服务端一致:cwd 向上找 config.yml → <repo>/.AgentWorkShop,
|
|
4
|
+
// 找不到(全局安装任意 cwd)→ ~/.AgentWorkShop。
|
|
5
|
+
// ============================================================
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { join, resolve, dirname } from 'node:path'
|
|
9
|
+
|
|
10
|
+
/** 向上查找文件(repo 模式判定) */
|
|
11
|
+
function findUp(startDir, filename) {
|
|
12
|
+
let dir = resolve(startDir)
|
|
13
|
+
for (;;) {
|
|
14
|
+
const candidate = join(dir, filename)
|
|
15
|
+
if (existsSync(candidate)) return candidate
|
|
16
|
+
const parent = dirname(dir)
|
|
17
|
+
if (parent === dir) return null
|
|
18
|
+
dir = parent
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 配置根(与服务端同策略):仅当用户自建了 <检出根>/.AgentWorkShop 时优先使用;
|
|
23
|
+
* 否则一律回落 ~/.AgentWorkShop(绝不在 pwd 下自动创建)。 */
|
|
24
|
+
export function configRoot(cwd = process.cwd()) {
|
|
25
|
+
const marker = findUp(cwd, 'config.yml')
|
|
26
|
+
if (marker) {
|
|
27
|
+
const candidate = join(dirname(marker), '.AgentWorkShop')
|
|
28
|
+
if (existsSync(candidate)) return candidate
|
|
29
|
+
}
|
|
30
|
+
return join(homedir(), '.AgentWorkShop')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const authPath = cwd => join(configRoot(cwd), 'tui-auth.json')
|
|
34
|
+
|
|
35
|
+
/** 读取已存凭据({ baseUrl, token, email? } 或 null) */
|
|
36
|
+
export function loadAuth(cwd = process.cwd()) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = JSON.parse(readFileSync(authPath(cwd), 'utf8'))
|
|
39
|
+
if (raw && typeof raw.token === 'string' && typeof raw.baseUrl === 'string') return raw
|
|
40
|
+
}
|
|
41
|
+
catch { /* 无凭据/损坏 → 重新登录 */ }
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 持久化凭据(0600;路径加入 .gitignore) */
|
|
46
|
+
export function saveAuth(auth, cwd = process.cwd()) {
|
|
47
|
+
const file = authPath(cwd)
|
|
48
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
49
|
+
writeFileSync(file, `${JSON.stringify(auth, null, 2)}\n`, 'utf8')
|
|
50
|
+
try {
|
|
51
|
+
chmodSync(file, 0o600)
|
|
52
|
+
}
|
|
53
|
+
catch { /* Windows FAT 卷 chmod 无效,忽略 */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 清除凭据(登出) */
|
|
57
|
+
export function clearAuth(cwd = process.cwd()) {
|
|
58
|
+
try {
|
|
59
|
+
existsSync(authPath(cwd)) && (writeFileSync(authPath(cwd), '{}\n', 'utf8'), true)
|
|
60
|
+
}
|
|
61
|
+
catch { /* 尽力而为 */ }
|
|
62
|
+
}
|