@whyshu/argent 0.1.27 → 0.2.1
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/README.md +16 -65
- package/cordis.patch.yml +6 -0
- package/dist/client.js +691 -0
- package/mcp/billing-upload.mjs +77 -0
- package/mcp/erp-server.mjs +296 -0
- package/mcp/gateway.mjs +336 -0
- package/mcp/meter.mjs +101 -0
- package/mcp/vault.mjs +78 -0
- package/mcp/vendor/account-token.mjs +37 -0
- package/mcp/vendor/redact.mjs +34 -0
- package/package.json +28 -18
- package/src/account-token.mjs +40 -0
- package/src/actions.mjs +114 -0
- package/src/config.mjs +47 -0
- package/src/error-log.mjs +56 -0
- package/src/fulfillment-result.mjs +84 -0
- package/src/health.mjs +231 -0
- package/src/index.js +239 -0
- package/src/kernel.mjs +111 -0
- package/src/lease.mjs +90 -0
- package/src/llm-whyshu.mjs +155 -0
- package/src/machine-session.mjs +114 -0
- package/src/office-runtime.js +1102 -0
- package/src/skuflow-runner.mjs +57 -0
- package/src/timer-status.mjs +160 -0
- package/src/whyshu-auth.mjs +277 -0
- package/bin/argent.mjs +0 -385
- package/lib/index.mjs +0 -604
- package/runner/argent-runner.mjs +0 -223
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// 由 core/lib/account-token.mjs 复制(插件是可发布产物,⛔ 不跨目录 import 仓库)。
|
|
2
|
+
// 漂移由 scripts/tests/argent-bundle.test.mjs 的逐字节比对拦截。
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 账号 token 的唯一解析点(2026-11-03)
|
|
6
|
+
*
|
|
7
|
+
* 为什么要单独成文件:ERP MCP 适配层(core/mcp/erp-server.mjs)与 dsh 宿主插件
|
|
8
|
+
* (plugins/argent-erp-ui)都要用**同一把**问述账号 JWT,⛔ 不许各写一套"先读哪个文件"。
|
|
9
|
+
*
|
|
10
|
+
* 顺序(实测依据见 docs/Argent-0.9-ERP入口与MCP网关-规划.md):
|
|
11
|
+
* ① `ARGENT_ACCOUNT_TOKEN`(显式注入,测试/排障)
|
|
12
|
+
* ② `WHYSHU_API_KEY` 环境变量(宿主 profile 注入)
|
|
13
|
+
* ③ `~/.dsh/.credentials.yaml` 的 `refs.WHYSHU_API_KEY`(现役;只读,⛔ 绝不写)
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ `~/.dsh/argent/whyshu.env` 里的 JWT **已过期**(2026-11-03 实测 401),故意不读它。
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
/** 只读:从 credentials 文件里取 `WHYSHU_API_KEY: <token>`(窄正则,避免解析整份 YAML) */
|
|
22
|
+
export function readTokenFromCredentials(dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')) {
|
|
23
|
+
try {
|
|
24
|
+
const raw = readFileSync(join(dshHome, '.credentials.yaml'), 'utf8')
|
|
25
|
+
const m = raw.match(/WHYSHU_API_KEY\s*:\s*"?([A-Za-z0-9._-]{20,})/)
|
|
26
|
+
return m ? m[1] : ''
|
|
27
|
+
} catch {
|
|
28
|
+
return ''
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @returns {{token:string, source:'env-explicit'|'env-whyshu'|'credentials'|'none'}} */
|
|
33
|
+
export function resolveAccountToken({ env = process.env, dshHome } = {}) {
|
|
34
|
+
const explicit = String(env.ARGENT_ACCOUNT_TOKEN || '').trim()
|
|
35
|
+
if (explicit) return { token: explicit, source: 'env-explicit' }
|
|
36
|
+
const whyshu = String(env.WHYSHU_API_KEY || '').trim()
|
|
37
|
+
if (whyshu) return { token: whyshu, source: 'env-whyshu' }
|
|
38
|
+
const fromFile = readTokenFromCredentials(dshHome)
|
|
39
|
+
return fromFile ? { token: fromFile, source: 'credentials' } : { token: '', source: 'none' }
|
|
40
|
+
}
|
package/src/actions.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* actions.mjs —— 宿主壳跑引擎任务的**唯一入口**(方案 C · P2 第二步,2026-11-02)
|
|
3
|
+
*
|
|
4
|
+
* 背景:原先 `office-runtime.js` 直接 `spawn(python, ['scripts/*.py'])` 并自己持有 child。
|
|
5
|
+
* 切到 MCP 后同步 `execFileSync` 无法中途取消 ⇒ 会丢 `task_cancel`。
|
|
6
|
+
* 现在引擎侧补了 job 三件套(start/status/cancel),这里做**统一封装**:
|
|
7
|
+
* · 默认走 MCP(可取消、可轮询、有超时);
|
|
8
|
+
* · `ARGENT_DESKTOP_MCP_ACTIONS=0` 或 MCP 不可用 ⇒ 回退 spawn(保留旧语义)。
|
|
9
|
+
* 两条路径返回**同一形状**,调用方不需要知道自己走的是哪条。
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from 'node:child_process'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { pathToFileURL } from 'node:url'
|
|
15
|
+
import { engineDirSync } from './kernel.mjs'
|
|
16
|
+
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
18
|
+
|
|
19
|
+
let conn = null
|
|
20
|
+
let connDir = ''
|
|
21
|
+
|
|
22
|
+
async function getConn(engineDir) {
|
|
23
|
+
if (conn && connDir === engineDir) return conn
|
|
24
|
+
try { conn?.close?.() } catch { /* noop */ }
|
|
25
|
+
const mod = await import(pathToFileURL(join(engineDir, 'core', 'mcp', 'client.mjs')).href)
|
|
26
|
+
conn = mod.connect(join(engineDir, 'core', 'mcp', 'server.mjs'))
|
|
27
|
+
connDir = engineDir
|
|
28
|
+
await conn.rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'argent', version: '0.2.3' } })
|
|
29
|
+
conn.notify('notifications/initialized')
|
|
30
|
+
return conn
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function mcpCall(engineDir, name, args) {
|
|
34
|
+
const c = await getConn(engineDir)
|
|
35
|
+
const r = await c.rpc('tools/call', { name, arguments: args })
|
|
36
|
+
const text = (r.content && r.content[0] && r.content[0].text) || '{}'
|
|
37
|
+
try { return JSON.parse(text) } catch { return { ok: false, error: `MCP 返回非 JSON:${String(text).slice(0, 200)}` } }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lastJsonLine(out) {
|
|
41
|
+
const line = String(out || '').split('\n').map((x) => x.trim()).filter((x) => x.startsWith('{')).pop() || ''
|
|
42
|
+
if (!line) return {}
|
|
43
|
+
try { return JSON.parse(line) } catch { return {} }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** spawn 回退(旧语义:流式 stdout/stderr + 可 kill + 超时) */
|
|
47
|
+
function runViaSpawn({ engineDir, kind, record, confirm, step, timeoutMs, onStart }) {
|
|
48
|
+
const py = process.env.ARGENT_ENGINE_PY || join(process.env.HOME || '', '.dsh', 'argent', 'venv', 'bin', 'python3')
|
|
49
|
+
const argv = kind === 'skuflow'
|
|
50
|
+
? ['scripts/skuflow_invoice_runner.py', '--record-json', JSON.stringify(record), ...(confirm ? [] : ['--generate-only'])]
|
|
51
|
+
: ['scripts/orchestrator.py', '--record-json', JSON.stringify(record), '--no-lip',
|
|
52
|
+
...(step ? ['--mode', 'step', '--step', String(step)] : ['--mode', confirm ? 'full' : 'dry-run']),
|
|
53
|
+
...(confirm ? ['--allow-write'] : [])]
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
let stdout = ''
|
|
56
|
+
let stderr = ''
|
|
57
|
+
const child = spawn(py, argv, { cwd: engineDir, env: { ...process.env, PYTHONPATH: 'scripts' }, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
58
|
+
onStart?.({ pid: child.pid, kill: () => { try { child.kill('SIGTERM') } catch { /* noop */ } } })
|
|
59
|
+
const timer = timeoutMs ? setTimeout(() => { try { child.kill('SIGTERM') } catch { /* noop */ } }, timeoutMs) : null
|
|
60
|
+
if (timer) timer.unref?.()
|
|
61
|
+
child.stdout.on('data', (b) => { stdout += b.toString() })
|
|
62
|
+
child.stderr.on('data', (b) => { stderr += b.toString(); if (stderr.length > 20000) stderr = stderr.slice(-20000) })
|
|
63
|
+
child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, code: 2, stdout, stderr: String(e?.message || e), result: {} }) })
|
|
64
|
+
child.on('close', (code) => {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
const result = lastJsonLine(stdout)
|
|
67
|
+
resolve({ ok: code === 0 && result.ok !== false, code, stdout, stderr, result })
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 跑一个引擎任务(skuflow / orchestrator)。
|
|
74
|
+
* @returns {Promise<{ok:boolean, code:number|null, state?:string, stdout?:string, stderr?:string, result?:object, error?:string}>}
|
|
75
|
+
*/
|
|
76
|
+
export async function runEngineTask({ kind, record, confirm = false, step = null, timeoutMs = 0, onStart } = {}) {
|
|
77
|
+
const engineDir = engineDirSync()
|
|
78
|
+
if (!engineDir) return { ok: false, code: 2, error: '未找到引擎目录(argent.json 契约未建立)', result: {} }
|
|
79
|
+
const wantMcp = process.env.ARGENT_DESKTOP_MCP_ACTIONS !== '0' && existsSync(join(engineDir, 'core', 'mcp', 'client.mjs'))
|
|
80
|
+
if (!wantMcp) return runViaSpawn({ engineDir, kind, record, confirm, step, timeoutMs, onStart })
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const st = await mcpCall(engineDir, 'argent_job_start', {
|
|
84
|
+
kind, record, confirm, ...(step ? { step } : {}), ...(timeoutMs ? { timeoutMs } : {}),
|
|
85
|
+
})
|
|
86
|
+
if (!st.ok) return { ok: false, code: st.code ?? 2, error: st.error || 'job_start 失败', result: {} }
|
|
87
|
+
onStart?.({ jobId: st.jobId, pid: st.pid, kill: () => { mcpCall(engineDir, 'argent_job_cancel', { jobId: st.jobId }).catch(() => {}) } })
|
|
88
|
+
const deadline = timeoutMs ? Date.now() + timeoutMs : 0
|
|
89
|
+
for (;;) {
|
|
90
|
+
await sleep(400)
|
|
91
|
+
const s = await mcpCall(engineDir, 'argent_job_status', { jobId: st.jobId })
|
|
92
|
+
if (!s.ok) return { ok: false, code: 2, error: s.error || 'job_status 失败', result: {} }
|
|
93
|
+
if (s.state !== 'running' && s.state !== 'cancelling') {
|
|
94
|
+
return { ok: s.state === 'done' && s.code === 0, code: s.code, state: s.state, stdout: s.outputTail, stderr: s.errorTail, result: s.result || {} }
|
|
95
|
+
}
|
|
96
|
+
if (deadline && Date.now() > deadline) {
|
|
97
|
+
await mcpCall(engineDir, 'argent_job_cancel', { jobId: st.jobId }).catch(() => {})
|
|
98
|
+
return { ok: false, code: null, state: 'cancelled', error: `任务超时(${timeoutMs}ms)已取消`, result: {} }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// MCP 起不来(缺 client / 协议错)⇒ 回退 spawn,保证功能不因新通道挂掉
|
|
103
|
+
const r = await runViaSpawn({ engineDir, kind, record, confirm, step, timeoutMs, onStart })
|
|
104
|
+
r.stderr = `${r.stderr || ''}\n[actions] MCP 不可用,已回退 spawn:${e?.message || e}`.trim()
|
|
105
|
+
return r
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 关掉长连接(插件卸载/测试用) */
|
|
110
|
+
export function closeActions() {
|
|
111
|
+
try { conn?.close?.() } catch { /* noop */ }
|
|
112
|
+
conn = null
|
|
113
|
+
connDir = ''
|
|
114
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置契约:agent-report.json 的唯一解析点。
|
|
3
|
+
*
|
|
4
|
+
* 单独成文件是为了让 **office 运行时** 与 **health 聚合** 共用同一份解析,
|
|
5
|
+
* 避免"两处各写一遍 enabled/token 判据"(历史事故的根因形态)。
|
|
6
|
+
* 纯 node 内建,无宿主 import。
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
export const REPORT_FILE = 'agent-report.json'
|
|
13
|
+
export const MIN_INTERVAL_SEC = 5
|
|
14
|
+
export const MAX_INTERVAL_SEC = 300
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} dataDir
|
|
18
|
+
* @returns {false|{ok:false,reason:string,error?:string}|{ok:true,serverUrl:string,tenantAppId:string,machineId:string,runnerToken:string,machineName:string,intervalSec:number,includeSessionTitles:boolean,chatEnabled:boolean,chatPollSec:number}}
|
|
19
|
+
*/
|
|
20
|
+
export function readConfig(dataDir) {
|
|
21
|
+
if (!dataDir) return { ok: false, reason: 'no-data-dir' }
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(readFileSync(join(dataDir, REPORT_FILE), 'utf8'))
|
|
24
|
+
if (!raw || raw.enabled === false) return { ok: false, reason: 'disabled' }
|
|
25
|
+
const serverUrl = String(raw.serverUrl || '').replace(/\/+$/, '')
|
|
26
|
+
const tenantAppId = String(raw.tenantAppId || '').trim()
|
|
27
|
+
const machineId = String(raw.machineId || '').trim()
|
|
28
|
+
// Phase 1 兼容:优先 machineToken;旧配置仍可用 runnerToken(其值是 machine token)
|
|
29
|
+
const runnerToken = String(raw.machineToken || raw.runnerToken || '').trim()
|
|
30
|
+
if (!serverUrl || !tenantAppId || !machineId || !runnerToken) return { ok: false, reason: 'incomplete' }
|
|
31
|
+
return {
|
|
32
|
+
ok: true,
|
|
33
|
+
serverUrl,
|
|
34
|
+
tenantAppId,
|
|
35
|
+
machineId,
|
|
36
|
+
runnerToken,
|
|
37
|
+
machineName: String(raw.machineName || os.hostname()),
|
|
38
|
+
intervalSec: Math.min(Math.max(Number(raw.intervalSec) || 10, MIN_INTERVAL_SEC), MAX_INTERVAL_SEC),
|
|
39
|
+
includeSessionTitles: raw.includeSessionTitles !== false,
|
|
40
|
+
// P2:机器对话执行器(默认关;开启后本机出站轮询 ERP 命令并投给目标 dsh 会话)
|
|
41
|
+
chatEnabled: raw.chatEnabled === true,
|
|
42
|
+
chatPollSec: Math.min(Math.max(Number(raw.chatPollSec) || 2, 1), 30),
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { ok: false, reason: e?.code === 'ENOENT' ? 'missing' : 'invalid', error: String(e?.message || e) }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端错误日志(2026-11-06,场景一 P3b)
|
|
3
|
+
*
|
|
4
|
+
* 目的:断网/代理故障/鉴权失败时留一份**脱敏**现场,方便用户反馈与排查。
|
|
5
|
+
* · 落 `~/.dsh/argent/error-log.jsonl`(0600),单文件 2MB 轮转为 `.1`;
|
|
6
|
+
* · ⛔ 不记录 token / 请求正文 / 响应正文;只记 scope/code/message(≤300)/requestId/model;
|
|
7
|
+
* · 任何写盘失败都静默(不能因为日志把调用链弄挂)。
|
|
8
|
+
*/
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
const MAX_BYTES = 2 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
export function errorLogDir(dataDir = process.env.ARGENT_DATA_DIR || join(homedir(), '.dsh', 'argent')) {
|
|
16
|
+
return dataDir
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function errorLogPath(dataDir) {
|
|
20
|
+
return join(errorLogDir(dataDir), 'error-log.jsonl')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 脱敏 + 截断:去掉常见密钥形态,避免把凭据写进日志 */
|
|
24
|
+
export function maskMessage(value, max = 300) {
|
|
25
|
+
return String(value ?? '')
|
|
26
|
+
.replace(/\b(?:npm_|sk-)[A-Za-z0-9_-]{8,}/g, '***')
|
|
27
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer ***')
|
|
28
|
+
.slice(0, max)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 写一条客户端错误日志。
|
|
33
|
+
* @returns {boolean} 是否写入成功(失败静默,不抛)
|
|
34
|
+
*/
|
|
35
|
+
export function logClientError({ scope = '', code = '', message = '', requestId = '', model = '', dataDir } = {}) {
|
|
36
|
+
try {
|
|
37
|
+
const dir = errorLogDir(dataDir)
|
|
38
|
+
mkdirSync(dir, { recursive: true })
|
|
39
|
+
const file = errorLogPath(dir)
|
|
40
|
+
if (existsSync(file) && statSync(file).size > MAX_BYTES) {
|
|
41
|
+
try { renameSync(file, `${file}.1`) } catch { /* 轮转失败就继续追加 */ }
|
|
42
|
+
}
|
|
43
|
+
const line = JSON.stringify({
|
|
44
|
+
at: new Date().toISOString(),
|
|
45
|
+
scope: String(scope || '').slice(0, 80),
|
|
46
|
+
code: String(code || '').slice(0, 80),
|
|
47
|
+
message: maskMessage(message),
|
|
48
|
+
...(requestId ? { requestId: String(requestId).slice(0, 120) } : {}),
|
|
49
|
+
...(model ? { model: String(model).slice(0, 80) } : {}),
|
|
50
|
+
})
|
|
51
|
+
appendFileSync(file, `${line}\n`, { mode: 0o600 })
|
|
52
|
+
return true
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkuFlow 机器任务产物回传:
|
|
3
|
+
* agent 在最终回复末尾输出一行
|
|
4
|
+
* FULFILLMENT_RESULT: {"files":[{"path":"/abs/x.pdf","kind":"product_label"}]}
|
|
5
|
+
* 本模块解析路径、校验大小/扩展名,并通过 ERP 机器附件接口上传,返回 attachmentId。
|
|
6
|
+
*
|
|
7
|
+
* 红线:只接受绝对路径、pdf/png/jpg、单文件 ≤2MB、最多 10 个;不读目录、不执行命令。
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
10
|
+
import { basename, extname, isAbsolute, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
export const MARKER = 'FULFILLMENT_RESULT:'
|
|
13
|
+
export const MAX_FILES = 10
|
|
14
|
+
export const MAX_BYTES = 2 * 1024 * 1024
|
|
15
|
+
export const KIND_WHITELIST = new Set(['product_label', 'carton', 'bulk_label', 'invoice'])
|
|
16
|
+
const MIME = { '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
|
|
17
|
+
|
|
18
|
+
/** 从文本里提取最后一个 FULFILLMENT_RESULT JSON;解析失败只返回 errors,不抛。 */
|
|
19
|
+
export function extractFulfillmentResult(text) {
|
|
20
|
+
const s = String(text ?? '')
|
|
21
|
+
const idx = s.lastIndexOf(MARKER)
|
|
22
|
+
if (idx < 0) return { files: [], errors: [] }
|
|
23
|
+
let raw = s.slice(idx + MARKER.length).trim()
|
|
24
|
+
raw = raw.replace(/^```(?:json)?/i, '').replace(/```$/i, '').trim()
|
|
25
|
+
const start = raw.indexOf('{')
|
|
26
|
+
if (start < 0) return { files: [], errors: ['FULFILLMENT_RESULT 不是 JSON 对象'] }
|
|
27
|
+
let depth = 0
|
|
28
|
+
let inStr = false
|
|
29
|
+
let esc = false
|
|
30
|
+
let end = -1
|
|
31
|
+
for (let i = start; i < raw.length; i++) {
|
|
32
|
+
const ch = raw[i]
|
|
33
|
+
if (inStr) {
|
|
34
|
+
if (esc) esc = false
|
|
35
|
+
else if (ch === '\\') esc = true
|
|
36
|
+
else if (ch === '"') inStr = false
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (ch === '"') { inStr = true; continue }
|
|
40
|
+
if (ch === '{') depth++
|
|
41
|
+
else if (ch === '}') { depth--; if (depth === 0) { end = i; break } }
|
|
42
|
+
}
|
|
43
|
+
if (end < 0) return { files: [], errors: ['FULFILLMENT_RESULT JSON 未闭合'] }
|
|
44
|
+
let data
|
|
45
|
+
try { data = JSON.parse(raw.slice(start, end + 1)) } catch (e) {
|
|
46
|
+
return { files: [], errors: [`FULFILLMENT_RESULT JSON 解析失败: ${String(e?.message || e).slice(0, 160)}`] }
|
|
47
|
+
}
|
|
48
|
+
const files = (Array.isArray(data?.files) ? data.files : []).slice(0, MAX_FILES).map((f) => ({
|
|
49
|
+
path: String(f?.path || f?.localPath || f?.file || '').trim(),
|
|
50
|
+
kind: String(f?.kind || '').trim(),
|
|
51
|
+
filename: String(f?.filename || '').trim(),
|
|
52
|
+
})).filter((f) => f.path || f.filename)
|
|
53
|
+
return { files, errors: [] }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 上传产物;apiCall(method, path, body) 由调用方注入(office-runtime 的 ERP 客户端)。 */
|
|
57
|
+
export async function uploadFulfillmentFiles({ files = [], cmdBase = '', apiCall, maxBytes = MAX_BYTES } = {}) {
|
|
58
|
+
if (typeof apiCall !== 'function') throw new TypeError('apiCall 必须是函数')
|
|
59
|
+
const out = []
|
|
60
|
+
const errors = []
|
|
61
|
+
for (const f of files.slice(0, MAX_FILES)) {
|
|
62
|
+
try {
|
|
63
|
+
if (!f.path || !isAbsolute(f.path)) { errors.push(`跳过非绝对路径: ${f.filename || f.path || ''}`); continue }
|
|
64
|
+
if (!KIND_WHITELIST.has(f.kind)) { errors.push(`跳过非法 kind: ${f.kind || '(空)'}`); continue }
|
|
65
|
+
const real = resolve(f.path)
|
|
66
|
+
const ext = extname(real).toLowerCase()
|
|
67
|
+
if (!MIME[ext]) { errors.push(`跳过非 PDF/图片: ${basename(real)}`); continue }
|
|
68
|
+
const st = await stat(real)
|
|
69
|
+
if (!st.isFile()) { errors.push(`不是文件: ${basename(real)}`); continue }
|
|
70
|
+
if (st.size > maxBytes) { errors.push(`文件超过 ${maxBytes} 字节: ${basename(real)}`); continue }
|
|
71
|
+
const buf = await readFile(real)
|
|
72
|
+
const filename = (f.filename || basename(real)).slice(0, 200)
|
|
73
|
+
const res = await apiCall('POST', `${cmdBase}/attachments`, {
|
|
74
|
+
filename, mime: MIME[ext], dataBase64: buf.toString('base64'), kind: 'task-artifact',
|
|
75
|
+
}, 30_000)
|
|
76
|
+
const ref = res?.data?.attachment?._id || res?.attachment?._id || ''
|
|
77
|
+
if (!ref) { errors.push(`附件接口未返回 attachmentId: ${filename}`); continue }
|
|
78
|
+
out.push({ filename, kind: f.kind, ref })
|
|
79
|
+
} catch (e) {
|
|
80
|
+
errors.push(`${f.filename || f.path || '文件'} 上传失败: ${String(e?.message || e).slice(0, 160)}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { files: out, errors }
|
|
84
|
+
}
|
package/src/health.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 桌宠 / CLI 共用的健康聚合(2026-10-29)。
|
|
3
|
+
*
|
|
4
|
+
* 设计:
|
|
5
|
+
* · `buildHealth()` 是**纯函数**(配置 + 运行时状态 + 外部探测结果 → 检查项),可单测;
|
|
6
|
+
* · `collectHealth()` 负责取外部探测(3080 / ziniao-cli / 技能目录),带 TTL 缓存;
|
|
7
|
+
* · 不返回 token / 消息正文;`/api/argent/health` 由宿主认证兜底。
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from 'node:child_process'
|
|
10
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { promisify } from 'node:util'
|
|
14
|
+
import { readConfig, REPORT_FILE } from './config.mjs'
|
|
15
|
+
import { getWhyshuStatus } from './whyshu-auth.mjs'
|
|
16
|
+
import { collectTimer } from './timer-status.mjs'
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile)
|
|
19
|
+
|
|
20
|
+
const TTL = { web: 15_000, ziniao: 60_000, skills: 60_000, doctor: 5 * 60_000 }
|
|
21
|
+
const cache = { webAt: 0, web: null, ziniaoAt: 0, ziniao: null, skillsAt: 0, skills: null, doctorAt: 0, doctor: null }
|
|
22
|
+
|
|
23
|
+
const fmtAge = (ms) => {
|
|
24
|
+
if (!Number.isFinite(ms) || ms < 0) return '未知'
|
|
25
|
+
const s = Math.round(ms / 1000)
|
|
26
|
+
if (s < 60) return `${s}s`
|
|
27
|
+
const m = Math.round(s / 60)
|
|
28
|
+
if (m < 60) return `${m}m`
|
|
29
|
+
return `${Math.round(m / 60)}h`
|
|
30
|
+
}
|
|
31
|
+
const errText = (e) => String(e?.stderr || e?.stdout || e?.message || e || 'unknown').replace(/\s+/g, ' ').trim().slice(0, 240)
|
|
32
|
+
|
|
33
|
+
async function probeWeb() {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch('http://127.0.0.1:3080/', { signal: AbortSignal.timeout(800) })
|
|
36
|
+
try { await res.body?.cancel?.() } catch { /* noop */ }
|
|
37
|
+
return { up: true, status: res.status }
|
|
38
|
+
} catch {
|
|
39
|
+
return { up: false }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function ziniaoVersion() {
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execFileAsync('ziniao-cli', ['--version'], { timeout: 3000 })
|
|
46
|
+
return { ok: true, out: String(stdout).trim().split('\n')[0] }
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return { ok: false, error: errText(e) }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function ziniaoDoctor() {
|
|
53
|
+
try {
|
|
54
|
+
const { stdout } = await execFileAsync('ziniao-cli', ['doctor'], { timeout: 12_000 })
|
|
55
|
+
const pass = /全部检查通过/.test(String(stdout))
|
|
56
|
+
return { ok: pass, out: pass ? '全部检查通过' : String(stdout).trim().slice(-300) }
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return { ok: false, error: errText(e) }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function skillsCount() {
|
|
63
|
+
try {
|
|
64
|
+
return readdirSync(join(homedir(), '.agents', 'skills')).filter((n) => n.startsWith('ziniao-')).length
|
|
65
|
+
} catch {
|
|
66
|
+
return 0
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function configMode(dataDir) {
|
|
71
|
+
try {
|
|
72
|
+
return statSync(join(dataDir, REPORT_FILE)).mode & 0o777
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function agentsInfo(ctx) {
|
|
79
|
+
const svc = typeof ctx?.get === 'function' ? ctx.get('agents') : null
|
|
80
|
+
let list = []
|
|
81
|
+
let roots = []
|
|
82
|
+
try { list = svc?.list?.() || [] } catch { /* noop */ }
|
|
83
|
+
try { roots = svc?.roots?.() || [] } catch { /* noop */ }
|
|
84
|
+
return { agents: list.length, roots: roots.length }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 纯函数:由配置/状态/探测结果生成检查清单。
|
|
89
|
+
* @param {{cfg:object,state:object,chatState:object,extras:object,dataDir:string}} input
|
|
90
|
+
*/
|
|
91
|
+
export function buildHealth({ cfg, state = {}, chatState = {}, extras = {}, dataDir = '' }) {
|
|
92
|
+
const checks = []
|
|
93
|
+
const push = (id, label, level, detail = '') => checks.push({ id, label, level, detail })
|
|
94
|
+
const now = Date.now()
|
|
95
|
+
|
|
96
|
+
if (!cfg?.ok) {
|
|
97
|
+
const reason = cfg?.reason || 'unknown'
|
|
98
|
+
const text = reason === 'missing' ? '缺少 agent-report.json'
|
|
99
|
+
: reason === 'disabled' ? 'enabled=false(已停用)'
|
|
100
|
+
: reason === 'incomplete' ? '缺少 machineId / tenantAppId / token'
|
|
101
|
+
: reason === 'no-data-dir' ? '数据目录未解析'
|
|
102
|
+
: `配置不可用:${reason}`
|
|
103
|
+
push('config', '机器配置', 'error', text)
|
|
104
|
+
} else {
|
|
105
|
+
push('config', '机器配置', 'ok', `${cfg.machineId} / ${cfg.tenantAppId}`)
|
|
106
|
+
if (extras.configMode != null) {
|
|
107
|
+
const mode = extras.configMode
|
|
108
|
+
push('config-mode', '配置文件权限', mode === 0o600 ? 'ok' : 'warn',
|
|
109
|
+
mode === 0o600 ? '600' : `0${mode.toString(8)}(建议 600)`)
|
|
110
|
+
}
|
|
111
|
+
const age = state.lastOkAt ? now - Date.parse(state.lastOkAt) : Infinity
|
|
112
|
+
push('snapshot', 'ERP 上报', age <= 30_000 ? 'ok' : age <= 90_000 ? 'warn' : 'error',
|
|
113
|
+
state.lastOkAt ? `${fmtAge(age)} 前` : '从未成功')
|
|
114
|
+
push('snapshot-error', '上报错误', state.lastError ? 'error' : 'ok', state.lastError || '无')
|
|
115
|
+
|
|
116
|
+
if (!cfg.chatEnabled) {
|
|
117
|
+
push('chat', '机器对话', 'warn', '未启用(chatEnabled=false)')
|
|
118
|
+
} else if (!chatState.started) {
|
|
119
|
+
push('chat', '机器对话', 'error', '轮询未启动')
|
|
120
|
+
} else {
|
|
121
|
+
const cAge = chatState.lastOkAt ? now - Date.parse(chatState.lastOkAt) : Infinity
|
|
122
|
+
push('chat', '对话轮询', cAge <= 10_000 ? 'ok' : cAge <= 30_000 ? 'warn' : 'error',
|
|
123
|
+
chatState.lastOkAt ? `${fmtAge(cAge)} 前` : '从未成功')
|
|
124
|
+
push('chat-error', '对话错误', chatState.lastError ? 'error' : 'ok', chatState.lastError || '无')
|
|
125
|
+
if (chatState.lastCommandAt) push('chat-last', '最近命令', 'ok', `${fmtAge(now - Date.parse(chatState.lastCommandAt))} 前`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (extras.agents != null) {
|
|
130
|
+
push('agents', '桌面会话', extras.agents > 0 ? 'ok' : 'warn',
|
|
131
|
+
extras.agents > 0 ? `${extras.agents} 个 agent(根 ${extras.roots ?? '?'})` : '没有活会话(ERP AI 团队会为空)')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (extras.web) {
|
|
135
|
+
push('web-poller', '单实例', extras.web.up ? 'warn' : 'ok',
|
|
136
|
+
extras.web.up ? `web 3080 在跑(HTTP ${extras.web.status}),双 poller 风险` : 'web 3080 已关闭')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (extras.ziniao) {
|
|
140
|
+
push('ziniao', '紫鸟 CLI', extras.ziniao.ok ? 'ok' : 'warn',
|
|
141
|
+
extras.ziniao.ok ? extras.ziniao.out : (extras.ziniao.error || '未找到 ziniao-cli'))
|
|
142
|
+
}
|
|
143
|
+
if (extras.whyshu) {
|
|
144
|
+
const w = extras.whyshu
|
|
145
|
+
if (w.loggedIn) {
|
|
146
|
+
const d = w.daysLeft
|
|
147
|
+
push('whyshu', '问述账号', d != null && d <= 3 ? 'warn' : 'ok',
|
|
148
|
+
`${w.nickname || '已登录'}${w.plan ? ` · ${w.plan}` : ''} · 剩余 ${d ?? '?'} 天`)
|
|
149
|
+
} else if (w.expired) {
|
|
150
|
+
push('whyshu', '问述账号', 'warn', '登录已过期,请在桌宠重新登录')
|
|
151
|
+
} else if (w.invalid) {
|
|
152
|
+
push('whyshu', '问述账号', 'warn', 'token 无效,请重新登录')
|
|
153
|
+
} else {
|
|
154
|
+
push('whyshu', '问述账号', 'warn', '未登录(选「问述代理」前需一键登录)')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (extras.skills != null) {
|
|
158
|
+
push('skills', '紫鸟技能', extras.skills > 0 ? 'ok' : 'warn', `${extras.skills} 个(~/.agents/skills)`)
|
|
159
|
+
}
|
|
160
|
+
if (extras.doctor) {
|
|
161
|
+
push('doctor', '紫鸟自检', extras.doctor.ok ? 'ok' : 'warn',
|
|
162
|
+
extras.doctor.ok ? extras.doctor.out : (extras.doctor.error || extras.doctor.out || 'doctor 未通过'))
|
|
163
|
+
}
|
|
164
|
+
// 日报定时器(2026-10-30):引擎每小时直跑确定性脚本落状态文件;这里只读它 + LaunchAgent 在册状态。
|
|
165
|
+
// 为什么必须有这一项:日报不再产生会话/台账,**只有这份状态能回答"今天推没推"**。
|
|
166
|
+
if (extras.timer) {
|
|
167
|
+
const t = extras.timer
|
|
168
|
+
checks.push({ id: t.id, label: t.label, level: t.level, detail: t.detail })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const errors = checks.filter((c) => c.level === 'error').length
|
|
172
|
+
const warns = checks.filter((c) => c.level === 'warn').length
|
|
173
|
+
const level = errors > 0 ? 'error' : warns > 0 ? 'warn' : 'ok'
|
|
174
|
+
const summary = level === 'ok' ? 'ERP 已连接' : level === 'warn' ? `注意:${warns} 项待处理` : `异常:${errors} 项`
|
|
175
|
+
return {
|
|
176
|
+
ok: level !== 'error',
|
|
177
|
+
level,
|
|
178
|
+
summary,
|
|
179
|
+
at: new Date().toISOString(),
|
|
180
|
+
machineId: cfg?.ok ? cfg.machineId : '',
|
|
181
|
+
tenantAppId: cfg?.ok ? cfg.tenantAppId : '',
|
|
182
|
+
serverUrl: cfg?.ok ? cfg.serverUrl : '',
|
|
183
|
+
dataDir,
|
|
184
|
+
checks,
|
|
185
|
+
// 结构化载荷:桌宠面板要用(下次/上次推送/欠账),⛔ 客户端不要自己再算一份
|
|
186
|
+
timer: extras.timer?.timer || null,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function gatherExtras({ ctx, dataDir, refresh = false }) {
|
|
191
|
+
const now = Date.now()
|
|
192
|
+
if (refresh) {
|
|
193
|
+
cache.webAt = 0; cache.ziniaoAt = 0; cache.skillsAt = 0; cache.doctorAt = 0
|
|
194
|
+
}
|
|
195
|
+
if (!cache.web || now - cache.webAt > TTL.web) {
|
|
196
|
+
cache.web = await probeWeb()
|
|
197
|
+
cache.webAt = now
|
|
198
|
+
}
|
|
199
|
+
if (!cache.ziniao || now - cache.ziniaoAt > TTL.ziniao) {
|
|
200
|
+
cache.ziniao = await ziniaoVersion()
|
|
201
|
+
cache.ziniaoAt = now
|
|
202
|
+
}
|
|
203
|
+
if (cache.skills == null || now - cache.skillsAt > TTL.skills) {
|
|
204
|
+
cache.skills = skillsCount()
|
|
205
|
+
cache.skillsAt = now
|
|
206
|
+
}
|
|
207
|
+
if (refresh || (!cache.doctor || now - cache.doctorAt > TTL.doctor)) {
|
|
208
|
+
cache.doctor = refresh ? await ziniaoDoctor() : null
|
|
209
|
+
cache.doctorAt = now
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
...agentsInfo(ctx),
|
|
213
|
+
configMode: configMode(dataDir),
|
|
214
|
+
timer: await collectTimer({ dataDir, refresh }),
|
|
215
|
+
web: cache.web,
|
|
216
|
+
ziniao: cache.ziniao,
|
|
217
|
+
skills: cache.skills,
|
|
218
|
+
doctor: cache.doctor,
|
|
219
|
+
whyshu: await getWhyshuStatus(ctx, { refresh }),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 取一次健康结果(带缓存;refresh=true 强制重跑 3080/ziniao/doctor)。
|
|
225
|
+
* @param {{ctx:object,dataDir:string,state:object,chatState:object,refresh?:boolean}} input
|
|
226
|
+
*/
|
|
227
|
+
export async function collectHealth({ ctx, dataDir, state, chatState, refresh = false }) {
|
|
228
|
+
const cfg = readConfig(dataDir)
|
|
229
|
+
const extras = await gatherExtras({ ctx, dataDir, refresh })
|
|
230
|
+
return buildHealth({ cfg, state, chatState, extras, dataDir })
|
|
231
|
+
}
|