Archived
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2): - 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进 S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视 - 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo, 计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标) - 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源, 正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填 - 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键; 标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段 - 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数 - 阶段刻度对齐文档 7 步;起单样例预填满 基座侧: - 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY 广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻 - 多行 write 整批先验后写(dryRun),被拒 = 一行都没落 - 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors) - 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行) - 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端 - 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸 - 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组; JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖 - 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01 契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo, objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。 1700 测 / typecheck / lint / depcruise / api:check 绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2706 lines
131 KiB
TypeScript
2706 lines
131 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 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())}`;
|
|
}
|
|
|
|
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 })),
|
|
}));
|
|
},
|
|
/**
|
|
* 过程写入 —— 一律落草稿层(放行前不落事实),且**先验地址再落笔**。
|
|
*
|
|
* 两道验都不能省:
|
|
* · 地址不在活动清单声明的台账对象内 → 拒。越域写与越权调工具同罪,
|
|
* 得在结构上拦住,不能靠活动自律。
|
|
* · 列名不在声明的字段里 → 拒。`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('、')})`);
|
|
}
|
|
|
|
/*
|
|
* **引用列查引用**(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';
|
|
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();
|
|
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'] }),
|
|
});
|
|
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
|
|
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,
|
|
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: {
|
|
positions: async () => new Set((await deps.org.listStaff()).positions.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}`);
|
|
const { staff } = await deps.org.listStaff();
|
|
for (const person of staff) {
|
|
const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern');
|
|
if (!governs) continue;
|
|
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 };
|
|
}
|