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/app/wiring/engines.ts
T
xcsandClaude Fable 5 b98ec75a89 数据工单由剧本声明、引擎决定 —— 不靠模型抄卡骨架(W05 1.4.3)
用户:「右边 AI 聊天框里这个数据工单怎么又显示的执行工单?」查实:说明书骨架里
`"variant":"data"` 与 `gateLedgers` 并排写着,模型抄卡时 gateLedgers 抄到了、variant 丢了
(看着像元数据)。靠模型抄一个标记本来就靠不住。

改成与 `view` 同一条道理:这一步派的是什么单,写剧本时就定死 ——
- 契约 `PlanStep.cardVariant: 'data'`;活动在 S0d_p08_master / S0d_p10_document 两步上声明;
- `cards.issue` 入参多一项可选 `itemId`(turn-runner 从 human 动作带下来;引擎自发的验收/兜底卡没有项);
- 组装层按 itemId 查剧本,`stepVariant ?? 卡上写的` —— 模型写不写都一样。
在跑那一单的卡已就地补上 variant(payload),刷新即见「数据工单」。
2102 测 / typecheck / lint / depcruise 绿;api-report 已刷;两处 CONTRACT.md 同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 13:51:14 +08:00

2782 lines
136 KiB
TypeScript

/**
* 范式引擎与任务运行时的装配 —— app 是唯一组装层(编码规范 §5.2)。
*
* 这里把 M4/M5 的模块用**端口**接起来:跨层协作一律经端口,模块之间不互相认识。
*/
import { sql } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import type { LandedFacts } from '@modules/task';
import type { EventService } from '@modules/event';
import type { LedgerService } from '@modules/ledger';
import type { MemoryService } from '@modules/memory';
import type { ToolboxService, ToolContract, ToolHandoffStore, ToolInvoker } from '@modules/toolbox';
import type { TraceService } from '@modules/trace';
import { callKeyOf, coerceBySchema, compileToolBus, handoffFields } from '@modules/toolbox';
import type { ExecutionWorkOrderDraft, HumanToolService } from '@modules/human-tool';
import { renderView } from '@modules/interaction';
import type { InteractionService } from '@modules/interaction';
import type { MessageService } from '@modules/message';
import type { OrgService } from '@modules/org';
import { NONE_POSITION_CODE } from '@modules/org';
import type { LlmService, ModelGateway } from '@modules/llm';
import type { ActivityRegistryService } from '@modules/activity-registry';
import type { SkillEngineService } from '@modules/skill-engine';
import type { ReleaseService } from '@modules/release';
import { idempotencyKey } from '@modules/release';
import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine';
import type { LoopEngineService } from '@modules/loop-engine';
import { createSkillEngineService } from '@modules/skill-engine';
import { createTaskService, createTaskStore } from '@modules/task';
import type { TaskService } from '@modules/task';
import { createProcessService, createProcessStore, createSelfHostedRuntime } from '@modules/process';
import type { FlowRuntime, ProcessServiceDeps } from '@modules/process';
import type { ProcessService } from '@modules/process';
import { createTriggerService, createTriggerStore } from '@modules/trigger';
import type { TriggerService } from '@modules/trigger';
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
import type { StaffId, TaskId } from '@contracts';
import { cardIdSchema, staffIdSchema } from '@contracts';
import { archiveAssetFiles } from '../asset-archive.ts';
import { createAgentRunner } from '../agent-runner.ts';
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
import type { Clock, IdGenerator } from '../runtime.ts';
export type EngineWiringDeps = {
readonly db: Db;
readonly clock: Clock;
readonly ids: IdGenerator;
readonly event: EventService;
readonly ledger: LedgerService;
readonly memory: MemoryService;
readonly toolbox: ToolboxService;
readonly toolInvoker: ToolInvoker;
/** 工具转人台(§6.2 未命中转人)—— 人是工具的一种实现 */
readonly toolHandoff: ToolHandoffStore;
readonly humanTool: HumanToolService;
readonly interaction: InteractionService;
readonly message: MessageService;
readonly org: OrgService;
readonly llm: LlmService;
readonly modelGateway: ModelGateway;
readonly registry: ActivityRegistryService;
readonly skills: SkillEngineService;
readonly release: ReleaseService;
readonly schemas: { validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] } };
/** 全链观测:节点起落、工具/模型/人卡各打一个 span —— 观测尽力而为,不掀翻业务 */
readonly trace: TraceService;
};
export type EngineWiring = {
readonly task: TaskService;
readonly process: ProcessService;
readonly trigger: TriggerService;
/** 案例引擎本体 —— 工作台要按它算阶段进度(`phaseOf`);推进走 task,不从这里走 */
readonly loop: LoopEngineService;
/**
* **此刻这一单的判据长什么样** —— 按当下数据现算。
*
* 发卡那一刻算死是不行的:人在件上补一格、撤一行,判据就该跟着变。
* 与提交门读的是**同一段**(`criterionCheck`)—— 两处各算各的,
* 就会出现「卡上说达标、提交门说不行」,那种矛盾人是判不出对错的。
*/
readonly checks: { of(taskId: TaskId): Promise<Array<ReturnType<typeof criterionCheck>>> };
/** 视图闸 —— 推一帧;`repush` 按当下数据重渲同一张(人在件上改完要用它) */
readonly views: {
push(input: { taskId: TaskId; activityId: string; template: string; scope: Record<string, unknown> }): Promise<{
pushed: boolean;
templates: string[];
}>;
repush(taskId: TaskId): Promise<boolean>;
};
};
function writeLog(line: string): void {
globalThis.process.stderr.write(`${line}\n`);
}
/** S-FEEL 三件套 —— 求值异常按「不成立」处置,不让流转停摆 */
const sfeel = {
condition: (expression: string, scope: Readonly<Record<string, unknown>>): boolean => {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
},
expression: (expression: string, scope: Readonly<Record<string, unknown>>): unknown => {
try {
return evalSfeel(expression, scope);
} catch {
return undefined;
}
},
unaryTest: (cell: string, value: unknown, scope: Readonly<Record<string, unknown>>): boolean => {
try {
return evaluateUnaryTest(cell, value, scope);
} catch {
return false;
}
},
};
/**
* 台账列的归型 —— **写进草稿之前就把型归好**(见 `draftWrite` 里的注释)。
*
* 三种结局:`value` 写这个值 / `empty` 这一列没有值(不写)/ `reject` 当场拒。
* 归不出来宁可拒:留到 `releaseWrite` 才炸,是在人签完字之后炸 —— 最坏的失败点。
*
* 纯函数、导出可测:这类归型规则错一条,症状是几天后某一单在放行时莫名其妙挂掉。
*/
/**
* 本单每张表写下了几行 —— `{ 'mine:域.对象': 行[] }` → `{ '域_对象_rows': 行数 }`。
*
* 名字为什么是下划线:S-FEEL 的路径不认冒号也不认点号,`mine:域.对象` 那一份取不到;
* 而判据要能写成 `quote_intake_record_rows = line_total` 这样一句才有用。
*
* 它存在的理由只有一个:**让机检判据有一边不是活动自报的**。
* 两边都由活动写,它数错一次就一起错、一起「对上」(实测:条目数报 9、表里躺着 10,
* 派单数与三类条数同错同过)—— 那样的对账防的是疏忽,防不了口径。
*/
/**
* **算出这一单走哪一口** —— 选择组内按序求值,首个命中即出(与 DMN 的 FIRST 同一口径)。
*
* 组内**每一口都得写 `when`** 才算得出来:少一口就说明这组分支有一段没有判据,
* 那时宁可退回「人选一口」,也不要按半套规则替他决定。
* 谓词求值抛错当作不命中:一条写坏的谓词不该让整单卡在放行门前。
*/
export function routePort(
group: {
group: string;
ports: ReadonlyArray<{ name: string; description?: string | undefined; when?: string | undefined }>;
},
scope: Record<string, unknown>,
evaluate: (expression: string, scope: Record<string, unknown>) => boolean,
): { group: string; name: string; label?: string; because: string } | undefined {
if (!group.ports.every((p) => p.when !== undefined && p.when.trim() !== '')) return undefined;
for (const port of group.ports) {
let hit = false;
try {
hit = evaluate(port.when ?? '', scope);
} catch {
hit = false;
}
if (hit) {
return {
group: group.group,
name: port.name,
...(port.description === undefined ? {} : { label: port.description }),
because: port.when ?? '',
};
}
}
return undefined;
}
/**
* 存的是 UTC,摆到屏上要**本地时刻**。
*
* 直接切 ISO 串那八个字符,屏上就写着 `07-31 20:16`,而人这边是 `08-01 04:16` ——
* 差八小时的时刻比没有时刻更坏:它看起来像一个可信的事实。
* 归零到分:报告上摆秒没有意义,还会让同一单的两份件看起来不一样。
*/
export function localStamp(iso: string): string {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return '';
const two = (n: number): string => String(n).padStart(2, '0');
return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
}
/**
* 「只建不改」的表:这一笔写的键是不是已经在正式表里了 —— 在就回一句拒回的话(带指路),不在回 undefined。
* 纯函数:正式表的行由调用方查好递进来,这里只比键。
*/
export function createOnlyViolation(
target: { readonly label: string; readonly keys: ReadonlyArray<string> },
key: Readonly<Record<string, string | number>>,
formalRows: ReadonlyArray<Record<string, unknown>>,
): string | undefined {
const same = (row: Record<string, unknown>) => target.keys.every((k) => String(row[k] ?? '') === String(key[k] ?? ''));
if (!formalRows.some(same)) return undefined;
const shown = target.keys.map((k) => `${k}=${String(key[k] ?? '')}`).join('、');
return (
`${target.label}里已经有「${shown}」这一行 —— 这张表在本单只许**新建库里没有的记录**(数据工单是从零建主数据用的),` +
'在库的料号不用建、也不许改:别再写它;要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉,免得件上多出一行要人填的东西。'
);
}
export function rowReadings(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{ domain: string; object: string }> = [],
): Record<string, number> {
const out: Record<string, number> = {};
/*
* **一行没写也是一个读数:0** —— 先按声明把每张表铺成 0,再让实际行数盖上去。
*
* 只数「写过的表」栽过一次(2026-08-01,全 A 类那条路):一单没有 C 类,
* 于是替代评估请求那张表一行都没写,`quote_intake_alt_request_rows` 整个键**不存在**;
* 而判据是 `quote_intake_alt_request_rows = type_c_total`(0 = 0 本该成立),
* 未绑定的名字参与比较求值为假 —— 这条判据**永远过不去**,模型连撞五拍熔断,
* 而数据本身完全合规。「没写过」与「写了 0 行」在业务上是同一件事,读数上也得是。
*
* 铺的是**这个活动声明过的表**:声明面就是它的授权面,不在声明里的表本来也数不着。
*/
for (const { domain, object } of declared) out[`${domain}_${object}_rows`] = 0;
for (const [key, list] of Object.entries(mine)) {
if (!key.startsWith('mine:')) continue;
out[`${key.slice('mine:'.length).replace(/\./g, '_')}_rows`] = list.length;
}
return out;
}
/**
* 本单每张表写下的**是哪几行** —— `{ '域_对象_keys': 'A、B、C' }`。
*
* 行数类判据拒回时只给两个数(「10 ≠ 9」),模型看得见对不上、看不见多的是哪一行,
* 于是只能猜:改数、重写、再提 —— 三条路都不对,连拒五拍熔断(实测三种形态全是这一类)。
* 主键名单一摆就是一句话能办的事:多的那个 PN 就在名单里对着。
*
* 摆的是**主键**(声明里 `primaryKey` 那几列),不是整行:整行铺出来会把拒回理由撑爆,
* 而认一行只需要它的主键。单行封顶 40 字、整张表封顶 12 行 ——
* 再多就不是「差在哪」而是把台账抄了一遍;超出的如实写「还有 N 行」。
*/
/**
* 本单每张表**缺必填的有几行,是哪几行** —— `{ '域_对象_missing': N, '…_missing_keys': '…' }`。
*
* 存在的理由只有一个:**让机检判据读得到基座数出来的数**。
* A1 的「必填齐(PN / 年用量)」是 L1 机检,读的却是活动自己写的 `missing_key_total` ——
* 人在卡上补了年用量、基座已经写回草稿层,而那个汇总列还要模型自己重算;
* 它算漏一次,这条判据就永远绿不了(连跑十轮里三轮死在这,每次都是连拒五拍熔断)。
* 活动自报的数不是门,是一句「改哪个数能过」的提示。
*
* 哪几列算必填由**台账声明**说了算(`fields[].required`)——业务知识写在声明里,
* 基座只管照着数。缺 = 没有这个键、或值是 null / 空串(与 `coerceToColumn` 的空值口径一致)。
*/
/**
* **摆给人看的行标识:主键去掉单标识那一段**(2026-08-25 用户:「只保留斜杠右边部分」)。
*
* 单据表的主键是 `单号 + 业务键`(如 round_ref + contact_code)。缺项红带、执行卡提交闸、
* 拒回主键名单从前把两段都摆(`COV-20260825002227/CU-0142-01`)—— 可人正在**这一单里**
* 看这张件,单号是他脚下的地面,不是区分行的信息;真正认得出行的是右边那一段。
* `caseKey` 是声明里的单标识,照它去掉;去完一段都不剩(主键只有单号)就退回整段。
*/
export function rowLabel(id: Readonly<Record<string, unknown>>, caseKey: string | undefined): string {
const parts = Object.entries(id)
.filter(([k, v]) => k !== caseKey && v !== undefined && v !== null && String(v) !== '')
.map(([, v]) => String(v));
return parts.length > 0 ? parts.join('/') : Object.values(id).map(String).join('/');
}
export function missingReadings(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{
domain: string;
object: string;
caseKey?: string | undefined;
fields: ReadonlyArray<{
name: string;
label?: string | undefined;
primaryKey: boolean;
required?: boolean | undefined;
expect?: boolean | undefined;
requiredWhen?: { column: string; value: string } | undefined;
}>;
}>,
): Record<string, string | number> {
const out: Record<string, string | number> = {};
for (const { domain, object, caseKey, fields } of declared) {
const rows = mine[`mine:${domain}.${object}`] ?? [];
const keys = fields.filter((f) => f.primaryKey).map((f) => f.name);
const blank = (one: Record<string, unknown>, c: string): boolean =>
one[c] === undefined || one[c] === null || String(one[c]).trim() === '';
/*
* 两组读数同一套算法,只是看哪一个标记:
* · `required` → `_missing` 缺必填几行(**不齐不许放行**,是验收标准);
* · `expect` → `_gaps` 缺选填几行(不拦单,但**那一问不能省**)。
* 缺口摆的是**中文列名**:这两个数会进拒回与卡面,给人看的地方不摆列名。
*
* `requiredWhen` 把必填钉在**该轮到的那一行**(2026-08-02,逐颗派单的闸):
* 行上那一列不等于指定值,这一格就还没轮到,不算缺 —— 排队的颗不拦当前的卡。
*/
for (const [mark, suffix] of [
['required', 'missing'],
['expect', 'gaps'],
] as const) {
const watched = fields.filter((f) => f[mark] === true || (mark === 'required' && f.requiredWhen !== undefined));
if (watched.length === 0) continue;
const bad: string[] = [];
for (const row of rows) {
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
const due = watched.filter(
(f) => f.requiredWhen === undefined || String(one[f.requiredWhen.column] ?? '') === f.requiredWhen.value,
);
const gaps = due.filter((f) => blank(one, f.name));
if (gaps.length === 0) continue;
const id: Record<string, string> = {};
for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]);
bad.push(`${rowLabel(id, caseKey)} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
}
out[`${domain}_${object}_${suffix}`] = bad.length;
if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';');
}
}
return out;
}
/**
* **按值现数** —— `{ '域_对象_<列>_<值>': 行数 }`,取值域由台账声明的 `tally` 给。
*
* 存在的理由与 `_rows` / `_missing` 是同一条:**能由数据算出来的,别让活动自报**。
* 三分类条数从前是活动写的 `type_a_total` 之流,而分布条、放行时的去向谓词、验收判据
* 读的全是它们 —— 于是人在放行页把九条料全改成 A,类徽变了,分布条还是 5/3/1、
* 去向还是「去选型」:逐行数据变了,汇总数没人重算。
* 而**手工修改的全部意义就是纠正 AI 的判断**;改了类标而流向不动,这个兜底就是假的。
*
* 声明给取值域,所以**没出现的值也铺 0** —— 零条也是一个数,判据才算得下去
* (与 `rowReadings` 铺 0 同一口径,那一条是全 A 类那条路踩出来的)。
* 值消毒成小写标识符(与 `classToken` 同一把尺),`A` → `..._type_tag_a`。
*/
export function tallyReadings(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{
domain: string;
object: string;
fields: ReadonlyArray<{ name: string; tally?: readonly string[] | undefined }>;
}>,
): Record<string, number> {
const out: Record<string, number> = {};
for (const { domain, object, fields } of declared) {
for (const field of fields) {
if (field.tally === undefined || field.tally.length === 0) continue;
const rows = mine[`mine:${domain}.${object}`] ?? [];
const slug = (v: string): string => v.toLowerCase().replace(/[^a-z0-9_]/g, '');
for (const value of field.tally) {
const key = `${domain}_${object}_${field.name}_${slug(value)}`;
if (key.endsWith('_')) continue; // 值里一个合规字符都没有 —— 不给一个歧义的钩子
out[key] = rows.filter((row) => {
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
return String(one[field.name] ?? '').trim() === value;
}).length;
}
}
}
return out;
}
/**
* **这一列有几个不同的值** —— `{ '域_对象_distinct_<列>': N }`,声明列标 `distinct: true` 才数。
*
* 为什么要有这一类(2026-08-24 柴浩实测:「新增了一行要覆盖的客户,后面推进直接无视了」):
* 覆盖范围确认那张卡上人加的行落进了台账、focus 段里模型也看得见,可 S3 的收步判据
* 只数「计划有没有」(`plan_rows > 0`)—— 计划盖住一家还是两家,没有一个基座数说得出来。
* 模型锚在自己轨迹的「识别出 1 家」上把新行漏掉,引擎照样自动收步、放行一路绿灯。
* 「每家候选客户都进了经营计划」要成为机检,就得有「计划里有几家客户」这个数;
* 它必须由基座数:计划行数 ≠ 客户数(一家客户几位关键人就几行),而模型自报的数
* 恰恰就是这次数漏的那一个。
*
* 只数**本单写下的行**(与 `_rows` 同一基准);空值不算一种 —— 「没写」不是一个取值。
* 声明了的列**一律先铺 0**:未绑定的名字参与比较求值为假,零行也得是一个数。
*/
export function distinctReadings(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{
domain: string;
object: string;
fields: ReadonlyArray<{ name: string; distinct?: boolean | undefined }>;
}>,
): Record<string, number> {
const out: Record<string, number> = {};
for (const { domain, object, fields } of declared) {
for (const field of fields) {
if (field.distinct !== true) continue;
const rows = mine[`mine:${domain}.${object}`] ?? [];
const seen = new Set<string>();
for (const row of rows) {
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
const value = String(one[field.name] ?? '').trim();
if (value !== '') seen.add(value);
}
out[`${domain}_${object}_distinct_${field.name}`] = seen.size;
}
}
return out;
}
/**
* **本轮碰过的行,以「落库后的样子」摆出来** —— `touched:域.对象`。
*
* 起因(2026-08-24 柴浩实测):台账更新确认屏的落库预览绑的是 `mine:`(本单草稿),
* 而草稿行只有模型这轮写过的那几列 —— Itelma 档案里既有的客户类型/行业/区域、
* Ekaterina 的职务/角色全不在里面:只读面一片空白,编辑框也空(像是数据丢了)。
* 签字的人要看的是**签下去之后这一行长什么样** = 正式现值 + 本单草稿叠加;
* 但整表不行(没碰过的客户不该出现在「将写入」里)—— 所以按草稿键过滤:
* 本轮碰过哪几行,就摆那几行的叠加后全貌。
*/
export function touchedRows(
merged: ReadonlyArray<unknown>,
draftKeys: ReadonlyArray<Readonly<Record<string, unknown>>>,
): unknown[] {
const rows = merged.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null);
return draftKeys
.map((key) => rows.find((row) => Object.entries(key).every(([k, v]) => String(row[k] ?? '') === String(v ?? ''))))
.filter((r) => r !== undefined);
}
/**
* **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。
*
* 「机检只读基座数」这条纪律此前只覆盖到台账(`_rows` / `_missing` / tally),
* **覆盖不到工具**:一条判据若要说「转 A 的依据来自本单亲查的行情」,
* 从前只能读活动自报的列 —— 而模型偶发压根没调工具就把料判成「行情稳」,
* 自报的那一列照样写得漂亮(A2 实测约十次一次,去向就此走岔)。
* 说明书已经写到头(「这一拍亲自查 alt_pn」「不许拿上游的数顶替」),
* **措辞治不了的结构治**(设计律第六条)。
*
* 工具出参本来就入痕(`turn-runner` 每次调完记一条 `kind:'result'`、
* 标题 `${tool} 返回`),所以「调没调」是**基座数得出来的事实**,不必让活动自报。
*
* 只数**成功返回**的那几次:调失败等于没查到行情,不该拿来当依据。
* 名字取 `tool_<工具名去符号>_calls`(S-FEEL 不认点号与横杠);
* 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假,
* 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。
*/
export function toolCallReadings(
trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
declared: ReadonlyArray<{ readonly name: string }>,
): Record<string, number> {
const key = (tool: string): string => `tool_${tool.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`;
const out: Record<string, number> = {};
for (const { name } of declared) out[key(name)] = 0;
for (const entry of trail) {
if (entry.kind !== 'result') continue;
for (const { name } of declared) {
if (entry.title.startsWith(`${name} 返回`)) out[key(name)] = (out[key(name)] ?? 0) + 1;
}
}
return out;
}
export function rowKeyList(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }>,
): Record<string, string> {
const out: Record<string, string> = {};
for (const { domain, object, fields } of declared) {
const rows = mine[`mine:${domain}.${object}`];
if (rows === undefined || rows.length === 0) continue;
const keys = fields.filter((f) => f.primaryKey).map((f) => f.name);
if (keys.length === 0) continue;
/*
* **名单要能直接照抄进 `drop` 的 keys** —— 所以摆成 JSON,不摆成 `A/B` 拼串。
*
* 实测栽过一次:名单写作 `RFQ MD14.1/XTSD04GLGEAQG-I`,模型得自己猜第一段是哪一列;
* 列名它猜对了,值却把空格写成了横杠(`RFQ-MD14.1` 是更常见的写法)——
* 连撤三次都撤不到,而那一行就在名单里。
* 主键是**认行的钥匙**,一个字符都不能差;那就别让它转写,给它能原样粘的形状。
*/
const shown = rows.slice(0, 12).map((row) => {
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
const pairs: Record<string, string> = {};
for (const k of keys) {
const v = one[k];
if (v === undefined || v === null || String(v) === '') continue;
const text = String(v);
pairs[k] = text.length <= 60 ? text : `${text.slice(0, 60)}…`;
}
return Object.keys(pairs).length === 0 ? '' : JSON.stringify(pairs);
});
const listed = shown.filter((t) => t !== '');
if (listed.length === 0) continue;
out[`${domain}_${object}_keys`] =
listed.join('、') + (rows.length > shown.length ? `(还有 ${rows.length - shown.length} 行)` : '');
}
return out;
}
export type Coerced = { kind: 'value'; value: unknown } | { kind: 'empty' } | { kind: 'reject' };
export function coerceToColumn(value: unknown, type: string): Coerced {
// null / undefined / 空串一律是「没有值」——「原文没写的不要补」在数据面的落点
if (value === null || value === undefined) return { kind: 'empty' };
if (typeof value === 'string' && value.trim() === '') return { kind: 'empty' };
switch (type) {
case 'number':
case 'integer': {
if (typeof value === 'number') return Number.isFinite(value) ? { kind: 'value', value } : { kind: 'reject' };
if (typeof value === 'string') {
const n = Number(value.trim());
return Number.isFinite(n) ? { kind: 'value', value: n } : { kind: 'reject' };
}
return { kind: 'reject' };
}
case 'boolean': {
if (typeof value === 'boolean') return { kind: 'value', value };
if (typeof value === 'string') {
const t = value.trim().toLowerCase();
if (t === 'true' || t === 'false') return { kind: 'value', value: t === 'true' };
}
return { kind: 'reject' };
}
case 'json':
// json 列什么都装得下(对象、数组、标量)—— 不归型
return { kind: 'value', value };
default:
// text / date / timestamp:字符串原样;对象序列化(比把 [object Object] 写进库强)
return {
kind: 'value',
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
};
}
}
/** 延后绑定的流程层引用 —— 只用到「任务终态上报」这一件 */
function createDeferredProcess(): {
onTaskTerminal: ProcessService['onTaskTerminal'];
bind(real: ProcessService): void;
} {
let real: ProcessService | undefined;
return {
onTaskTerminal: async (taskId, terminal, ports) => real?.onTaskTerminal(taskId, terminal, ports),
bind(next) {
real = next;
},
};
}
/** 录入卡的一格 —— 主段与 `also` 各段同一形状 */
function entryField(f: { key: string; label: string; value?: string; required?: boolean }) {
return {
key: f.key,
label: f.label,
required: f.required ?? false,
...(f.value === undefined || f.value === '' ? {} : { value: f.value }),
};
}
/** L1/L2 才机检 —— L3 环内实证、L4 独立评审、L5 人放行,都不由引擎代答 */
function machineCheckable(level: string): boolean {
return level === 'L1' || level === 'L2';
}
/**
* 判据机检:带 predicate 的用 S-FEEL 对任务视图求值。
* **没写 predicate 的 L1/L2 判 false**(不是 true)—— 装不出证据就不算过;
* 安装期 `acceptance-level-misreported` 本就该挡住这种漏配,这里再兜一层。
*/
function evaluatePredicate(predicate: string | undefined, scope: Readonly<Record<string, unknown>>): boolean {
if (predicate === undefined || predicate.trim() === '') return false;
return sfeel.condition(predicate, scope);
}
/**
* 一条判据此刻算不算达标 —— **卡面与提交门读的是同一段**。
*
* 三类各有各的口径:
* · **机检(L1/L2)** —— 谓词对任务视图求值,不掺裁断;
* · **人放行(L5)** —— 只有人按下去才算,引擎与 Agent 都不许代答;
* · **裁量(L3/L4)** —— 读 Agent 的裁定(`judge` 行动),没裁过就是没达标。
*
* ── 裁量项**带谓词时,谓词说了算**(2026-08-02 用户实测逼出来的)────────────
*
* 「选填缺口:已补齐,或由你放行时确认可以先受理」是个复合命题:前半句
* (**已补齐**)基座数得出来,后半句(**人确认先受理**)才需要人。
* 从前整条只读裁断,于是模型凭记忆写理由 —— 用户在件上把目标价填好了,
* 它照样裁「未满足」,理由是「NE555P 缺目标价,客户未提供」。用户原话:
* 「这个价格是我填写的,但是它判错了」。实测复现的那一单更离谱:
* 两行的年用量都填着,它写「CJTB 缺 EAU、LM358DR-NEW 缺 EAU 与客户料号,共 4 处」——
* 而 EAU 压根是必填不是选填,两行也都有值。
*
* **数得出来的就别让它自报**(这条纪律第三次被违反)。谓词为真即达标,
* 裁断连同那句理由一起丢掉:一件已经办成的事,不需要谁再解释一遍为什么没办成。
* 谓词为假才轮到裁量 —— 那时理由才有意义,而它该照基座的 `_gaps_keys` 写。
*/
export function criterionCheck<L extends string>(
c: { key: string; label: string; level: L; predicate?: string | undefined },
scope: Readonly<Record<string, unknown>>,
judgement: { satisfied: boolean; rationale?: string | undefined } | undefined,
): { key: string; label: string; level: L; pass: boolean; settled?: boolean; rationale?: string } {
const machine = machineCheckable(c.level);
const hasPredicate = c.predicate !== undefined && c.predicate.trim() !== '';
const settledByData = !machine && c.level !== 'L5' && hasPredicate && evaluatePredicate(c.predicate, scope);
const pass = machine
? evaluatePredicate(c.predicate, scope)
: c.level === 'L5'
? false
: settledByData || (judgement?.satisfied ?? false);
return {
key: c.key,
label: c.label,
level: c.level,
pass,
/** 裁量项:裁过没有(裁成什么是另一回事)—— 提交门看的是这个;数据已经说了话的也算裁过 */
...(machine || c.level === 'L5' ? {} : { settled: settledByData || judgement !== undefined }),
/*
* **裁断的理由跟着上卡** —— 裁成「不满足」的那一条,理由才是签字的人要看的。
* 没有它,卡面上「裁了但办不到」与「压根没裁」都是一个红叉,人只能凭感觉签。
* 但数据已经说了「办成了」的,理由一律丢掉:留着就是一句与事实相反的话。
*/
...(settledByData || judgement?.rationale === undefined ? {} : { rationale: judgement.rationale }),
};
}
export function wireEngines(deps: EngineWiringDeps): EngineWiring {
const { db, clock, ids } = deps;
/**
* 必交付物「有没有产出」—— 数**本单自己写下的**草稿行。
*
* 数行数是硬证据:活动说交付了、台账里一行没有,预检当场拒收。
* 但数的必须是**本单的**行:任务视图 = 正式版 + 本单草稿,拿视图去数,
* 别单放行过的记录就成了自己的交付 —— 实测踩过:一单什么都没写就提交放行,
* 三件必交付物全判「有」(上一单落地的行还躺在同一张表里),卡照发、人照签。
* 这道门于是只在**第一单**有效,之后永远形同虚设。
*
* 没声明地址的退回作用域键判断:有值即算有(作用域顶层铺的也只是本单草稿)。
*/
function deliverablePresent(
address: string | undefined,
key: string,
scope: Readonly<Record<string, unknown>>,
mine: ReadonlySet<string>,
): boolean {
if (address === undefined) {
const value = scope[key];
return value !== undefined && value !== null && value !== '';
}
return mine.has(address);
}
/* ── 任务上下文:活动 ID / 负责人 / 调人预算 —— 引擎共用 ─────────── */
const taskStore = createTaskStore(db);
const taskContext = {
async of(taskId: TaskId) {
const task = await taskStore.get(taskId);
if (task === undefined) throw new Error(`任务不存在:${taskId}`);
return { activityId: task.activityId, ownerId: task.ownerId, humanCallCap: task.caps.humanCalls };
},
/**
* Agent 对**裁量判据**的裁定 —— `judge` 行动落在任务记忆里的那些。
*
* 读的是 `detail.criterion` / `detail.satisfied`(结构化的),不解析标题字符串:
* 标题是给人看的一句话,措辞随时会改,拿它当协议迟早对不上。
* 同一条判据裁过多次以**最后一次**为准 —— 人改了主意、Agent 补了证据都算数。
*
* 键一律**归一化后再存**(见 `criterionKey`):判据名是让模型照抄的一句中文,
* 而它抄的时候会顺手把半角标点写成全角、把空格吃掉 —— 一个标点差异就让
* 这条判据永远对不上,提交被拒到预算烧完(实测:32 次被拒 → exhausted)。
*/
async judgements(taskId: TaskId): Promise<Map<string, { satisfied: boolean; rationale?: string }>> {
const out = new Map<string, { satisfied: boolean; rationale?: string }>();
for (const line of await deps.memory.window(taskId, 400)) {
if (line.kind !== 'evaluation') continue;
const detail = line.detail;
if (typeof detail !== 'object' || detail === null) continue;
const criterion = (detail as Record<string, unknown>)['criterion'];
const satisfied = (detail as Record<string, unknown>)['satisfied'];
/*
* **理由跟着裁断一起收** —— 裁成「不满足」的那一条,理由才是签字的人要看的东西。
*
* 从前只收 `satisfied` 这个布尔:卡面上于是「裁了但裁成不满足」与「压根没裁」
* 长得一模一样 —— 都是一个红叉「未达标」。人看到一条永远不绿的项,
* 既不知道 AI 有没有办过,也不知道它为什么办不到,只能凭感觉签。
* 「AI 说这一条它办不到,理由是……」比一片虚假的绿勾有用得多。
*/
const rationale = (detail as Record<string, unknown>)['rationale'];
if (typeof criterion === 'string' && typeof satisfied === 'boolean') {
out.set(criterionKey(criterion), {
satisfied,
...(typeof rationale === 'string' && rationale.trim() !== '' ? { rationale: rationale.trim() } : {}),
});
}
}
return out;
},
/**
* 机检判据名一览 —— key 与 label 都归一化后放进来,
* 因为模型两种写法都可能用(`judgements` 认的也是这两种)。
*/
async machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>> {
const task = await taskStore.get(taskId);
if (task === undefined) return new Set();
const manifest = await deps.registry.manifestOf(task.activityId);
const out = new Set<string>();
for (const c of manifest.contract.acceptanceCriteria) {
if (!machineCheckable(c.level)) continue;
out.add(criterionKey(c.key));
out.add(criterionKey(c.label));
}
return out;
},
};
/*
* 视图流控网关 —— 两个范式共用一份。
* 按名取模版 → 填数 → 推给前端。模版取自**该任务装载时的资产版本**(经 registry),
* 不是「当前最新」:在途任务按其启动时版本跑完,呈现也该跟着那一版。
*/
const viewGateway = {
async push({
taskId,
activityId,
template,
scope,
}: {
taskId: TaskId;
activityId: string;
template: string;
scope: Record<string, unknown>;
}): Promise<{ pushed: boolean; templates: string[] }> {
const manifest = await deps.registry.manifestOf(activityId);
const templates = manifest.assets.viewTemplates.map((t) => t.name);
const found = manifest.assets.viewTemplates.find((t) => t.name === template);
// 查不到就如实回「没推上」+ 有哪些可推 —— 不再静默丢帧(见 ViewGateway 的注释)
if (found === undefined) return { pushed: false, templates };
/*
* 视图作用域 = 任务视图 + **本单自己写下的行**。
* 两者要分得开:任务视图里的 `域.对象` 是正式版 + 我的草稿的合并结果,
* 拿它画「本单交付」会把别单放行过的记录一并列出来。
* 本单的行放在 `mine:域.对象` 下 —— 整键,取值不歧义。
*/
const own: Record<string, unknown[]> = {};
for (const draft of await deps.ledger.taskDrafts(taskId)) {
const key = `mine:${draft.domain}.${draft.object}`;
(own[key] ??= []).push({ ...draft.key, ...draft.patch });
}
/*
* **这一单是什么时候起来的** —— 来件那一块要写「几时入站」,
* 而入参里没有时刻:信是外面来的,时刻是基座记的。
* 起单即入站(邮件触发那一路,通道收到信就起单),所以取任务的 startedAt。
* 只给到分钟:报告上摆秒没有意义,还会让两份同一单的件看起来不一样。
*/
const task = await taskStore.get(taskId);
const openedAt = task === undefined ? '' : localStamp(task.startedAt);
/*
* **这一单是怎么来的、归了谁** —— 报告上那枚归属签。
*
* 两样都是基座知道的事实(触发来源、任务负责人),活动写不出也不该写:
* 让它自报「归属解析 → 冯兵」等于让被指派的人自己说自己是谁。
* 认不出就留空 —— 模版那一格自会空着,不摆一个假名字。
*/
const owner = task === undefined ? undefined : await deps.org.getStaff(task.ownerId).catch(() => undefined);
const ownerLabel = owner === undefined ? '' : `${owner.name} · ${owner.position.name}`;
const origin = task?.origin === 'testbed' ? '试跑起单' : '通道入站';
/*
* **这一单的任务号** —— 工单模版卡头「关联任务」那一格(E04 目标件 §6.2 点名的
* 共用动态位)。与归属签同理:任务号是基座的事实,活动写不出也不该写;
* 全长 UUID 如实给,摆多长由模版自己的样式定(截断是排版,不是数据)。
*/
const taskRef = task === undefined ? '' : String(taskId);
/*
* **这一单算出来要走哪一口** —— 报告上那张分支判定表要标出命中的是哪一行。
*
* 关键是**别让报告自己再算一遍**:模版里没有表达式(填数只有取值/展开/上色),
* 真让它算就只能把判据抄成静态文字,而抄本与判据是两份东西 ——
* 改了 `when` 忘了改报告,屏上那一行就一直指着旧规则。
* 这里调的是放行时发口用的同一个 `routePort`,同一份 scope、同一次求值口径:
* 命中第几条 → `r1/r2/r3`(经 `data-class-of` 消毒成 `v-r1`,行自己上色)。
* 算不出来(有一口没写谓词、或一条都不命中)三个键留空 —— 那一行不高亮、
* 结论横幅空着,而不是随便点亮一行。
*/
const group = manifest.contract.output.selective[0];
const routed = group === undefined ? undefined : routePort(group, scope, (e, s) => evaluatePredicate(e, s));
const at = group === undefined || routed === undefined ? -1 : group.ports.findIndex((p) => p.name === routed.name);
const route: Record<string, unknown> =
routed === undefined || at < 0
? {}
: {
'case:routeKey': `r${at + 1}`,
'case:routePort': routed.name,
'case:routeTo': routed.label ?? routed.name,
'case:routeWhy': routed.because,
};
await deps.interaction.pushViewHtml(
taskId,
renderView(found.html, {
...scope,
'case:openedAt': openedAt,
'case:origin': origin,
'case:owner': ownerLabel,
'case:taskId': taskRef,
...route,
...own,
}),
template,
);
return { pushed: true, templates };
},
/**
* **按当下的数据把同一张模版再渲一遍** —— 人在件上改完东西之后要用它。
*
* 件是**投影**:只读那几格是他自己敲的,而由数据算出来的东西
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在上一次渲染那一刻。
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」——
* 一半新一半旧,那不是没更新,是界面在撒谎。
*
* 只重渲不换模版:换哪一张是剧本的事,这里只负责「同一张,用新数据」。
* 没推过帧(还没到那一步)就什么也不做。
*/
async repush(taskId: TaskId): Promise<boolean> {
const current = await deps.interaction.viewOf(taskId);
const template = current?.template;
if (template === undefined || template === '') return false;
const task = await taskStore.get(taskId);
if (task === undefined) return false;
const scope = await taskData.view(taskId);
const out = await this.push({ taskId, activityId: task.activityId, template, scope });
return out.pushed;
},
};
/* ── 任务数据:引擎读**任务视图** = 正式版 + 本任务草稿层 ───────── */
const taskData = {
/**
* 任务视图 = 正式版 + 本任务草稿层(AM 4.2),再叠上起单入参。
*
* 作用域的**形状要对得上声明的写法**:步声明里写的是裸字段名
* (`input: { pn: 'pn' }`),不是 `域.对象.行号.字段`。所以这里三层铺开:
* ① 起单入参在底 —— 契约的 data 是这单的出发事实;
* ② 各对象**最新一行**的字段名铺到顶层 —— 过程工作区的数据形态即草稿层;
* ③ `域.对象` 仍保留整表,要整表的照样取得到。
* 顶层同名以「后写的赢」为准:草稿层是这一单最新的过程事实,盖过起单入参。
*/
async view(taskId: TaskId) {
const task = await taskStore.get(taskId);
if (task === undefined) throw new Error(`任务不存在:${taskId}`);
const objects = await deps.ledger.listObjects();
const scope: Record<string, unknown> = { ...task.data };
for (const address of objects) {
const page = await deps.ledger.taskView(taskId, {
address,
where: {},
orderBy: [],
limit: 200,
offset: 0,
});
scope[`${address.domain}.${address.object}`] = page.rows;
}
/*
* 铺到顶层的只能是**这一单自己写下的**事实(草稿层 = 读己之写)。
* 不能拿「该对象最新一行」来铺:那一行很可能是别的任务放行后的正式版记录,
* 一铺就把别人的结论当成了自己的输入 —— 打标会照着上一单的情报出结果。
* 别人的事实仍在 `域.对象` 整表里,要用得显式去取。
*
* 同时把本单的行**整行**收在 `mine:域.对象` 下(与视图模版同一约定)。
* 为什么必须有这一份:`域.对象` 是正式版全表 —— 别单放行过的记录也在里面。
* Agent 分不清哪几行是自己写的,就会拿别单的数据当自己的结论讲出来
* (实测:一单 RFQ-SSE-01 的交代里,说的全是 RFQ-2026-1007 / 1016 的分类结果)。
*/
const mine: Record<string, unknown[]> = {};
const draftKeysOf: Record<string, Array<Record<string, unknown>>> = {};
for (const draft of await deps.ledger.taskDrafts(taskId)) {
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
(draftKeysOf[`${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key });
for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) {
if (value !== null && value !== undefined) scope[field] = value;
}
}
/*
* `touched:域.对象` —— 本轮碰过的行的**叠加后全貌**(正式现值 + 草稿),
* 给落库预览这类「签下去之后长什么样」的画面用;`域.对象` 整表里本就有
* 叠加行,这里只按草稿键挑出本轮的那几行。不进提示词(restPart 滤掉 ——
* 内容与 mine/整表重叠,白占截断窗)。
*/
const touched: Record<string, unknown[]> = {};
for (const [address, draftKeys] of Object.entries(draftKeysOf)) {
const merged = scope[address];
if (Array.isArray(merged)) touched[`touched:${address}`] = touchedRows(merged, draftKeys);
}
/*
* **本单每张表写下了几行** —— 判据要能自己数,不能只信活动报的数。
*
* 起因是实测的一次「对账过关而账是错的」:活动把条目数记成 9(line_total),
* 而受理记录里实际躺着 10 行(有一条 PN 抄漏了后缀,补写时又成了新的一行 ——
* 主键写错一次就是多一行)。验收的机检判据读的全是活动自己写的汇总列,
* 两边同源,自然对得上;人签完字,下游拿到的是一份多一行的交付物。
*
* 名字取 `域_对象_rows`:S-FEEL 的路径不认冒号与点号,`mine:域.对象` 那份取不到;
* 而判据要写成 `quote_intake_record_rows = line_total` 这样一句才有用。
* 只数**本单自己写下的行**(草稿层),别单放行过的记录不算在内。
*/
const manifest = await deps.registry.manifestOf(task.activityId);
const declared = manifest.declarations.ledger;
// 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的
const trail = await deps.memory.window(taskId, 400);
/*
* **开案时刻要真的在 Agent 的作用域里**(2026-08-22,E04 实测逼出来的):
* 说明书写着「round_ref = case:openedAt 按 YYYYMMDDHHmmss 格式化(引擎注入的
* 开案时刻)」,而这个键从前只注进**视图渲染**那一侧,Agent 的任务视图里没有 ——
* 模型只能自己编;正式表里一旦有别单的案号,它就照抄(A2 治过的「从台账借号」
* 同款),测试台里所有单于是共用同一个号,跨单主键相撞、上一单的正式行渗进
* 下一单的读面。给全到秒(视图那份只到分,是给人看的;这份是给铸主键用的)。
*/
const two = (n: number): string => String(n).padStart(2, '0');
const opened = new Date(task.startedAt);
const openedAt = Number.isNaN(opened.getTime())
? ''
: `${opened.getFullYear()}-${two(opened.getMonth() + 1)}-${two(opened.getDate())} ` +
`${two(opened.getHours())}:${two(opened.getMinutes())}:${two(opened.getSeconds())}`;
const enriched: Record<string, unknown> = {
'case:openedAt': openedAt,
...scope,
...rowReadings(mine, declared),
...rowKeyList(mine, declared),
...missingReadings(mine, declared),
...tallyReadings(mine, declared),
...distinctReadings(mine, declared),
...toolCallReadings(trail, manifest.declarations.tools),
...mine,
...touched,
};
return enriched;
},
/**
* 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。
*
* 这不是便利函数,是**授权面**:活动能写哪儿由清单定死,与工具白名单同一道理。
* 同时也是给 Agent 的字典 —— 不告诉它有哪些列,它只会照自己的想象编列名。
*/
async writable(taskId: TaskId) {
const { activityId } = await taskContext.of(taskId);
const manifest = await deps.registry.manifestOf(activityId);
return manifest.declarations.ledger.map((d) => ({
domain: d.domain,
object: d.object,
label: d.label ?? `${d.domain}.${d.object}`,
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
createOnly: d.createOnly === true,
}));
},
/**
* 过程写入 —— 一律落草稿层(放行前不落事实),且**先验地址再落笔**。
*
* 两道验都不能省:
* · 地址不在活动清单声明的台账对象内 → 拒。越域写与越权调工具同罪,
* 得在结构上拦住,不能靠活动自律。
* · 列名不在声明的字段里 → 拒。`ledger.draftWrite` 对陌生列是**静默丢弃**,
* 于是「写成功了」而字段没落,验收判据求值取不到值 —— 查起来一层皮。
* 宁可当场拒,把可用列名摆给它看。
*
* 拒是抛错:回合那边捕获成「行动被拒」喂回下一拍,Agent 自己改对。
*/
async draftWrite(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
patch: Record<string, unknown>;
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
dryRun?: boolean;
}) {
const allowed = await taskData.writable(input.taskId);
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
if (target === undefined) {
throw new Error(
`台账地址未声明:${input.domain}.${input.object}(本活动可写:${
allowed.map((a) => `${a.domain}.${a.object}`).join('、') || '无'
})`,
);
}
const known = new Set([...target.keys, ...target.fields.map((f) => f.name)]);
const strayed = [...Object.keys(input.key), ...Object.keys(input.patch)].filter((c) => !known.has(c));
if (strayed.length > 0) {
throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`);
}
/*
* **只建不改的表,键已在库就拒**(契约 `createOnly`,2026-08-27 W05 实测逼出):
* 数据工单让人从零建主数据,模型却可能给在库的料号也写一行空骨架 —— 件上多出一行
* 要人填的记录,红带点名它缺品牌,而正式表里它什么都不缺。剧本写了拦不住,写入那一刻查。
*/
if (target.createOnly && target.keys.length > 0) {
const formal = await deps.ledger.query({
address: { domain: input.domain, object: input.object },
where: {},
orderBy: [],
limit: 500,
offset: 0,
});
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>);
if (why !== undefined) throw new Error(why);
}
/*
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
* contact_code 本该指向真实联系人,模型三次给缺口节点**自造顺延引用码 +
* 占位人名** —— 反例描述、说明书纪律、禁令逐层写全都拦不住;
* 现在写入那一刻当场拒,拒回指路(墙要配路)。
*/
for (const field of target.fields) {
if (field.refersTo === undefined) continue;
const raw = input.key[field.name] ?? input.patch[field.name];
const value = raw === undefined || raw === null ? '' : String(raw).trim();
if (value === '') continue;
const [rd, ro, rc] = field.refersTo.split('.');
if (rd === undefined || ro === undefined || rc === undefined) continue;
const referenced = await deps.ledger.taskView(input.taskId, {
address: { domain: rd, object: ro },
where: {},
orderBy: [],
limit: 500,
offset: 0,
});
const exists = referenced.rows.some((r) => String((r as Record<string, unknown>)[rc] ?? '') === value);
if (!exists) {
const refLabel = allowed.find((a) => a.domain === rd && a.object === ro)?.label ?? `${rd}.${ro}`;
throw new Error(
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
'要指的那一行还不存在的话,先把它真实建出来(该派数据工单的派数据工单、该发数据录入卡的发录入卡),' +
'或者把这一笔挂到已存在的那一行名下',
);
}
}
/*
* **按列型归型,当场归不了当场拒** —— 不能留到放行那一刻。
*
* 模型用 JSON 说话:「这个字段原文没给」它写成 `""`。草稿层照单全收没问题,
* 可 `releaseWrite` 是往真表里写 —— 空串进 numeric 列,PG 直接掀桌:
* `invalid input syntax for type double precision: ""`。**而那是人已经签完字之后**,
* 整单落成 `blocked`(实测:NE555P 原文确实没给目标价,业务判断完全正确,
* 却在最后一步炸掉)。最坏的失败点就是这里 —— 前面全对,签完才说不行。
*
* 归型三条:
* · 空串 = **没有值** → 从补丁里去掉(草稿是合并语义,不写 = 不动,列留 NULL);
* · 数值/布尔列的字符串 → 转成真类型(模型给 `"12000"` 是常态);
* · 转不出来 → 当场抛,理由喂回下一拍让它改 —— 比签完字再炸早得多。
*/
const typeOf = new Map(target.fields.map((f) => [f.name, f.type]));
const patch: Record<string, unknown> = {};
for (const [column, value] of Object.entries(input.patch)) {
const coerced = coerceToColumn(value, typeOf.get(column) ?? 'text');
if (coerced.kind === 'reject') {
throw new Error(`列 ${column} 要 ${typeOf.get(column)},给的是 ${JSON.stringify(value)} —— 换个值或留空`);
}
// 'empty' = 没有值:不写这一列(合并语义下等于不动它)
if (coerced.kind === 'value') patch[column] = coerced.value;
}
/*
* 写之前先看一眼:值和现有草稿一模一样就是**空转**。
* 照写不误也不会错,但 Agent 从此看不出自己在原地打转 —— 实测同一行
* verdict 被反复重写十几拍。所以照写,但如实回报「这一笔什么也没改」。
*/
const before = (await deps.ledger.taskDrafts(input.taskId)).find(
(d) =>
d.domain === input.domain &&
d.object === input.object &&
Object.entries(input.key).every(([k, v]) => String(d.key[k] ?? '') === String(v)),
);
const changed =
before === undefined ||
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
if (input.dryRun === true) return { changed };
await deps.ledger.draftWrite({
taskId: input.taskId,
address: { domain: input.domain, object: input.object },
key: input.key,
patch,
});
return { changed };
},
/**
* **撤掉自己写错的那一行** —— 授权面与写同一道:没声明的地址撤不了。
*
* 撤的只是本单草稿层那一笔,正式版一个字都动不了。
* 回 `dropped: false` = 草稿层里根本没有这一行 —— 多半是主键写法对不上
* (`DAG…` 与 `DAG…-R0` 是两行);如实说比静默成功有用。
*/
async draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record<string, string | number> }) {
const allowed = await taskData.writable(input.taskId);
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
if (target === undefined) {
throw new Error(
`台账地址未声明:${input.domain}.${input.object}(本活动可写:${
allowed.map((a) => `${a.domain}.${a.object}`).join('、') || '无'
})`,
);
}
const dropped = await deps.ledger.draftDrop({
taskId: input.taskId,
address: { domain: input.domain, object: input.object },
key: input.key,
});
return { dropped };
},
};
const memoryRecorder = {
append: async (input: { taskId: TaskId; kind: string; title: string; detail?: Record<string, unknown> }) => {
await deps.memory.append({
taskId: input.taskId,
kind: input.kind as never,
title: input.title,
...(input.detail === undefined ? {} : { detail: input.detail }),
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
});
},
};
const listeners = {
async register(input: { taskId: TaskId; eventType: 'data' | 'time' | 'human'; keyFilter: Record<string, string>; deadline: string }) {
const listener = await deps.event.registerListener(input.taskId, {
origin: 'dynamic',
eventType: input.eventType,
keyFilter: input.keyFilter,
semantic: 'complete-step',
deadline: input.deadline,
escalation: ['nudge', 'escalate'],
});
return { id: listener.id };
},
/** 立监听的授权面 —— 活动清单声明过的那几种(见 port.ts 的注释) */
async declared(taskId: TaskId) {
const { activityId } = await taskContext.of(taskId);
const manifest = await deps.registry.manifestOf(activityId);
return manifest.declarations.listeners.map((l) => ({ eventType: l.eventType, keyFilter: l.keyFilter }));
},
};
/**
* 录入卡的一段 —— **表名换人读名、自增列不摆给人填**(2026-08-02 用户两条)。
*
* ① 卡面摆的是台账登记的表注释(「客户档案」),不是 `customer.profile` ——
* 界面不摆系统内的英文名。登记里没有人读名就退回地址:宁可笼统,不可编。
* ② 声明里标了 `auto` 的列(联系人编码这类内部流水号)**从表单里拿掉**,
* 另摆一句「由系统生成」。让人填是白占一格他答不上来的表单
* (他哪知道这家客户已经有几位联系人),让模型编更糟 —— 编重了就是覆盖别人那一行。
* 但不能一声不吭地拿掉:那样他会以为这张档少了一格。
*/
const entryGroup = async (
taskId: TaskId,
address: string,
key: Readonly<Record<string, string>>,
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>,
) => {
const [domain, object] = address.split('.');
const declared = (await deps.ledger.listObjects()).find((o) => o.domain === domain && o.object === object);
const { columns: autoCols, labels: autoLabels, all: colLabels } = await autoColumns(taskId, address);
const kept = fields.filter((f) => !autoCols.has(f.key));
/*
* 那句话按**声明**说,不按「模型这次塞没塞进来」说 —— 一度写成后者,
* 于是模型照新说明书没写 `contact_code`,卡上就一个字都没有:
* 人看到联系人档只要姓名与邮箱,不知道编码去哪了。
* 自增是这张表的事实,与模型这一拍写了什么无关。
*/
const auto = [...autoCols].map((n) => autoLabels.get(n) ?? n);
return {
ledger: address,
...(declared?.label === undefined || declared.label === '' ? {} : { ledgerLabel: declared.label }),
// 自增的主键也从卡面的主键行里摘掉 —— 那一格此刻还不存在,摆个空值是撒谎
key: Object.fromEntries(Object.entries(key).filter(([k]) => !autoCols.has(k))),
/*
* 主键那几列的**中文列名** —— 卡面上摆它,不摆 `customer_code`(界面不摆英文列名)。
* 表单里有的那几列自带 label,这里补的是**只在主键里出现**的那些
* (如联系人档的客户编码:它由上一段定死,不在这一段的表单里)。
*/
keyLabels: Object.fromEntries(
Object.keys(key)
.filter((k) => !autoCols.has(k))
.map((k) => [k, colLabels.get(k) ?? k]),
),
fields: kept.map(entryField),
...(auto.length === 0 ? {} : { autoNote: `${auto.join('、')}由系统按顺序生成,不用你填` }),
};
};
/** 这张表哪几列是基座自己生成的(连它们的中文列名)—— 认本单活动清单里的 `auto` 声明 */
const autoColumns = async (
taskId: TaskId,
address: string,
): Promise<{ columns: Set<string>; labels: Map<string, string>; all: Map<string, string> }> => {
const [domain, object] = address.split('.');
const { activityId } = await taskContext.of(taskId);
const declared = (await deps.registry.manifestOf(activityId)).declarations.ledger.find(
(l) => l.domain === domain && l.object === object,
);
const auto = (declared?.fields ?? []).filter((f) => f.auto === true);
return {
columns: new Set(auto.map((f) => f.name)),
labels: new Map(auto.map((f) => [f.name, f.label ?? f.name])),
all: new Map((declared?.fields ?? []).map((f) => [f.name, f.label ?? f.name])),
};
};
const cards = {
/**
* 发**补全卡** —— 对话卡族:缺口清单摆在对话区、带一张表单,人就地填齐。
* 与执行工单分开:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
*/
/**
* 数据录入卡 —— 人按下确认那一刻由**基座**写草稿层(见 `desk.ts` 的回传口),
* 不回到 Agent 手里再转写一遍。这里只管把卡发出去。
*/
async entry(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
ledger: string;
key: Readonly<Record<string, string>>;
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
/** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */
also?: ReadonlyArray<{
title: string;
ledger: string;
key: Readonly<Record<string, string>>;
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
}>;
basis?: ReadonlyArray<{ k: string; v: string }>;
}) {
const issued = await deps.humanTool.issueChatCard(
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
{
k: 'entry',
id: cardIdSchema.parse(ids.next()),
at: clock.now().toISOString(),
ask: input.ask,
...(input.basis === undefined || input.basis.length === 0 ? {} : { basis: [...input.basis] }),
...(await entryGroup(input.taskId, input.ledger, input.key, input.fields)),
...(input.also === undefined || input.also.length === 0
? {}
: {
also: await Promise.all(
input.also.map(async (g) => ({
title: g.title,
...(await entryGroup(input.taskId, g.ledger, g.key, g.fields)),
})),
),
}),
},
);
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
return { id: issued.card.id };
},
async ask(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
fields: ReadonlyArray<{ key: string; label: string; hint?: string; value?: string }>;
basis?: ReadonlyArray<{ k: string; v: string }>;
}) {
const cardSpan = deps.trace.beginAction({
taskId: input.taskId,
name: `补全卡:${input.ask.slice(0, 24)}`,
kind: 'human',
input: { fields: input.fields.map((f) => f.key) },
});
const issued = await deps.humanTool.issueChatCard(
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
{
k: 'elicitation',
id: cardIdSchema.parse(ids.next()),
at: clock.now().toISOString(),
ask: input.ask,
...(input.basis === undefined || input.basis.length === 0 ? {} : { basis: [...input.basis] }),
fields: input.fields.map((f) => ({
key: f.key,
label: f.label,
...(f.hint === undefined ? {} : { placeholder: f.hint }),
/*
* **预填值必须搬过来** —— 这里从前只搬 key/label/hint,
* 模型照剧本给的 `value` 到这一步被静默丢掉:剧本让它"尽量先把空填上",
* 它照做了,人拿到的还是一排空格,而两边都不知道东西丢在哪儿。
*/
...(f.value === undefined || f.value === '' ? {} : { value: f.value }),
})),
},
);
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
await cardSpan?.end({ output: { cardId: issued.card.id, arrival: issued.arrival } });
return { id: issued.card.id };
},
/**
* 发**决策卡** —— 岔路口摆给人点,不是让人敲字。
* 与补全卡同一条路(对话卡族 → 对话区 → 立监听),差别只在卡面:那边是表单,这边是选项。
*/
async decide(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
options: ReadonlyArray<{
value: string;
label: string;
detail?: string;
consequence?: string;
recommended?: boolean;
}>;
basis?: ReadonlyArray<{ k: string; v: string }>;
allowCustom?: boolean;
}) {
const cardSpan = deps.trace.beginAction({
taskId: input.taskId,
name: `决策卡:${input.ask.slice(0, 24)}`,
kind: 'human',
input: { options: input.options.map((o) => o.value) },
});
const recommended = input.options.find((o) => o.recommended === true);
const issued = await deps.humanTool.issueChatCard(
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
{
k: 'decision',
id: cardIdSchema.parse(ids.next()),
at: clock.now().toISOString(),
ask: input.ask,
basis: input.basis === undefined ? [] : [...input.basis],
...(recommended === undefined ? {} : { recommend: recommended.value }),
options: input.options.map((o) => ({
value: o.value,
label: o.label,
...(o.detail === undefined ? {} : { detail: o.detail }),
...(o.consequence === undefined ? {} : { consequence: o.consequence }),
...(o.recommended === undefined ? {} : { recommended: o.recommended }),
})),
allowCustom: input.allowCustom ?? false,
},
);
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
await cardSpan?.end({ output: { cardId: issued.card.id, arrival: issued.arrival } });
return { id: issued.card.id };
},
async issue(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
workOrder: 'execution' | 'acceptance';
/** 这张卡挂在哪个计划项上 —— 引擎按它查剧本声明(如 `cardVariant`),不靠模型抄卡骨架;引擎自己发的卡(验收/兜底)没有项 */
itemId?: string | undefined;
card: Record<string, unknown>;
}) {
const context = { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap };
const title = String(input.card['title'] ?? '请处理');
// 人卡一张一个 span:发给谁、问了什么、什么工单 —— 「等人」这一段也要在时间轴上
const cardSpan = deps.trace.beginAction({
taskId: input.taskId,
name: `人卡:${title}`,
kind: 'human',
input: { workOrder: input.workOrder, ownerId: input.ownerId },
});
/*
* 依据快照:优先用活动给的结构化 `basis`(`{k,v}[]`);没给才把卡面其余键铺成依据。
* 铺的时候要跳过 `ask`(它是问题不是依据)与空值,嵌套对象转 JSON ——
* 不然人看到的是一行 `[object Object]`,那还不如不给。
*/
const declared = input.card['basis'];
const basis = Array.isArray(declared)
? declared
.filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b)
.map((b) => ({ k: String(b.k), v: String(b.v) }))
: Object.entries(input.card)
.filter(([k, v]) => !['title', 'fields', 'ask', 'basis'].includes(k) && v !== null && v !== undefined && v !== '')
.map(([k, v]) => ({ k, v: typeof v === 'object' ? JSON.stringify(v) : String(v) }));
/*
* 卡面声明的字段 → 表单域。执行与验收共用这一份解析:
* 验收工单从前写死 `fields: []`,可 `acceptanceWorkOrderSchema` 本来就有 `fields` ——
* 是这里没填,不是设计上不要。放行时人要修订的值(如「这一单到底要不要选型」)
* 就靠它;答完经 `writeOutput` 落台账,覆盖上游步写的同一列。
*/
/*
* 卡面声明的字段 → 表单域。**两种写法都收**:
* · 一串字段名 `['result']` —— 执行工单常见,人从空白填起;
* · 一串对象 `[{key,label,ai,ledger}]` —— 验收工单要的形态:
* `ai` 是**AI 拟值**(PRD:验收报告「AI 已填好、可编辑」),
* `ledger` 是这个值改完该落回哪个台账地址(`域.对象.列`)。
*
* 没有 `ai`,「可编辑」就没有可编辑的东西;没有 `ledger`,人改了也只留在工单留痕里,
* 落地时用的还是 AI 的原值 —— 修订永远落不下来(第 12 章③)。
*/
const declaredFields = Array.isArray(input.card['fields'])
? (input.card['fields'] as unknown[])
.map((raw) => {
if (typeof raw === 'string') {
return raw === '' ? undefined : { key: raw, label: raw, kind: 'text' as const, required: true };
}
if (typeof raw !== 'object' || raw === null) return undefined;
const f = raw as Record<string, unknown>;
const key = String(f['key'] ?? '').trim();
if (key === '') return undefined;
const ai = f['ai'] === undefined || f['ai'] === null ? undefined : String(f['ai']);
const ledger = String(f['ledger'] ?? '').trim();
return {
key,
label: String(f['label'] ?? key),
kind: 'text' as const,
required: f['required'] !== false,
...(ai === undefined ? {} : { ai }),
...(ledger === '' ? {} : { ledger }),
};
})
.filter((f): f is NonNullable<typeof f> => f !== undefined)
: undefined;
if (input.workOrder === 'acceptance') {
/*
* 提交放行**只走 release.submitForRelease** —— 它才是那道门(OS 7.3):
* 预检全过 → 写类意向从 drafted 推到 prechecked → 已验证动作清单上卡。
*
* 从前这里直接 `issueAcceptance`,绕过了预检:后果是写类动作登记完永远停在
* drafted,`land()` 只执行 prechecked,于是**放行时一个外部动作都不执行**;
* 卡面 `actions` 恒空、`criteria` 恒是一条硬编码 L5 —— 人在验收单上看不见
* 「通过之后系统会做什么」,那这一下「通过」就是闭着眼签的。
*
* 预检不过不发卡(抛 PrecheckFailedError / DeliverablesMissingError),
* 由回合循环捕获成「行动被拒」喂回下一拍 —— 打回环内修正,正是规范要的。
*/
const { activityId } = await taskContext.of(input.taskId);
const manifest = await deps.registry.manifestOf(activityId);
const selective = manifest.contract.output.selective[0];
const scope = await taskData.view(input.taskId);
/*
* 必交付物:按声明的台账地址数行(任务视图 = 正式版 + 本单草稿,读己之写)。
* 没声明地址的退回作用域键判断 —— 有值即算有,没值即缺。
*/
// 本单写过的台账地址 —— 「读己之写」的那一份,别单的落地行不算自己的交付
const mine = new Set((await deps.ledger.taskDrafts(input.taskId)).map((d) => `${d.domain}.${d.object}`));
const deliverables = manifest.contract.requiredDeliverables.map((d) => ({
key: d.key,
label: d.label,
present: deliverablePresent(d.ledger, d.key, scope, mine),
}));
/*
* 判据逐条标注真实等级:L1/L2 带 predicate 的**当场机检**(S-FEEL 对任务视图求值),
* L3–L5 交人 —— 等级不得谎报(安装期 acceptance-level-misreported 已挡住漏配)。
*/
/*
* 裁量判据(L3/L4)读 **Agent 的裁定**,不再一律判 false。
*
* 从前这里写死 `machineCheckable ? predicate : false`:L3「吃不准降 B」、
* L4「去向判断合乎业务常理」这类机器判不了的,于是**永远**是红叉 ——
* 一单办得再干净,卡面也停在 5/8,人签字时面对三条谁也消不掉的未达标,
* 「达标数」这个读数就此失去意义(反正它永远不满)。
*
* 它们本就有归属:`judge` 行动 —— 「评估留痕,可回放可归因」。
* 裁定按 key 或 label 认(模型两种都可能写),没裁过的照旧算未达标:
* **没人裁过 ≠ 通过**,那是这道门的意义所在。
*/
const judged = await taskContext.judgements(input.taskId);
/* 判据 key → 谓词:拒回时好把「这条读的是哪几个数」摆给模型看 */
const criteriaOf = new Map(
manifest.contract.acceptanceCriteria
.filter((c) => c.predicate !== undefined && c.predicate.trim() !== '')
.map((c) => [c.key, c.predicate ?? '']),
);
const verdictOf = (c: { key: string; label: string }): { satisfied: boolean; rationale?: string } | undefined =>
judged.get(criterionKey(c.key)) ?? judged.get(criterionKey(c.label));
const criteria = manifest.contract.acceptanceCriteria.map((c) =>
criterionCheck(c, scope, verdictOf(c)),
);
/*
* **只许剩放行那一条**(2026-07-31 用户定的口径),但两类判据把的是两道不同的关:
*
* · **机检(L1/L2)**:数不对就是活没干完 —— 必须为真才让提交。
* · **裁量(L3/L4)**:必须**裁过**,裁成什么不拦。
*
* 后半句是 2026-08-01 用实测改回来的。从前这里对裁量项也要求「为真」,
* 而裁量的本义恰恰是**可以裁出「不满足」**,且不满足正是最该交给人的那种情形。
* 于是模型只剩两条路:改数据,或者改裁断 —— 两条都是撒谎,它两条都走过:
* ① 一颗料只缺目标价,它如实记 missing_minor_total = 1,被判据连拒四次,
* 第五拍把那个数改回 0,判据当场变绿(台账从此写着「缺选填 0 条」);
* ② 换成 L4 之后,它如实裁了「未满足」并写了理由,提交照样被拒 ——
* 判、提、拒、再判……十二轮 21 拍 187k token,最后一拍把裁断翻成「满足」才过。
* **一条只接受「是」的判据不是判据,是一句教模型说谎的提示。**
*
* 裁成「不满足」的,连着理由一起进验收卡:那正是签字的人要看的东西 ——
* 「AI 说这一条它办不到,理由是……」比一片虚假的绿勾有用得多。
*/
const unmet = criteria.filter((c) =>
c.level === 'L5' ? false : c.settled === undefined ? !c.pass : !c.settled,
);
if (unmet.length > 0) {
/*
* 判据名要**用引号夹起来**,等级摆在引号外(2026-07-31 修)。
*
* 从前写成 `${label}(${level})`,后面又说「criterion 照判据名原样写」——
* 模型照做了:它把 `(L3)` 一并抄进 criterion,于是裁定的键与判据名对不上,
* 裁了等于没裁,再提交再被拒,连拒五拍熔断(实测 A2 死于此)。
* 让模型抄什么,就得让那个东西在句子里**看得出边界**。
*/
/*
* 机检项要**连着它此刻的读数**一起摆(2026-07-31 加,与收步判据同一口径)。
* 只说「C 类都开了替代评估请求 没达标」,模型看不见 alt_request_total 与
* type_c_total 现在各是几 —— 它以为自己开齐了,原样再提一遍,连拒五拍熔断
* (实测 A1 死于此)。差的那个数摆出来,才是一句话能办的事。
*/
const detail = unmet.map((c) => {
const head = `「${c.label}」[${c.level}]`;
if (c.settled === false) return `${head} 还没裁过`;
const predicate = criteriaOf.get(c.key);
return predicate === undefined ? head : `${head} 判据 ${predicate}${readingsOf(predicate, scope)}`;
});
/*
* **建议要分清「数不对」与「行不对」**(2026-08-01 实测改)。
*
* 从前只有一句「去把台账里那几个数改对(通常是汇总表的 _total 计数列)」——
* 而实测那一单差的根本不是数:客户说本轮跳过的那颗料被多写了一行,
* `line_total = 9` 是对的、表里 10 行是错的。这句话把模型直直引向改数,
* 它照做(把 9 改成 10),`skipped_total` 又不对,再拒 —— 五拍熔断。
* 读数里已经把两份主键名单摆出来了,一比就看得出多的是哪一行;
* **指路要指到那一行上去**,不是指向那个数。
*/
/*
* **「活没干完」得说成活没干完**(2026-08-02 实测逼出来的)。
*
* 那一单跳过了打标与定去向就来提交,判据 `…_type_tag_a + _b + _c = …_rows`
* 正确地拦住了 —— 而拒回的话给了两条路:改数、改行。两条都不对:
* 数是对的(它诚实地报了「3 条没打标」)、行也是对的(记录三行一行不多),
* **真正该做的是回去把那一步办了**。于是它只能原样重提,连撞五拍熔断。
*
* 判法很硬:判据里点名的那张表,**这一单一行都没写**(基座数的 `_rows` 是 0),
* 那就不是数错也不是行错,是那一步压根没办。
* 「拒回的话是模型下一拍的行动指南,指错方向比不给指引更糟」——
* 指向两扇打不开的门,比不指还糟。
*/
const empty = [
...new Set(
unmet.flatMap((c) =>
[...(criteriaOf.get(c.key) ?? '').matchAll(/([a-z][a-z0-9_]*)_rows\b/g)]
.map((m) => m[1] ?? '')
.filter((name) => name !== '' && Number(scope[`${name}_rows`] ?? -1) === 0),
),
),
];
const undone =
empty.length === 0
? ''
: `。**${empty.join('、')} 这一单一行都没写** —— 那不是数错也不是行错,` +
'是产出它的那一步还没办:回去把它办了(该调的工具调、该写的行写),再来提交。';
throw new Error(
`还不能提交放行:除「人放行」外还有 ${unmet.length} 条判据没过 —— ` +
detail.join(';') +
undone +
'。机检项先分清差的是**数**还是**行**:' +
'① 数不对 —— 汇总表的 _total 与实情对不上,把那个数改对;' +
'② 行不对 —— 上面两份主键名单一比就看得出多了或少了哪一行:' +
'多的用 drop 撤掉、少的补写。**名单里那一段 JSON 原样粘进 keys 就行**,' +
'一个字符都别改(值里的空格就是空格,换成横杠就撤不到那一行了)。' +
'**数是台账的结论,别为了过判据去改它** —— 名单说多了一行,那就是真多了一行。' +
'别原样重提;裁量项(L3/L4)用 judge 行动裁一次即可 —— **裁成「不满足」照样能提交**,' +
'把理由写清楚交给放行的人定;criterion 只抄引号里那一句,别把后面的等级标记带上。',
);
}
/*
* 可修订字段 = **契约声明的那几个**,拟值从台账现读。
*
* 设计期定「哪几个值该让人改、改完落哪儿」(`contract.revisableAtRelease`),
* 运行期填「此刻它是什么值」—— 拟值不让模型重述:重述就会和台账里的值不一致,
* 而以哪个为准说不清。活动在 submit 上另给的字段也收,与声明的并到一处。
*/
const revisable = manifest.contract.revisableAtRelease.map((r) => ({
key: r.key,
label: r.label,
ledger: r.ledger,
...(scope[r.key] === undefined || scope[r.key] === null ? {} : { ai: String(scope[r.key]) }),
}));
/*
* 同名字段**只留第一个** —— 表单按 key 索引,两个同 key 的字段本就摆不出来。
*
* 从前只拿声明字段去重 `revisable`,没拿它去重**自己**:
* A4 一单三条料,模型很自然地把三条的 `quoted_price` 都列成可改字段 ——
* 基座以「表单字段 key 重复」拒发,而模型看不出该删哪一个(三个都是真的),
* 于是原样再提一遍,连拒五拍熔断(实测 A4 死于此)。
* 逐行改值本来就不走这张表单(它是整单级的修订面),去重是如实的收敛,不是丢数据。
*/
const seenField = new Set<string>();
const mergedFields = [...revisable, ...(declaredFields ?? [])]
.filter((f) => {
if (seenField.has(f.key)) return false;
seenField.add(f.key);
return true;
})
.map((f) => ({ kind: 'text' as const, required: false, ...f }));
/*
* **验收页上那张可就地改的成果表**(2026-08-01)。
*
* 必交付物声明了 `editableAtRelease` 的,基座在放行卡上铺一张绑台账的表:
* 整份行都在、逐格可改。验收是最重的一道门 —— 人得能在成果本身上改,
* 而不只是在旁边几个整单级字段上改;改完由基座写回草稿层、随放行合并,AI 不经手。
*/
const editable = await Promise.all(
manifest.contract.requiredDeliverables
.filter((d) => d.ledger !== undefined && (d.editableAtRelease?.length ?? 0) > 0)
.map(async (d) => {
const address = d.ledger ?? '';
const declared = manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address);
const fields = d.editableAtRelease ?? [];
return {
t: 'table' as const,
title: `${d.label}(可就地改)`,
editable: true,
ledger: address,
fields: [...fields],
cols: fields.map((f) => declared?.fields.find((x) => x.name === f)?.label ?? f),
rows: [],
};
}),
);
const releaseBlocks = await fillBoundTables(input.taskId, editable);
const submitted = await deps.release.submitForRelease({
taskId: input.taskId,
ownerId: input.ownerId,
humanCallCap: input.humanCallCap,
deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'),
title,
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }],
deliverables,
/*
* **卡面上不摆 L5**(2026-08-01 用户当场指出:「当前卡片就是放行卡,
* 也不该显示在这里 —— 这里是判据」)。
*
* L5 是**责任关**,而这张卡本身就是那道关。把它列进判据表,等于在放行卡上
* 摆一条恒为「未达标」的项:人一路核到底,最后一行永远是红的,
* 而消掉它的动作就是按下同一张卡上的「确认通过」。
* 判据是**签字前要核的东西**,不是签字这件事自己;
* 「n/m 达标」那个数也因此永远差一条,看着像还有活没干完。
*
* 声明照旧留在契约里(它说明这份交付物必须由人放行,并由 `T_release`
* 的人工激活落实),提交门与预检也照旧把它排除在外 —— 少的只是这一处呈现。
*/
criteria: criteria.filter((c) => c.level !== 'L5'),
...(mergedFields.length === 0 ? {} : { fields: mergedFields }),
...(releaseBlocks.length === 0 ? {} : { blocks: releaseBlocks as Array<Record<string, unknown>> }),
});
/*
* 选择组上卡由 humanTool 侧补齐:组是契约的事实,不在卡面声明。
*
* **能算就别问**:组内每一口都写了 `when`,按序求值(首个命中即出)——
* 卡上只如实告诉人走哪一口、凭哪一条。去向多半是台账里那几个数的函数
* (有没有 C 类、有没有 B 类);让人凭印象点一下,等于把一个可回放的判定
* 换成一次不可回放的手感。有一口没写谓词就算不出来,退回「人选一口」。
*/
if (selective !== undefined) {
const routed = routePort(selective, scope, (expression, s) => evaluatePredicate(expression, s));
if (routed === undefined) {
await deps.humanTool.attachPortChoice(submitted.workOrderId, {
group: selective.group,
ports: selective.ports.map((port) => ({ name: port.name, label: port.description })),
});
} else {
await deps.humanTool.attachRoutedPort(submitted.workOrderId, routed);
await memoryRecorder.append({
taskId: input.taskId,
kind: 'engine-transition',
title: `去向已算出:${selective.group} → ${routed.name}(${routed.because})`,
detail: { group: selective.group, port: routed.name, because: routed.because },
});
}
}
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
await cardSpan?.end({ output: { cardId: submitted.workOrderId, precheck: submitted.report.verified.length } });
return { id: submitted.workOrderId };
}
/*
* **执行卡不带表单字段**(2026-08-02 用户定:「填写全去工作区」)——
* 填报的地方是工作区那份件(editGate + 孪生件 + 增删行),卡上只剩
* 指导书、依据与提交钮。从前这里兜底一格必填「结果」textarea:
* 活动没声明字段就替它造一个,人对着一张没处下笔的卡还得编一句话才按得下提交。
*/
const fields = declaredFields ?? [];
/*
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
*
* 执行工单的契约一直收 `blocks`,而这里从没往下传:活动照说明书派了一张
* 「带可编辑需求表」的执行单,人收到的却只有一句话,一张表都没有(实测)。
* 形状交给契约层校验(`viewBlockSchema`),这里只负责别把它弄丢;
* 认不出的块由 zod 拒下,不至于把脏东西端上卡面。
*/
const raw = Array.isArray(input.card['blocks']) ? input.card['blocks'] : undefined;
const blocks = raw === undefined ? undefined : await fillBoundTables(input.taskId, raw);
/*
* **作业指导书要到得了卡面**(2026-08-02 用户:「执行卡应该是查看工单按钮和作业指导书」)。
*
* 活动照骨架写了 `steps` / `standard`,这里从前**一个字都不往下传** ——
* 于是卡上只剩一句 ask 和一张表:人拿到「请核对确认」四个字,
* 不知道该核什么、核到什么程度算完。空着不是简洁,是把交代省掉了。
* (「步序声明」那句旧注说的是 flow 期的步序,与这份给人看的指导书不是一回事。)
*/
const steps = Array.isArray(input.card['steps'])
? input.card['steps'].map((x) => String(x)).filter((x) => x.trim() !== '')
: [];
const standard = String(input.card['standard'] ?? '').trim();
/*
* **这张卡该拦哪几张表** —— 零输入卡没有 `blocks`,闸只能回落到必交付物,
* 而数据工单要人补的是 `parts.*` 这类主数据(不在交付物名单里):闸盯着一堆
* 与这张卡无关的表,一格不缺,空手交卷一路放行(2026-08-27 W05 实测)。
* 认不出形状的一律丢掉,不把脏东西端给闸。
*/
const gateLedgers = Array.isArray(input.card['gateLedgers'])
? input.card['gateLedgers'].map((x) => String(x).trim()).filter((x) => /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(x))
: [];
/*
* 数据工单 = 执行工单的变体(契约 `variant`)—— **由剧本声明、这里决定**(`PlanStep.cardVariant`),
* 不靠模型抄卡骨架:说明书里写着 `"variant":"data"`,模型抄卡时把它丢了(2026-08-27 实测,
* 同一张卡 gateLedgers 抄到了、variant 没有),对话区于是照旧摆「执行工单」。
* 与 `view` 同一条道理:这一步派的是什么单,写剧本时就定死,模型写不写都一样。卡上写了只当提示。
*/
const stepVariant =
input.itemId === undefined
? undefined
: (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
(s) => s.item === input.itemId,
)?.cardVariant;
const variant = stepVariant ?? (input.card['variant'] === 'data' ? ('data' as const) : undefined);
const issued = await deps.humanTool.issueExecution(context, {
title,
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
ask: String(input.card['ask'] ?? input.card['reason'] ?? title),
fields,
...(steps.length === 0 ? {} : { steps }),
...(standard === '' ? {} : { standard }),
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
...(variant === undefined ? {} : { variant }),
});
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
await cardSpan?.end({ output: { cardId: issued.order.id, arrival: issued.arrival } });
return { id: issued.order.id };
},
};
/**
* 绑了台账的可编辑表:**行由基座从草稿层铺,不由活动塞进回复里**。
*
* 人要在卡上改的是那份清单本身(PRD 的执行页就是整份逐格可改)。让活动把整份塞进
* 回复里有两处死结:回复有长度上限,铺长了半途截断整拍作废(实测);
* 而那些行**台账里已经有了**,让模型再抄一遍是拿 token 换一次抄错的机会。
*
* 铺不出来(地址写错、这一单还没写过那张表)就**原样留着活动给的行** ——
* 宁可少一份便利,不要把人正在办的那张卡弄成空表。
*/
const fillBoundTables = async (taskId: TaskId, blocks: readonly unknown[]): Promise<unknown[]> => {
const drafts = await deps.ledger.taskDrafts(taskId);
return blocks.map((block) => {
if (typeof block !== 'object' || block === null) return block;
const b = block as Record<string, unknown>;
if (b['t'] !== 'table' || typeof b['ledger'] !== 'string' || !Array.isArray(b['fields'])) return block;
const address = b['ledger'];
const fields = (b['fields'] as unknown[]).map((f) => String(f));
const rows = drafts
.filter((d) => `${d.domain}.${d.object}` === address)
.map((d) => {
const row = { ...d.key, ...d.patch } as Record<string, unknown>;
return fields.map((f) => {
const v = row[f];
return v === null || v === undefined ? '' : String(v);
});
});
return rows.length === 0 ? block : { ...b, rows };
});
};
/**
* 未命中转人(§6.2)—— **人是工具的一种实现**。
*
* 系统里没有这件工具时,从前是抛错:Agent 拿到一个异常,既不知道该等谁,也没人被通知,
* 于是这一单就停在那儿 —— 名义上「工具未注册」,实际上是**一步走不动**。
* 现在改成开一张执行卡请人做,人填回的表单值就是这次调用的出参,下一拍直接取到。
*
* 两种不命中,卡面不一样:
* · `tool-unimplemented` 有契约无实现 —— 卡面 = 契约的 `guide` 段 + 本次入参 + 按出参 Schema 生成的回填表单;
* · `tool-unregistered` 连契约都没有 —— 如实说明系统里没有这件工具,只给一格自由文本。
*
* 一次调用只发一张卡:入参指纹 `callKey` 做内容地址,重问拿同一份答案。
*/
const requestHandoff = async (
input: { tool: string; input: Record<string, unknown>; taskId: TaskId },
contract: ToolContract | undefined,
): Promise<{ ok: boolean; output?: Record<string, unknown>; awaitingHuman?: { workOrderId: string } }> => {
const key = callKeyOf(input.tool, input.input);
const existing = await deps.toolHandoff.find(input.taskId, key);
if (existing?.state === 'answered') {
// 人已答:值即出参(按出参 Schema 归型后交回活动)
return { ok: true, output: coerceBySchema(contract?.output, existing.output ?? {}) };
}
if (existing !== undefined) return { ok: false, awaitingHuman: { workOrderId: existing.workOrderId } };
const context = await taskContext.of(input.taskId);
const name = contract?.displayName ?? input.tool;
const guide = contract?.guide;
const issued = await deps.humanTool.issueExecution(
{ taskId: input.taskId, ownerId: context.ownerId, humanCallCap: context.humanCallCap },
{
title: `请代办:${name}`,
// 依据 = 本次调用的入参原文:人得看见「拿什么去做」,不然只能猜
basis: [
{ k: '工具', v: name },
...Object.entries(input.input).map(([k, v]) => ({
k,
v: typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v),
})),
],
ask:
guide?.ask ??
(contract === undefined
? `系统里还没有这件工具(${input.tool})。这一步请你直接做,把结果填在下面。`
: `这件工具(${name})系统里还没有可用实现。请你按契约把这一步做掉,把结果填在下面。`),
standard: guide?.standard ?? '照实填写;做不了就说明卡在哪,不要编数。',
...(guide?.steps === undefined ? {} : { steps: [...guide.steps] }),
fields: handoffFields(contract?.output).map((f) => ({ ...f })),
submitLabel: '提交结果',
},
);
await deps.toolHandoff.open({
id: ids.next(),
taskId: input.taskId,
tool: input.tool,
callKey: key,
input: input.input,
workOrderId: issued.order.id,
});
// 立监听听人:卡回传驱动下一拍(不立就没人叫醒这一单)
await listeners.register({ taskId: input.taskId, eventType: 'human', keyFilter: { cardId: issued.order.id }, deadline: 'P3D' });
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
await memoryRecorder.append({
taskId: input.taskId,
kind: 'engine-transition',
title: `${name} 无可用实现 —— 已转人代办`,
detail: { tool: input.tool, workOrderId: issued.order.id, registered: contract !== undefined },
});
return { ok: false, awaitingHuman: { workOrderId: issued.order.id } };
};
const toolGateway = {
invoke: async (input: { tool: string; input: Record<string, unknown>; activityId: string; taskId: TaskId; stepNo?: number }) => {
// 每次工具调用一个 span —— 观测面要看得见活动内部到底调了什么、花了多久
const span = deps.trace.beginAction({ taskId: input.taskId, name: `工具:${input.tool}`, kind: 'tool', input: input.input });
const result = await deps.toolInvoker
.invoke({
tool: input.tool,
input: input.input,
activityId: input.activityId,
taskId: input.taskId,
...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }),
})
.catch(async (error: unknown) => {
await span?.fail(error);
throw error;
});
// 未注册 / 有契约无实现:不是失败,是「这件事该由人做」
if (!result.ok && (result.error?.code === 'tool-unregistered' || result.error?.code === 'tool-unimplemented')) {
const handoff = await requestHandoff(input, await deps.toolbox.get(input.tool));
await span?.end({
output: handoff.ok ? (handoff.output ?? {}) : { handoff: handoff.awaitingHuman?.workOrderId },
attributes: { handoff: 'human' },
});
return handoff;
}
await span?.end({ output: result.ok ? (result.output ?? {}) : { error: result.error?.message } });
return {
ok: result.ok,
...(result.output === undefined ? {} : { output: result.output }),
...(result.error === undefined ? {} : { error: { message: result.error.message, retryable: result.error.retryable } }),
};
},
isWriteEffect: async (tool: string) => (await deps.toolbox.get(tool))?.effect === 'write',
/**
* 这一单能调什么 —— 声明 × 目录编译而来,**只给功能工具**。
*
* 系统内化那几件(`human.ask` / `ledger.write` / LLM 五件)在单拍路径上**不是工具**,
* 是独立的行动型(human / write / …)。把它们混进「可调工具」里,Agent 就会
* 拿 `tool: ledger.write` 去调 —— 缝里查无此工具,当场转人代办:
* 一张「请代办:ledger.write」的卡送到人手上,而那本是它自己一句话就能干的事(实测踩过)。
* 开环路径反过来:那边它们**就是**工具(经 `busOf(taskId)` 拿全套)。
*/
busOf: async (activityId: string) => {
const report = await busForActivity(activityId);
return report.tools
.filter((t) => t.kind === 'function')
.map((t) => ({ id: t.id, description: t.description, input: t.input, effect: t.effect }));
},
registerIntent: async (input: { taskId: TaskId; tool: string; input: Record<string, unknown>; label: string }) => {
// 写类动作登记为动作意向:入参冻结,放行后由系统执行落地
/*
* 幂等键**只有一个公式** —— `${taskId}:${intentId}`,与落地时 `land()` 用的同一个
* (`idempotencyKey` 从 release 导出,两边共用)。
*
* 从前登记侧自拼 `${taskId}:${tool}:${随机}`,落地侧另算一个:两把钥匙对不上,
* 「同一动作至多执行一次」就成了空话 —— 重试会以另一把键再执行一遍。
*/
const intentId = ids.next();
await deps.release.registerIntent(input.taskId, {
id: intentId,
tool: input.tool,
input: input.input,
idempotencyKey: idempotencyKey(input.taskId, intentId),
label: input.label,
});
},
};
/**
* 把人在验收报告上改过的值落回草稿层 —— 放行合并前的最后一笔。
*
* 只认**声明了台账地址**的字段(`FormField.ledger`),且值确实与 AI 拟值不同才写:
* 一个字没改就不该在台账上留一笔「改过」的痕。
*/
const applyRevisions = async (taskId: TaskId): Promise<void> => {
const orders = await deps.humanTool.allWorkOrders(taskId);
const drafts = await deps.ledger.taskDrafts(taskId);
for (const order of orders) {
if (order.mode !== 'acceptance' || order.done === undefined) continue;
const answered = order.done.values ?? {};
for (const field of order.fields ?? []) {
const address = field.ledger;
if (address === undefined || address === '') continue;
const value = answered[field.key];
if (value === undefined || value === field.ai) continue;
const at = address.indexOf('.');
if (at <= 0) continue;
const domain = address.slice(0, at);
const object = address.slice(at + 1);
const rows = drafts.filter((d) => d.domain === domain && d.object === object);
if (rows.length !== 1) {
// 不猜是哪一行:如实记痕,人看得见「这一改没落下去」
await memoryRecorder.append({
taskId,
kind: 'result',
title: `修订未落台账:${field.label}(${address} 本单有 ${rows.length} 行,改哪一行说不清)`,
detail: { field: field.key, address, rows: rows.length },
});
continue;
}
await taskData.draftWrite({ taskId, domain, object, key: rows[0]!.key as never, patch: { [field.key]: value } });
await memoryRecorder.append({
taskId,
kind: 'ledger-write' as never,
title: `人工修订:${field.label} → ${String(value)}(落 ${address})`,
// `by` = 这一改是**谁**改的 —— 日志按它认主语(没有它就是基座自己落的账)
detail: { field: field.key, address, from: field.ai, to: value, by: (await taskContext.of(taskId)).ownerId },
});
}
}
};
/* ── skill-engine ─────────────────────────────────────────────── */
const skills = createSkillEngineService({
catalog: {
skillsOf: async (activityId) => (await deps.registry.manifestOf(activityId)).assets.skills,
allowlistOf: (activityId) => deps.toolbox.allowlistOf(activityId),
},
tools: {
invoke: (input) =>
toolGateway.invoke({ ...input, ...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }) }),
},
memory: memoryRecorder as never,
clock,
});
void skills;
/**
* 模型网关加观测:每次模型调用一个 span(用途名、标签、用量)——
* 「活动内部到底问了几次模型」在时间轴上要看得见。
* 包一层不改语义:录制回放、四元绑定、凭证隔离全在原网关里,这里只旁观。
*/
const tracedGateway: ModelGateway = {
...deps.modelGateway,
generate: async (call) => {
const span =
call.taskId === undefined
? undefined
: deps.trace.beginAction({
taskId: call.taskId,
name: `模型:${call.label ?? call.model}`,
kind: 'model',
input: { model: call.model, label: call.label },
});
try {
const result = await deps.modelGateway.generate(call);
await span?.end({
output: { chars: result.text.length },
/*
* 入/出分开记,推理长度单独记 —— 排「一拍为什么慢」全靠这三个数:
* 只有总数时,「入参膨胀」与「推理跑长」两种病看起来一模一样
* (实测 56s 离群拍:可见出参 719 字对不上多出的 4k token,
* 拆开才看得见是隐藏推理在烧解码)。
*/
attributes: {
tokens: result.usage.inputTokens + result.usage.outputTokens,
tokIn: result.usage.inputTokens,
tokOut: result.usage.outputTokens,
reasonChars: result.reasoning?.length ?? 0,
mode: result.mode,
},
});
return result;
} catch (error) {
await span?.fail(error);
throw error;
}
},
};
/* ── Agent 回路:单拍 or 开环(工具总线)────────────────────────── */
/** 活动声明的自定义模型名 —— 活动只见用途名,不见厂商型号 */
const modelOf = async (taskId: TaskId) => {
const { activityId } = await taskContext.of(taskId);
const manifest = await deps.registry.manifestOf(activityId);
return manifest.declarations.models[0] ?? 'default';
};
const onAgentUsage = (taskId: TaskId, tokens: number) =>
taskStore.addConsumption(taskId, { tokens }).then(() => undefined);
/**
* 工具总线:**声明 × 目录**,编译期定死这一单能调什么(声明面在 toolbox/domain)。
* 功能工具按活动白名单查目录;人类工具恒在(Harness 给的能力);
* LLM 五件仅当活动声明了模型用途名才给 —— 没声明模型却会问模型,是越权也无从归因。
*/
const busForActivity = async (activityId: string) => {
const manifest = await deps.registry.manifestOf(activityId);
const catalog = new Map(
(await deps.toolbox.list()).map((c) => [
c.name,
{
name: c.name,
description: c.description,
input: c.input,
...(c.output === undefined ? {} : { output: c.output }),
effect: c.effect === 'read' ? ('read' as const) : ('write' as const),
},
]),
);
const report = compileToolBus({
declaredTools: manifest.declarations.tools.map((t) => t.name),
catalog,
declaredModels: manifest.declarations.models,
});
return report;
};
/**
* 开环路径要的那份(带任务上下文)——「缺件如实记痕」发生在这一层:
* 声明了却查无此工具 / 已停用,Agent 手上就少了件东西,得在任务轨迹上看得见。
*/
const busOf = async (taskId: TaskId) => {
const { activityId } = await taskContext.of(taskId);
const report = await busForActivity(activityId);
if (report.missing.length > 0 || report.disabled.length > 0) {
await memoryRecorder.append({
taskId,
kind: 'observation',
title: `工具总线缺件:未装 ${report.missing.join('、') || '无'};已停用 ${report.disabled.join('、') || '无'}`,
} as never);
}
return report.tools;
};
/*
* 开环 or 单拍:`JCAI_AGENT_OPEN_LOOP=1` 时用 Mastra Agent 放开拍内工具循环。
* 缺省仍是单拍版 —— 开环把「一拍调几次模型」交给了模型自己,
* 得有第一个真 loop 活动压着测过才好当缺省(见 debt M5-2)。
*/
const agentRunner =
globalThis.process.env['JCAI_AGENT_OPEN_LOOP'] === '1'
? createMastraAgentRunner({
gateway: tracedGateway,
modelOf,
busOf,
onUsage: onAgentUsage,
contextOf: async (taskId) => {
const ctx = await taskContext.of(taskId);
return { taskId, ownerId: ctx.ownerId, humanCallCap: ctx.humanCallCap };
},
ports: {
invoke: async (input) => {
const { activityId } = await taskContext.of(input.taskId);
return toolGateway.invoke({ ...input, activityId });
},
isWriteEffect: (tool) => toolGateway.isWriteEffect(tool),
registerIntent: (input) => toolGateway.registerIntent(input),
// 台账草稿写:与单拍路径共用同一道把关(地址与列名都验)
draftWrite: async (input) => {
const at = input.address.indexOf('.');
if (at <= 0 || at === input.address.length - 1) {
throw new Error(`台账地址要写成「域.对象」:收到 ${input.address || '(空)'}`);
}
const domain = input.address.slice(0, at);
const object = input.address.slice(at + 1);
/*
* **一笔 write 整批先验后写**(2026-08-24 柴浩实测逼出):从前逐行边验边写,
* 第 2 行撞墙(refersTo 查无此码)时第 1 行已经落了草稿,而轨迹只记「行动被拒」——
* 模型与人都以为一行没写,回执骨架从此少一行(计划 7 / 回执 6),
* 到放行门撞成两堵墙(「每条计划都有回执」vs「每家候选都进了计划」)五拍熔断。
* 被拒就得是一行都没落:先把每一行过一遍门,全过了再落。
*/
for (const row of input.rows) {
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch, dryRun: true });
}
for (const row of input.rows) {
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch });
}
return { written: input.rows.length };
},
issueCard: (input) => cards.issue(input),
listen: (input) => listeners.register(input),
generate: async (input) => {
const result = await deps.modelGateway.generate(input);
await onAgentUsage(input.taskId, result.usage.inputTokens + result.usage.outputTokens);
return { text: result.text, ...(result.object === undefined ? {} : { object: result.object }) };
},
},
})
: createAgentRunner({
gateway: tracedGateway,
onUsage: onAgentUsage,
modelOf,
/*
* 输出不成形就地重问时,把模型原文存进工作日志(内部痕)——
* 卡面从此不摆 JSON 原话,尸检去日志里看。stamp 与 memoryRecorder 同款兜底。
*/
note: async (taskId, title, raw) => {
await deps.memory.append({
taskId,
kind: 'result',
title,
detail: { internal: true, raw },
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
});
},
});
/* ── loop-engine ──────────────────────────────────────────────── */
const loop = createTurnRunner({
state: createLoopStateStore(db),
sfeel,
data: taskData,
agent: agentRunner,
tools: toolGateway,
cards,
listeners,
views: viewGateway,
skills: {
index: (activityId) => deps.skills.index(activityId),
stamp: (activityId, ids) => deps.skills.stamp(activityId, ids),
},
memory: {
append: memoryRecorder.append as never,
loadActivityMemory: async (activityId: string, limit: number) =>
(await deps.memory.loadActivityMemory(activityId, { limit })).map((m) => ({ layer: m.layer, statement: m.statement })),
// 任务自身的最近轨迹:窗口大小是活动声明的唯一 Harness 记忆配置(manifest.declarations.harness.memoryWindow)
window: async (taskId, want) => {
const { activityId } = await taskContext.of(taskId);
// 不指定就按活动声明的窗口(叙事面);熔断检测会显式要一个更大的
const size = want ?? (await deps.registry.manifestOf(activityId)).declarations.harness.memoryWindow;
// detail 一起带上 —— 人回填的值在 detail.values 里,不给就等于把答案藏了起来
return (await deps.memory.window(taskId, size)).map((m) => ({
kind: m.kind,
title: m.title,
...(m.detail === undefined ? {} : { detail: m.detail }),
}));
},
},
tasks: taskContext,
clock,
});
/**
* process 晚于 task 构造 —— 延后绑定(与 container 里 `TriggerMatcher` 同一手法)。
* 未绑定时静默不报:装配尚未完成的那一瞬没有任务会终态。
*/
const processRef = createDeferredProcess();
/**
* 任务的输出口载荷。
*
* 载荷来自**落地收敛的事实**(`facts`),不是从作用域里再捞一遍:
* 放行那一刻草稿层已合并并清空,此时再捞只剩起单入参,下游节点会因缺字段起不来。
* 输出口声明的属性名与台账列名可能不同形(驼峰 / 下划线),两种写法都认。
*/
function outputPortsOf(
manifest: {
contract: {
output: {
mandatory: ReadonlyArray<{ name: string; payload: unknown }>;
selective?: ReadonlyArray<{ group: string; ports: ReadonlyArray<{ name: string; payload: unknown }> }>;
};
};
},
landed: LandedFacts,
): Record<string, Record<string, unknown>> {
const snake = (name: string): string => name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
const ports: Record<string, Record<string, unknown>> = {};
// 必交付口与选择组的口都要装配:活动可以只有选择组(如 A6 的成交/有异议/拒绝),
// 只看必交付口会得到一张空表,下游连分支判据都读不到。
const declaredPorts = [
...manifest.contract.output.mandatory,
...(manifest.contract.output.selective ?? []).flatMap((g) => g.ports),
];
for (const port of declaredPorts) {
const payload = port.payload as { properties?: Record<string, { type?: string }> };
const declared = Object.entries(payload.properties ?? {});
const picked: Record<string, unknown> = {};
for (const [field, spec] of declared) {
// 数组属性 = 这一单落地的那些行(逐行的键 + 值);标量属性 = 铺平后的事实
if (spec.type === 'array') {
picked[field] = landed.rows.map((r) => ({ ...r.key, ...r.values }));
continue;
}
const value = landed.flat[field] ?? landed.flat[snake(field)];
if (value !== undefined) picked[field] = value;
}
// 声明之外的落地事实一并带上:边映射也可能直接引用台账列名
ports[port.name] = { ...landed.flat, ...picked };
}
return ports;
}
/* ── task(L6):活动的运行时 —— 只有一种引擎(2026-07-29 起) ────── */
const task = createTaskService({
store: taskStore,
activities: {
async load(activityId) {
const manifest = await deps.registry.manifestOf(activityId);
const envelope = await deps.registry.loadEnvelope(activityId);
return {
caps: manifest.contract.resources.budget,
envelope: { ...envelope },
inputSchema: manifest.contract.input.data,
...(manifest.contract.input.inboundComm === undefined
? {}
: { inboundComm: manifest.contract.input.inboundComm }),
businessModel: manifest.assets.businessModel,
stallAfter: manifest.declarations.harness.stallAfter,
};
},
},
/*
* 沟通记录 —— 一单往来的原件存档。直接落库而不绕模块:它是**任务的一件附属事实**,
* 只有起单归档与工作台那一屏读它两处用,不值得为它立一个模块出口。
*/
comms: {
async record(input) {
await db.execute(sql`
insert into sys.task_comm (id, task_id, direction, channel, party, subject, body, attachments, note)
values (${ids.next()}, ${input.taskId}, ${input.direction}, ${input.channel}, ${input.party},
${input.subject}, ${input.body}, ${JSON.stringify(input.attachments)}::jsonb, ${input.note})`);
},
},
schemas: deps.schemas,
/*
* 唯一的引擎(2026-07-29 用户定:活动不再分 flow 和 loop,内部实现都是 Loop)。
* 从前这里是两个引擎按 paradigm 分派;flow 那一路连同 flow-engine 模块一并退役 ——
* 一件事该怎么办由 Agent 依案例模型临场决定,不再有「照谱执行」的第二种活动。
*/
engine: {
load: (taskId, model) => loop.load(taskId, model as never).then(() => undefined),
advance: async (taskId) => {
const outcome = await loop.runTurn(taskId);
return {
status: outcome.status,
...(outcome.blockedReason === undefined ? {} : { reason: outcome.blockedReason }),
// 终局唯放行:runTurn 依在途验收的裁决给出 finished 时,released/选口一并带回
...(outcome.released === undefined ? {} : { released: outcome.released }),
...(outcome.selectedPorts === undefined ? {} : { selectedPorts: outcome.selectedPorts }),
};
},
// 只记不跑:登记事件/收裁决即返,推进统一归 deliver 随后的 tick —— 预算一个口径
onEvent: async (taskId, event) => {
await loop.onEvent(taskId, event);
return { status: 'running' as const };
},
},
landing: {
/** 放行通过即刻落地:草稿合并 + 动作执行(附幂等键)→ 收敛才发射输出口(OS 7.3) */
async land(input) {
/*
* **人在验收报告上改的值,放行前落进草稿层** —— 这样 `releaseWrite` 合并的是修订后的事实。
*
* 不做这一步的后果:改动只留在工单留痕里,落地时用的还是 AI 的原值 ——
* 「验收报告 AI 已填好、可编辑」就成了摆设(第 12 章③ 的另一半)。
*
* 地址由字段自己声明(`FormField.ledger` = `域.对象`),只改**本单在该对象的那一行**:
* 该对象本单写了多行时不猜是哪一行,如实记一条痕跳过 —— 逐行的修改得回环里改,
* 在一张验收单上改几十行是另一件事。
*/
await applyRevisions(input.taskId);
/*
* **产出的资料文件在这儿冻住** —— 修订刚落回草稿层、合并还没发生:
* 存下来的正是他签字那一刻的样子(与工单的 `finalHtml` 快照同一个道理)。
*
* 为什么非存不可,而不是回头照台账现造:资料常落在**主数据**上,
* 而试跑单放行时主数据只留在活动缓存、不落正式表 —— 不存这一份,
* 试跑单一放行,人在工作成果里就什么都点不开了。正式单则是另一头的理由:
* 台账后来被下一单改了,归档件不该跟着改口。
* 存不下来不拦放行(资料是成果的一份,不是放行的前提):记一条痕,人看得见。
*/
try {
const manifest = await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId);
const saved = await archiveAssetFiles({
db,
taskId: input.taskId,
ids,
declarations: manifest.declarations.ledger,
// 「这一单产出的」= 本单碰过的行:整表里躺着历次跑出来的资产,不是这一单的成果
touched: await deps.ledger.taskDrafts(input.taskId),
rowsOfAddress: async (address) =>
(
await deps.ledger.taskView(input.taskId, { address, where: {}, orderBy: [], limit: 500, offset: 0 })
).rows as Array<Record<string, unknown>>,
});
if (saved > 0) {
await memoryRecorder.append({
taskId: input.taskId,
kind: 'ledger-write' as never,
title: `产出的资料已存档:${saved} 份(工作成果里点得开)`,
detail: { assetFiles: saved },
});
}
} catch (error) {
await memoryRecorder.append({
taskId: input.taskId,
kind: 'result',
title: `资料存档没做成:${error instanceof Error ? error.message : String(error)}`,
});
}
const outcome = await deps.release.land({
taskId: input.taskId,
ownerId: input.ownerId,
humanCallCap: input.humanCallCap,
selectedPorts: input.selectedPorts,
});
return {
converged: outcome.verdict.kind === 'converged',
emittedPorts: outcome.emittedPorts,
ledgerRowsMerged: outcome.ledgerRowsMerged,
// 输出口载荷 = 已落地的事实:铺平供标量取值,逐行供数组取值
facts: {
flat: {
...outcome.merged.reduce<Record<string, unknown>>(
(acc, row) => ({ ...acc, ...row.key, ...row.values }),
{},
),
/*
* **选择组发射的那一口就是这次运行的 outcome**。
* 「恰发一口,随放行一并确认」——那一口本身就是这一单的分支结论,
* 下游异或网关的判据读的就是它(如 G4:成交 / 有异议 / 拒绝)。
* 只有选择组才产生 outcome;必交付口是「都要交」,不构成选择。
*/
...(Object.values(input.selectedPorts).length > 0
? { outcome: Object.values(input.selectedPorts)[0] }
: {}),
},
rows: outcome.merged.map((r) => ({ key: r.key, values: r.values })),
},
};
},
},
memory: {
append: memoryRecorder.append as never,
seal: (taskId) => deps.memory.seal(taskId),
distill: (input) => deps.memory.distill(input),
hasHistory: async (taskId) => (await deps.memory.history(taskId)).length > 0,
},
listeners: {
hasArmedListener: async (taskId) =>
(await deps.event.listListeners(taskId)).some((l) => l.state === 'armed'),
},
persistence: {
hasDrafts: async (taskId) => (await deps.ledger.checkConflicts(taskId)) !== undefined,
hasRuntimeState: async (taskId) => (await loop.runtime(taskId)) !== undefined,
},
events: {
publishControl: async (taskId, semantic, by, reason) => {
await deps.event.publish({
type: 'control',
correlation: { target: { kind: 'task', id: taskId }, cause: { kind: 'governance', ref: by }, keys: {} },
payload: { data: { semantic, issuedBy: by, reason } },
occurredAt: clock.now().toISOString(),
source: { kind: 'governance' },
});
},
publishTerminal: async (taskId, terminal, reason, facts) => {
await deps.event.publish({
type: 'data',
correlation: { target: { kind: 'task', id: taskId }, cause: { kind: 'engine', ref: 'terminal' }, keys: { terminal } },
payload: { data: { terminal, reason } },
occurredAt: clock.now().toISOString(),
source: { kind: 'engine', detail: 'task' },
});
/*
* 任务终态到达 → 通知流程层(L7)。
*
* task 在 L6、process 在 L7,task 不得反向依赖 process —— 这一跳由 app 这层缝合。
* 「工作流状态是聚合投影」的前提是节点实例的终态**报得上去**:不报,
* 流就停在那个节点上,既不流转也不走失败走向。
* 用延后绑定是因为 process 在本函数里晚于 task 构造。
*/
const task = await taskStore.get(taskId);
const manifest = task === undefined ? undefined : await deps.registry.manifestOf(task.activityId);
const ports = manifest === undefined ? {} : outputPortsOf(manifest, facts);
await processRef.onTaskTerminal(taskId, terminal, ports);
},
},
notifier: {
notify: async (input) => {
// 改派留痕并**通知双方**
for (const to of [input.from, input.to]) {
await deps.message.notify({
staffId: to,
title: `任务改派:${input.title}`,
body: `任务 ${input.taskId} 的负责人由 ${input.from} 改为 ${input.to}(操作人 ${input.by})`,
});
}
},
},
interaction: { setPhase: async (taskId, phase) => void (await deps.interaction.setPhase(taskId, phase)) },
/*
* 受理补全台(宽进):缺必填 → 补全卡进任务对话流 + 武装 human 监听等答复。
* 不占活动的人卡预算 —— 补全发生在活动开跑之前,是受理的事,不是活动的打扰。
*/
intake: {
askCompletion: async (input) => {
const card = {
id: cardIdSchema.parse(ids.next()),
at: clock.now().toISOString(),
k: 'elicitation' as const,
ask: `受理这一单还缺几个信息 —— 补上,流程才跑得下去;实在没法填就写明原因。`,
fields: input.fields.map((f) => ({ key: f.key, label: f.label })),
};
await deps.humanTool.issueChatCard(
{ taskId: input.taskId as never, ownerId: input.ownerId as never, humanCallCap: 1_000_000 },
card,
);
await listeners.register({
taskId: input.taskId as never,
eventType: 'human',
keyFilter: { cardId: card.id },
// 时限是 ISO-8601 **时长**(P2D = 两天),不是时刻 —— 到点抛时间事件,无死等
deadline: 'P2D',
});
},
},
clock,
ids,
});
/* ── process(L7) ─────────────────────────────────────────────── */
const processDeps: ProcessServiceDeps = {
store: createProcessStore(db),
activities: {
async faces() {
const cards = await deps.registry.list();
const map = new Map<string, Awaited<ReturnType<ProcessService['validate']>> extends never ? never : never>();
void map;
const faces = new Map();
for (const card of cards) {
const manifest = await deps.registry.manifestOf(card.activityId);
const input = manifest.contract.input.data as { required?: string[]; properties?: Record<string, { type?: string }> };
const outputs: Record<string, Record<string, string>> = {};
for (const port of manifest.contract.output.mandatory) {
const payload = port.payload as { properties?: Record<string, { type?: string }> };
outputs[port.name] = Object.fromEntries(
Object.entries(payload.properties ?? {}).map(([k, v]) => [k, v.type ?? 'string']),
);
}
faces.set(card.activityId, {
activityId: card.activityId,
requiredInputs: input.required ?? [],
inputTypes: Object.fromEntries(
Object.entries(input.properties ?? {}).map(([k, v]) => [k, v.type ?? 'string']),
),
outputs,
});
}
return faces;
},
},
org: {
// 哨兵岗 NONE(未分配)不是流程包能声明的岗位 —— 不进候选集(ADR-012)
positions: async () =>
new Set(
(await deps.org.listStaff()).positions.filter((p) => p.code !== NONE_POSITION_CODE).map((p) => p.code),
),
candidates: async (position) => {
const staff = await deps.org.staffOfPosition(position);
const withLoad = await Promise.all(
staff.map(async (s) => ({
staffId: s.id,
name: s.name,
openTasks: (await task.openTasksOf(s.id)).length,
})),
);
return withLoad;
},
// 起单时按来源人取其工作组(ADR-011);没建组即 undefined,全走四策略
workGroupBinding: async (staffId) => deps.org.bindingOf(staffIdSchema.parse(staffId)),
isActive: async (staffId) => {
const staff = await deps.org.getStaff(staffIdSchema.parse(staffId)).catch(() => undefined);
return staff?.state === 'active';
},
},
tasks: {
start: async (input) => {
const started = await task.start({
activityId: input.activityId,
ownerId: input.ownerId,
title: input.title,
workflowId: input.workflowId,
nodeId: input.nodeId,
data: input.data,
...(input.message === undefined ? {} : { message: input.message }),
});
// 节点 span:活动内的每一步、每次调用都会挂在它下面
deps.trace.beginNode({
workflowId: input.workflowId,
nodeId: input.nodeId,
label: input.title,
taskId: started.id,
assigneeId: input.ownerId,
input: input.data,
});
return started;
},
},
approvals: {
async raise(input) {
// 审批点:卡到审批人工位 —— 与活动级的放行不同层
deps.trace.beginNode({
workflowId: input.workflowId,
nodeId: input.nodeId,
label: `审批:${input.subject}`,
assigneeId: input.approverId,
input: Object.fromEntries(input.basis.map((b) => [b.k, b.v])),
});
const issued = await deps.humanTool.issueExecution(
{ taskId: input.workflowId as never, ownerId: input.approverId, humanCallCap: 99 },
{
title: `审批:${input.subject}`,
basis: input.basis.length > 0 ? [...input.basis] : [{ k: '节点', v: input.nodeId }],
ask: '请裁决:通过 / 驳回(驳回必附意见)',
fields: [
{
key: 'verdict',
label: '裁决',
kind: 'radio',
required: true,
options: [
{ value: 'pass', label: '通过' },
{ value: 'reject', label: '驳回', negative: true },
],
},
{ key: 'comment', label: '依据 / 意见', kind: 'textarea' },
],
},
);
return { id: issued.order.id };
},
},
sfeel,
schemas: deps.schemas,
events: {
publish: async (input) => {
await deps.event.publish({
type: 'data',
correlation: {
target: { kind: 'workflow', id: input.workflowId as never },
cause: { kind: 'engine', ref: input.kind },
keys: {},
},
payload: { data: input.detail },
occurredAt: clock.now().toISOString(),
source: { kind: 'engine', detail: 'process' },
});
},
},
receipts: {
notify: async (input) => {
await deps.message.notify({
staffId: input.to,
title: `工作流回执:${input.subject}`,
body: `工作流 ${input.workflowId} 状态:${input.state}`,
});
},
},
clock,
ids,
};
/*
* 观测的挂点:**root span 必须先于任何节点 span**。
* `runtime.begin` 正是「单已建好、还没进第一个节点」那一刻 —— 挂在这儿,
* 后面所有节点/工具/模型/人卡的 span 才认得到父亲。挂在 `start` 返回之后就晚了:
* 起单是同步走到第一个节点的,节点 span 会先出生、另起一棵孤树(实测)。
*/
const selfRuntime = createSelfHostedRuntime(processDeps);
const tracedRuntime: FlowRuntime = {
...selfRuntime,
async begin(record, pkg) {
deps.trace.beginWorkflow({ workflowId: record.id, subject: record.subject, input: record.initiation });
await selfRuntime.begin(record, pkg);
},
};
const process: ProcessService = createProcessService({ ...processDeps, runtime: tracedRuntime });
/* ── trigger(L7) ────────────────────────────────────────────── */
const trigger = createTriggerService({
store: createTriggerStore(db),
process: {
start: async (input) => process.start(input),
requiredInputs: async (packageId) => {
// 与起单取版同口径:读**生效版本**的调用接口(一条流恰一个生效版本)
const all = await process.listPackages();
const pkg = all.find((x) => x.id === packageId && x.state === 'active');
const input = pkg?.callInterface.input as { required?: string[] } | undefined;
return input?.required ?? [];
},
},
sfeel,
/*
* 确认台:命中但勾了「需人工确认」—— 带来件内容的系统消息送到指定人工作台,
* `eventRef` 挂待确认单号,工作台消息列表凭它贴出「确认起单」那枚钮。
*/
confirmDesk: {
request: async (input) => {
const lines = [
`触发器「${input.triggerName}」命中一件来件,按运营配置须你确认后才起单。`,
input.preview['from'] === undefined ? undefined : `发件人:${input.preview['from']}`,
input.preview['subject'] === undefined ? undefined : `主题:${input.preview['subject']}`,
input.preview['body'] === undefined ? undefined : `\n${input.preview['body'].slice(0, 600)}`,
].filter((l): l is string => l !== undefined);
await deps.message.notify({
staffId: input.staffId as never,
title: `确认起单:${input.subject}`,
body: lines.join('\n'),
eventRef: `trigger-pending:${input.pendingId}`,
});
},
},
deadLetters: {
// 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃
report: async (input) => {
writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`);
// 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端)
const recipients = await deps.org.staffWithEnd('ops');
// 一个收件人都没有:这条通知这一次谁也收不到 —— 把「出事了」无声丢掉最糟,
// 所以出声(与 container.ts 的 governanceStaff 同一口径)
if (recipients.length === 0) {
writeLog(
'[trigger] 死信通知这一次没有收件人 —— 没有能进运营中心的活跃员工。' +
'去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。',
);
}
for (const person of recipients) {
await deps.message.notify({
staffId: person.id,
title: `触发器起单失败:${input.triggerId}`,
body: input.reason,
});
}
},
},
ledger: {
scan: async (input) => {
const page = await deps.ledger.query({
address: { domain: input.domain, object: input.object },
where: {},
orderBy: [],
limit: 200,
offset: 0,
});
return page.rows.flatMap((row) => {
const date = row[input.dateField];
if (typeof date !== 'string') return [];
return [{ key: { id: String(row['id'] ?? '') }, date, row }];
});
},
},
clock,
ids,
});
// 延后绑定落定:此后任务终态才报得到流程层
processRef.bind(process);
/**
* 流程服务加观测:起单开一条 trace(root span),终态收尾。
* 包一层不改语义 —— 节点、工具、模型、人卡的 span 都会挂进这条 trace。
*/
const tracedProcess: ProcessService = {
...process,
async onTaskTerminal(taskId, terminal, ports) {
/*
* 节点 span 先收(这一步办完了),再让流程往下走 —— 时间轴上不倒序。
* **非放行终态标成错**:blocked / exhausted / 失败都要在时间轴上看得出来,
* 收成一条无害的「已结束」等于把事故藏起来(实测:落地失败的那几单,
* 观测面上与走完的单长得一模一样)。原因从任务上现读。
*/
const failed = terminal !== 'released';
const reason = failed
? await task
.get(taskId as TaskId)
.then((t) => JSON.stringify(t.reason ?? terminal))
.catch(() => terminal)
: undefined;
await deps.trace.endNode({
taskId,
output: { terminal },
...(reason === undefined ? {} : { error: `${terminal}:${reason}` }),
});
const state = await process.onTaskTerminal(taskId, terminal, ports);
return state;
},
async onApprovalVerdict(workflowId, nodeId, verdict, payload) {
await deps.trace.endNode({ workflowId, nodeId, output: { verdict } });
const state = await process.onApprovalVerdict(workflowId, nodeId, verdict, payload);
if (state === 'completed' || state === 'terminated') await deps.trace.endWorkflow({ workflowId, state });
return state;
},
async refreshState(workflowId) {
const state = await process.refreshState(workflowId);
if (state === 'completed' || state === 'terminated') await deps.trace.endWorkflow({ workflowId, state });
return state;
},
};
/** 此刻这一单的判据 —— 与提交门同一段(`criterionCheck`),按当下数据现算 */
const checks = {
of: async (taskId: TaskId) => {
const { activityId } = await taskContext.of(taskId);
const manifest = await deps.registry.manifestOf(activityId);
const scope = await taskData.view(taskId);
const judged = await taskContext.judgements(taskId);
return manifest.contract.acceptanceCriteria.map((c) =>
criterionCheck(c, scope, judged.get(criterionKey(c.key)) ?? judged.get(criterionKey(c.label))),
);
},
};
return { task, process: tracedProcess, trigger, loop, checks, views: viewGateway };
}