This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts
T
ChaiHaoandClaude Opus 5 ac2fecd005 汇报流:活动按步序位置推视图,容器沙箱渲染
「flow:按**步序位置**推送声明的模版」(AM 07)。原先这条链只有写面没有读面,
活动的 viewTemplates 是一行占位字符串,工作台视图区永远只显示一份硬编码概览。

补齐整条链:
  · 步声明新增 `view`:这一步呈现哪张模版 —— 推什么由声明给定,不由临场决定。
  · 新增模版填数的纯函数(interaction/domain):`data-slot` 取值、`data-rows` 逐条展开。
    **填数只做取值与转义**,没有表达式、没有分支、没有循环 —— 一旦模版能算,
    它就成了代码,容器的「无脚本执行权」也就形同虚设。填充**消费槽位**,
    交出去的是干净 HTML,不是带模板属性的半成品。
  · interaction 补读面 `viewOf`;desk 路由把推送的 HTML 交给前端;
    ViewRegion 经 `@ui` 的 ViewContainer(sandbox="" 的 iframe)渲染 —— feature 不碰 innerHTML。
  · A1 写了三张真模版(事态全景 / 打标判据 / 成果预览),内联自带样式
    (沙箱页面不继承工作台 CSS),按步序位置挂在 S3 / S5 / S6 上。

沿路两处修:
① 取值路径遇到**本身带点号的键**(台账地址 `域.对象`)会被拆成两级、永远取不到。
   改为整键优先、找不到再下行;`view-render` 与 `step-sequence` 两处同修。
② 「本单交付」不能画成台账全表:任务视图是正式版 + 我的草稿的合并结果,
   直接拿来列会把**别单**放行过的记录一并列出。本单自己写下的行单独放在
   `mine:域.对象` 下(整键,取值不歧义)。

另:视图推送失败原先静默吞掉 —— 会让「视图区一直空着」变成查不出原因的怪事;
改为留痕(仍不影响步序推进,呈现是投影)。

实测:一封 RFQ 入站 → 放行前那一屏列出本单交付 LM358DR / TI / 12000 / 8.2 / 10周,
以及 Type A「直接快答」的分类结论与四项打标判据。

gates: tsc / eslint / depcruise(321 模块)/ api-report / 423 测试 全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-27 10:47:20 +08:00

185 lines
8.2 KiB
TypeScript

/**
* flow 步序推进 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 05)。
*
* 「flow 是活动内的**确定性范式**:业务模型 = 步序声明 —— 一条谱;
* 引擎照谱执行,**零临场调度**,调度权归系统。
* 差异只有一处:下一步由**声明给定**,不由 AI 决断。
* LLM 在 flow 里可以出现,但只以『被调用的工具』出现,不是调度者。」
*/
import type { FlowStep } from '@contracts';
export const STEP_STATES = ['pending', 'running', 'awaiting', 'done', 'skipped', 'failed'] as const;
export type StepState = (typeof STEP_STATES)[number];
export type StepRuntime = {
readonly name: string;
readonly state: StepState;
readonly attempt: number;
readonly output?: Record<string, unknown>;
readonly error?: string;
/** 验收工单的放行裁决:pass / 驳回类别 —— 末步据此定终局 */
readonly verdict?: string;
/** 选择组的出口选择:恰发一口,随放行一并确认(AM 1.2) */
readonly selectedPorts?: Record<string, string>;
};
export type FlowPosition = {
/** 步序位置 —— 三份持久状态之一(OS 09 断点续跑) */
readonly index: number;
readonly steps: readonly StepRuntime[];
};
export function initialPosition(steps: readonly FlowStep[]): FlowPosition {
return { index: 0, steps: steps.map((s) => ({ name: s.name, state: 'pending', attempt: 0 })) };
}
/** 条件跳步:条件必须是任务数据上的**确定性谓词**,由引擎求值,零 LLM(AM 5.2) */
export type PredicateEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
export type NextAction =
| { readonly kind: 'run'; readonly step: FlowStep; readonly index: number }
| { readonly kind: 'skip'; readonly step: FlowStep; readonly index: number; readonly reason: string }
| { readonly kind: 'await'; readonly step: FlowStep; readonly index: number }
| { readonly kind: 'finished' };
/**
* 下一步由声明给定。异步步(人类工具步 / 等事件的步)进入 `await` —— 完成条件 = 监听。
*/
export function nextAction(
declaration: readonly FlowStep[],
position: FlowPosition,
scope: Readonly<Record<string, unknown>>,
evaluate: PredicateEvaluator,
): NextAction {
for (let i = position.index; i < declaration.length; i += 1) {
const step = declaration[i]!;
const runtime = position.steps[i];
if (runtime?.state === 'done' || runtime?.state === 'skipped') continue;
if (runtime?.state === 'awaiting') return { kind: 'await', step, index: i };
if (step.when !== undefined && !evaluate(step.when, scope)) {
return { kind: 'skip', step, index: i, reason: `条件不成立:${step.when}` };
}
return { kind: 'run', step, index: i };
}
return { kind: 'finished' };
}
export function markStep(position: FlowPosition, index: number, patch: Partial<StepRuntime>): FlowPosition {
const steps = position.steps.map((s, i) => (i === index ? { ...s, ...patch } : s));
const advanced = patch.state === 'done' || patch.state === 'skipped';
return { index: advanced ? index + 1 : index, steps };
}
/* ── 失败与缺口:声明给定,不临场(AM 5.3) ────────────────────────── */
export type FailureAction =
| { readonly kind: 'retry'; readonly attempt: number; readonly backoff: string }
/** 转人兜底:改发人类工具卡,卡内容随声明 */
| { readonly kind: 'human'; readonly workOrder: 'execution' | 'acceptance' }
/** 受阻待人:挂起,携阻因 */
| { readonly kind: 'blocked' };
/**
* 「每步声明重试次数与间隔;重试仍败,按声明走下一步。
* **flow 内不得出现『由 AI 决定怎么办』的失败处理** —— 需要临场,即说明该活动不该是 flow。」
*/
export function failureAction(step: FlowStep, attempt: number): FailureAction {
if (attempt < step.failure.retry.times) {
return { kind: 'retry', attempt: attempt + 1, backoff: step.failure.retry.backoff };
}
return step.failure.fallback.kind === 'human'
? { kind: 'human', workOrder: step.failure.fallback.workOrder }
: { kind: 'blocked' };
}
/* ── 静态检查:谱必须全程可静态检查(AM 12.3) ────────────────────── */
export type FlowIssue = { readonly step: string; readonly message: string };
export function checkDeclaration(steps: readonly FlowStep[]): FlowIssue[] {
const out: FlowIssue[] = [];
const names = new Set<string>();
for (const step of steps) {
if (names.has(step.name)) out.push({ step: step.name, message: '步名重复' });
names.add(step.name);
// 步序声明完整:步型 / 入参映射 / 出参写入 / 完成条件 / 失败策略齐备
if (step.failure.retry.times < 0) out.push({ step: step.name, message: '重试次数不得为负' });
if (step.kind === 'human' && step.deadline.trim() === '') {
out.push({ step: step.name, message: '人类工具步必配时限 —— 无死等' });
}
}
return out;
}
/** 终态映射(AM 5.3):放行完成 = 末步验收工单「放行通过」 */
export function lastStepIsAcceptance(steps: readonly FlowStep[]): boolean {
const last = steps.at(-1);
return last !== undefined && last.kind === 'human' && last.workOrder === 'acceptance';
}
/** 并行组:组内步无依赖、**全完成再继续** */
export function parallelGroupComplete(position: FlowPosition, members: readonly string[]): boolean {
return members.every((name) => {
const runtime = position.steps.find((s) => s.name === name);
return runtime?.state === 'done' || runtime?.state === 'skipped';
});
}
/**
* 人兜底时要回填的字段 —— 取自这一步**声明的出参**。
*
* 出参契约属于**步**,不属于执行者:同一步无论由 LLM、工具还是人做完,
* 落进作用域的形状必须一致。若兜底卡只给一个笼统的「结果」文本框,
* 人填了也落不进去 —— 下游按 `output.fields` 取值,取不到就在下一步崩。
*
* 返回键名数组,与 human 步的 `card.fields` 同形:卡的渲染只有一套。
*/
export function fallbackFields(step: FlowStep, scope: Readonly<Record<string, unknown>> = {}): readonly string[] {
if (step.output === undefined) return [];
// 出参的值要问,**行标识**也要问 —— 主键取不到就写不进台账(非空约束会在合并时挡下)。
// 作用域里已经有的键不必再问一遍:上游给过就是给过了。
const keys = Object.values(step.output.key).filter((path) => scope[path] === undefined);
return [...new Set([...keys, ...Object.values(step.output.fields)])];
}
/**
* 末步的放行裁决是否「通过」—— **完成只出自放行通过**(OS 05)。
* 驳回(重做 / 迭代)不是完成:谱虽走完,任务不得进 released。
*/
export function lastVerdictPassed(position: FlowPosition): boolean {
const last = position.steps[position.steps.length - 1];
return last?.verdict === 'pass';
}
/** 末步随放行确认的出口选择 —— 落地收敛时按它发射 */
export function lastSelectedPorts(position: FlowPosition): Record<string, string> {
return position.steps[position.steps.length - 1]?.selectedPorts ?? {};
}
/**
* 取值路径:点号分段下行,数组下标按数字段走(`records.0.pn`)。
*
* **入参与出参共用这一份**:声明里写的路径是同一种写法,不该一边能走点号、
* 另一边只认扁平键 —— 那会让「模型按 schema 回了嵌套结构」这种最常见的情形
* 悄悄写出一条空记录。
*/
export function resolvePath(source: unknown, path: string): unknown {
/*
* 整键优先:作用域里有些键**本身带点号**(台账地址 `域.对象`)。
* 先按字面整键找,找不到再按点号下行 —— 反过来会把地址键拆成两级、永远找不到。
*/
if (typeof source === 'object' && source !== null && !Array.isArray(source)) {
const direct = (source as Record<string, unknown>)[path];
if (direct !== undefined) return direct;
}
return path.split('.').reduce<unknown>((acc, seg) => {
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
if (Array.isArray(acc)) {
const index = Number(seg);
return Number.isInteger(index) ? acc[index] : undefined;
}
return (acc as Record<string, unknown>)[seg];
}, source);
}