工具总线 + Agent 开环:声明式工具、人类工具与 LLM 五件系统内化(Mastra tool 形状)

用户定:Agent 调用工具一律**声明式**;人类工具与 LLM 工具要系统内化;
工具总线基于 Mastra tool 设计。

**声明面**(`toolbox/internal/domain/tool-bus.ts`,纯逻辑零框架):
`compileToolBus(声明 × 目录) → 一张清单` —— 编译期定死这一单能调什么,
清单之外结构上调不到(白名单在缝里再把一次关);缺件/停用如实报不静默。
三路同一形状:
  · `function` 功能工具:按活动白名单查目录,带出效应(read/write)
  · `human` **人类工具**(系统内化、零声明即得):调人 = 发工单 + 立监听听人 ——
    这是 Harness 给的能力,不进目录、不需白名单,每个活动天然可以调人
  · `llm` **LLM 五件**(系统内化):extract/classify/summarize/judge/rewrite,
    各有入参出参契约、经模型网关一缝;**仅当活动声明了模型用途名才上总线**
    (没声明却会问模型 = 越权,用量也无从归因);提示词归系统,活动改不了

**装配面**(`app/tool-bus.ts`,Mastra `createTool`):清单 → 可调用工具,
JSON Schema 现译 zod(模型照它填参,填错当场被拦);三路各穿各的缝:
写类工具**过程中一律不落地**,只登记动作意向(骗 Agent 说已执行是最坏选项);
调人把卡号记进 trace,引擎据此把这一拍挂起。

**Agent 开环**(`app/mastra-agent-runner.ts`,M5-2 第二实现,用户选的路线):
Mastra `Agent` + 工具总线,拍内自主连环调工具,拍末交回结构化结论。
放开的是工具循环不是规矩:写类不落地、终局唯放行、调人即挂起(新增
`AgentAction.asked` 型 —— 卡已由总线发出,引擎只收尾,不重复发卡)、
用量按 usage 计入预算。`JCAI_AGENT_OPEN_LOOP=1` 开启,缺省仍是单拍版。
`ModelGateway.resolve` 新增:名 → 四元(密钥当场解出,只给 app 层装配用)。

代价如实记 debt M5-2:开环下模型**逐轮**调用不经录制回放(tool-calling 往返
`generate` 的 text-in/text-out 承载不了)—— 凭证隔离与工具调用一缝完整。

测试:tool-bus 六例(白名单/缺件/停用/人类工具恒在/LLM 按声明/五件契约);
四闸(typecheck/lint/depcruise/test)全过;spike 目录排除出 lint(验证性原型)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 23:46:12 +08:00
co-authored by Claude Fable 5
parent 7c609e852c
commit f96eff906a
32 changed files with 6400 additions and 15 deletions
+110
View File
@@ -0,0 +1,110 @@
/**
* 模拟主工程的“实例/任务”台账。
*
* 主工程语义:回边 reopen = 回到 A4 **重开一个新实例**(新 instance 号、新任务 ID,
* 旧实例留痕不动)。这里用一个进程内表来记账,用来验证「哪条路线能自然地拿到新实例号」。
*/
export type NodeId = 'A4' | 'A5' | 'A6';
export type InstanceRow = {
/** 实例号:同一节点上单调递增,A4#1 / A4#2 ... */
instanceNo: number;
nodeId: NodeId;
/** 任务 ID:每个实例一个,全局唯一 */
taskId: string;
/** 哪个 run 起的它(路线 B 会出现多个 runId) */
runId: string;
/** 哪一轮循环起的它(路线 A 才有意义) */
iteration: number;
/** 由哪条边重开(E10 / E12);首开为 null */
reopenedBy: string | null;
status: 'open' | 'closed';
outcome?: string;
openedAt: number;
closedAt?: number;
};
export class Ledger {
readonly rows: InstanceRow[] = [];
private seq = 0;
openInstance(args: {
nodeId: NodeId;
runId: string;
iteration: number;
reopenedBy?: string | null;
}): InstanceRow {
const instanceNo = this.rows.filter((r) => r.nodeId === args.nodeId).length + 1;
const row: InstanceRow = {
instanceNo,
nodeId: args.nodeId,
taskId: `T-${args.nodeId}-${String(++this.seq).padStart(3, '0')}`,
runId: args.runId,
iteration: args.iteration,
reopenedBy: args.reopenedBy ?? null,
status: 'open',
openedAt: Date.now(),
};
this.rows.push(row);
return row;
}
closeTask(taskId: string, outcome: string): InstanceRow {
const row = this.rows.find((r) => r.taskId === taskId);
if (!row) throw new Error(`ledger: unknown taskId ${taskId}`);
row.status = 'closed';
row.outcome = outcome;
row.closedAt = Date.now();
return row;
}
dump(title: string): void {
console.log(`\n ── 台账 ${title} ──`);
console.log(
' ' +
['节点', '实例', '任务ID', '轮次', '重开自', '状态', '结论', 'runId'].join('\t'),
);
for (const r of this.rows) {
console.log(
' ' +
[
r.nodeId,
`#${r.instanceNo}`,
r.taskId,
`it=${r.iteration}`,
r.reopenedBy ?? '-',
r.status,
r.outcome ?? '-',
r.runId.slice(0, 8),
].join('\t'),
);
}
}
}
/** 外部注入的“决策脚本”:模拟人在另一个调用链里点了按钮 */
export type Decision =
| { node: 'A4'; price: number }
| { node: 'A5'; verdict: 'pass' | 'reject' }
| { node: 'A6'; outcome: 'won' | 'revise' };
export class DecisionScript {
private i = 0;
constructor(private readonly script: Decision[]) {}
next(expectNode: NodeId): Decision {
const d = this.script[this.i++];
if (!d) throw new Error(`decision script exhausted at ${expectNode}`);
if (d.node !== expectNode) {
throw new Error(`decision script mismatch: expect ${expectNode}, got ${d.node}`);
}
return d;
}
get consumed(): number {
return this.i;
}
}
export const log = (...args: unknown[]): void => console.log(...args);
export const banner = (s: string): void =>
console.log(`\n${'='.repeat(78)}\n${s}\n${'='.repeat(78)}`);
+46
View File
@@ -0,0 +1,46 @@
/** 冒烟:确认 createWorkflow / suspend / resume / InMemoryStore 这套 API 在 1.53 上能跑 */
import { Mastra } from '@mastra/core';
import { InMemoryStore } from '@mastra/core/storage';
import { createStep, createWorkflow, createWorkflowStateReader } from '@mastra/core/workflows';
import { z } from 'zod';
const ask = createStep({
id: 'ask',
inputSchema: z.object({ q: z.string() }),
outputSchema: z.object({ a: z.string() }),
resumeSchema: z.object({ a: z.string() }),
suspendSchema: z.object({ taskId: z.string() }),
execute: async ({ inputData, resumeData, suspend }) => {
console.log('[ask.execute] inputData=', inputData, 'resumeData=', resumeData);
if (!resumeData) return await suspend({ taskId: 'T-1' }, { resumeLabel: 'ask' });
return { a: resumeData.a };
},
});
const wf = createWorkflow({
id: 'smoke',
inputSchema: z.object({ q: z.string() }),
outputSchema: z.object({ a: z.string() }),
})
.then(ask)
.commit();
const mastra = new Mastra({
workflows: { smoke: wf },
storage: new InMemoryStore(),
logger: false,
});
const run = await mastra.getWorkflow('smoke').createRun();
const r1 = await run.start({ inputData: { q: 'hi' } });
console.log('r1.status =', r1.status);
console.log('r1 =', JSON.stringify(r1, null, 2).slice(0, 1200));
const state = await mastra.getWorkflow('smoke').getWorkflowRunById(run.runId);
console.log('state.status =', state?.status);
const reader = createWorkflowStateReader(state!);
console.log('suspendedStep =', JSON.stringify(reader.getSuspendedStep(), null, 2));
console.log('resumeLabels =', JSON.stringify(reader.getResumeLabels(), null, 2));
const r2 = await run.resume({ step: 'ask', resumeData: { a: '42' } });
console.log('r2.status =', r2.status, 'result =', (r2 as { result?: unknown }).result);
@@ -0,0 +1,131 @@
/**
* 路线 A 深挖:
* a) 快照(WorkflowRunState)里循环轮次到底怎么表示?
* b) resume 能不能定位「第几轮的那个 step」?(label / forEachIndex / path)
* c) 重复 resume、跨轮 resume、run 结束后 resume 会怎样?
*
* 运行:SPIKE_NO_AUTORUN=1 tsx src/probe-01-route-a-snapshot.ts
*/
import { createWorkflowStateReader } from '@mastra/core/workflows';
import { DecisionScript, Ledger, banner } from './ledger.js';
import { mastraA, storeA, _internals } from './route-a-dowhile.js';
import { resetTrace, t } from './trace.js';
_internals.setLedger(new Ledger());
_internals.setScript(new DecisionScript([]));
resetTrace();
const wf = mastraA.getWorkflow('routeA');
const rawSnap = async (runId: string) =>
(await (storeA as any).stores.workflows.loadWorkflowSnapshot({
workflowName: 'route-a',
runId,
})) as Record<string, any> | null;
banner('探针 A-1:跑到「第 2 轮 A4 挂起」,看快照');
const run = await wf.createRun();
console.log('runId =', run.runId);
let res = await run.start({ inputData: { quoteId: 'Q-probe' } });
console.log('after start: status =', res.status);
// 第 1 轮 A4
res = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 100 } });
// 第 1 轮 A5 → reject → 触发回边 E10 → 第 2 轮开始,A4 再次挂起
res = await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'reject' } });
console.log('after A5(reject): status =', res.status, 'suspended =', JSON.stringify((res as any).suspended));
const s2 = await rawSnap(run.runId);
console.log('\n── 原始快照 WorkflowRunState 的顶层键 ──');
console.log(Object.keys(s2 ?? {}));
console.log('\nvalue =', JSON.stringify(s2?.value));
console.log('activePaths =', JSON.stringify(s2?.activePaths));
console.log('activeStepsPath=', JSON.stringify(s2?.activeStepsPath));
console.log('suspendedPaths =', JSON.stringify(s2?.suspendedPaths));
console.log('resumeLabels =', JSON.stringify(s2?.resumeLabels));
console.log('stepExecutionPath =', JSON.stringify(s2?.stepExecutionPath));
console.log('\ncontext 的键(= 快照里保存了哪些 step 的结果):');
console.log(JSON.stringify(Object.keys(s2?.context ?? {}), null, 0));
console.log('\ncontext["round-body.A4"] =');
console.log(JSON.stringify(s2?.context?.['round-body.A4'], null, 2));
console.log('\ncontext["round-body.A5"] =');
console.log(JSON.stringify(s2?.context?.['round-body.A5'], null, 2));
console.log('\ncontext["round-body"] (循环体本身) =');
console.log(JSON.stringify(s2?.context?.['round-body'], null, 2));
banner('探针 A-2:resume 能不能指名「第 1 轮的 A4」?');
// 现在挂起的是「第 2 轮的 A4」。第 1 轮 A4 的 resumeLabel 是 A4-round-1。
const reader = createWorkflowStateReader((await wf.getWorkflowRunById(run.runId))!);
console.log('当前 resumeLabels =', JSON.stringify(reader.getResumeLabels()));
console.log('getResumeLabel("A4-round-1") =', JSON.stringify(reader.getResumeLabel('A4-round-1')));
console.log('getResumeLabel("A4-round-2") =', JSON.stringify(reader.getResumeLabel('A4-round-2')));
try {
const r = await run.resume({ label: 'A4-round-1', resumeData: { price: 111 } });
console.log('resume(label=A4-round-1) → status =', r.status);
console.log(' 报错?', JSON.stringify((r as any).error ?? null));
} catch (e) {
console.log('resume(label=A4-round-1) 抛异常:', (e as Error).message);
}
banner('探针 A-3:forEachIndex 对 dowhile 有用吗?');
try {
const r = await run.resume({
step: ['round-body', 'A4'],
resumeData: { price: 222 },
forEachIndex: 0,
});
console.log('resume(step=round-body.A4, forEachIndex=0) → status =', r.status);
console.log(' error =', JSON.stringify((r as any).error ?? null));
const snap = await rawSnap(run.runId);
console.log(
' 快照里 A4 的 output.price =',
JSON.stringify(snap?.context?.['round-body.A4']?.output?.price),
);
} catch (e) {
console.log('resume(forEachIndex=0) 抛异常:', (e as Error).message);
}
banner('探针 A-4:对「已经推进过的 step」重复 resume');
try {
const r = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 333 } });
console.log('重复 resume A4 → status =', r.status, 'error =', JSON.stringify((r as any).error ?? null));
} catch (e) {
console.log('重复 resume A4 抛异常:', (e as Error).message);
}
banner('探针 A-5:resume 一个「没有挂起」的 step');
try {
const r = await run.resume({ step: ['round-body', 'A6-sub', 'A6'], resumeData: { outcome: 'won' } });
console.log('resume 未挂起的 A6 → status =', r.status, 'error =', JSON.stringify((r as any).error ?? null));
} catch (e) {
console.log('resume 未挂起的 A6 抛异常:', (e as Error).message);
}
banner('探针 A-6:把这一单跑完,再看终态快照能否回溯轮次');
let guard = 0;
let cur = await wf.getWorkflowRunById(run.runId);
while (cur?.status === 'suspended' && guard++ < 8) {
const rd = createWorkflowStateReader(cur);
const sus = rd.getSuspendedStep()!;
const nodeId = (sus.suspendPayload as { nodeId: string }).nodeId;
const data =
nodeId === 'A4' ? { price: 77 } : nodeId === 'A5' ? { verdict: 'pass' } : { outcome: 'won' };
t('[probe]', `resume ${nodeId} @ ${JSON.stringify(sus.path)}`, data);
await run.resume({ step: sus.path, resumeData: data });
cur = await wf.getWorkflowRunById(run.runId);
}
console.log('最终 status =', cur?.status, 'result =', JSON.stringify(cur?.result));
const fin = await rawSnap(run.runId);
console.log('\n终态快照 context 的键:', JSON.stringify(Object.keys(fin?.context ?? {})));
console.log('终态 context["round-body.A4"].suspendPayload =', JSON.stringify(fin?.context?.['round-body.A4']?.suspendPayload));
console.log('终态 context["round-body.A4"].output =', JSON.stringify(fin?.context?.['round-body.A4']?.output));
console.log('终态 context["round-body.A4"] 是数组吗? ', Array.isArray(fin?.context?.['round-body.A4']));
console.log('终态 stepExecutionPath =', JSON.stringify(fin?.stepExecutionPath));
console.log('\n第 1 轮的 A4(taskId=T-A4-001, price=100)还在快照里吗?→',
JSON.stringify(fin?.context?.['round-body.A4']).includes('T-A4-001') ? '在' : '不在(被覆盖)');
console.log('\n台账(我们自己记的)里第 1 轮还在吗?');
_internals.getLedger().dump('probe');
@@ -0,0 +1,117 @@
/**
* 路线 A 的坑(每个坑一个全新的 run,避免互相污染):
* 1. dowhile 循环体的嵌套快照存在哪?循环第 2 轮会不会覆盖第 1 轮?
* 2. forEachIndex 能不能定位 dowhile 的第 N 轮?
* 3. resume 打错 step(或任务回调重复到达)会怎样?
*
* 运行:SPIKE_NO_AUTORUN=1 tsx src/probe-02-route-a-pitfalls.ts
*/
import { createWorkflowStateReader } from '@mastra/core/workflows';
import { DecisionScript, Ledger, banner } from './ledger.js';
import { mastraA, storeA, _internals } from './route-a-dowhile.js';
import { resetTrace } from './trace.js';
_internals.setLedger(new Ledger());
_internals.setScript(new DecisionScript([]));
resetTrace();
const wf = mastraA.getWorkflow('routeA');
const snapOf = async (workflowName: string, runId: string) =>
(await (storeA as any).stores.workflows.loadWorkflowSnapshot({ workflowName, runId })) as
| Record<string, any>
| null;
/** 开一个 run 并推进到「第 2 轮 A4 挂起」 */
async function toRound2() {
const run = await wf.createRun();
await run.start({ inputData: { quoteId: 'Q-pit' } });
await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 100 } });
await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'reject' } });
return run;
}
banner('坑 1:循环体(嵌套 workflow)的快照存在哪?第 2 轮会不会盖掉第 1 轮?');
{
const run = await toRound2();
const outer = await snapOf('route-a', run.runId);
const inner = await snapOf('round-body', run.runId);
console.log('外层快照 context 键 =', JSON.stringify(Object.keys(outer?.context ?? {})));
console.log('外层 context["round-body"].metadata =', JSON.stringify(outer?.context?.['round-body']?.metadata));
console.log('\n内层(workflowName="round-body", 同一个 runId)快照存在吗?', !!inner);
console.log('内层 context 键 =', JSON.stringify(Object.keys(inner?.context ?? {})));
console.log('内层 A4.suspendPayload =', JSON.stringify(inner?.context?.['A4']?.suspendPayload));
console.log('内层 A4.output =', JSON.stringify(inner?.context?.['A4']?.output));
console.log('内层 A5.output =', JSON.stringify(inner?.context?.['A5']?.output));
console.log(
'\n判定:第 1 轮的 A4(taskId=T-A4-001 / price=100)还在内层快照里吗?→',
JSON.stringify(inner?.context?.['A4']).includes('T-A4-001') ? '在' : '不在(已被第 2 轮覆盖)',
);
const runs = await wf.listWorkflowRuns();
console.log('listWorkflowRuns() 里能看到几条?', runs.total, JSON.stringify(runs.runs.map((r: any) => `${r.workflowName}/${r.runId.slice(0, 8)}`)));
}
banner('坑 2:forEachIndex 能不能定位 dowhile 的第 N 轮?(此刻第 2 轮 A4 正挂起)');
{
const run = await toRound2();
for (const idx of [0, 1]) {
const fresh = await wf.createRun({ runId: `${run.runId}-x${idx}` });
void fresh; // 只是为了不复用同一个 run
}
try {
const r = await run.resume({
step: ['round-body', 'A4'],
resumeData: { price: 999 },
forEachIndex: 0,
});
console.log('resume(step=[round-body,A4], forEachIndex=0) → status =', r.status);
console.log(' error =', JSON.stringify((r as any).error ?? null));
const inner = await snapOf('round-body', run.runId);
console.log(' 内层 A4.output.price =', JSON.stringify(inner?.context?.['A4']?.output?.price));
console.log(' → forEachIndex 被忽略了(打进的是「当前正挂起的那一轮」)');
} catch (e) {
console.log('抛异常:', (e as Error).message);
}
}
banner('坑 3:resume 打错 step(= 旧任务的回调迟到)会怎样?');
{
const run = await toRound2();
const st = await wf.getWorkflowRunById(run.runId);
console.log('resume 之前 status =', st?.status, '挂起的是',
JSON.stringify(createWorkflowStateReader(st!).getSuspendedStep()?.path));
let r;
try {
r = await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'pass' } });
console.log('resume(错的 step=A5) → status =', r.status);
console.log(' error =', JSON.stringify((r as any).error ?? null));
} catch (e) {
console.log('resume(错的 step=A5) 抛异常:', (e as Error).message);
}
const after = await wf.getWorkflowRunById(run.runId);
console.log(' 之后整个 run 的 status =', after?.status, ' ← 注意:run 被打成 failed 了');
try {
const r2 = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 1 } });
console.log(' 再补一次正确的 resume → status =', r2.status);
} catch (e) {
console.log(' 再补一次正确的 resume 抛异常:', (e as Error).message, ' ← 这一单救不回来了');
}
}
banner('坑 4:同一个任务回调重复到达(重复 resume 同一个 step)');
{
const run = await toRound2();
const r1 = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 50 } });
console.log('第一次 resume A4 → status =', r1.status, '(此时应轮到 A5 挂起)');
try {
const r2 = await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 50 } });
console.log('第二次 resume A4 → status =', r2.status, 'error =', JSON.stringify((r2 as any).error ?? null));
} catch (e) {
console.log('第二次 resume A4 抛异常:', (e as Error).message);
}
const after = await wf.getWorkflowRunById(run.runId);
console.log('之后 run 的 status =', after?.status);
}
banner('坑 5:dowhile 的循环体输入/输出必须同构吗?');
console.log('见 route-a-dowhile.ts:循环体 round-body 的 inputSchema 与 outputSchema 都是 RoundState。');
console.log('把 outputSchema 改成别的形状时,TS 报错(dowhile 的 TStepInputSchema extends TPrevSchema 约束)。');
@@ -0,0 +1,57 @@
/**
* 精确定位:dowhile 每一轮的 step 记录,在快照里是「追加」还是「覆盖/重置」?
* 运行:SPIKE_NO_AUTORUN=1 tsx src/probe-03-loop-snapshot-detail.ts
*/
import { DecisionScript, Ledger, banner } from './ledger.js';
import { mastraA, storeA, _internals } from './route-a-dowhile.js';
import { resetTrace } from './trace.js';
_internals.setLedger(new Ledger());
_internals.setScript(new DecisionScript([]));
resetTrace();
const wf = mastraA.getWorkflow('routeA');
const snapOf = async (workflowName: string, runId: string) =>
(await (storeA as any).stores.workflows.loadWorkflowSnapshot({ workflowName, runId })) as
| Record<string, any>
| null;
const run = await wf.createRun();
const inner = async () => await snapOf('round-body', run.runId);
const dumpInner = async (tag: string) => {
const s = await inner();
console.log(`\n[${tag}] 内层快照 context 的键 = ${JSON.stringify(Object.keys(s?.context ?? {}))}`);
for (const k of ['A4', 'A5', 'A6-sub']) {
const rec = s?.context?.[k];
if (!rec) continue;
console.log(
` ${k}: status=${rec.status} suspendPayload.taskId=${rec.suspendPayload?.taskId} output.price=${rec.output?.price} output.verdict=${rec.output?.verdict}`,
);
}
};
banner('第 1 轮');
await run.start({ inputData: { quoteId: 'Q-detail' } });
await dumpInner('第1轮 A4 挂起');
await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 100 } });
await dumpInner('第1轮 A4 已推进,A5 挂起');
await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'reject' } });
banner('第 2 轮(E10 回边重开 A4)');
await dumpInner('第2轮 A4 挂起');
console.log('\n→ 第 1 轮的 A5(verdict=reject)记录还在内层快照里吗?',
(await inner())?.context?.['A5'] ? '在' : '不在(嵌套快照按轮重置了)');
console.log('→ 第 1 轮的 A4(taskId=T-A4-001, price=100)记录还在吗?');
console.log(' 当前 A4 记录全文 =', JSON.stringify((await inner())?.context?.['A4'], null, 2));
banner('跑完这一单,看终态');
await run.resume({ step: ['round-body', 'A4'], resumeData: { price: 90 } });
await run.resume({ step: ['round-body', 'A5'], resumeData: { verdict: 'pass' } });
const fin = await run.resume({ step: ['round-body', 'A6-sub', 'A6'], resumeData: { outcome: 'won' } });
console.log('最终 status =', fin.status, JSON.stringify((fin as any).result));
await dumpInner('终态');
const outer = await snapOf('route-a', run.runId);
console.log('\n外层 context["round-body"].metadata =', JSON.stringify(outer?.context?.['round-body']?.metadata));
console.log('外层 context 键 =', JSON.stringify(Object.keys(outer?.context ?? {})));
console.log('\n结论素材:整个 run 只有一份「A4 的记录」,内容是最后一轮的;第 1 轮的 A4/A5 记录不可回溯。');
_internals.getLedger().dump('probe-03(我们自己记的台账则保留了每一轮)');
@@ -0,0 +1,144 @@
/**
* 路线 B 的坑 + 一个额外候选(同 run 内 timeTravel):
* 1. 自定义 runId 撞车会怎样?(重开用确定性 runId 时的幂等性)
* 2. run 结束后再 resume / 重复 resume 会怎样?
* 3. 旧 run 的快照会不会被新 run 影响?
* 4. 回边目标如果不是图的入口节点,新 run 怎么从中间开始?—— 试 run.timeTravel()
*
* 运行:SPIKE_NO_AUTORUN=1 tsx src/probe-04-route-b-pitfalls.ts
*/
import { createWorkflowStateReader } from '@mastra/core/workflows';
import { DecisionScript, Ledger, banner } from './ledger.js';
import { mastraB, storeB, _internals } from './route-b-external.js';
import { resetTrace } from './trace.js';
_internals.setLedger(new Ledger());
_internals.setScript(new DecisionScript([]));
resetTrace();
const wf = mastraB.getWorkflow('routeB');
const snapOf = async (workflowName: string, runId: string) =>
(await (storeB as any).stores.workflows.loadWorkflowSnapshot({ workflowName, runId })) as
| Record<string, any>
| null;
const drive = async (run: any, steps: Array<[string[], any]>) => {
for (const [path, data] of steps) await run.resume({ step: path, resumeData: data });
};
banner('坑 1:确定性 runId 撞车(重开时用 `${quoteId}-inst-N` 这种可读 ID)');
{
const runId = 'dup-run-1';
const r1 = await wf.createRun({ runId });
await r1.start({ inputData: { quoteId: 'Q1', instanceSeq: 1, reopenedBy: null, prevRunId: null } });
console.log('第一次 createRun+start → status =', (await wf.getWorkflowRunById(runId))?.status);
const r2 = await wf.createRun({ runId });
console.log('用同一个 runId 再 createRun → 没报错,拿到的是同一个 run?', r2.runId === runId);
const st = await wf.getWorkflowRunById(runId);
console.log(' 此时 status =', st?.status, ' 挂起的是',
JSON.stringify(createWorkflowStateReader(st!).getSuspendedStep()?.path));
try {
const rr = await r2.start({
inputData: { quoteId: 'Q1', instanceSeq: 99, reopenedBy: null, prevRunId: null },
});
console.log(' 对已挂起的 run 再 start() → status =', rr.status, 'error=', JSON.stringify((rr as any).error ?? null));
const st2 = await wf.getWorkflowRunById(runId);
console.log(' A4.suspendPayload 变了吗?', JSON.stringify((st2?.steps as any)?.A4?.suspendPayload));
} catch (e) {
console.log(' 对已挂起的 run 再 start() 抛异常:', (e as Error).message);
}
}
banner('坑 2:run 已经 success 之后再 resume');
{
const runId = 'done-run-1';
const run = await wf.createRun({ runId });
await run.start({ inputData: { quoteId: 'Q2', instanceSeq: 1, reopenedBy: null, prevRunId: null } });
await drive(run, [
[['A4'], { price: 100 }],
[['A5'], { verdict: 'pass' }],
[['A6-sub', 'A6'], { outcome: 'won' }],
]);
console.log('status =', (await wf.getWorkflowRunById(runId))?.status);
try {
const r = await run.resume({ step: ['A6-sub', 'A6'], resumeData: { outcome: 'won' } });
console.log('已完成的 run 再 resume → status =', r.status, JSON.stringify((r as any).error ?? null));
} catch (e) {
console.log('已完成的 run 再 resume 抛异常:', (e as Error).message);
}
console.log('之后 status 还是 =', (await wf.getWorkflowRunById(runId))?.status, ' ← 老 run 没被打坏');
}
banner('坑 3:重复 resume 同一个 step(任务回调重复到达)');
{
const runId = 'dupresume-1';
const run = await wf.createRun({ runId });
await run.start({ inputData: { quoteId: 'Q3', instanceSeq: 1, reopenedBy: null, prevRunId: null } });
await run.resume({ step: ['A4'], resumeData: { price: 100 } });
try {
const r = await run.resume({ step: ['A4'], resumeData: { price: 100 } });
console.log('第二次 resume A4 → status =', r.status, 'error =', JSON.stringify((r as any).error ?? null));
} catch (e) {
console.log('第二次 resume A4 抛异常:', (e as Error).message);
}
console.log('run 状态 =', (await wf.getWorkflowRunById(runId))?.status, ' ← 与路线 A 一样会被打成 failed');
}
banner('坑 4:回边目标不是入口节点时,新 run 能不能「从中间开始」?试 run.timeTravel()');
{
const runId = 'tt-1';
const run = await wf.createRun({ runId });
await run.start({ inputData: { quoteId: 'Q4', instanceSeq: 1, reopenedBy: null, prevRunId: null } });
await run.resume({ step: ['A4'], resumeData: { price: 100 } });
await run.resume({ step: ['A5'], resumeData: { verdict: 'reject' } });
const st = await wf.getWorkflowRunById(runId);
console.log('E10 回边出口跑完,run status =', st?.status, 'result =', JSON.stringify(st?.result));
// 用 timeTravel 在同一个 run 里回到 A5(模拟「回边目标是中间节点」)
try {
const r = await run.timeTravel({
step: 'A5',
inputData: {
quoteId: 'Q4',
instanceSeq: 2,
reopenedBy: 'E10',
prevRunId: null,
price: 100,
verdict: null,
finalOutcome: null,
},
});
console.log('timeTravel(step=A5) → status =', r.status);
const st2 = await wf.getWorkflowRunById(runId);
console.log(' A5.suspendPayload =', JSON.stringify((st2?.steps as any)?.A5?.suspendPayload));
console.log(' A4 的记录还在吗? ', JSON.stringify((st2?.steps as any)?.A4?.output));
console.log(' 第一次跑的 result 还在吗?', JSON.stringify(st2?.result));
} catch (e) {
console.log('timeTravel 抛异常:', (e as Error).message);
}
}
banner('坑 5:旧 run 的快照会不会被新 run 影响');
{
const q = 'Q5';
const r1 = await wf.createRun({ runId: `${q}-inst-1`, resourceId: q });
await r1.start({ inputData: { quoteId: q, instanceSeq: 1, reopenedBy: null, prevRunId: null } });
await r1.resume({ step: ['A4'], resumeData: { price: 100 } });
await r1.resume({ step: ['A5'], resumeData: { verdict: 'reject' } });
const r2 = await wf.createRun({ runId: `${q}-inst-2`, resourceId: q });
await r2.start({ inputData: { quoteId: q, instanceSeq: 2, reopenedBy: 'E10', prevRunId: `${q}-inst-1` } });
await r2.resume({ step: ['A4'], resumeData: { price: 90 } });
await r2.resume({ step: ['A5'], resumeData: { verdict: 'pass' } });
await r2.resume({ step: ['A6-sub', 'A6'], resumeData: { outcome: 'won' } });
for (const id of [`${q}-inst-1`, `${q}-inst-2`]) {
const s = await snapOf('route-b', id);
console.log(
`${id}: status=${s?.status} A4.taskId=${s?.context?.A4?.suspendPayload?.taskId} A4.price=${s?.context?.A4?.output?.price} result=${JSON.stringify(s?.result)}`,
);
}
const runs = await wf.listWorkflowRuns({ resourceId: q });
console.log('listWorkflowRuns({resourceId}) →', runs.total, JSON.stringify(runs.runs.map((r: any) => r.runId)));
_internals.getLedger().dump('probe-04');
}
+379
View File
@@ -0,0 +1,379 @@
/**
* 路线 A:.dowhile() 子图
*
* 把「A4 → A5 (→ A6)」整体包成 dowhile 的循环体,循环条件 =「本轮发生了回跳」。
* 拓扑(对齐种子 unified-quote):
* start → A4(定价,活动) → A5(审批)
* E11 A5 --verdict=pass--> A6(议价)
* E10 A5 --verdict=reject--> A4 (reopen)
* E13 A6 --outcome=won--> end
* E12 A6 --outcome=revise--> A4 (reopen)
*
* 每个活动/审批点都是:起任务 → suspend();终态由外部 run.resume() 送达。
*/
import { Mastra } from '@mastra/core';
import { RequestContext } from '@mastra/core/request-context';
import { InMemoryStore } from '@mastra/core/storage';
import { createStep, createWorkflow, createWorkflowStateReader } from '@mastra/core/workflows';
import { z } from 'zod';
import { banner, DecisionScript, Ledger, type Decision } from './ledger.js';
import { resetTrace, t } from './trace.js';
/** 循环体的输入/输出必须同构 —— dowhile 把上一轮的输出直接当下一轮的输入 */
const RoundState = z.object({
round: z.number(),
reopen: z.boolean(),
reopenedBy: z.string().nullable(),
price: z.number().nullable(),
verdict: z.string().nullable(),
finalOutcome: z.string().nullable(),
a4TaskId: z.string().nullable(),
});
type RoundState = z.infer<typeof RoundState>;
/** 每个场景一份新的 ledger / script,用模块级变量喂给 step(step 定义是静态的) */
let ledger = new Ledger();
let script = new DecisionScript([]);
/* ------------------------------------------------------------------ 活动点 */
const a4 = createStep({
id: 'A4',
description: '定价(活动点)',
inputSchema: RoundState,
outputSchema: RoundState,
resumeSchema: z.object({ price: z.number() }),
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
execute: async ({ inputData, resumeData, suspend, runId }) => {
t('A4.execute', `enter round=${inputData.round} resume=${!!resumeData}`);
if (!resumeData) {
const row = ledger.openInstance({
nodeId: 'A4',
runId,
iteration: inputData.round,
reopenedBy: inputData.reopenedBy,
});
t('A4.execute', `起任务 → suspend`, { instanceNo: row.instanceNo, taskId: row.taskId });
return await suspend(
{ nodeId: 'A4', taskId: row.taskId, instanceNo: row.instanceNo },
{ resumeLabel: `A4-round-${inputData.round}` },
);
}
const taskId = ledger.rows.filter((r) => r.nodeId === 'A4').at(-1)!.taskId;
ledger.closeTask(taskId, `price=${resumeData.price}`);
t('A4.execute', `任务终态到达 price=${resumeData.price}`);
return { ...inputData, price: resumeData.price, a4TaskId: taskId };
},
});
const a5 = createStep({
id: 'A5',
description: '审批(审批点)',
inputSchema: RoundState,
outputSchema: RoundState,
resumeSchema: z.object({ verdict: z.enum(['pass', 'reject']) }),
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
execute: async ({ inputData, resumeData, suspend, runId }) => {
t('A5.execute', `enter round=${inputData.round} resume=${!!resumeData}`);
if (!resumeData) {
const row = ledger.openInstance({ nodeId: 'A5', runId, iteration: inputData.round });
t('A5.execute', `起任务 → suspend`, { instanceNo: row.instanceNo, taskId: row.taskId });
return await suspend(
{ nodeId: 'A5', taskId: row.taskId, instanceNo: row.instanceNo },
{ resumeLabel: `A5-round-${inputData.round}` },
);
}
const taskId = ledger.rows.filter((r) => r.nodeId === 'A5').at(-1)!.taskId;
ledger.closeTask(taskId, resumeData.verdict);
t('A5.execute', `任务终态到达 verdict=${resumeData.verdict}`);
return { ...inputData, verdict: resumeData.verdict };
},
});
const a6 = createStep({
id: 'A6',
description: '议价(活动点)',
inputSchema: RoundState,
outputSchema: RoundState,
resumeSchema: z.object({ outcome: z.enum(['won', 'revise']) }),
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
execute: async ({ inputData, resumeData, suspend, runId }) => {
t('A6.execute', `enter round=${inputData.round} resume=${!!resumeData}`);
if (!resumeData) {
const row = ledger.openInstance({ nodeId: 'A6', runId, iteration: inputData.round });
t('A6.execute', `起任务 → suspend`, { instanceNo: row.instanceNo, taskId: row.taskId });
return await suspend(
{ nodeId: 'A6', taskId: row.taskId, instanceNo: row.instanceNo },
{ resumeLabel: `A6-round-${inputData.round}` },
);
}
const taskId = ledger.rows.filter((r) => r.nodeId === 'A6').at(-1)!.taskId;
ledger.closeTask(taskId, resumeData.outcome);
t('A6.execute', `任务终态到达 outcome=${resumeData.outcome}`);
return { ...inputData, finalOutcome: resumeData.outcome };
},
});
/* --------------------------------------------------------------- 边(出边) */
const e10Reopen = createStep({
id: 'E10-reopen-A4',
description: 'A5 --verdict=reject--> A4 (reopen)',
inputSchema: RoundState,
outputSchema: RoundState,
execute: async ({ inputData }) => {
t('E10.execute', `回边命中,轮次 ${inputData.round} → ${inputData.round + 1}`);
return { ...inputData, round: inputData.round + 1, reopen: true, reopenedBy: 'E10' };
},
});
const e12Reopen = createStep({
id: 'E12-reopen-A4',
description: 'A6 --outcome=revise--> A4 (reopen)',
inputSchema: RoundState,
outputSchema: RoundState,
execute: async ({ inputData }) => {
t('E12.execute', `回边命中,轮次 ${inputData.round} → ${inputData.round + 1}`);
return { ...inputData, round: inputData.round + 1, reopen: true, reopenedBy: 'E12' };
},
});
const e13End = createStep({
id: 'E13-end',
description: 'A6 --outcome=won--> end',
inputSchema: RoundState,
outputSchema: RoundState,
execute: async ({ inputData }) => {
t('E13.execute', '成交,走向 end');
return { ...inputData, reopen: false, reopenedBy: null };
},
});
/**
* branch 的输出是 { [每一条分支的 stepId]: output | undefined } —— 没命中的分支键也在,值是
* undefined。所以收口步骤的 inputSchema 必须允许 undefined,并自己做 XOR 判定。
*/
const pick = (id: string) =>
createStep({
id,
inputSchema: z.record(z.string(), RoundState.optional()),
outputSchema: RoundState,
execute: async ({ inputData }) => {
const allKeys = Object.keys(inputData);
const hit = allKeys.filter((k) => inputData[k] !== undefined);
t(`${id}.execute`, `branch 收口`, { '所有分支键': allKeys, '命中': hit });
if (hit.length !== 1) throw new Error(`XOR 违约:命中 ${hit.length} 条出边`);
return inputData[hit[0]!]!;
},
});
/* ------------------------------------------------------------ A6 子图 + 循环体 */
const a6Sub = createWorkflow({
id: 'A6-sub',
inputSchema: RoundState,
outputSchema: RoundState,
})
.then(a6)
.branch([
[
async ({ inputData, getStepResult, requestContext }) => {
t('cond(E13 won?)', 'evaluated', {
finalOutcome: inputData.finalOutcome,
'上一步输出可见': getStepResult(a6)?.finalOutcome ?? null,
'requestContext.tenant': requestContext?.get?.('tenant') ?? null,
});
return inputData.finalOutcome === 'won';
},
e13End,
],
[
async ({ inputData }) => {
t('cond(E12 revise?)', 'evaluated', { finalOutcome: inputData.finalOutcome });
return inputData.finalOutcome === 'revise';
},
e12Reopen,
],
])
.then(pick('pick-A6-out'))
.commit();
const roundBody = createWorkflow({
id: 'round-body',
inputSchema: RoundState,
outputSchema: RoundState,
})
.then(a4)
.then(a5)
.branch([
[
async ({ inputData, getStepResult, requestContext }) => {
t('cond(E11 pass?)', 'evaluated', {
verdict: inputData.verdict,
'上一步输出可见': getStepResult(a5)?.verdict ?? null,
'requestContext.tenant': requestContext?.get?.('tenant') ?? null,
});
return inputData.verdict === 'pass';
},
a6Sub,
],
[
async ({ inputData }) => {
t('cond(E10 reject?)', 'evaluated', { verdict: inputData.verdict });
return inputData.verdict === 'reject';
},
e10Reopen,
],
])
.then(pick('pick-A5-out'))
.commit();
const start = createStep({
id: 'start',
inputSchema: z.object({ quoteId: z.string() }),
outputSchema: RoundState,
execute: async ({ inputData }) => {
t('start.execute', `起单 ${inputData.quoteId}`);
return {
round: 1,
reopen: false,
reopenedBy: null,
price: null,
verdict: null,
finalOutcome: null,
a4TaskId: null,
};
},
});
const end = createStep({
id: 'end',
inputSchema: RoundState,
outputSchema: z.object({ rounds: z.number(), finalOutcome: z.string().nullable() }),
execute: async ({ inputData }) => {
t('end.execute', `收单 rounds=${inputData.round}`);
return { rounds: inputData.round, finalOutcome: inputData.finalOutcome };
},
});
export const routeA = createWorkflow({
id: 'route-a',
inputSchema: z.object({ quoteId: z.string() }),
outputSchema: z.object({ rounds: z.number(), finalOutcome: z.string().nullable() }),
})
.then(start)
.dowhile(roundBody, async ({ inputData, iterationCount }) => {
t('dowhile.condition', 'evaluated', {
iterationCount,
reopen: inputData.reopen,
round: inputData.round,
'继续循环?': inputData.reopen === true,
});
return inputData.reopen === true;
})
.then(end)
.commit();
export const storeA = new InMemoryStore();
export const mastraA = new Mastra({
workflows: { routeA },
storage: storeA,
logger: false,
});
const mastra = mastraA;
/** 探针需要拿到 ledger/script 的控制权 */
export const _internals = {
setLedger: (l: Ledger) => (ledger = l),
setScript: (s: DecisionScript) => (script = s),
getLedger: () => ledger,
};
/* ----------------------------------------------------------------- 驱动器 */
type NodeIdT = 'A4' | 'A5' | 'A6';
async function drive(scenario: string, decisions: Decision[]): Promise<void> {
banner(`路线 A / 场景:${scenario}`);
resetTrace();
ledger = new Ledger();
script = new DecisionScript(decisions);
const wf = mastra.getWorkflow('routeA');
const run = await wf.createRun();
console.log(` runId = ${run.runId}`);
// 外部注入的上下文:必须是 RequestContext 实例(传普通对象会在持久化时炸,见 FINDINGS)
const rc = new RequestContext();
rc.set('tenant', 'acme');
let res = await run.start({
inputData: { quoteId: 'Q-2026-0001' },
requestContext: rc as never,
});
let guard = 0;
while (res.status === 'suspended') {
if (++guard > 20) throw new Error('driver: too many suspends');
const state = await wf.getWorkflowRunById(run.runId);
const reader = createWorkflowStateReader(state!);
const s = reader.getSuspendedStep();
t('[driver]', 'getSuspendedStep()', {
stepId: s?.stepId,
path: s?.path,
executionPath: s?.executionPath,
suspendPayload: s?.suspendPayload,
resumeLabels: Object.keys(reader.getResumeLabels()),
});
t('[driver]', 'result.suspended (来自 start/resume 的返回值)', res.suspended);
const nodeId = (s!.suspendPayload as { nodeId: NodeIdT }).nodeId;
const d = script.next(nodeId);
const resumeData =
d.node === 'A4'
? { price: d.price }
: d.node === 'A5'
? { verdict: d.verdict }
: { outcome: d.outcome };
t('[driver]', `外部推进 ${nodeId}`, resumeData);
res = await run.resume({ step: s!.path, resumeData });
}
console.log(`\n 最终 status = ${res.status}`);
if (res.status === 'success') console.log(' result =', res.result);
else console.log(' 非 success:', JSON.stringify(res).slice(0, 800));
ledger.dump(scenario);
// 跑完之后还能不能查到历史轮次?
const finalState = await wf.getWorkflowRunById(run.runId);
console.log('\n ── 终态快照 steps 的 key ──');
console.log(' ' + JSON.stringify(Object.keys(finalState?.steps ?? {})));
const a4Snap = finalState?.steps?.['round-body.A4'] ?? finalState?.steps?.['A4'];
console.log(' 快照里 A4 这一格是数组吗?', Array.isArray(a4Snap), '→', JSON.stringify(a4Snap));
}
// 被 probe 引入时不自动跑三个场景
if (!process.env.SPIKE_NO_AUTORUN) {
await drive('① 一次过(A4→A5 pass→A6 won→end)', [
{ node: 'A4', price: 100 },
{ node: 'A5', verdict: 'pass' },
{ node: 'A6', outcome: 'won' },
]);
await drive('② A5 驳回一次后再通过', [
{ node: 'A4', price: 100 },
{ node: 'A5', verdict: 'reject' },
{ node: 'A4', price: 90 },
{ node: 'A5', verdict: 'pass' },
{ node: 'A6', outcome: 'won' },
]);
await drive('③ A6 revise 一次后再成交', [
{ node: 'A4', price: 100 },
{ node: 'A5', verdict: 'pass' },
{ node: 'A6', outcome: 'revise' },
{ node: 'A4', price: 80 },
{ node: 'A5', verdict: 'pass' },
{ node: 'A6', outcome: 'won' },
]);
}
+373
View File
@@ -0,0 +1,373 @@
/**
* 路线 B:外部胶合(回跳 = 结束本 run,外部用同一份 workflow 定义新起一个 run)
*
* workflow 定义里 **没有循环**,就是一张 DAG:
* A4 → A5 --E11 pass--> A6 --E13 won--> end
* --E10 reject--> [回边出口]
* A6 --E12 revise--> [回边出口]
* 「回边出口」是一个终止步骤:它把 reopen 意图写进 workflow 的 result,run 正常 success 结束。
* 外部胶合层看到 result.reopen 就 createRun 一个新 run,把重开载荷(重开边、实例号、上一个 runId)
* 当 inputData 传进去。run 之间用 runId 链 + resourceId 关联。
*/
import { Mastra } from '@mastra/core';
import { RequestContext } from '@mastra/core/request-context';
import { InMemoryStore } from '@mastra/core/storage';
import { createStep, createWorkflow, createWorkflowStateReader } from '@mastra/core/workflows';
import { z } from 'zod';
import { banner, DecisionScript, Ledger, type Decision } from './ledger.js';
import { resetTrace, t } from './trace.js';
/** 一个 run = 一趟「A4 起,到 end 或到回边出口」 */
const RunIn = z.object({
quoteId: z.string(),
/** A4 的第几个实例(= 第几次重开 + 1),由外部胶合层递增后传进来 */
instanceSeq: z.number(),
/** 由哪条回边重开;首开为 null */
reopenedBy: z.string().nullable(),
/** 上一个 run 的 runId,形成链 */
prevRunId: z.string().nullable(),
});
type RunIn = z.infer<typeof RunIn>;
const Carry = RunIn.extend({
price: z.number().nullable(),
verdict: z.string().nullable(),
finalOutcome: z.string().nullable(),
});
const RunOut = z.object({
/** 本 run 是否以回跳结束 */
reopen: z.boolean(),
/** 回跳边号 */
reopenedBy: z.string().nullable(),
/** 回跳目标节点 */
reopenTarget: z.string().nullable(),
finalOutcome: z.string().nullable(),
instanceSeq: z.number(),
});
let ledger = new Ledger();
let script = new DecisionScript([]);
/* ------------------------------------------------------------------ 活动点 */
const a4 = createStep({
id: 'A4',
inputSchema: RunIn,
outputSchema: Carry,
resumeSchema: z.object({ price: z.number() }),
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
execute: async ({ inputData, resumeData, suspend, runId }) => {
t('A4.execute', `enter seq=${inputData.instanceSeq} resume=${!!resumeData}`);
if (!resumeData) {
const row = ledger.openInstance({
nodeId: 'A4',
runId,
iteration: inputData.instanceSeq,
reopenedBy: inputData.reopenedBy,
});
t('A4.execute', '起任务 → suspend', { instanceNo: row.instanceNo, taskId: row.taskId });
return await suspend(
{ nodeId: 'A4', taskId: row.taskId, instanceNo: row.instanceNo },
{ resumeLabel: 'A4' },
);
}
const taskId = ledger.rows.filter((r) => r.nodeId === 'A4').at(-1)!.taskId;
ledger.closeTask(taskId, `price=${resumeData.price}`);
t('A4.execute', `任务终态到达 price=${resumeData.price}`);
return { ...inputData, price: resumeData.price, verdict: null, finalOutcome: null };
},
});
const a5 = createStep({
id: 'A5',
inputSchema: Carry,
outputSchema: Carry,
resumeSchema: z.object({ verdict: z.enum(['pass', 'reject']) }),
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
execute: async ({ inputData, resumeData, suspend, runId }) => {
t('A5.execute', `enter seq=${inputData.instanceSeq} resume=${!!resumeData}`);
if (!resumeData) {
const row = ledger.openInstance({ nodeId: 'A5', runId, iteration: inputData.instanceSeq });
t('A5.execute', '起任务 → suspend', { instanceNo: row.instanceNo, taskId: row.taskId });
return await suspend(
{ nodeId: 'A5', taskId: row.taskId, instanceNo: row.instanceNo },
{ resumeLabel: 'A5' },
);
}
const taskId = ledger.rows.filter((r) => r.nodeId === 'A5').at(-1)!.taskId;
ledger.closeTask(taskId, resumeData.verdict);
t('A5.execute', `任务终态到达 verdict=${resumeData.verdict}`);
return { ...inputData, verdict: resumeData.verdict };
},
});
const a6 = createStep({
id: 'A6',
inputSchema: Carry,
outputSchema: Carry,
resumeSchema: z.object({ outcome: z.enum(['won', 'revise']) }),
suspendSchema: z.object({ nodeId: z.string(), taskId: z.string(), instanceNo: z.number() }),
execute: async ({ inputData, resumeData, suspend, runId }) => {
t('A6.execute', `enter seq=${inputData.instanceSeq} resume=${!!resumeData}`);
if (!resumeData) {
const row = ledger.openInstance({ nodeId: 'A6', runId, iteration: inputData.instanceSeq });
t('A6.execute', '起任务 → suspend', { instanceNo: row.instanceNo, taskId: row.taskId });
return await suspend(
{ nodeId: 'A6', taskId: row.taskId, instanceNo: row.instanceNo },
{ resumeLabel: 'A6' },
);
}
const taskId = ledger.rows.filter((r) => r.nodeId === 'A6').at(-1)!.taskId;
ledger.closeTask(taskId, resumeData.outcome);
t('A6.execute', `任务终态到达 outcome=${resumeData.outcome}`);
return { ...inputData, finalOutcome: resumeData.outcome };
},
});
/* ----------------------------------------------------- 出边(含两条回边出口) */
const reopenExit = (id: string, edge: string) =>
createStep({
id,
inputSchema: Carry,
outputSchema: RunOut,
execute: async ({ inputData }) => {
t(`${id}.execute`, `回边命中 → 本 run 到此为止,交给外部胶合层重开 A4`);
return {
reopen: true,
reopenedBy: edge,
reopenTarget: 'A4',
finalOutcome: inputData.finalOutcome,
instanceSeq: inputData.instanceSeq,
};
},
});
const e10Exit = reopenExit('E10-reopen-exit', 'E10');
const e12Exit = reopenExit('E12-reopen-exit', 'E12');
const e13End = createStep({
id: 'E13-end',
inputSchema: Carry,
outputSchema: RunOut,
execute: async ({ inputData }) => {
t('E13.execute', '成交,走向 end');
return {
reopen: false,
reopenedBy: null,
reopenTarget: null,
finalOutcome: inputData.finalOutcome,
instanceSeq: inputData.instanceSeq,
};
},
});
const pick = (id: string) =>
createStep({
id,
inputSchema: z.record(z.string(), RunOut.optional()),
outputSchema: RunOut,
execute: async ({ inputData }) => {
const hit = Object.keys(inputData).filter((k) => inputData[k] !== undefined);
t(`${id}.execute`, 'branch 收口', { '所有分支键': Object.keys(inputData), '命中': hit });
if (hit.length !== 1) throw new Error(`XOR 违约:命中 ${hit.length} 条出边`);
return inputData[hit[0]!]!;
},
});
const a6Sub = createWorkflow({ id: 'A6-sub', inputSchema: Carry, outputSchema: RunOut })
.then(a6)
.branch([
[
async ({ inputData, requestContext }) => {
t('cond(E13 won?)', 'evaluated', {
finalOutcome: inputData.finalOutcome,
'requestContext.tenant': requestContext?.get?.('tenant') ?? null,
});
return inputData.finalOutcome === 'won';
},
e13End,
],
[
async ({ inputData }) => {
t('cond(E12 revise?)', 'evaluated', { finalOutcome: inputData.finalOutcome });
return inputData.finalOutcome === 'revise';
},
e12Exit,
],
])
.then(pick('pick-A6-out'))
.commit();
export const routeB = createWorkflow({
id: 'route-b',
inputSchema: RunIn,
outputSchema: RunOut,
})
.then(a4)
.then(a5)
.branch([
[
async ({ inputData, requestContext }) => {
t('cond(E11 pass?)', 'evaluated', {
verdict: inputData.verdict,
'requestContext.tenant': requestContext?.get?.('tenant') ?? null,
});
return inputData.verdict === 'pass';
},
a6Sub,
],
[
async ({ inputData }) => {
t('cond(E10 reject?)', 'evaluated', { verdict: inputData.verdict });
return inputData.verdict === 'reject';
},
e10Exit,
],
])
.then(pick('pick-A5-out'))
.commit();
export const storeB = new InMemoryStore();
export const mastraB = new Mastra({
workflows: { routeB },
storage: storeB,
logger: false,
});
const mastra = mastraB;
export const _internals = {
setLedger: (l: Ledger) => (ledger = l),
setScript: (s: DecisionScript) => (script = s),
getLedger: () => ledger,
};
/* ------------------------------------------------------- 外部胶合层(驱动器) */
type NodeIdT = 'A4' | 'A5' | 'A6';
/** run 链:外部胶合层自己维护的关联表(主工程里就是「实例表 + 边留痕」) */
type ChainRow = { seq: number; runId: string; prevRunId: string | null; reopenedBy: string | null; endedAs: string };
async function drive(scenario: string, decisions: Decision[]): Promise<void> {
banner(`路线 B / 场景:${scenario}`);
resetTrace();
ledger = new Ledger();
script = new DecisionScript(decisions);
const wf = mastra.getWorkflow('routeB');
const quoteId = `Q-${Math.random().toString(36).slice(2, 8)}`;
const chain: ChainRow[] = [];
let input: RunIn = { quoteId, instanceSeq: 1, reopenedBy: null, prevRunId: null };
let guard = 0;
for (;;) {
if (++guard > 10) throw new Error('driver: too many reopens');
// 自己指定 runId —— 可读、可幂等、可与实例号一一对应
const runId = `${quoteId}-inst-${input.instanceSeq}`;
const run = await wf.createRun({ runId, resourceId: quoteId });
t('[glue]', `createRun`, { runId: run.runId, resourceId: quoteId, inputData: input });
const rc = new RequestContext();
rc.set('tenant', 'acme');
let res = await run.start({ inputData: input, requestContext: rc as never });
let g2 = 0;
while (res.status === 'suspended') {
if (++g2 > 10) throw new Error('driver: too many suspends');
const state = await wf.getWorkflowRunById(run.runId);
const reader = createWorkflowStateReader(state!);
const s = reader.getSuspendedStep();
t('[glue]', 'getSuspendedStep()', {
stepId: s?.stepId,
path: s?.path,
suspendPayload: s?.suspendPayload,
resumeLabels: Object.keys(reader.getResumeLabels()),
});
const nodeId = (s!.suspendPayload as { nodeId: NodeIdT }).nodeId;
const d = script.next(nodeId);
const resumeData =
d.node === 'A4'
? { price: d.price }
: d.node === 'A5'
? { verdict: d.verdict }
: { outcome: d.outcome };
t('[glue]', `外部推进 ${nodeId}`, resumeData);
res = await run.resume({ step: s!.path, resumeData });
}
if (res.status !== 'success') {
console.log(' 非 success:', JSON.stringify(res).slice(0, 600));
return;
}
const out = res.result;
chain.push({
seq: input.instanceSeq,
runId: run.runId,
prevRunId: input.prevRunId,
reopenedBy: input.reopenedBy,
endedAs: out.reopen ? `reopen(${out.reopenedBy}) → ${out.reopenTarget}` : `end(${out.finalOutcome})`,
});
if (!out.reopen) {
t('[glue]', `run 结束:${JSON.stringify(out)} —— 流程完结`);
break;
}
t('[glue]', `run 结束:回跳 ${out.reopenedBy} → ${out.reopenTarget},新起一个 run`);
input = {
quoteId,
instanceSeq: input.instanceSeq + 1,
reopenedBy: out.reopenedBy,
prevRunId: run.runId,
};
}
console.log('\n ── run 链 ──');
for (const c of chain) {
console.log(
` seq=${c.seq}\trunId=${c.runId}\tprev=${c.prevRunId ?? '-'}\treopenedBy=${c.reopenedBy ?? '-'}\t${c.endedAs}`,
);
}
ledger.dump(scenario);
// 旧 run 留痕:流程都跑完了,还能不能查到第 1 个 run 的快照?
console.log('\n ── 历史 run 是否还查得到(旧实例留痕不动) ──');
for (const c of chain) {
const st = await wf.getWorkflowRunById(c.runId);
const a4 = st?.steps?.['A4'] as { output?: { price?: number }; suspendPayload?: { taskId?: string } } | undefined;
console.log(
` ${c.runId}: status=${st?.status} A4.taskId=${a4?.suspendPayload?.taskId} A4.price=${a4?.output?.price} result=${JSON.stringify(st?.result)}`,
);
}
const runs = await wf.listWorkflowRuns({ resourceId: quoteId });
console.log(` listWorkflowRuns({resourceId:'${quoteId}'}) → total=${runs.total}, runIds=${JSON.stringify(runs.runs.map((r) => r.runId))}`);
}
if (!process.env.SPIKE_NO_AUTORUN) {
await drive('① 一次过(A4→A5 pass→A6 won→end)', [
{ node: 'A4', price: 100 },
{ node: 'A5', verdict: 'pass' },
{ node: 'A6', outcome: 'won' },
]);
await drive('② A5 驳回一次后再通过', [
{ node: 'A4', price: 100 },
{ node: 'A5', verdict: 'reject' },
{ node: 'A4', price: 90 },
{ node: 'A5', verdict: 'pass' },
{ node: 'A6', outcome: 'won' },
]);
await drive('③ A6 revise 一次后再成交', [
{ node: 'A4', price: 100 },
{ node: 'A5', verdict: 'pass' },
{ node: 'A6', outcome: 'revise' },
{ node: 'A4', price: 80 },
{ node: 'A5', verdict: 'pass' },
{ node: 'A6', outcome: 'won' },
]);
}
+10
View File
@@ -0,0 +1,10 @@
/** 带序号的轨迹打印,用来看「什么时候被调用了」 */
let seq = 0;
export const resetTrace = (): void => {
seq = 0;
};
export const t = (who: string, what: string, extra?: unknown): void => {
seq += 1;
const tail = extra === undefined ? '' : ` ${JSON.stringify(extra)}`;
console.log(` #${String(seq).padStart(2, '0')} ${who.padEnd(26)} ${what}${tail}`);
};