@bolloon/bolloon-agent 0.4.26 → 0.4.27
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/dist/agents/execution-supervisor.js +30 -0
- package/dist/agents/p2p-info.js +175 -0
- package/dist/agents/run-store.js +15 -0
- package/dist/agents/trace-export.js +125 -0
- package/dist/agents/write-staging.js +12 -4
- package/dist/agents/x402/goal-run-bridge.js +105 -0
- package/dist/agents/x402/milestone-settlement.js +150 -0
- package/dist/agents/x402/paid-info-store.js +66 -12
- package/dist/agents/x402/payment-recovery.js +290 -0
- package/dist/agents/x402/resource-contract.js +473 -0
- package/dist/agents/x402/settlement-state.js +378 -0
- package/dist/agents/x402/trade.js +257 -0
- package/dist/agents/x402/transaction-protocol.js +99 -0
- package/dist/agents/x402/transaction-store.js +350 -0
- package/dist/cli-entry.js +74 -0
- package/dist/index.js +103 -0
- package/dist/web/routes-x402-info.js +1 -0
- package/dist/web/server.js +82 -0
- package/package.json +1 -1
|
@@ -203,6 +203,35 @@ export class ExecutionSupervisor {
|
|
|
203
203
|
this.reconciledOnce = true;
|
|
204
204
|
if (report.reconciled.interrupted.length)
|
|
205
205
|
this.log(`[supervisor] 对账: ${report.reconciled.interrupted.length} 条僵尸 run → interrupted`);
|
|
206
|
+
// 1.1 (2026-09-18, Phase 3): 支付中断对账 —— 只把结算事实钉死, **绝不代替付款方花钱**
|
|
207
|
+
try {
|
|
208
|
+
const { reconcileInterruptedPayments } = await import('./x402/payment-recovery.js');
|
|
209
|
+
const home = process.env.HOME || os.homedir();
|
|
210
|
+
report.payments = await reconcileInterruptedPayments({
|
|
211
|
+
home,
|
|
212
|
+
reconcile: async (rec) => {
|
|
213
|
+
// 对账依据: 记录里既有的链上证据。查真链上历史属 Phase 1 (需 RPC/facilitator), 这里不猜。
|
|
214
|
+
if (rec.chainSettled === true && rec.txHash)
|
|
215
|
+
return { fact: 'payment_verified', txHash: rec.txHash, note: '对账: 记录自带 txHash 与链上结算事实' };
|
|
216
|
+
if (rec.txHash)
|
|
217
|
+
return { fact: 'payment_verified', txHash: rec.txHash, note: '对账: 发现 txHash → 结算事实升级' };
|
|
218
|
+
if (rec.paymentReceipt)
|
|
219
|
+
return { fact: 'unknown', note: '有支付凭据但无 txHash → 维持 unknown, 需 facilitator 澄清 (不重付)' };
|
|
220
|
+
return { fact: 'unpaid', note: '没有任何支付凭据 → 确认没付过 (可安全重试)' };
|
|
221
|
+
},
|
|
222
|
+
persist: async (transactionId, patch, event) => {
|
|
223
|
+
const { updateTransaction } = await import('./x402/transaction-store.js');
|
|
224
|
+
await updateTransaction(transactionId, { ...patch, event }, home);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
if (report.payments.reconciled.length || report.payments.mustNotRepay.length || report.payments.goalsWoken.length) {
|
|
228
|
+
this.log(`[supervisor] 支付对账: ${report.payments.reconciled.length} 条已钉结算事实 · ${report.payments.mustNotRepay.length} 条绝不重付 · `
|
|
229
|
+
+ `${report.payments.awaitingPayment.length} 条等付款 · **唤醒 ${report.payments.goalsWoken.length} 个 Goal** · ${report.payments.goalsFlagged.length} 个转人工`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
report.payments.errors.push(String(err?.message || err).slice(0, 120));
|
|
234
|
+
}
|
|
206
235
|
}
|
|
207
236
|
report.supervised = await superviseRuns();
|
|
208
237
|
// 1.5 (2026-09-16, 2-C.4): 外部等待超时 → 明确转人工 (不允许无限等待)
|
|
@@ -256,6 +285,7 @@ export class ExecutionSupervisor {
|
|
|
256
285
|
return {
|
|
257
286
|
at: new Date().toISOString(), owner: this.owner, tick: this.tickCount,
|
|
258
287
|
reconciled: { interrupted: [], stillRunning: [], failed: [] },
|
|
288
|
+
payments: { scanned: 0, reconciled: [], awaitingPayment: [], mustNotRepay: [], closed: [], goalsWoken: [], goalsFlagged: [], errors: [] },
|
|
259
289
|
supervised: { stalled: [], failed: [] },
|
|
260
290
|
claimed: [], executed: [], skipped: [], errors: [], dryRun: !this.runner,
|
|
261
291
|
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* p2p-info.ts — 本机 P2P 连接信息出口 (2026-09-18)
|
|
3
|
+
*
|
|
4
|
+
* 目的: 把"我这台机器/这台手机怎么被别的智能体拨通"变成**一条可抄走的连接信息**
|
|
5
|
+
* (peerId + 可拨入 multiaddr),好递给名片/交接串/对方智能体。
|
|
6
|
+
* 诚实原则: 只报真实拿到的;拿不到就说清原因与下一步,不编 peerId、不假装"能连通"。
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
function home() { return process.env.HOME || os.homedir(); }
|
|
12
|
+
function readJsonSafe(p) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function readIdentity() {
|
|
21
|
+
const p = path.join(home(), '.bolloon', 'identity', 'user.json');
|
|
22
|
+
const j = readJsonSafe(p);
|
|
23
|
+
return { did: j?.did, name: j?.name };
|
|
24
|
+
}
|
|
25
|
+
function readGatewayJoin() {
|
|
26
|
+
return readJsonSafe(path.join(home(), '.bolloon', 'gateway-join.json'));
|
|
27
|
+
}
|
|
28
|
+
/** 确保地址带 /p2p/<peerId> 段 (没有就补上, 否则对端拨不通) */
|
|
29
|
+
export function ensureDialable(addr, peerId) {
|
|
30
|
+
const a = String(addr || '').trim();
|
|
31
|
+
if (!a)
|
|
32
|
+
return a;
|
|
33
|
+
if (a.includes('/p2p/'))
|
|
34
|
+
return a;
|
|
35
|
+
return peerId ? `${a}/p2p/${peerId}` : a;
|
|
36
|
+
}
|
|
37
|
+
export async function getLocalP2pInfo() {
|
|
38
|
+
const info = { ok: false, source: 'none', multiaddrs: [], relayAddrs: [], isRelay: false };
|
|
39
|
+
const id = readIdentity();
|
|
40
|
+
info.did = id.did;
|
|
41
|
+
info.name = id.name;
|
|
42
|
+
// ① 先看本进程里节点是否真的在跑
|
|
43
|
+
try {
|
|
44
|
+
const mod = await import('../network/p2p.js');
|
|
45
|
+
const net = mod.p2pNetwork;
|
|
46
|
+
const node = net?.getNode?.();
|
|
47
|
+
if (net && node) {
|
|
48
|
+
const peerId = String(net.getNodePeerId?.() || '');
|
|
49
|
+
if (peerId) {
|
|
50
|
+
info.source = 'live';
|
|
51
|
+
info.peerId = peerId;
|
|
52
|
+
// 优先给浏览器/手机能用的 ws 地址; 没有 ws 时回退到节点真实全部地址 (不编造)
|
|
53
|
+
let raw = (net.getWsMultiaddrs?.() || []).map((m) => String(m));
|
|
54
|
+
if (!raw.length) {
|
|
55
|
+
try {
|
|
56
|
+
raw = (node.getMultiaddrs?.() || []).map((m) => String(m));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
raw = [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
info.multiaddrs = raw.map((m) => ensureDialable(m, peerId));
|
|
63
|
+
try {
|
|
64
|
+
const ra = (net.getRelayAddrs?.() || []).map((m) => String(m));
|
|
65
|
+
info.relayAddrs = ra.map((m) => ensureDialable(m, peerId));
|
|
66
|
+
}
|
|
67
|
+
catch { /* 没有中继也能继续 */ }
|
|
68
|
+
try {
|
|
69
|
+
const svc = net.getRelayServiceInfo?.();
|
|
70
|
+
if (svc) {
|
|
71
|
+
info.isRelay = !!svc.active;
|
|
72
|
+
info.relayService = {
|
|
73
|
+
active: !!svc.active,
|
|
74
|
+
protocol: svc.protocol,
|
|
75
|
+
maxReservations: svc.maxReservations,
|
|
76
|
+
reservations: svc.reservations,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { /* 忽略 */ }
|
|
81
|
+
try {
|
|
82
|
+
const nat = net.getNatStatus?.();
|
|
83
|
+
if (nat)
|
|
84
|
+
info.natStatus = String(nat.status || nat.kind || JSON.stringify(nat).slice(0, 60));
|
|
85
|
+
}
|
|
86
|
+
catch { /* 忽略 */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch { /* 客户端环境没有 p2p 模块 → 走落盘记录 */ }
|
|
91
|
+
// ② 落盘记录兜底 (CLI 一次性命令里节点通常没跑)
|
|
92
|
+
const gj = readGatewayJoin();
|
|
93
|
+
if (gj) {
|
|
94
|
+
info.joinedAt = gj.joinedAt;
|
|
95
|
+
info.capabilities = Array.isArray(gj.capabilities) ? gj.capabilities : undefined;
|
|
96
|
+
if (!info.peerId && gj.peerId) {
|
|
97
|
+
info.peerId = String(gj.peerId);
|
|
98
|
+
info.source = 'persisted';
|
|
99
|
+
}
|
|
100
|
+
if (!info.did && gj.did)
|
|
101
|
+
info.did = String(gj.did);
|
|
102
|
+
if (!info.name && gj.name)
|
|
103
|
+
info.name = String(gj.name);
|
|
104
|
+
}
|
|
105
|
+
if (info.peerId && !info.multiaddrs.length) {
|
|
106
|
+
info.note = '只拿到 peerId (落盘记录), 当前进程没有运行中的 P2P 节点 → 可拨入地址需要一个在跑的节点来生成 '
|
|
107
|
+
+ '(启动: `bolloon --web` 或让智能体执行 join_global_gateway; 之后在此重跑 `bolloon p2p`)';
|
|
108
|
+
}
|
|
109
|
+
else if (!info.peerId) {
|
|
110
|
+
info.note = '还没有本机 peerId: 先让智能体入网 (read https://bolloon.cn/bolloon-gateway-join.md) 或启动 P2P 节点, 再跑 `bolloon p2p`';
|
|
111
|
+
}
|
|
112
|
+
else if (!info.multiaddrs.length) {
|
|
113
|
+
info.note = '节点在跑, 但当前没有被拨入地址 (可能只有拨出能力); 若在手机端需先建立 relay 预约';
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
info.note = undefined;
|
|
117
|
+
}
|
|
118
|
+
info.ok = !!info.peerId;
|
|
119
|
+
return info;
|
|
120
|
+
}
|
|
121
|
+
/** 人可读 + 可直接抄进小工具/名片 (peerId 与 multiaddr 分开列, 方便逐项粘贴) */
|
|
122
|
+
export function formatP2pInfoText(info) {
|
|
123
|
+
const lines = [];
|
|
124
|
+
lines.push('── 本机 P2P 连接信息 ──────────────────────────────');
|
|
125
|
+
lines.push(`来源: ${info.source === 'live' ? '运行中的节点 (live)' : info.source === 'persisted' ? '落盘记录 (persisted)' : '无'}`);
|
|
126
|
+
if (info.name)
|
|
127
|
+
lines.push(`名称: ${info.name}`);
|
|
128
|
+
if (info.did)
|
|
129
|
+
lines.push(`身份: ${info.did}`);
|
|
130
|
+
lines.push(`peerId: ${info.peerId || '(未拿到)'}`);
|
|
131
|
+
if (info.capabilities?.length)
|
|
132
|
+
lines.push(`能力: ${info.capabilities.join(', ')}`);
|
|
133
|
+
if (info.natStatus)
|
|
134
|
+
lines.push(`NAT: ${info.natStatus}`);
|
|
135
|
+
lines.push(`本机是中继: ${info.isRelay ? '是' : '否'}${info.relayService?.active ? ` (协议 ${info.relayService.protocol || '-'}, 预约 ${info.relayService.reservations ?? '-'}/${info.relayService.maxReservations ?? '-'})` : ''}`);
|
|
136
|
+
if (info.multiaddrs.length) {
|
|
137
|
+
lines.push('可拨入地址:');
|
|
138
|
+
for (const m of info.multiaddrs.slice(0, 6))
|
|
139
|
+
lines.push(` ${m}`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
lines.push('可拨入地址: (当前没有 —— 对端暂时拨不进来)');
|
|
143
|
+
}
|
|
144
|
+
if (info.relayAddrs.length) {
|
|
145
|
+
lines.push('经中继可拨入:');
|
|
146
|
+
for (const m of info.relayAddrs.slice(0, 4))
|
|
147
|
+
lines.push(` ${m}`);
|
|
148
|
+
}
|
|
149
|
+
if (info.note)
|
|
150
|
+
lines.push(`说明: ${info.note}`);
|
|
151
|
+
lines.push('── 抄进小工具: 地址填上面的 multiaddr (含 /p2p/ 段), peerId 填上面的 peerId ──');
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
|
154
|
+
/** 机器可读 (给小工具/App/验收脚本用; 字段名与名片里的 p2p 结构对齐) */
|
|
155
|
+
export function formatP2pInfoJson(info) {
|
|
156
|
+
const primary = info.relayAddrs[0] || info.multiaddrs[0] || '';
|
|
157
|
+
return JSON.stringify({
|
|
158
|
+
schema: 'bolloon-p2p-info/1',
|
|
159
|
+
ok: info.ok,
|
|
160
|
+
source: info.source,
|
|
161
|
+
did: info.did || '',
|
|
162
|
+
name: info.name || '',
|
|
163
|
+
peerId: info.peerId || '',
|
|
164
|
+
multiaddr: primary,
|
|
165
|
+
multiaddrs: info.multiaddrs,
|
|
166
|
+
relayAddrs: info.relayAddrs,
|
|
167
|
+
isRelay: info.isRelay,
|
|
168
|
+
relayService: info.relayService || null,
|
|
169
|
+
natStatus: info.natStatus || null,
|
|
170
|
+
capabilities: info.capabilities || [],
|
|
171
|
+
note: info.note || null,
|
|
172
|
+
/** 直接可用的名片字段 (小工具 agent-card 的 p2p 结构) */
|
|
173
|
+
cardP2p: { peerId: info.peerId || '', multiaddr: primary, relay: info.isRelay ? (info.peerId || '') : '' },
|
|
174
|
+
}, null, 2);
|
|
175
|
+
}
|
package/dist/agents/run-store.js
CHANGED
|
@@ -485,6 +485,21 @@ export function argsDigestOf(v) {
|
|
|
485
485
|
* 追加一步 (工具调用后立即落盘) —— 崩在这里也能看到做到哪一步。
|
|
486
486
|
* 锁内读改写: 并发调用不会互相覆盖步骤。
|
|
487
487
|
*/
|
|
488
|
+
/**
|
|
489
|
+
* 追加证据到 Run (2026-09-16): 交易等"外部事实"要能进 Run 的 evidence,
|
|
490
|
+
* 而不是只留在工具内部 (支付必须可审计)。
|
|
491
|
+
*/
|
|
492
|
+
export async function addRunEvidence(runId, lines) {
|
|
493
|
+
return withRunLock(runId, () => coreWrite('addRunEvidence', runId, async () => {
|
|
494
|
+
const rec = await readRun(runId);
|
|
495
|
+
if (!rec)
|
|
496
|
+
return null;
|
|
497
|
+
const merged = Array.from(new Set([...(rec.evidence || []), ...lines.map((l) => String(l).slice(0, 300))])).slice(-50);
|
|
498
|
+
rec.evidence = merged;
|
|
499
|
+
await writeRun(rec);
|
|
500
|
+
return rec;
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
488
503
|
export async function recordStep(runId, step) {
|
|
489
504
|
return withRunLock(runId, () => coreWrite('recordStep', runId, async () => {
|
|
490
505
|
const rec = await readRun(runId);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trace-export.ts — 智能体**工具执行轨迹**的导出/解析 (2026-09-18)
|
|
3
|
+
*
|
|
4
|
+
* 定位: Run 的 steps[] 就是这台机器上真实发生过的工具调用事实
|
|
5
|
+
* (n / ts / tool / argsDigest / ok / ms / summary / error)。
|
|
6
|
+
* 这一层只做一件事: 把这份事实变成**可读、可交换、可再解析**的文本/JSON,
|
|
7
|
+
* 好让 App、CLI、Web 与外部工具(如小红书小工具)看到同一份轨迹。
|
|
8
|
+
*
|
|
9
|
+
* 文本格式 (小工具侧解析器依赖, 改前先看 minitools/agent-card/src/assets/app.js 的 parseTraceText):
|
|
10
|
+
* # Bolloon 执行轨迹 · run <runId> (<N> 步)
|
|
11
|
+
* 1. [ok] 2026-09-18T08:46:15.093Z shell_exec — ls -la /tmp (12ms)
|
|
12
|
+
* 2. [fail] 2026-09-18T08:46:17.001Z write_file — EACCES: permission denied (3ms)
|
|
13
|
+
* 规则: "<n>. [ok|fail] <无空格时间戳> <工具名> — <细节>" —— 时间戳与工具名不能含空格。
|
|
14
|
+
*/
|
|
15
|
+
export const TRACE_HEADER_PREFIX = '# Bolloon 执行轨迹';
|
|
16
|
+
function stepDetail(s) {
|
|
17
|
+
const base = String(s.summary || s.error || (s.ok ? '完成' : '失败')).replace(/\s+/g, ' ').trim();
|
|
18
|
+
const ms = s.ms ? ` (${s.ms}ms)` : '';
|
|
19
|
+
const args = s.argsDigest ? ` [args:${String(s.argsDigest).slice(0, 8)}]` : '';
|
|
20
|
+
return `${base}${args}${ms}`.slice(0, 400);
|
|
21
|
+
}
|
|
22
|
+
/** Run → 轨迹 JSON (机器可读; 所有消费方都应基于这个结构, 而不是各自解析文本) */
|
|
23
|
+
export function runToTraceJson(run) {
|
|
24
|
+
const steps = (run.steps || []).map((s) => ({
|
|
25
|
+
n: s.n,
|
|
26
|
+
ts: s.ts,
|
|
27
|
+
tool: s.tool,
|
|
28
|
+
ok: !!s.ok,
|
|
29
|
+
ms: s.ms,
|
|
30
|
+
summary: s.summary,
|
|
31
|
+
error: s.error,
|
|
32
|
+
argsDigest: s.argsDigest,
|
|
33
|
+
}));
|
|
34
|
+
const ok = steps.filter((s) => s.ok).length;
|
|
35
|
+
const byTool = new Map();
|
|
36
|
+
for (const s of steps) {
|
|
37
|
+
const cur = byTool.get(s.tool) || { tool: s.tool, count: 0, fail: 0 };
|
|
38
|
+
cur.count += 1;
|
|
39
|
+
if (!s.ok)
|
|
40
|
+
cur.fail += 1;
|
|
41
|
+
byTool.set(s.tool, cur);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
schema: 'bolloon-agent-trace/1',
|
|
45
|
+
runId: run.runId,
|
|
46
|
+
goalId: run.goalId,
|
|
47
|
+
surface: run.surface,
|
|
48
|
+
status: run.status,
|
|
49
|
+
goal: run.goal,
|
|
50
|
+
startedAt: run.startedAt || (run.steps || [])[0]?.ts,
|
|
51
|
+
updatedAt: run.updatedAt,
|
|
52
|
+
steps,
|
|
53
|
+
counts: { total: steps.length, ok, fail: steps.length - ok, totalMs: steps.reduce((a, s) => a + (s.ms || 0), 0) },
|
|
54
|
+
tools: Array.from(byTool.values()).sort((a, b) => b.count - a.count),
|
|
55
|
+
evidence: run.evidence,
|
|
56
|
+
error: run.error,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Run → 轨迹文本 (人可读 + 可被小工具解析; 供 CLI / 复制粘贴) */
|
|
60
|
+
export function runToTraceText(run, opts = {}) {
|
|
61
|
+
const steps = (run.steps || []).slice(opts.limit ? Math.max(0, (run.steps || []).length - opts.limit) : 0);
|
|
62
|
+
const lines = [];
|
|
63
|
+
lines.push(`${TRACE_HEADER_PREFIX} · run ${run.runId} (${(run.steps || []).length} 步)`);
|
|
64
|
+
if (run.goal)
|
|
65
|
+
lines.push(`# 目标: ${String(run.goal).replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
66
|
+
lines.push(`# 状态: ${run.status}${run.goalId ? ` · goal ${run.goalId}` : ''}${run.surface ? ` · surface ${run.surface}` : ''}`);
|
|
67
|
+
if (!steps.length)
|
|
68
|
+
lines.push('# (这次运行没有工具步骤)');
|
|
69
|
+
for (const s of steps) {
|
|
70
|
+
lines.push(`${s.n}. [${s.ok ? 'ok' : 'fail'}] ${s.ts} ${s.tool} — ${stepDetail(s)}`);
|
|
71
|
+
}
|
|
72
|
+
if (run.error)
|
|
73
|
+
lines.push(`# 结束原因: ${String(run.error).replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 解析轨迹文本 (容错: 只认 "<n>. [ok|fail] <ts> <tool> — <detail>" 这一行格式)。
|
|
78
|
+
* 用于: 把别处(小工具/别的机器)的轨迹读回来, 以及本模块的自校验往返。
|
|
79
|
+
*/
|
|
80
|
+
export function parseTraceText(text) {
|
|
81
|
+
const out = { steps: [] };
|
|
82
|
+
const lines = String(text || '').split('\n');
|
|
83
|
+
const re = /^\s*(\d+)\.\s*\[(ok|fail)\]\s*(\S+)\s+(\S+)\s*(?:—\s*)?([\s\S]*)$/;
|
|
84
|
+
for (const raw of lines) {
|
|
85
|
+
const line = raw.replace(/\r$/, '');
|
|
86
|
+
if (line.startsWith(TRACE_HEADER_PREFIX)) {
|
|
87
|
+
out.header = line;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (line.startsWith('# 目标:')) {
|
|
91
|
+
out.goal = line.slice(5).trim();
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (line.startsWith('# 状态:')) {
|
|
95
|
+
out.status = line.slice(5).trim();
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (line.startsWith('# 结束原因:')) {
|
|
99
|
+
out.errorLine = line.slice(7).trim();
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!line.trim() || line.startsWith('#'))
|
|
103
|
+
continue;
|
|
104
|
+
const m = re.exec(line);
|
|
105
|
+
if (!m)
|
|
106
|
+
continue;
|
|
107
|
+
const detail = String(m[5] || '');
|
|
108
|
+
const msMatch = /\s*\((\d+)ms\)\s*$/.exec(detail);
|
|
109
|
+
out.steps.push({
|
|
110
|
+
n: Number(m[1]),
|
|
111
|
+
ok: m[2] === 'ok',
|
|
112
|
+
ts: m[3],
|
|
113
|
+
tool: m[4],
|
|
114
|
+
ms: msMatch ? Number(msMatch[1]) : undefined,
|
|
115
|
+
summary: msMatch ? detail.slice(0, msMatch.index).trim() : detail.trim(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** 一行摘要 (CLI 列表/日志用) */
|
|
121
|
+
export function summarizeTrace(run) {
|
|
122
|
+
const j = runToTraceJson(run);
|
|
123
|
+
const tools = j.tools.slice(0, 3).map((t) => `${t.tool}×${t.count}`).join(' ');
|
|
124
|
+
return `${j.counts.total} 步 (✓${j.counts.ok}/✗${j.counts.fail}, ${j.counts.totalMs}ms)${tools ? ` · ${tools}` : ''}`;
|
|
125
|
+
}
|
|
@@ -18,9 +18,16 @@ const home = () => process.env.HOME || os.homedir() || '/tmp';
|
|
|
18
18
|
export function writeLogDir(homeDir = home()) {
|
|
19
19
|
return path.join(homeDir, '.bolloon', 'write-log');
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* 生成唯一 stage id。
|
|
23
|
+
* ★ 2026-09-18: 加**进程内单调序号** —— 原来只有 `Date.now()-随机`, 同一毫秒内的两次写入
|
|
24
|
+
* 顺序由随机后缀决定, `listStagedWrites` 的"最新在前"就不成立了 (真跑: 同毫秒 a.txt/b.txt 偶发反序,
|
|
25
|
+
* 让 write-staging 单测在全量跑时随机变红)。序号按 3 位 36 进制, 与时间戳一起保证字典序 == 发生顺序。
|
|
26
|
+
*/
|
|
27
|
+
let stageSeq = 0;
|
|
22
28
|
function genId() {
|
|
23
|
-
|
|
29
|
+
const seq = (stageSeq++ % 46656).toString(36).padStart(3, '0');
|
|
30
|
+
return `${Date.now()}-${seq}-${Math.random().toString(36).substring(2, 8)}`;
|
|
24
31
|
}
|
|
25
32
|
/**
|
|
26
33
|
* 写前暂存: 记录一次写操作 (准备阶段). 返回记录; 失败静默返回 null.
|
|
@@ -54,13 +61,14 @@ export async function listStagedWrites(homeDir = home()) {
|
|
|
54
61
|
return [];
|
|
55
62
|
}
|
|
56
63
|
const out = [];
|
|
57
|
-
for (const f of files.filter(f => f.endsWith('.json'))
|
|
64
|
+
for (const f of files.filter(f => f.endsWith('.json'))) {
|
|
58
65
|
try {
|
|
59
66
|
out.push(JSON.parse(await fs.readFile(path.join(dir, f), 'utf-8')));
|
|
60
67
|
}
|
|
61
68
|
catch { /* 坏文件跳过 */ }
|
|
62
69
|
}
|
|
63
|
-
|
|
70
|
+
// 最新在前: 先按 createdAt, 同一毫秒再按 id (id 内含单调序号) —— 确定性, 不看 readdir 顺序
|
|
71
|
+
return out.sort((a, b) => (b.createdAt - a.createdAt) || String(b.id).localeCompare(String(a.id)));
|
|
64
72
|
}
|
|
65
73
|
/** 撤销最近一次写 (若文件内容仍等于 afterContent → 恢复 beforeContent). 返回是否撤销. */
|
|
66
74
|
export async function undoLastWrite(homeDir = home()) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal-run-bridge.ts — 交易证据接入 Run / Goal (Phase 3, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 支付不能只是工具副作用: 一次 Run 必须能回答 —— 为什么付款、买了什么、花了多少、
|
|
5
|
+
* 是否真结算、拿到什么、资源是否验证通过、结果对 Goal 有没有帮助。
|
|
6
|
+
*
|
|
7
|
+
* 事件映射 (与 leo 的规格一致):
|
|
8
|
+
* discovered → transaction.discovered · quoted → transaction.quoted · policy_denied → transaction.policy_denied
|
|
9
|
+
* paying → transaction.paying · settled → transaction.settled · delivered → transaction.delivered
|
|
10
|
+
* verified → transaction.verified · delivery_failed/verification_failed → 同名
|
|
11
|
+
*
|
|
12
|
+
* Goal 侧只在 **交易 verified + 资源执行成功 + 命中判据** 时累计成功证据;
|
|
13
|
+
* 仅付款成功或仅拿到内容 **不能**满足 Goal 判据。
|
|
14
|
+
*/
|
|
15
|
+
import { recordStep, addRunEvidence } from '../run-store.js';
|
|
16
|
+
import { addEvidence as addGoalEvidenceViaStore } from '../goal-store.js';
|
|
17
|
+
import { aggregateMilestones, milestoneGoalEligibility } from './milestone-settlement.js';
|
|
18
|
+
export function bridgeEventFor(status) {
|
|
19
|
+
switch (status) {
|
|
20
|
+
case 'discovered': return 'transaction.discovered';
|
|
21
|
+
case 'quoted': return 'transaction.quoted';
|
|
22
|
+
case 'policy_denied': return 'transaction.policy_denied';
|
|
23
|
+
case 'paying':
|
|
24
|
+
case 'payment_required': return 'transaction.paying';
|
|
25
|
+
case 'settled': return 'transaction.settled';
|
|
26
|
+
case 'delivered': return 'transaction.delivered';
|
|
27
|
+
case 'verified': return 'transaction.verified';
|
|
28
|
+
case 'delivery_failed': return 'transaction.delivery_failed';
|
|
29
|
+
case 'verification_failed': return 'transaction.verification_failed';
|
|
30
|
+
default: return 'transaction.quoted';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** 一次交易的证据行 (写进 Run evidence / Goal evidence 的同一组字段) */
|
|
34
|
+
export function transactionEvidenceLines(rec) {
|
|
35
|
+
return [
|
|
36
|
+
`transactionId=${rec.transactionId}`,
|
|
37
|
+
`itemId=${rec.itemId}`,
|
|
38
|
+
`paymentMode=${rec.paymentMode}`,
|
|
39
|
+
`chainSettled=${rec.chainSettled}`,
|
|
40
|
+
`settlementFact=${rec.settlementFact || '(legacy-未记)'}`, // 两层状态: 钱到底动没动, 审计一眼可见
|
|
41
|
+
`protocolVerified=${rec.protocolVerified === true}`,
|
|
42
|
+
`txHash=${rec.txHash || '(none)'}`,
|
|
43
|
+
`receiptHash=${rec.receiptHash || '(none)'}`,
|
|
44
|
+
`contentHash=${rec.contentHash || '(none)'}`,
|
|
45
|
+
`verificationTrust=${rec.verificationTrust || 'unverified'}`,
|
|
46
|
+
`transactionStatus=${rec.status}`,
|
|
47
|
+
...(rec.responsibility ? [`responsibility=${rec.responsibility.type}(${rec.responsibility.reason})`] : []),
|
|
48
|
+
...(rec.milestones?.length ? (() => { const a = aggregateMilestones(rec.milestones); return [`milestones=${a.verified}/${a.total}`, `milestoneSettlement=${a.settlementFact}`]; })() : []),
|
|
49
|
+
...(rec.dispute ? [`dispute=opened(${rec.dispute.reason})`, `disputeResolved=${rec.dispute.resolution ? rec.dispute.resolution.decision : 'no'}`] : []),
|
|
50
|
+
...(rec.execution ? [`executionOk=${rec.execution.ok === true} schemaOk=${rec.execution.schemaOk === true}`] : []),
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 把一笔交易写进 Run (step + evidence) 与 Goal (仅在满足条件时)。
|
|
55
|
+
* @param opts.goalCriteriaHit 资源执行结果是否命中 Goal 判据 (由调用方判定, 默认 false)
|
|
56
|
+
* @param opts.executionOk 资源是否被实际执行且符合契约
|
|
57
|
+
*/
|
|
58
|
+
export async function bridgeTransactionToRunGoal(rec, opts = {}) {
|
|
59
|
+
const out = { runId: opts.runId, goalId: opts.goalId, stepWritten: false, evidenceWritten: false, goalEvidenceWritten: false };
|
|
60
|
+
const lines = transactionEvidenceLines(rec);
|
|
61
|
+
const ok = rec.status === 'verified' || rec.status === 'delivered';
|
|
62
|
+
if (opts.runId) {
|
|
63
|
+
try {
|
|
64
|
+
const step = {
|
|
65
|
+
tool: 'x402_transaction',
|
|
66
|
+
ok,
|
|
67
|
+
summary: `${bridgeEventFor(rec.status)} · ${opts.summary || rec.itemId} (${rec.amount || rec.price || '?'} ${rec.currency || ''} via ${rec.paymentMode} · 结算 ${rec.settlementFact || '?'})`,
|
|
68
|
+
error: ok ? undefined : (rec.failureReason || rec.status),
|
|
69
|
+
args: { itemId: rec.itemId, amount: rec.amount, currency: rec.currency, network: rec.network, requestId: rec.requestId },
|
|
70
|
+
};
|
|
71
|
+
await recordStep(opts.runId, step);
|
|
72
|
+
out.stepWritten = true;
|
|
73
|
+
await addRunEvidence(opts.runId, lines);
|
|
74
|
+
out.evidenceWritten = true;
|
|
75
|
+
}
|
|
76
|
+
catch { /* 记账失败不改变交易事实, 但上面会让 out.* 保持 false (调用方可见) */ }
|
|
77
|
+
}
|
|
78
|
+
// Goal 侧: 只有 verified + 执行成功 + 命中判据 才计入成功证据
|
|
79
|
+
if (opts.goalId) {
|
|
80
|
+
// 纵深防御 + Phase 4 门槛: 链上结算 + 全部里程碑完成 + 无争议 + 执行成功 + 命中判据
|
|
81
|
+
// (partially_settled 一律不算 —— leo: 不要让 partially_settled 直接进 Goal 成功证据)
|
|
82
|
+
const elig = milestoneGoalEligibility(rec, { executionOk: opts.executionOk, goalCriteriaHit: opts.goalCriteriaHit });
|
|
83
|
+
const eligible = elig.eligible;
|
|
84
|
+
try {
|
|
85
|
+
if (eligible) {
|
|
86
|
+
await addGoalEvidenceViaStore(opts.goalId, [
|
|
87
|
+
`付费资源已执行并命中判据: ${lines.join(' ')}`,
|
|
88
|
+
]);
|
|
89
|
+
out.goalEvidenceWritten = true;
|
|
90
|
+
}
|
|
91
|
+
else if (rec.status === 'verified' || (rec.milestones?.length || 0) > 0) {
|
|
92
|
+
await addGoalEvidenceViaStore(opts.goalId, [
|
|
93
|
+
`付费资源未达成功证据门槛 (${elig.reason}): ${lines.join(' ')}`,
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
await addGoalEvidenceViaStore(opts.goalId, [
|
|
98
|
+
`交易未成立 (${rec.status}): ${lines.join(' ')}`,
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch { /* 同上 */ }
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* milestone-settlement.ts — 里程碑结算 / 争议 / 责任落地 (Phase 4, 2026-09-18)
|
|
3
|
+
*
|
|
4
|
+
* leo 的 Phase 4:
|
|
5
|
+
* 4.1 **PartiallySettled**: 分阶段服务按里程碑结算 (第一版只支持明确里程碑)
|
|
6
|
+
* 每里程碑: milestoneId / amount / paymentStatus / deliveryStatus / verificationStatus / evidence
|
|
7
|
+
* 规则: 部分成功 → partially_settled; 全部支付且全部交付验证 → verified; 任一交付失败 → disputed 或 delivery_failed
|
|
8
|
+
* ★ `partially_settled` **不许**直接进 Goal 成功证据
|
|
9
|
+
* 4.2 **争议**: disputed / refund_pending / refunded; 争议必须绑定
|
|
10
|
+
* 原始报价 · Payment Header · facilitator response · txHash · 内容哈希 · 签名信封 · Run step · Goal evidence · 失败时点 · 责任候选
|
|
11
|
+
* 三条禁令: 不能自动重付 · 不能标 verified · **不能静默关闭**
|
|
12
|
+
* 4.3 **责任判定**: 机器只给候选 (Phase 0 的 deriveResponsibility), 候选连同证据进交易记录 + Run/Goal 证据
|
|
13
|
+
*/
|
|
14
|
+
import { deriveResponsibility } from './settlement-state.js';
|
|
15
|
+
/** 里程碑的不变式: 金额必须是正整数原子单位字符串 (不许浮点/负数/空) */
|
|
16
|
+
export function validateMilestoneSpec(m) {
|
|
17
|
+
if (!m.milestoneId)
|
|
18
|
+
return { ok: false, reason: 'milestoneId 不能为空' };
|
|
19
|
+
if (!/^\d+$/.test(String(m.amount)))
|
|
20
|
+
return { ok: false, reason: `amount 必须是正整数原子单位字符串, 实际: ${m.amount}` };
|
|
21
|
+
if (Number(m.amount) <= 0)
|
|
22
|
+
return { ok: false, reason: 'amount 必须大于 0' };
|
|
23
|
+
return { ok: true };
|
|
24
|
+
}
|
|
25
|
+
export function makeMilestone(m) {
|
|
26
|
+
const chk = validateMilestoneSpec(m);
|
|
27
|
+
if (!chk.ok)
|
|
28
|
+
throw new Error(chk.reason);
|
|
29
|
+
return {
|
|
30
|
+
milestoneId: m.milestoneId, title: m.title, amount: String(m.amount),
|
|
31
|
+
paymentStatus: 'unpaid', deliveryStatus: 'pending', verificationStatus: 'pending',
|
|
32
|
+
evidence: [], updatedAt: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** 里程碑总金额必须等于交易金额 (否则就是账不平) */
|
|
36
|
+
export function milestonesMatchAmount(ms, amount) {
|
|
37
|
+
const sum = ms.reduce((a, m) => a + BigInt(m.amount || '0'), 0n).toString();
|
|
38
|
+
if (amount && String(amount) !== sum)
|
|
39
|
+
return { ok: false, sum, reason: `里程碑金额合计 ${sum} ≠ 交易金额 ${amount}` };
|
|
40
|
+
return { ok: true, sum };
|
|
41
|
+
}
|
|
42
|
+
export function aggregateMilestones(ms) {
|
|
43
|
+
const list = ms || [];
|
|
44
|
+
const paid = list.filter((m) => m.paymentStatus === 'paid').length;
|
|
45
|
+
const delivered = list.filter((m) => m.deliveryStatus === 'delivered').length;
|
|
46
|
+
const verified = list.filter((m) => m.verificationStatus === 'verified').length;
|
|
47
|
+
const failed = list.filter((m) => m.deliveryStatus === 'failed' || m.verificationStatus === 'failed').length;
|
|
48
|
+
const allComplete = list.length > 0 && verified === list.length;
|
|
49
|
+
const anyProgress = paid > 0 || delivered > 0 || verified > 0;
|
|
50
|
+
const partiallyComplete = anyProgress && !allComplete && failed === 0;
|
|
51
|
+
const next = list.find((m) => m.verificationStatus !== 'verified' && m.deliveryStatus !== 'failed');
|
|
52
|
+
let settlementFact = 'unpaid';
|
|
53
|
+
if (allComplete)
|
|
54
|
+
settlementFact = 'fully_settled';
|
|
55
|
+
else if (partiallyComplete)
|
|
56
|
+
settlementFact = 'partially_settled';
|
|
57
|
+
else if (paid > 0 || delivered > 0)
|
|
58
|
+
settlementFact = 'payment_submitted';
|
|
59
|
+
const shouldDispute = failed > 0;
|
|
60
|
+
const reason = shouldDispute
|
|
61
|
+
? `${failed} 个里程碑交付/验真失败 → 进争议 (不许静默关闭)`
|
|
62
|
+
: allComplete ? '全部里程碑支付+交付+验真完成'
|
|
63
|
+
: partiallyComplete ? `${verified}/${list.length} 个里程碑完成 → partially_settled (不算完成, 也不进 Goal 成功证据)`
|
|
64
|
+
: list.length === 0 ? '没有里程碑 (单笔交易)' : '还没有里程碑完成';
|
|
65
|
+
return { total: list.length, paid, delivered, verified, failed, allComplete, partiallyComplete, nextMilestoneId: next?.milestoneId, settlementFact, shouldDispute, reason };
|
|
66
|
+
}
|
|
67
|
+
/** 应用一个里程碑结果并给出应落的结算事实 (纯函数; 落盘由调用方做, 保证可测) */
|
|
68
|
+
export function applyMilestoneResult(ms, milestoneId, result) {
|
|
69
|
+
const list = (ms || []).map((m) => ({ ...m, evidence: [...(m.evidence || [])] }));
|
|
70
|
+
const target = list.find((m) => m.milestoneId === milestoneId);
|
|
71
|
+
if (!target)
|
|
72
|
+
return { milestones: list, aggregate: aggregateMilestones(list), ok: false, reason: `没有这个里程碑: ${milestoneId}` };
|
|
73
|
+
if (result.paymentStatus)
|
|
74
|
+
target.paymentStatus = result.paymentStatus;
|
|
75
|
+
if (result.deliveryStatus)
|
|
76
|
+
target.deliveryStatus = result.deliveryStatus;
|
|
77
|
+
if (result.verificationStatus)
|
|
78
|
+
target.verificationStatus = result.verificationStatus;
|
|
79
|
+
if (result.evidence?.length)
|
|
80
|
+
target.evidence.push(...result.evidence);
|
|
81
|
+
target.updatedAt = new Date().toISOString();
|
|
82
|
+
return { milestones: list, aggregate: aggregateMilestones(list), ok: true };
|
|
83
|
+
}
|
|
84
|
+
const REQUIRED_EVIDENCE_FIELDS = [
|
|
85
|
+
'quote', 'txHash', 'contentHash', 'envelopeDigest', 'failurePoint',
|
|
86
|
+
];
|
|
87
|
+
/** 开争议: 记录绑定的证据 + 缺口; 生命周期进 disputed (自动化到此为止) */
|
|
88
|
+
export function buildDispute(opts) {
|
|
89
|
+
const missing = REQUIRED_EVIDENCE_FIELDS.filter((f) => {
|
|
90
|
+
const v = opts.evidence[f];
|
|
91
|
+
if (v === undefined || v === null)
|
|
92
|
+
return true;
|
|
93
|
+
if (typeof v === 'string')
|
|
94
|
+
return v.trim().length === 0;
|
|
95
|
+
if (Array.isArray(v))
|
|
96
|
+
return v.length === 0;
|
|
97
|
+
if (typeof v === 'object')
|
|
98
|
+
return Object.values(v).every((x) => x === undefined || x === null || x === '');
|
|
99
|
+
return false;
|
|
100
|
+
});
|
|
101
|
+
const responsibility = opts.evidence.responsibility
|
|
102
|
+
|| (opts.responsibilityEvidence ? deriveResponsibility(opts.responsibilityEvidence) : undefined);
|
|
103
|
+
return {
|
|
104
|
+
openedAt: new Date().toISOString(),
|
|
105
|
+
reason: opts.reason,
|
|
106
|
+
evidence: { ...opts.evidence, responsibility },
|
|
107
|
+
missingEvidence: missing,
|
|
108
|
+
mustNotRepay: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** 争议禁令的唯一实现在 settlement-state (`disputeForbids`), 这里只做转发, 避免两份判断漂移 */
|
|
112
|
+
export { disputeForbids } from './settlement-state.js';
|
|
113
|
+
/** 争议收尾: 必须带决定 + 依据 + 证据 (退款走结算层 refund_pending → refunded) */
|
|
114
|
+
export function resolveDispute(rec, opts) {
|
|
115
|
+
if (!rec.dispute)
|
|
116
|
+
throw new Error('这笔交易没有争议记录');
|
|
117
|
+
if (!opts.evidence?.length)
|
|
118
|
+
throw new Error('争议收尾必须带证据 (不许无凭据关闭)');
|
|
119
|
+
return {
|
|
120
|
+
...rec.dispute,
|
|
121
|
+
resolution: { decision: opts.decision, at: new Date().toISOString(), by: opts.by, reason: opts.reason, evidence: [...opts.evidence] },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 里程碑/争议叠加后的 Goal 成功证据门槛:
|
|
126
|
+
* 交易 verified 或 (全部里程碑完成 + 结算 fully_settled) ∧ 无争议 ∧ 执行成功 ∧ 命中判据
|
|
127
|
+
* —— `partially_settled` 一律不算 (leo: 不要让 partially_settled 直接进 Goal 成功证据)
|
|
128
|
+
*/
|
|
129
|
+
export function milestoneGoalEligibility(rec, opts = {}) {
|
|
130
|
+
if (rec.dispute && !rec.dispute.resolution)
|
|
131
|
+
return { eligible: false, reason: '交易在争议中 (未收尾) → 不计入 Goal 成功证据' };
|
|
132
|
+
const agg = aggregateMilestones(rec.milestones);
|
|
133
|
+
if (agg.total > 0) {
|
|
134
|
+
if (agg.shouldDispute)
|
|
135
|
+
return { eligible: false, reason: '里程碑有失败项 → 需争议处理, 不计入成功证据' };
|
|
136
|
+
if (!agg.allComplete)
|
|
137
|
+
return { eligible: false, reason: `里程碑未全部完成 (${agg.verified}/${agg.total}) → partially_settled 不计入成功证据` };
|
|
138
|
+
}
|
|
139
|
+
if (rec.settlementFact === 'partially_settled')
|
|
140
|
+
return { eligible: false, reason: '结算事实是 partially_settled → 不计入成功证据' };
|
|
141
|
+
if (rec.status !== 'verified')
|
|
142
|
+
return { eligible: false, reason: `交易状态是 ${rec.status}, 不是 verified` };
|
|
143
|
+
if (rec.chainSettled !== true)
|
|
144
|
+
return { eligible: false, reason: '链上没有真实结算' };
|
|
145
|
+
if (opts.executionOk !== true)
|
|
146
|
+
return { eligible: false, reason: '资源执行没成功 (买到 ≠ 用上)' };
|
|
147
|
+
if (opts.goalCriteriaHit !== true)
|
|
148
|
+
return { eligible: false, reason: '没有命中 Goal 判据' };
|
|
149
|
+
return { eligible: true, reason: '链上结算 + 全部里程碑完成 + 无争议 + 执行成功 + 命中判据' };
|
|
150
|
+
}
|