agentworkshop 0.7.4 → 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 -0
- package/cli/infra.mjs +108 -109
- package/config.yml +1 -1
- package/package.json +1 -1
- package/scripts/e2e-aw-tui-global.mjs +277 -0
- package/scripts/start.mjs +31 -8
- package/scripts/test-tui-commands.mjs +17 -0
- package/scripts/tui-smoke.mjs +13 -4
- package/shared/config/single-instance.mjs +129 -86
- package/tui/README.md +11 -2
- package/tui/aw-tui.mjs +64 -14
- package/tui/commands/index.mjs +43 -6
- package/tui/components/status-bar.mjs +4 -0
- package/tui/lib/config.mjs +62 -58
- package/tui/lib/pickers.mjs +102 -0
- package/tui/lib/state.mjs +6 -0
|
@@ -0,0 +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
|
+
}
|
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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全局 `aw tui` 完整 E2E(用户真实路径:npm i -g agentworkshop → aw tui):
|
|
3
|
+
* node scripts/e2e-aw-tui-global.mjs [--base http://127.0.0.1:3000]
|
|
4
|
+
*
|
|
5
|
+
* 前置:全局已安装 agentworkshop@0.7.4(npm root -g 可解析);dev/prod 服务运行。
|
|
6
|
+
* 场景:加载【全局包】的 tui 入口(非仓库源码)→ 建频道(内联 omp lead)→
|
|
7
|
+
* 成员列表 → 任务下发(触发 omp spawn)→ /monitor 独立监控接入 →
|
|
8
|
+
* /send 注入 ask → /hitl 定位作答 → REST 确认落定 → 终端帧证实答案
|
|
9
|
+
* 回传 agent → /task 正式任务 + /tasks 列表 → /monitor off → 清理。
|
|
10
|
+
*/
|
|
11
|
+
import { execSync } from 'node:child_process'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
const BASE = (() => {
|
|
17
|
+
const i = process.argv.indexOf('--base')
|
|
18
|
+
return i > 0 ? process.argv[i + 1] : 'http://127.0.0.1:3000'
|
|
19
|
+
})()
|
|
20
|
+
// --repo:加载仓库源码的 tui(开发验证新交互);默认加载全局安装包(用户真实路径)
|
|
21
|
+
const USE_REPO = process.argv.includes('--repo')
|
|
22
|
+
const WS_BASE = BASE.replace(/^http/, 'ws')
|
|
23
|
+
const TAG = Date.now().toString(36)
|
|
24
|
+
const ASK_INSTRUCTION = 'Use the ask tool NOW to ask me: Deploy? with options go and abort. After I answer, call complete_task with the deliverable set to my answer.'
|
|
25
|
+
|
|
26
|
+
let failures = 0
|
|
27
|
+
let passed = 0
|
|
28
|
+
const check = (name, ok, detail = '') => {
|
|
29
|
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
30
|
+
if (ok) passed += 1
|
|
31
|
+
else failures += 1
|
|
32
|
+
}
|
|
33
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
34
|
+
|
|
35
|
+
async function waitUntil(name, cond, timeoutMs = 300_000, intervalMs = 1000) {
|
|
36
|
+
const deadline = Date.now() + timeoutMs
|
|
37
|
+
let last = null
|
|
38
|
+
while (Date.now() < deadline) {
|
|
39
|
+
try {
|
|
40
|
+
last = await cond()
|
|
41
|
+
if (last) return last
|
|
42
|
+
}
|
|
43
|
+
catch (e) { last = e }
|
|
44
|
+
await sleep(intervalMs)
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`waitUntil timeout: ${name} (last=${String(last).slice(0, 200)})`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const api = async (method, path, { body, token } = {}) => {
|
|
50
|
+
const headers = { 'content-type': 'application/json' }
|
|
51
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
52
|
+
const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined })
|
|
53
|
+
return { status: res.status, ...(await res.json().catch(() => ({}))) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 终端 WS(答案回传验证用) */
|
|
57
|
+
function openTerm(agentId, channelId, token) {
|
|
58
|
+
const messages = []
|
|
59
|
+
const ws = new WebSocket(`${WS_BASE}/api/system/monitor/terminal/ws?agentId=${agentId}&channelId=${channelId}&token=${encodeURIComponent(token)}`)
|
|
60
|
+
ws.addEventListener('message', (ev) => {
|
|
61
|
+
try {
|
|
62
|
+
messages.push(JSON.parse(ev.data))
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
})
|
|
66
|
+
return { ws, messages, frames: () => messages.filter(m => m.type === 'term.frames').flatMap(m => m.frames) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 解析 TUI 入口:--repo = 仓库源码;默认 = 全局安装包(用户 `aw tui` 的真实加载路径) */
|
|
70
|
+
function tuiEntry() {
|
|
71
|
+
if (USE_REPO) {
|
|
72
|
+
const entry = join(process.cwd(), 'tui', 'aw-tui.mjs')
|
|
73
|
+
return { entry, ok: existsSync(entry), repo: true }
|
|
74
|
+
}
|
|
75
|
+
const root = execSync('npm root -g', { encoding: 'utf8' }).trim()
|
|
76
|
+
const entry = join(root, 'agentworkshop', 'tui', 'aw-tui.mjs')
|
|
77
|
+
return { entry, ok: existsSync(entry), repo: false }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
console.log(`\n━━━ 全局 aw tui 完整 E2E @ ${BASE} ━━━`)
|
|
82
|
+
|
|
83
|
+
// 0. 入口存在(全局包 = 0.7.4 发布包含 tui/;repo = 源码)
|
|
84
|
+
const g = tuiEntry()
|
|
85
|
+
check(`0.1 TUI 入口存在(${g.repo ? '仓库源码' : '全局包'})`, g.ok, g.entry)
|
|
86
|
+
if (!g.ok) process.exit(1)
|
|
87
|
+
|
|
88
|
+
// 登录
|
|
89
|
+
const reg = await api('POST', '/api/users/register', {
|
|
90
|
+
body: { email: `awtui-${TAG}@test.local`, password: 'Passw0rd!123', name: `awtui-${TAG}` },
|
|
91
|
+
})
|
|
92
|
+
const token = reg.data?.token
|
|
93
|
+
if (!token) throw new Error(`注册失败: ${JSON.stringify(reg).slice(0, 160)}`)
|
|
94
|
+
|
|
95
|
+
// 0.2 预建一个频道(启动频道选择器需要至少一个可选项;全局包模式同样受益)
|
|
96
|
+
const bootCh = (await api('POST', '/api/workshop/channels', {
|
|
97
|
+
body: { name: `awtui-boot-${TAG}`, description: '启动选择器测试频道' },
|
|
98
|
+
token,
|
|
99
|
+
})).data
|
|
100
|
+
check('0.2 预建启动频道', Boolean(bootCh?.channelId), `id=${bootCh?.channelId?.slice(0, 8)}`)
|
|
101
|
+
|
|
102
|
+
// 1. 加载 TUI,headless 驱动
|
|
103
|
+
const mod = await import(pathToFileURL(g.entry).href)
|
|
104
|
+
await mod.main(['--headless', '--url', BASE, '--token', token, '--channel', ''])
|
|
105
|
+
const vt = globalThis.__tuiSmokeTerminal
|
|
106
|
+
const type = async (text) => {
|
|
107
|
+
for (const c of text) vt.emitInput(c)
|
|
108
|
+
await sleep(50)
|
|
109
|
+
vt.emitInput('\r')
|
|
110
|
+
await sleep(400)
|
|
111
|
+
}
|
|
112
|
+
const waitTui = async (needle, timeoutMs = 300_000) => {
|
|
113
|
+
const deadline = Date.now() + timeoutMs
|
|
114
|
+
while (Date.now() < deadline) {
|
|
115
|
+
if (vt.text().includes(needle)) return true
|
|
116
|
+
await sleep(500)
|
|
117
|
+
}
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
check('1.1 TUI 启动就绪', await waitTui('TUI 已就绪', 30_000), g.entry.slice(-60))
|
|
121
|
+
|
|
122
|
+
// 1.2 [新交互] 启动频道选择器:↑↓ + Enter 选择预建频道;Esc 自动进第一个
|
|
123
|
+
if (USE_REPO) {
|
|
124
|
+
check('1.2 启动弹出频道选择器', await waitTui('选择要进入的频道', 20_000))
|
|
125
|
+
vt.emitInput('\x1b[B') // 下移一次,选中第二个(证明方向键导航)
|
|
126
|
+
await sleep(200)
|
|
127
|
+
vt.emitInput('\r')
|
|
128
|
+
check('1.3 Enter 进入所选频道', await waitTui('已切换到频道「', 30_000))
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
check('1.2 (全局包)自动接入频道', await waitTui('已切换到频道「', 30_000))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2. Channel 操作:列表 + 创建(内联 omp lead)+ 自动切换
|
|
135
|
+
await type('/channels')
|
|
136
|
+
check('2.1 /channels 列出频道', await waitTui('个:', 15_000))
|
|
137
|
+
const channelName = `awtui-e2e-${TAG}`
|
|
138
|
+
await type(`/channel new ${channelName} --lead 领航员 全局tui端到端频道`)
|
|
139
|
+
check('2.2 /channel new 创建成功', await waitTui('✔ 频道已创建', 30_000))
|
|
140
|
+
check('2.3 自动切换到新频道', await waitTui(`已切换到频道「${channelName}」`, 30_000))
|
|
141
|
+
|
|
142
|
+
// 2.4 [新交互] /channel use 无参 → 频道选择器 → 方向键导航到目标频道 → Enter
|
|
143
|
+
// (SelectList 的可打印字符过滤不生效,导航用方向键;序号按 REST 顺序计算)
|
|
144
|
+
if (USE_REPO) {
|
|
145
|
+
await type('/channel use')
|
|
146
|
+
check('2.4 /channel use 弹出频道选择器', await waitTui('选择要进入的频道', 20_000))
|
|
147
|
+
const order = (await api('GET', '/api/workshop/channels', { token })).data ?? []
|
|
148
|
+
const downTimes = Math.max(0, order.findIndex(c => c.name === channelName))
|
|
149
|
+
for (let i = 0; i < downTimes; i++) {
|
|
150
|
+
vt.emitInput('\x1b[B')
|
|
151
|
+
await sleep(150)
|
|
152
|
+
}
|
|
153
|
+
await sleep(200)
|
|
154
|
+
vt.emitInput('\r')
|
|
155
|
+
await sleep(800)
|
|
156
|
+
check('2.5 方向键选择后频道正确', await waitTui(`已切换到频道「${channelName}」`, 20_000))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. Agent 操作:成员列表出现内联建的 omp lead(选择器切换后刷新有竞态,轮询重发)
|
|
160
|
+
{
|
|
161
|
+
const ok = await waitUntil('3.1 /agents 出现 lead 成员', async () => {
|
|
162
|
+
await type('/agents')
|
|
163
|
+
return vt.text().includes('领航员(lead)') ? true : null
|
|
164
|
+
}, 30_000).catch(() => null)
|
|
165
|
+
check('3.1 /agents 出现 lead 成员', ok === true)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 4. 任务下发:普通文本 → lead(触发 omp spawn)
|
|
169
|
+
await type('请保持待命,等待操作员通过终端下达进一步指令。')
|
|
170
|
+
console.log(' … 等待 omp spawn(冷启动可达 2-3 分钟)…')
|
|
171
|
+
const ch = (await api('GET', '/api/workshop/channels', { token })).data?.find?.(c => c.name === channelName)
|
|
172
|
+
?? (await api('GET', '/api/workshop/channels', { token })).data?.at(-1)
|
|
173
|
+
const channelId = ch?.id
|
|
174
|
+
const members = channelId ? (await api('GET', `/api/workshop/channels/${channelId}/agents`, { token })).data ?? [] : []
|
|
175
|
+
const lead = members.find(a => a.role === 'lead')
|
|
176
|
+
check('4.1 频道/lead 实例可查(REST 交叉核对)', Boolean(lead?.id), `lead=${lead?.id?.slice(0, 8)}`)
|
|
177
|
+
|
|
178
|
+
const spawnOk = await waitUntil('4.2 omp 进程 spawn', async () => {
|
|
179
|
+
const mon = await api('GET', '/api/system/monitor', { token })
|
|
180
|
+
return (mon.data?.processes ?? []).find(p => p.harness === 'omp' && p.alive && p.agentId === lead?.id) ? true : null
|
|
181
|
+
}, 300_000).catch(() => null)
|
|
182
|
+
check('4.2 omp 进程 spawn(REST 监控)', spawnOk === true)
|
|
183
|
+
|
|
184
|
+
// 5. 独立 monitor:/monitor 领航员 → 终端面板接入
|
|
185
|
+
await type('/monitor 领航员')
|
|
186
|
+
check('5.1 /monitor 面板开启', await waitTui('监控已开启:领航员', 20_000))
|
|
187
|
+
const termConnected = await waitTui('已接入 领航员', 120_000)
|
|
188
|
+
check('5.2 监控面板接入 omp 会话(term.init)', termConnected)
|
|
189
|
+
|
|
190
|
+
// 6. HITL:等首回合结束 → /send 注入 ask → /hitl 定位作答
|
|
191
|
+
const idleOk = await waitUntil('6.1 lead 首回合结束(idle)', async () => {
|
|
192
|
+
const terms = await api('GET', `/api/workshop/channels/${channelId}/terminals`, { token })
|
|
193
|
+
const mine = (terms.data ?? []).find(t2 => t2.agentId === lead?.id)
|
|
194
|
+
return mine && mine.running === false ? true : null
|
|
195
|
+
}, 300_000).catch(() => null)
|
|
196
|
+
check('6.1 lead 空闲(可注入)', idleOk === true)
|
|
197
|
+
await sleep(1500)
|
|
198
|
+
await type(`/send 领航员 ${ASK_INSTRUCTION}`)
|
|
199
|
+
check('6.2 /send 直发回执', await waitTui('✔ 已直发 领航员', 30_000))
|
|
200
|
+
|
|
201
|
+
const item = await waitUntil('6.3 HITL 待办到达(REST Deploy?)', async () => {
|
|
202
|
+
const p = await api('GET', `/api/workshop/hitl/pending?channelId=${channelId}`, { token })
|
|
203
|
+
return (p.data?.items ?? []).find(i => String(i.title).includes('Deploy?')) ?? null
|
|
204
|
+
}, 300_000).catch(() => null)
|
|
205
|
+
check('6.3 omp ask → 全局待办(Deploy?)', Boolean(item), item ? `method=${item.method} options=${JSON.stringify(item.options)}` : 'timeout')
|
|
206
|
+
|
|
207
|
+
await type('/hitl')
|
|
208
|
+
check('6.4 /hitl 列表渲染', await waitTui('待人工处理', 20_000))
|
|
209
|
+
const row = await waitUntil('6.5 列表出现 Deploy? 行', () => {
|
|
210
|
+
const m = vt.text().match(/(\d+)\. \[omp-dialog\] Deploy\?/)
|
|
211
|
+
return m ? m[1] : null
|
|
212
|
+
}, 60_000).catch(() => null)
|
|
213
|
+
check('6.5 目标待办行定位', Boolean(row), `序号=${row}`)
|
|
214
|
+
await type(`/hitl ${row}`)
|
|
215
|
+
check('6.6 进入作答卡', await waitTui('HITL 作答', 20_000))
|
|
216
|
+
await type('1')
|
|
217
|
+
check('6.7 应答提交回执(选项 1 = go)', await waitTui('应答已提交', 30_000))
|
|
218
|
+
if (failures > 0) console.log('[diag 6.7 tail]', JSON.stringify(vt.text().slice(-700)))
|
|
219
|
+
|
|
220
|
+
const cleared = await waitUntil('6.8 REST 待办不再含 Deploy?', async () => {
|
|
221
|
+
const p = await api('GET', `/api/workshop/hitl/pending?channelId=${channelId}`, { token })
|
|
222
|
+
return ((p.data?.items ?? []).every(i => !String(i.title).includes('Deploy?'))) ? true : null
|
|
223
|
+
}, 60_000).catch(() => null)
|
|
224
|
+
check('6.8 应答写入服务端(待办落定)', cleared === true)
|
|
225
|
+
|
|
226
|
+
// 6.9 答案回传 agent 铁证:ask 工具结果含 go(独立终端 WS 帧)
|
|
227
|
+
const term = openTerm(lead.id, channelId, token)
|
|
228
|
+
const askFed = await waitUntil('6.9 ask 工具收到 go(终端帧)', () => {
|
|
229
|
+
const list = term.frames().filter(f => f.frame.type === 'tool_execution_end' && String(f.frame.toolName) === 'ask')
|
|
230
|
+
const lastAsk = list.at(-1)
|
|
231
|
+
return lastAsk ? String(lastAsk.frame.result ?? '').toLowerCase().includes('go') : null
|
|
232
|
+
}, 120_000).catch(() => null)
|
|
233
|
+
check('6.9 闭环:答案回传 agent(ask 结果含 go)', Boolean(askFed))
|
|
234
|
+
|
|
235
|
+
// 6.10-6.14 [新交互] Tab 目标选择器 + 通信/任务分流(仓库源码特性)
|
|
236
|
+
if (USE_REPO) {
|
|
237
|
+
vt.emitInput('\t')
|
|
238
|
+
check('6.10 Tab 呼出目标选择器', await waitTui('选择对话目标', 20_000))
|
|
239
|
+
vt.emitInput('\x1b[B')
|
|
240
|
+
await sleep(200)
|
|
241
|
+
vt.emitInput('\r')
|
|
242
|
+
check('6.11 选成员 → 通信模式 + 自动开监控', await waitTui('监控已开启:领航员', 20_000) && await waitTui('@领航员(通信)', 20_000))
|
|
243
|
+
await type('你在吗?看到请回应')
|
|
244
|
+
check('6.12 普通文本按通信直发目标', await waitTui('通信消息已发 → @领航员', 30_000))
|
|
245
|
+
vt.emitInput('\t')
|
|
246
|
+
await waitTui('选择对话目标', 20_000)
|
|
247
|
+
vt.emitInput('\r')
|
|
248
|
+
await sleep(400)
|
|
249
|
+
check('6.13 选频道 → 重置任务模式', await waitTui('频道(任务)', 20_000))
|
|
250
|
+
await type('/msg 心跳检查,请确认收到')
|
|
251
|
+
check('6.14 /msg 通信消息(目标重置后路由 lead)', await waitTui('通信消息已发 → @', 30_000))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 7. 正式任务 + 任务列表
|
|
255
|
+
await type('/task 汇报当前待命状态')
|
|
256
|
+
check('7.1 /task 正式任务提交', await waitTui('✔ 任务已发布', 30_000))
|
|
257
|
+
await type('/tasks')
|
|
258
|
+
check('7.2 /tasks 列表出现任务', await waitTui('汇报当前待命状态', 30_000))
|
|
259
|
+
|
|
260
|
+
// 8. monitor 关闭 + 清理
|
|
261
|
+
await type('/monitor off')
|
|
262
|
+
check('8.1 /monitor off 关闭', await waitTui('监控面板已关闭', 20_000))
|
|
263
|
+
await api('DELETE', `/api/workshop/channels/${channelId}?purge=1`, { token }).catch(() => {})
|
|
264
|
+
await api('DELETE', `/api/workshop/channels/${bootCh.channelId}?purge=1`, { token }).catch(() => {})
|
|
265
|
+
|
|
266
|
+
console.log(`\n━━━ 结果: ${passed} passed / ${failures} failed ━━━`)
|
|
267
|
+
console.log('(经 /quit 退出全局 TUI 进程)')
|
|
268
|
+
if (failures > 0) process.exit(1)
|
|
269
|
+
await type('/quit')
|
|
270
|
+
console.log('[warn] /quit 未按预期退出,兜底退出')
|
|
271
|
+
process.exit(0)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
main().catch((err) => {
|
|
275
|
+
console.error('FATAL', err.message)
|
|
276
|
+
process.exit(1)
|
|
277
|
+
})
|