@bolloon/bolloon-agent 0.4.26 → 0.4.28
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 +51 -0
- package/dist/agents/p2p-info.js +175 -0
- package/dist/agents/run-store.js +15 -0
- package/dist/agents/task/local-seller.js +149 -0
- package/dist/agents/task/report-card.js +146 -0
- package/dist/agents/task/resource-advisor.js +182 -0
- package/dist/agents/task/task-budget.js +103 -0
- package/dist/agents/task/task-runner.js +618 -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 +109 -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 +484 -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 +168 -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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transaction-protocol.ts — 最小 Agent 资源交易协议 (Phase 0 冻结, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 设计来自 `docs/design-layer.md` / `docs/design-layer2.md` 的"Agent 资源交易闭环" +
|
|
5
|
+
* leo 的最小闭环规格 (跨境信息 0.001 USDC / Base Sepolia / x402 exact / 内容哈希+卖方签名+回执绑定)。
|
|
6
|
+
*
|
|
7
|
+
* 两条不可混淆的红线 (整个协议的可信度都建立在这上面):
|
|
8
|
+
* ① **本机联调 ≠ 真实支付**: `paymentMode='local-dev'` 的交易**永远不能**标 `verified`
|
|
9
|
+
* —— 它只是"协议闭环通过"(链上没动过钱), 验真分档最高到 `self-attested`。
|
|
10
|
+
* ② **支付成功 ≠ 交易成功**: 付款后交付失败是 `delivery_failed`, 验真失败是 `verification_failed`,
|
|
11
|
+
* 两者都**不是**成功; 付款回执不能脱离原内容复用。
|
|
12
|
+
*/
|
|
13
|
+
import { sha256Hex } from './paid-info-protocol.js';
|
|
14
|
+
// ── 交易状态 (leo 的 10 态; 括号里是 design-layer2 §三 九态机的对应) ──────────
|
|
15
|
+
export const TRANSACTION_STATUSES = [
|
|
16
|
+
'discovered', // 发现资源 (≈ Discovered)
|
|
17
|
+
'quoted', // 拿到报价/402 要求 (≈ Quoted)
|
|
18
|
+
'policy_denied', // 策略拒绝: 不解密/不签名/不扣预算/不交付
|
|
19
|
+
'payment_required', // 需要付款
|
|
20
|
+
'paying', // 付款中 (≈ Authorized+Accepted+Executing)
|
|
21
|
+
'settled', // 链上/联调结算完成
|
|
22
|
+
'delivered', // 内容已交付 (≈ Delivered)
|
|
23
|
+
'verified', // 全部条件满足 (仅 facilitator 真实结算可达)
|
|
24
|
+
'delivery_failed', // 付了钱但没拿到合格交付
|
|
25
|
+
'verification_failed', // 付了钱+拿到内容但验真不过
|
|
26
|
+
'failed', // 其它失败
|
|
27
|
+
];
|
|
28
|
+
export function newTransactionId(seed = Date.now().toString(36)) {
|
|
29
|
+
return `tx-${seed}-${Math.random().toString(36).slice(2, 8)}`;
|
|
30
|
+
}
|
|
31
|
+
export function event(kind, detail) {
|
|
32
|
+
return { at: new Date().toISOString(), kind, detail };
|
|
33
|
+
}
|
|
34
|
+
/** 回执哈希 = 对回执原文取哈希 (信封里 receiptHash 必须等于它) */
|
|
35
|
+
export function computeReceiptHash(receipt) {
|
|
36
|
+
return sha256Hex(String(receipt || ''));
|
|
37
|
+
}
|
|
38
|
+
/** 交付哈希 = 对买方拿到的正文取哈希 */
|
|
39
|
+
export function computeDeliveryHash(content) {
|
|
40
|
+
return sha256Hex(String(content ?? ''));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 最小成功条件 (全部满足才算 `verified`):
|
|
44
|
+
* 支付成功 + 收到内容 + contentHash 匹配 + 卖方签名验证通过 + 回执哈希与信封绑定
|
|
45
|
+
* + itemId/payTo/amount/network 全部一致 + **链上真的结算过**。
|
|
46
|
+
* 本机联调 (chainSettled=false) 最高只能是 `delivered` + `self-attested`。
|
|
47
|
+
*/
|
|
48
|
+
export function evaluateTransactionSuccess(rec) {
|
|
49
|
+
if (rec.paymentMode === 'local-dev' || rec.chainSettled !== true) {
|
|
50
|
+
return { success: false, reason: '本机联调: 协议闭环成立, 但链上没有真实结算 → 不能判 verified', chainRequired: true };
|
|
51
|
+
}
|
|
52
|
+
if (!rec.txHash)
|
|
53
|
+
return { success: false, reason: '没有 txHash', chainRequired: true };
|
|
54
|
+
if (!rec.contentHash || !rec.deliveryHash)
|
|
55
|
+
return { success: false, reason: '缺少内容哈希或交付哈希', chainRequired: true };
|
|
56
|
+
if (rec.contentHash !== rec.deliveryHash)
|
|
57
|
+
return { success: false, reason: '内容哈希不匹配 (内容被换过)', chainRequired: true };
|
|
58
|
+
if (rec.verificationTrust !== 'verified')
|
|
59
|
+
return { success: false, reason: `验真分档是 ${rec.verificationTrust || '未验'}, 不是 verified`, chainRequired: true };
|
|
60
|
+
if (!rec.receiptHash)
|
|
61
|
+
return { success: false, reason: '缺少回执哈希 (回执与信封未绑定)', chainRequired: true };
|
|
62
|
+
return { success: true, reason: '支付完成 + 内容匹配 + 卖方签名通过 + 回执绑定 + 字段一致 + 链上结算', chainRequired: true };
|
|
63
|
+
}
|
|
64
|
+
/** 402 要求与元数据是否自洽 (篡改任何一项都必须拒绝) */
|
|
65
|
+
export function validatePaymentRequirements(input) {
|
|
66
|
+
const r = input.requirements || {};
|
|
67
|
+
const m = input.metadata || {};
|
|
68
|
+
const exp = input.expected || {};
|
|
69
|
+
if (exp.itemId && m.itemId && exp.itemId !== m.itemId)
|
|
70
|
+
return { ok: false, reason: `402 的 itemId (${m.itemId}) 与预期 (${exp.itemId}) 不一致` };
|
|
71
|
+
// ★ 402 自带的 itemId 也要与 metadata/预期对齐 (与 payTo/network 的检查对称):
|
|
72
|
+
// 真跑抓到过 —— 402 声称另一条资源而 metadata 正常时, 原来完全不查。
|
|
73
|
+
const rItem = String(r.itemId || r.extra?.itemId || '');
|
|
74
|
+
if (rItem) {
|
|
75
|
+
if (m.itemId && rItem !== String(m.itemId))
|
|
76
|
+
return { ok: false, reason: `402 的 itemId (${rItem}) 与元数据 (${m.itemId}) 不一致 (被篡改?)` };
|
|
77
|
+
if (exp.itemId && rItem !== String(exp.itemId))
|
|
78
|
+
return { ok: false, reason: `402 的 itemId (${rItem}) 与预期 (${exp.itemId}) 不一致` };
|
|
79
|
+
}
|
|
80
|
+
if (!r.payTo)
|
|
81
|
+
return { ok: false, reason: '402 缺少收款地址 payTo' };
|
|
82
|
+
if (m.payTo && String(r.payTo).toLowerCase() !== String(m.payTo).toLowerCase())
|
|
83
|
+
return { ok: false, reason: '402 的 payTo 与元数据不一致 (被篡改?)' };
|
|
84
|
+
if (!r.amount)
|
|
85
|
+
return { ok: false, reason: '402 缺少金额' };
|
|
86
|
+
if (!r.network)
|
|
87
|
+
return { ok: false, reason: '402 缺少网络' };
|
|
88
|
+
if (m.network && String(r.network) !== String(m.network))
|
|
89
|
+
return { ok: false, reason: `402 的网络 (${r.network}) 与元数据 (${m.network}) 不一致` };
|
|
90
|
+
if (exp.networks && !exp.networks.includes(String(r.network)))
|
|
91
|
+
return { ok: false, reason: `网络 ${r.network} 不在允许网络里 (${exp.networks.join(', ')})` };
|
|
92
|
+
if (exp.maxAmount) {
|
|
93
|
+
const a = Number(r.amount) / 1e6; // USDC 原子单位 (6 位小数)
|
|
94
|
+
const max = Number(exp.maxAmount);
|
|
95
|
+
if (Number.isFinite(a) && Number.isFinite(max) && a > max)
|
|
96
|
+
return { ok: false, reason: `402 金额 ${a} 超过允许上限 ${max}` };
|
|
97
|
+
}
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transaction-store.ts — 交易记录与审计 (Phase 5, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 位置: `~/.bolloon/transactions/<transactionId>.json` (原子写)。
|
|
5
|
+
* 为什么必须有: 支付不能只是工具内部副作用 —— 进程被杀、买方重试、Supervisor 重启后,
|
|
6
|
+
* 必须能回答"这笔钱到底付了没有、拿到东西没有、算不算成功"。
|
|
7
|
+
*
|
|
8
|
+
* 幂等: `beginTransaction({requestId})` 命中已有记录 → 直接复用 (不重复付款)。
|
|
9
|
+
*/
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as fsp from 'fs/promises';
|
|
14
|
+
import { event, } from './transaction-protocol.js';
|
|
15
|
+
import { migrateTransactionRecord, deriveSettlementFact, checkLifecycleMove, canTransitionSettlement, isSettlementFact, hasPaymentEvidence, CURRENT_SCHEMA_VERSION, } from './settlement-state.js';
|
|
16
|
+
import { sha256Hex } from './paid-info-protocol.js';
|
|
17
|
+
/**
|
|
18
|
+
* 由 requestId **确定性派生** 交易 id。
|
|
19
|
+
* 为什么必须确定: 并发两个进程用同一 requestId 时, 随机 id + "标记已创建但内容未写入"的窗口
|
|
20
|
+
* 会让两边各建一条记录 (真跑抓到过: 同一 requestId 落了两条交易)。确定性 id 让"同一 requestId"
|
|
21
|
+
* 物理上只能指向同一个文件, 再用独占创建决定谁写第一份。
|
|
22
|
+
*/
|
|
23
|
+
export function transactionIdForRequest(requestId) {
|
|
24
|
+
return `tx-${sha256Hex(String(requestId)).slice(0, 12)}`;
|
|
25
|
+
}
|
|
26
|
+
/** 非法状态迁移 (拒绝写入, 不静默修正) */
|
|
27
|
+
export class IllegalTransactionTransition extends Error {
|
|
28
|
+
reason;
|
|
29
|
+
transactionId;
|
|
30
|
+
target;
|
|
31
|
+
constructor(reason, transactionId, target) {
|
|
32
|
+
super(`拒绝迁移 ${transactionId} → ${target}: ${reason}`);
|
|
33
|
+
this.reason = reason;
|
|
34
|
+
this.transactionId = transactionId;
|
|
35
|
+
this.target = target;
|
|
36
|
+
this.name = 'IllegalTransactionTransition';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function transactionsDir(home = os.homedir()) {
|
|
40
|
+
return path.join(home, '.bolloon', 'transactions');
|
|
41
|
+
}
|
|
42
|
+
function txPath(id, home) {
|
|
43
|
+
return path.join(transactionsDir(home), `${id}.json`);
|
|
44
|
+
}
|
|
45
|
+
export async function saveTransaction(rec, home = os.homedir()) {
|
|
46
|
+
await fsp.mkdir(transactionsDir(home), { recursive: true });
|
|
47
|
+
const p = txPath(rec.transactionId, home);
|
|
48
|
+
const tmp = `${p}.tmp`;
|
|
49
|
+
await fsp.writeFile(tmp, JSON.stringify(rec, null, 2), 'utf8');
|
|
50
|
+
await fsp.rename(tmp, p);
|
|
51
|
+
return rec;
|
|
52
|
+
}
|
|
53
|
+
export async function readTransaction(id, home = os.homedir()) {
|
|
54
|
+
const raw = await readTransactionRaw(id, home);
|
|
55
|
+
if (!raw)
|
|
56
|
+
return null;
|
|
57
|
+
const migrated = migrateTransactionRecord(raw);
|
|
58
|
+
if (migrated.changed) {
|
|
59
|
+
// 迁移必须可回放: 事件全保留 + 追加一条 migrate-v2; 写回时保留原文件备份
|
|
60
|
+
try {
|
|
61
|
+
const p = txPath(id, home);
|
|
62
|
+
if (fs.existsSync(p))
|
|
63
|
+
fs.copyFileSync(p, `${p}.bak-v1`);
|
|
64
|
+
await saveTransaction(migrated.record, home);
|
|
65
|
+
}
|
|
66
|
+
catch { /* 写不回不影响本次读取结果 */ }
|
|
67
|
+
}
|
|
68
|
+
return migrated.record;
|
|
69
|
+
}
|
|
70
|
+
async function readTransactionRaw(id, home = os.homedir()) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(await fsp.readFile(txPath(id, home), 'utf8'));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function listTransactions(home = os.homedir()) {
|
|
79
|
+
try {
|
|
80
|
+
const files = (await fsp.readdir(transactionsDir(home))).filter((f) => f.startsWith('tx-') && f.endsWith('.json'));
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const f of files) {
|
|
83
|
+
try {
|
|
84
|
+
out.push(JSON.parse(await fsp.readFile(path.join(transactionsDir(home), f), 'utf8')));
|
|
85
|
+
}
|
|
86
|
+
catch { /* 跳过坏文件 */ }
|
|
87
|
+
}
|
|
88
|
+
return out.sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** 幂等键查找: 同一个 requestId 只允许一笔交易 */
|
|
95
|
+
export async function findByRequestId(requestId, home = os.homedir()) {
|
|
96
|
+
const all = await listTransactions(home);
|
|
97
|
+
return all.find((t) => t.requestId === requestId) || null;
|
|
98
|
+
}
|
|
99
|
+
function reqMarkerPath(requestId, home) {
|
|
100
|
+
const safe = String(requestId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
|
|
101
|
+
return path.join(transactionsDir(home), `.req-${safe}`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 开始/复用一笔交易 (幂等 + **原子认领**)。
|
|
105
|
+
*
|
|
106
|
+
* 2026-09-16 修: 旧实现是"先查再写" —— 两个并发进程会同时看不到记录, 各写一条,
|
|
107
|
+
* 同一个 requestId 出现两笔交易 (真跑并发用例逼出来的)。现在用 O_EXCL 标记文件认领:
|
|
108
|
+
* 只有一个进程能创建记录, 另一个读它的 transactionId 复用。
|
|
109
|
+
*/
|
|
110
|
+
export async function beginTransaction(input, home = os.homedir()) {
|
|
111
|
+
await fsp.mkdir(transactionsDir(home), { recursive: true });
|
|
112
|
+
const id = transactionIdForRequest(input.requestId);
|
|
113
|
+
// ① 确定性 id 已经指向一份记录 → 直接复用 (文件可能正在被写 → 短暂轮询)
|
|
114
|
+
let existing = await readTransaction(id, home);
|
|
115
|
+
for (let i = 0; i < 20 && !existing && fs.existsSync(txPath(id, home)); i++) {
|
|
116
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
117
|
+
existing = await readTransaction(id, home);
|
|
118
|
+
}
|
|
119
|
+
if (existing)
|
|
120
|
+
return { record: existing, reused: true };
|
|
121
|
+
// ② 兼容历史上用随机 id 落盘的记录: 同一 requestId 有过交易就复用
|
|
122
|
+
const legacy = await findByRequestId(input.requestId, home);
|
|
123
|
+
if (legacy)
|
|
124
|
+
return { record: legacy, reused: true };
|
|
125
|
+
// ③ 真正没有 → 独占创建 (并发下只有一个能成功; 另一个回到 ①/② 复用)
|
|
126
|
+
const rec = {
|
|
127
|
+
transactionId: id,
|
|
128
|
+
requestId: input.requestId,
|
|
129
|
+
itemId: String(input.metadata.itemId || ''),
|
|
130
|
+
buyerDid: input.buyerDid,
|
|
131
|
+
providerDid: input.providerDid || String(input.metadata.providerDid || ''),
|
|
132
|
+
price: input.metadata.price,
|
|
133
|
+
currency: input.metadata.currency,
|
|
134
|
+
network: input.metadata.network,
|
|
135
|
+
payTo: input.metadata.payTo,
|
|
136
|
+
paymentMode: 'none',
|
|
137
|
+
chainSettled: false,
|
|
138
|
+
status: 'discovered',
|
|
139
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
140
|
+
settlementFact: 'unpaid',
|
|
141
|
+
goalId: input.goalId,
|
|
142
|
+
runId: input.runId,
|
|
143
|
+
startedAt: new Date().toISOString(),
|
|
144
|
+
events: [event('discovered', `item=${input.metadata.itemId || '?'} requestId=${input.requestId}`)],
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
const fh = await fsp.open(txPath(id, home), 'wx'); // ★ 独占: 并发只有一个成功
|
|
148
|
+
await fh.writeFile(JSON.stringify(rec, null, 2), 'utf8');
|
|
149
|
+
await fh.close();
|
|
150
|
+
return { record: rec, reused: false };
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// 别人抢先创建了 → 等它写完再复用 (绝不覆盖对方已经推进的状态)
|
|
154
|
+
let other = await readTransaction(id, home);
|
|
155
|
+
for (let i = 0; i < 20 && !other; i++) {
|
|
156
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
157
|
+
other = await readTransaction(id, home);
|
|
158
|
+
}
|
|
159
|
+
if (other)
|
|
160
|
+
return { record: other, reused: true };
|
|
161
|
+
const byReq = await findByRequestId(input.requestId, home);
|
|
162
|
+
if (byReq)
|
|
163
|
+
return { record: byReq, reused: true };
|
|
164
|
+
return { record: await saveTransaction(rec, home), reused: false };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** 追加事件 + 更新状态 (唯一写路径, 保证 events 有序可回放) */
|
|
168
|
+
export async function updateTransaction(transactionId, patch, home = os.homedir()) {
|
|
169
|
+
const rec = await readTransaction(transactionId, home);
|
|
170
|
+
if (!rec)
|
|
171
|
+
return null;
|
|
172
|
+
const { event: ev, ...rest } = patch;
|
|
173
|
+
// ★ Phase 0: 结算事实变化**无条件留痕** (调用方忘了给 event 也不许丢审计)
|
|
174
|
+
const factChanged = rest.settlementFact && String(rest.settlementFact) !== String(rec.settlementFact || '');
|
|
175
|
+
const ev2 = ev || (factChanged ? { kind: `settlement:${rest.settlementFact}`, detail: '结算事实变更 (自动留痕)' } : undefined);
|
|
176
|
+
// ★ Phase 0/4: 迁移必须合法 —— 非法就拒绝 (抛错), 不静默修正。
|
|
177
|
+
// 判定顺序: 先结算事实(它是状态的依据), 再状态; 两者都按**应用 patch 之后**的记录判断
|
|
178
|
+
// (允许"结算 + 状态"在一次写里原子推进, 例如 fully_settled + verified)。
|
|
179
|
+
if (rest.settlementFact && String(rest.settlementFact) !== String(rec.settlementFact || '')) {
|
|
180
|
+
const from = isSettlementFact(rec.settlementFact) ? String(rec.settlementFact) : deriveSettlementFact(rec);
|
|
181
|
+
const chk = canTransitionSettlement(from, String(rest.settlementFact), {
|
|
182
|
+
paymentMode: rec.paymentMode,
|
|
183
|
+
chainSettled: rec.chainSettled === true || rest.chainSettled === true,
|
|
184
|
+
txHash: String(rec.txHash || rest.txHash || ''),
|
|
185
|
+
});
|
|
186
|
+
if (!chk.ok)
|
|
187
|
+
throw new IllegalTransactionTransition(chk.reason || '非法结算迁移', transactionId, `fact:${rest.settlementFact}`);
|
|
188
|
+
}
|
|
189
|
+
if (rest.status && String(rest.status) !== String(rec.status)) {
|
|
190
|
+
const effective = (rest.settlementFact ? { ...rec, settlementFact: rest.settlementFact } : rec);
|
|
191
|
+
const chk = checkLifecycleMove(effective, String(rest.status));
|
|
192
|
+
if (!chk.ok)
|
|
193
|
+
throw new IllegalTransactionTransition(chk.reason || '非法迁移', transactionId, String(rest.status));
|
|
194
|
+
}
|
|
195
|
+
const next = {
|
|
196
|
+
...rec,
|
|
197
|
+
...rest,
|
|
198
|
+
events: ev2 ? [...(rec.events || []), event(ev2.kind, ev2.detail)] : (rec.events || []),
|
|
199
|
+
};
|
|
200
|
+
return await saveTransaction(next, home);
|
|
201
|
+
}
|
|
202
|
+
export async function setTransactionStatus(transactionId, status, detail, home = os.homedir()) {
|
|
203
|
+
return updateTransaction(transactionId, { status, ...(status === 'settled' ? { settledAt: new Date().toISOString() } : {}), ...(status === 'delivered' ? { deliveredAt: new Date().toISOString() } : {}), ...(status === 'verified' ? { verifiedAt: new Date().toISOString() } : {}), event: { kind: `status:${status}`, detail } }, home);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 结算事实的专用写路径 (钱动没动与生命周期分开记; 非法迁移一律拒绝)。
|
|
207
|
+
* `local-dev` 想写 payment_verified/fully_settled 会被这里挡下。
|
|
208
|
+
*/
|
|
209
|
+
export async function setSettlementFact(transactionId, fact, detail, home = os.homedir()) {
|
|
210
|
+
return updateTransaction(transactionId, { settlementFact: fact, event: { kind: `settlement:${fact}`, detail } }, home);
|
|
211
|
+
}
|
|
212
|
+
/** 审计回放 (CLI/Web 用): 一笔交易按时间顺序的完整事件链 */
|
|
213
|
+
export async function replayTransaction(transactionId, home = os.homedir()) {
|
|
214
|
+
const rec = await readTransaction(transactionId, home);
|
|
215
|
+
if (!rec)
|
|
216
|
+
return [];
|
|
217
|
+
return (rec.events || []).map((e) => `${e.at} ${e.kind}${e.detail ? ` — ${e.detail}` : ''}`);
|
|
218
|
+
}
|
|
219
|
+
/** 未完成交易 (付了钱但没交付/没验真) —— 重启后要能查出来, 不许静静丢掉 */
|
|
220
|
+
export async function pendingTransactions(home = os.homedir()) {
|
|
221
|
+
const all = await listTransactions(home);
|
|
222
|
+
return all.filter((t) => ['payment_required', 'paying', 'settled', 'delivery_failed', 'verification_failed'].includes(t.status));
|
|
223
|
+
}
|
|
224
|
+
// ── Phase 1: 支付阶段 claim (并发/重启安全) ────────────────────────────────
|
|
225
|
+
/** 已经付过钱的终态: **绝不允许**重新进入付款流程 (必须先对账) */
|
|
226
|
+
export const PAID_STATUSES = ['paying', 'settled', 'delivered', 'verified', 'delivery_failed', 'verification_failed'];
|
|
227
|
+
function claimPath(requestId, home) {
|
|
228
|
+
const safe = requestId.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
|
|
229
|
+
return path.join(transactionsDir(home), `.claim-${safe}`);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* 抢"这笔 requestId 的付款权" —— 用 O_EXCL 独占创建 claim 文件, 天然跨进程互斥。
|
|
233
|
+
* 两个进程同时用同一个 requestId 时: 只有一个能进入 paying, 另一个复用同一 transactionId。
|
|
234
|
+
*/
|
|
235
|
+
export async function claimPayment(requestId, transactionId, home = os.homedir()) {
|
|
236
|
+
await fsp.mkdir(transactionsDir(home), { recursive: true });
|
|
237
|
+
const existing = await findByRequestId(requestId, home);
|
|
238
|
+
if (existing && PAID_STATUSES.includes(existing.status)) {
|
|
239
|
+
// 已经付过 (或正在付/已交付) → 不允许再付一次, 复用同一交易
|
|
240
|
+
return { ok: false, reason: `该 requestId 已有交易处于 ${existing.status} (不允许重复付款)`, record: existing };
|
|
241
|
+
}
|
|
242
|
+
const cp = claimPath(requestId, home);
|
|
243
|
+
try {
|
|
244
|
+
const fh = await fsp.open(cp, 'wx'); // ★ 独占: 并发只有一个成功
|
|
245
|
+
await fh.writeFile(JSON.stringify({ holder: transactionId, pid: process.pid, at: new Date().toISOString() }), 'utf8');
|
|
246
|
+
await fh.close();
|
|
247
|
+
return { ok: true, holder: transactionId, record: existing || undefined };
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
// 已被别人持有 → 看看持有者是否还活着 (死了就接管)
|
|
251
|
+
try {
|
|
252
|
+
const info = JSON.parse(await fsp.readFile(cp, 'utf8'));
|
|
253
|
+
const alive = Number(info?.pid) ? isPidAlive(Number(info.pid)) : true;
|
|
254
|
+
if (!alive) {
|
|
255
|
+
await fsp.rm(cp, { force: true });
|
|
256
|
+
const fh = await fsp.open(cp, 'wx');
|
|
257
|
+
await fh.writeFile(JSON.stringify({ holder: transactionId, pid: process.pid, at: new Date().toISOString() }), 'utf8');
|
|
258
|
+
await fh.close();
|
|
259
|
+
return { ok: true, holder: transactionId, record: existing || undefined };
|
|
260
|
+
}
|
|
261
|
+
return { ok: false, reason: `付款权被另一个进程持有 (holder=${info?.holder}, pid=${info?.pid}) → 复用同一交易, 不重复付款`, record: existing || undefined };
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return { ok: false, reason: `无法取得付款权: ${String(err?.message || err).slice(0, 120)}`, record: existing || undefined };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export async function releasePaymentClaim(requestId, home = os.homedir()) {
|
|
269
|
+
await fsp.rm(claimPath(requestId, home), { force: true }).catch(() => { });
|
|
270
|
+
}
|
|
271
|
+
function isPidAlive(pid) {
|
|
272
|
+
try {
|
|
273
|
+
process.kill(pid, 0);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 对账: SIGKILL 之后重启必须先看"钱到底付了没有", 再决定能不能重付。
|
|
282
|
+
* paying + 无 txHash → payment_pending (可安全重试: 没证据证明付过)
|
|
283
|
+
* settled/delivered/verified → 绝不重付, 只恢复交付/验真
|
|
284
|
+
*/
|
|
285
|
+
export async function reconcilePendingTransactions(home = os.homedir()) {
|
|
286
|
+
const all = await listTransactions(home);
|
|
287
|
+
const requeued = [];
|
|
288
|
+
const mustNotRepay = [];
|
|
289
|
+
const notes = [];
|
|
290
|
+
for (const t of all) {
|
|
291
|
+
const fact = isSettlementFact(t.settlementFact) ? t.settlementFact : deriveSettlementFact(t);
|
|
292
|
+
const evidence = hasPaymentEvidence({ ...t, settlementFact: fact });
|
|
293
|
+
const status = String(t.status);
|
|
294
|
+
// ★ Phase 3: 有支付证据 (或有链上事实) → 一律进 mustNotRepay, 而且要把事实钉死
|
|
295
|
+
if (evidence) {
|
|
296
|
+
mustNotRepay.push(t.transactionId);
|
|
297
|
+
if (fact === 'unknown' || fact === 'payment_submitted') {
|
|
298
|
+
// 有 txHash 就能确认链上结算; 没有就维持"待确认", 继续挂在对账队列
|
|
299
|
+
if (t.txHash) {
|
|
300
|
+
try {
|
|
301
|
+
await setSettlementFact(t.transactionId, t.chainSettled ? 'payment_verified' : 'payment_verified', '对账: 发现 txHash → 结算事实升级为 payment_verified', home);
|
|
302
|
+
}
|
|
303
|
+
catch { /* 非法迁移则保持原值 */ }
|
|
304
|
+
notes.push(`${t.transactionId}: 发现 txHash → payment_verified (不重付)`);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
notes.push(`${t.transactionId}: 有支付凭据但没有 txHash → 维持 ${fact}, 需 facilitator 澄清 (不重付)`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
// 没有支付证据的"付款中/待付款" → 允许安全重试 (写清对账依据)
|
|
313
|
+
if (status === 'paying' || status === 'payment_required' || fact === 'unknown') {
|
|
314
|
+
try {
|
|
315
|
+
await setSettlementFact(t.transactionId, 'unpaid', '对账: 没有支付凭据/txHash → 确认没付过', home);
|
|
316
|
+
}
|
|
317
|
+
catch { /* 已是 unpaid 则忽略 */ }
|
|
318
|
+
if (status !== 'payment_required') {
|
|
319
|
+
await updateTransaction(t.transactionId, {
|
|
320
|
+
status: 'payment_required',
|
|
321
|
+
event: { kind: 'reconcile', detail: '重启对账: 没有支付证据 → 允许安全重试' },
|
|
322
|
+
}, home);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
await updateTransaction(t.transactionId, { event: { kind: 'reconcile', detail: '重启对账: 仍无支付证据 → 保持 payment_required (可安全重试)' } }, home);
|
|
326
|
+
}
|
|
327
|
+
await releasePaymentClaim(t.requestId, home);
|
|
328
|
+
requeued.push(t.transactionId);
|
|
329
|
+
notes.push(`${t.transactionId}: 无支付证据 → payment_required + unpaid (可安全重试)`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { requeued, mustNotRepay, notes };
|
|
333
|
+
}
|
|
334
|
+
export async function spentSummary(home = os.homedir()) {
|
|
335
|
+
const all = await listTransactions(home);
|
|
336
|
+
let total = 0;
|
|
337
|
+
const byMode = {};
|
|
338
|
+
let count = 0;
|
|
339
|
+
for (const t of all) {
|
|
340
|
+
if (!['settled', 'delivered', 'verified', 'delivery_failed', 'verification_failed'].includes(t.status))
|
|
341
|
+
continue;
|
|
342
|
+
// 金额只认 amount (原子单位/十进制字符串), 绝不 Number(price 对象)
|
|
343
|
+
const rawAmount = typeof t.amount === 'string' || typeof t.amount === 'number' ? t.amount : undefined;
|
|
344
|
+
const amt = Number(rawAmount ?? 0) || 0;
|
|
345
|
+
total += amt;
|
|
346
|
+
count++;
|
|
347
|
+
byMode[t.paymentMode] = (byMode[t.paymentMode] || 0) + amt;
|
|
348
|
+
}
|
|
349
|
+
return { count, total, byMode };
|
|
350
|
+
}
|
package/dist/cli-entry.js
CHANGED
|
@@ -156,6 +156,14 @@ function parseArgs() {
|
|
|
156
156
|
return { mode: 'update', args: args.slice(1) };
|
|
157
157
|
case 'model':
|
|
158
158
|
return { mode: 'model', args: args.slice(1) };
|
|
159
|
+
// 2026-09-18: 智能体工具执行轨迹 + 本机 P2P 连接信息 (递给名片/小工具用)
|
|
160
|
+
case 'trace':
|
|
161
|
+
return { mode: 'trace', args: args.slice(1) };
|
|
162
|
+
case 'p2p':
|
|
163
|
+
return { mode: 'p2p', args: args.slice(1) };
|
|
164
|
+
// 2026-09-18: M1 唯一入口 —— 一个任务 → 一个 Skill → 一个报告卡
|
|
165
|
+
case 'task':
|
|
166
|
+
return { mode: 'task', args: args.slice(1) };
|
|
159
167
|
// 2026-09-13: 初始化向导 (用户身份 + 模型供应商 + API key)
|
|
160
168
|
case 'setup':
|
|
161
169
|
case 'init':
|
|
@@ -321,6 +329,155 @@ async function handleUpdateCommand(updateArgs) {
|
|
|
321
329
|
}
|
|
322
330
|
}
|
|
323
331
|
/** model 子命令: 列出 / 切换模型供应商 (bolloon model [name] [model]) */
|
|
332
|
+
/**
|
|
333
|
+
* `bolloon trace [runId] [--json] [--last N]`
|
|
334
|
+
* 把 Run 的**工具执行轨迹**(真跑过什么工具、结果、耗时)导出来:
|
|
335
|
+
* 无参 → 列出最近几次运行 + 每步摘要; 带 runId → 输出完整轨迹 (文本或 JSON)。
|
|
336
|
+
* 文本格式与小工具/别的智能体对齐, 可直接复制粘贴交换。
|
|
337
|
+
*/
|
|
338
|
+
/**
|
|
339
|
+
* `bolloon task "<任务>" --budget 0.05 [--input '<json>'] [--json]`
|
|
340
|
+
* `bolloon task --resume <goalId>`
|
|
341
|
+
*
|
|
342
|
+
* M1 唯一入口 (leo 2026-09-18 冻结规则 ①): 用户只给任务和预算,
|
|
343
|
+
* 不点名 Skill、不看内部状态。进度只报 4 个用户态, 结论在报告卡里。
|
|
344
|
+
*/
|
|
345
|
+
async function handleTaskCommand(taskArgs) {
|
|
346
|
+
const { runTask, resumeTask } = await import('./agents/task/task-runner.js');
|
|
347
|
+
const wantJson = taskArgs.includes('--json');
|
|
348
|
+
const flag = (name) => {
|
|
349
|
+
const i = taskArgs.indexOf(name);
|
|
350
|
+
return i >= 0 ? taskArgs[i + 1] : undefined;
|
|
351
|
+
};
|
|
352
|
+
const resumeId = flag('--resume');
|
|
353
|
+
const budget = flag('--budget');
|
|
354
|
+
const perPurchase = flag('--per-purchase');
|
|
355
|
+
const daily = flag('--daily');
|
|
356
|
+
const inputRaw = flag('--input');
|
|
357
|
+
let input;
|
|
358
|
+
if (inputRaw !== undefined) {
|
|
359
|
+
try {
|
|
360
|
+
input = JSON.parse(inputRaw);
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
console.error(`${MAGENTA}--input 不是合法 JSON: ${String(e?.message || e)}${RESET}`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const STAGE_LABEL = { prepare: '准备中', acquire: '正在获取能力', execute: '正在执行', report: '报告' };
|
|
368
|
+
const onStage = (stage, note) => {
|
|
369
|
+
if (wantJson)
|
|
370
|
+
return;
|
|
371
|
+
console.error(` ${CYAN}${STAGE_LABEL[stage] || stage}${RESET} ${note}`);
|
|
372
|
+
};
|
|
373
|
+
if (resumeId) {
|
|
374
|
+
const r = await resumeTask({ goalId: resumeId, input, allowLocalDev: true });
|
|
375
|
+
if (wantJson)
|
|
376
|
+
console.log(JSON.stringify({ resumed: r.resumed, action: r.action, reason: r.reason, mustNotRepay: r.mustNotRepay, card: r.card }, null, 2));
|
|
377
|
+
else {
|
|
378
|
+
console.log('');
|
|
379
|
+
console.log(r.text);
|
|
380
|
+
console.log('');
|
|
381
|
+
}
|
|
382
|
+
process.exit(r.ok ? 0 : 1);
|
|
383
|
+
}
|
|
384
|
+
const words = taskArgs.filter((a, i) => {
|
|
385
|
+
if (a.startsWith('--'))
|
|
386
|
+
return false;
|
|
387
|
+
const prev = taskArgs[i - 1];
|
|
388
|
+
return !['--budget', '--per-purchase', '--daily', '--input'].includes(prev || '');
|
|
389
|
+
});
|
|
390
|
+
const task = words.join(' ').trim();
|
|
391
|
+
if (!task) {
|
|
392
|
+
console.log(`
|
|
393
|
+
${BOLD}bolloon task${RESET} — 给一个任务, 让智能体买到能力并做完它
|
|
394
|
+
|
|
395
|
+
${CYAN}bolloon task "判断这款厨房用品是否适合进入日本市场" --budget 0.05${RESET}
|
|
396
|
+
${CYAN}bolloon task --resume <goalId>${RESET}
|
|
397
|
+
|
|
398
|
+
选项:
|
|
399
|
+
--budget <USDC> 这笔任务最多花多少 (M1 硬上限 0.05; 不能中途扩大)
|
|
400
|
+
--per-purchase <USDC> 单次购买上限 (M1 硬上限 0.02)
|
|
401
|
+
--daily <USDC> 当日预算 (M1 硬上限 0.10)
|
|
402
|
+
--input '<json>' 显式给技能输入 (跳过自动推导)
|
|
403
|
+
--json 机器可读输出
|
|
404
|
+
`);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const r = await runTask({ task, budget, perPurchase, daily, input, allowLocalDev: true, onStage });
|
|
408
|
+
if (wantJson) {
|
|
409
|
+
console.log(JSON.stringify({
|
|
410
|
+
ok: r.ok, status: r.card.status, conclusion: r.card.conclusion, card: r.card,
|
|
411
|
+
goalId: r.goalId, runId: r.runId, transactionId: r.transactionId,
|
|
412
|
+
advisor: r.advisor, payment: r.payment, outputIssues: r.outputIssues,
|
|
413
|
+
budget: { taskBudget: r.budget.taskBudget, perPurchase: r.budget.perPurchase, daily: r.budget.daily, clamped: r.budget.clamped },
|
|
414
|
+
stages: r.stages,
|
|
415
|
+
}, null, 2));
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
console.log('');
|
|
419
|
+
console.log(r.text);
|
|
420
|
+
console.log('');
|
|
421
|
+
}
|
|
422
|
+
// 一次性命令: 显式收尾 (DIAP/HTTP 句柄不该吊住进程)
|
|
423
|
+
process.exit(r.ok ? 0 : 1);
|
|
424
|
+
}
|
|
425
|
+
async function handleTraceCommand(traceArgs) {
|
|
426
|
+
const { listRuns, readRun } = await import('./agents/run-store.js');
|
|
427
|
+
const { runToTraceText, runToTraceJson, summarizeTrace } = await import('./agents/trace-export.js');
|
|
428
|
+
const wantJson = traceArgs.includes('--json');
|
|
429
|
+
const lastIdx = traceArgs.indexOf('--last');
|
|
430
|
+
const last = lastIdx >= 0 ? Number(traceArgs[lastIdx + 1]) : undefined;
|
|
431
|
+
const runId = traceArgs.find((a) => !a.startsWith('--') && a !== String(last));
|
|
432
|
+
if (!runId) {
|
|
433
|
+
const runs = await listRuns({ limit: 20 });
|
|
434
|
+
if (wantJson) {
|
|
435
|
+
console.log(JSON.stringify(runs.map((r) => runToTraceJson(r)), null, 2));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
console.log(`\n${BOLD}最近 ${runs.length} 次运行的执行轨迹${RESET}\n`);
|
|
439
|
+
if (!runs.length) {
|
|
440
|
+
console.log(' 还没有运行记录 (落盘在 ~/.bolloon/runs/)');
|
|
441
|
+
console.log(` ${CYAN}先让智能体干点活: bolloon --prompt "列出当前目录文件"${RESET}\n`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
console.log('─'.repeat(72));
|
|
445
|
+
for (const r of runs) {
|
|
446
|
+
console.log(` ${r.runId} [${r.status}] ${summarizeTrace(r)}`);
|
|
447
|
+
}
|
|
448
|
+
console.log('─'.repeat(72));
|
|
449
|
+
console.log(` ${CYAN}bolloon trace <runId> 看完整轨迹 (文本, 可复制交换)`);
|
|
450
|
+
console.log(` bolloon trace <runId> --json 机器可读${RESET}\n`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const run = await readRun(runId);
|
|
454
|
+
if (!run) {
|
|
455
|
+
console.error(`${MAGENTA}没有这个运行: ${runId}${RESET}`);
|
|
456
|
+
process.exitCode = 1;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
console.log(wantJson ? JSON.stringify(runToTraceJson(run), null, 2) : runToTraceText(run, { limit: last }));
|
|
460
|
+
if (!wantJson) {
|
|
461
|
+
const j = runToTraceJson(run);
|
|
462
|
+
console.error(`\n${CYAN}(${j.counts.total} 步 · ✓${j.counts.ok} / ✗${j.counts.fail} · ${j.counts.totalMs}ms · 状态 ${j.status})${RESET}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* `bolloon p2p [--json]`
|
|
467
|
+
* 打印本机 P2P 连接信息 (peerId + 可拨入 multiaddr), 直接可抄进名片/小工具/递给对方智能体。
|
|
468
|
+
* 只报真实拿到的: 节点没跑就说明原因与下一步, 不编造 peerId。
|
|
469
|
+
*/
|
|
470
|
+
async function handleP2pCommand(p2pArgs) {
|
|
471
|
+
const { getLocalP2pInfo, formatP2pInfoText, formatP2pInfoJson } = await import('./agents/p2p-info.js');
|
|
472
|
+
const info = await getLocalP2pInfo();
|
|
473
|
+
if (p2pArgs.includes('--json'))
|
|
474
|
+
console.log(formatP2pInfoJson(info));
|
|
475
|
+
else
|
|
476
|
+
console.log(formatP2pInfoText(info));
|
|
477
|
+
// 一次性信息命令必须自己收尾: 读运行中节点会 import network/p2p (libp2p),
|
|
478
|
+
// 那是常驻模块, 句柄不会自己关 → 不显式退出会"输出完了还挂着"。
|
|
479
|
+
process.exit(info.ok ? 0 : 1);
|
|
480
|
+
}
|
|
324
481
|
async function handleModelCommand(modelArgs) {
|
|
325
482
|
const { llmConfigStore, PROVIDER_INFO } = await import('./llm/config-store.js');
|
|
326
483
|
await llmConfigStore.initialize();
|
|
@@ -603,6 +760,17 @@ async function main() {
|
|
|
603
760
|
case 'model':
|
|
604
761
|
await handleModelCommand(args);
|
|
605
762
|
break;
|
|
763
|
+
// 2026-09-18: 工具执行轨迹 / P2P 连接信息
|
|
764
|
+
case 'trace':
|
|
765
|
+
await handleTraceCommand(args);
|
|
766
|
+
break;
|
|
767
|
+
case 'p2p':
|
|
768
|
+
await handleP2pCommand(args);
|
|
769
|
+
break;
|
|
770
|
+
// 2026-09-18: M1 任务闭环 (bolloon task "<任务>" --budget 0.05 / --resume <goalId>)
|
|
771
|
+
case 'task':
|
|
772
|
+
await handleTaskCommand(args);
|
|
773
|
+
break;
|
|
606
774
|
// 2026-09-13: bolloon setup — 首次运行初始化向导
|
|
607
775
|
case 'setup':
|
|
608
776
|
await handleSetupCommand(args);
|