forked from ChaiHao/BPOS-MVP
M4: 范式引擎与任务运行时
skill-engine(L4):载方法不载权力(白名单外工具即拒)、结晶片段经 ToolInvoker 留痕、 装配入信封、技能目录渐进披露 flow-engine(L5):四种步型、确定性谓词跳步、重试与声明给定的兜底、 异步步即监听(时限必配)、写类工具登记动作意向、步序位置持久 loop-engine(L5):自研 CMMN 子集解析器(表外构件直接报错,子集封顶可机器证明)、 两副标准生命周期与迁移拓扑(引擎不得私改)、Enabled ≠ Active、 语义性前提标待裁量评估(引擎不代裁量)、DMN 确定性求值 + 命中行留痕、 DRD 缺口演绎与拓扑序、双向驱动与交集导航(断点即人环)、三型行动、 终局唯放行与 Harness task(L6):终态五名裁定(released 唯一成功)、Harness 预算环外强制与停滞熔断 (监听中是健康态)、控制三语义(叫停不是终态,记最重档纠正)、改派留痕通知双方、 断点续跑三份持久状态、封卷触发蒸馏钩子 301 个测试通过,门禁五道全绿 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
2850e6d4ef
commit
3c974d2860
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* module: flow-engine —— 对外类型(编码规范 §10.1;语义出处 AM 05)。
|
||||
*
|
||||
* flow 范式:步序声明运行器(四种步型、重试与兜底、事件即步条件)。
|
||||
* **零临场调度** —— 运行中不得由 LLM 决定步序、跳步或兜底。
|
||||
*/
|
||||
import type { FlowModel, FlowStep, TaskId } from '@contracts';
|
||||
import type { FailureAction, FlowIssue, FlowPosition, NextAction, StepRuntime, StepState } from './internal/domain/step-sequence.ts';
|
||||
export type { FailureAction, FlowIssue, FlowModel, FlowPosition, FlowStep, NextAction, StepRuntime, StepState };
|
||||
export declare class FlowDeclarationInvalidError extends Error {
|
||||
readonly issues: readonly FlowIssue[];
|
||||
constructor(issues: readonly FlowIssue[]);
|
||||
}
|
||||
/** 一次推进的结果 —— 由 task 驱动,不自转 */
|
||||
export type AdvanceOutcome = {
|
||||
readonly position: FlowPosition;
|
||||
readonly status: 'running' | 'awaiting' | 'finished' | 'blocked';
|
||||
/** awaiting 时:在等什么 */
|
||||
readonly waitingFor?: {
|
||||
readonly kind: 'human' | 'event' | 'time';
|
||||
readonly ref: string;
|
||||
readonly deadline: string;
|
||||
};
|
||||
/** finished 时:末步验收工单的裁决决定终态 */
|
||||
readonly released?: boolean;
|
||||
readonly blockedReason?: string;
|
||||
};
|
||||
export type FlowEngineService = {
|
||||
/** 装载步序声明并校验 —— 谱必须全程可静态检查 */
|
||||
load(taskId: TaskId, model: FlowModel): Promise<FlowPosition>;
|
||||
position(taskId: TaskId): Promise<FlowPosition | undefined>;
|
||||
/** 推进一步(或若干确定步);异步步进入 awaiting 并登记监听 */
|
||||
advance(taskId: TaskId): Promise<AdvanceOutcome>;
|
||||
/** 事件即步的完成条件:人事件 / 数据事件 / 时间事件到达即续跑 */
|
||||
resume(taskId: TaskId, event: {
|
||||
kind: 'human' | 'data' | 'time';
|
||||
ref: string;
|
||||
payload: Record<string, unknown>;
|
||||
}): Promise<AdvanceOutcome>;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* module: flow-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { AdvanceOutcome, FailureAction, FlowEngineService, FlowIssue, FlowModel, FlowPosition, FlowStep, NextAction, StepRuntime, StepState, } from './contract.ts';
|
||||
export { FlowDeclarationInvalidError } from './contract.ts';
|
||||
export type { CardGateway, Clock, FlowStateStore, IdGenerator, ListenerRegistrar, LlmStepGateway, MemoryRecorder, PredicateEvaluator, TaskContext, TaskDataGateway, ToolStepInvoker, } from './port.ts';
|
||||
export type { FlowRunnerDeps } from './internal/usecase/flow-runner.ts';
|
||||
export { createFlowRunner } from './internal/usecase/flow-runner.ts';
|
||||
export { checkDeclaration, failureAction, initialPosition, lastStepIsAcceptance, markStep, nextAction, parallelGroupComplete, STEP_STATES, } from './internal/domain/step-sequence.ts';
|
||||
export { createFlowStateStore } from './internal/adapter/flow-state-store.ts';
|
||||
export { flowStateTable } from './internal/adapter/flow-state-schema.ts';
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* module: flow-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { FlowModel, StaffId, TaskId } from '@contracts';
|
||||
import type { FlowPosition } from './internal/domain/step-sequence.ts';
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
export type IdGenerator = {
|
||||
next(): string;
|
||||
};
|
||||
/** 运行时实例状态 —— 三份持久状态之一(OS 09) */
|
||||
export type FlowStateStore = {
|
||||
save(taskId: TaskId, model: FlowModel, position: FlowPosition): Promise<void>;
|
||||
load(taskId: TaskId): Promise<{
|
||||
model: FlowModel;
|
||||
position: FlowPosition;
|
||||
} | undefined>;
|
||||
};
|
||||
/** 条件谓词求值 —— app 注入 `@sfeel`;零 LLM */
|
||||
export type PredicateEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||
/** 任务数据 —— app 注入 `ledger`(L1):读任务视图 = 正式版 + 本任务草稿层 */
|
||||
export type TaskDataGateway = {
|
||||
view(taskId: TaskId): Promise<Record<string, unknown>>;
|
||||
draftWrite(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
/** 工具步与纯工具步 —— app 注入 `toolbox`(L2) */
|
||||
export type ToolStepInvoker = {
|
||||
invoke(input: {
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
activityId: string;
|
||||
taskId: TaskId;
|
||||
stepNo: number;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
output?: Record<string, unknown>;
|
||||
error?: {
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
};
|
||||
}>;
|
||||
/** 写类工具在 flow 里也走「登记动作意向」,除非契约显式预授权 */
|
||||
registerIntent(input: {
|
||||
taskId: TaskId;
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
label: string;
|
||||
}): Promise<void>;
|
||||
isWriteEffect(tool: string): Promise<boolean>;
|
||||
};
|
||||
/** LLM 步 —— app 注入 `llm`(L1)的 ModelGateway;模板与出参 schema 随声明版本冻结 */
|
||||
export type LlmStepGateway = {
|
||||
generate(input: {
|
||||
model: string;
|
||||
prompt: string;
|
||||
outputSchema: Record<string, unknown>;
|
||||
taskId: TaskId;
|
||||
stepNo: number;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
object?: unknown;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
/** 人类工具步 —— app 注入 `human-tool`(L3) */
|
||||
export type CardGateway = {
|
||||
issue(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
workOrder: 'execution' | 'acceptance';
|
||||
card: Record<string, unknown>;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
/** 监听登记 —— app 注入 `event`(L1):异步步的完成条件 = 监听,时限必配 */
|
||||
export type ListenerRegistrar = {
|
||||
register(input: {
|
||||
taskId: TaskId;
|
||||
eventType: 'data' | 'time' | 'human';
|
||||
keyFilter: Record<string, string>;
|
||||
deadline: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'action' | 'result' | 'engine-transition';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
/** 任务上下文 —— 活动 ID、负责人、预算 */
|
||||
export type TaskContext = {
|
||||
of(taskId: TaskId): Promise<{
|
||||
activityId: string;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
}>;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* module: loop-engine —— 对外类型(编码规范 §10.1;语义出处 AM 03 / 04)。
|
||||
*
|
||||
* loop 的运行 = **CMMN 语义引擎(下层,零 LLM)× Agent 回路(上层,临场裁量)**。
|
||||
* 两层的耦合点是任务数据(驱动耦合)与任务记忆(记录同轴)。
|
||||
*/
|
||||
import type { LoopModel, TaskId } from '@contracts';
|
||||
import type { CaseModel, PlanItem, PlanItemType } from './internal/domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime, CaseState, EnabledEntry, EnabledSet, ItemRuntime, ItemState, Navigation, TerminalAuthority } from './internal/domain/lifecycle.ts';
|
||||
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts';
|
||||
export type { CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, };
|
||||
export { CmmnParseError } from './internal/domain/cmmn-parse.ts';
|
||||
export { IllegalTransitionError } from './internal/domain/lifecycle.ts';
|
||||
export declare class ForbiddenTerminalError extends Error {
|
||||
readonly by: TerminalAuthority;
|
||||
constructor(by: TerminalAuthority);
|
||||
}
|
||||
export declare class AutomaticOnWorldTouchingError extends Error {
|
||||
readonly itemId: string;
|
||||
constructor(itemId: string);
|
||||
}
|
||||
/** Agent 的三型行动 —— 每一步行动属于且仅属于其一(AM 4.2) */
|
||||
export type AgentAction =
|
||||
/** 调工具:系统调用与 LLM 工具 */
|
||||
{
|
||||
readonly kind: 'tool';
|
||||
readonly itemId: string;
|
||||
readonly tool: string;
|
||||
readonly input: Record<string, unknown>;
|
||||
}
|
||||
/** 调人:四卡之一 —— 调人 = 立监听听人 */
|
||||
| {
|
||||
readonly kind: 'human';
|
||||
readonly itemId: string;
|
||||
readonly card: Record<string, unknown>;
|
||||
readonly workOrder: 'execution' | 'acceptance';
|
||||
}
|
||||
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
|
||||
| {
|
||||
readonly kind: 'listen';
|
||||
readonly itemId: string;
|
||||
readonly eventType: 'data' | 'time';
|
||||
readonly keyFilter: Record<string, string>;
|
||||
readonly deadline: string;
|
||||
}
|
||||
/** 启动一个 Enabled 项(manualStart,留痕) */
|
||||
| {
|
||||
readonly kind: 'start';
|
||||
readonly itemId: string;
|
||||
}
|
||||
/** 规划裁量项入场 */
|
||||
| {
|
||||
readonly kind: 'plan';
|
||||
readonly itemId: string;
|
||||
}
|
||||
/** 弃用某项(此单不走这条) */
|
||||
| {
|
||||
readonly kind: 'disable';
|
||||
readonly itemId: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
/** 评估语义性前提(评估留痕,可回放可归因) */
|
||||
| {
|
||||
readonly kind: 'judge';
|
||||
readonly criterion: string;
|
||||
readonly satisfied: boolean;
|
||||
readonly rationale: string;
|
||||
}
|
||||
/** 判定成果已齐,提交放行 */
|
||||
| {
|
||||
readonly kind: 'submit';
|
||||
readonly rationale: string;
|
||||
};
|
||||
export type TurnOutcome = {
|
||||
readonly runtime: CaseRuntime;
|
||||
readonly enabled: EnabledSet;
|
||||
readonly navigation: Navigation;
|
||||
readonly action?: AgentAction;
|
||||
readonly status: 'running' | 'awaiting' | 'submitted' | 'blocked';
|
||||
readonly blockedReason?: string;
|
||||
};
|
||||
export type LoopEngineService = {
|
||||
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
|
||||
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
|
||||
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
|
||||
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
|
||||
publish(taskId: TaskId): Promise<EnabledSet>;
|
||||
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
|
||||
runTurn(taskId: TaskId): Promise<TurnOutcome>;
|
||||
/** 事件驱动续跑:数据 / 时间 / 人 / 控制 */
|
||||
onEvent(taskId: TaskId, event: {
|
||||
kind: 'data' | 'time' | 'human' | 'control';
|
||||
ref: string;
|
||||
payload: Record<string, unknown>;
|
||||
}): Promise<TurnOutcome>;
|
||||
/** DMN 确定性求值 —— 能算的不问 LLM */
|
||||
decide(taskId: TaskId, decision: string): Promise<DecisionResult>;
|
||||
/** 驳回:相关计划项以新实例重新入场(迁移不是状态) */
|
||||
reenter(taskId: TaskId, itemId: string): Promise<CaseRuntime>;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* module: loop-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { AgentAction, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
|
||||
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, } from './contract.ts';
|
||||
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
||||
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
||||
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
|
||||
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
|
||||
export { assertItemTransition, autoCompleteReady, canTransitionCase, canTransitionItem, CASE_STATES, evaluateCriterion, initialRuntime, ITEM_STATES, mayCompleteCase, mayTerminateCase, navigate, occur, publishEnabled, reenter, transitionItem, } from './internal/domain/lifecycle.ts';
|
||||
export { deduceGaps, evaluateTable, evaluationOrder, isBindingConclusion } from './internal/domain/dmn.ts';
|
||||
export { createLoopStateStore } from './internal/adapter/loop-state-store.ts';
|
||||
export { loopStateTable } from './internal/adapter/loop-state-schema.ts';
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* module: loop-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { LoopModel, StaffId, TaskId } from '@contracts';
|
||||
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts';
|
||||
import type { AgentAction } from './contract.ts';
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
/** 运行时实例状态 —— loop 的模型定位与就绪集(OS 09 三份持久状态之一) */
|
||||
export type LoopStateStore = {
|
||||
save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime): Promise<void>;
|
||||
load(taskId: TaskId): Promise<{
|
||||
model: LoopModel;
|
||||
parsed: CaseModel;
|
||||
runtime: CaseRuntime;
|
||||
} | undefined>;
|
||||
};
|
||||
/** S-FEEL —— app 注入 `@sfeel`;引擎零 LLM */
|
||||
export type Sfeel = {
|
||||
condition(expression: string, scope: Readonly<Record<string, unknown>>): boolean;
|
||||
expression(expression: string, scope: Readonly<Record<string, unknown>>): unknown;
|
||||
unaryTest(cell: string, value: unknown, scope: Readonly<Record<string, unknown>>): boolean;
|
||||
};
|
||||
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
|
||||
export type TaskDataGateway = {
|
||||
view(taskId: TaskId): Promise<Record<string, unknown>>;
|
||||
draftWrite(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
/**
|
||||
* Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。
|
||||
* **回合由 usecase 驱动,不放开自由 maxSteps,预算在环外由 Harness 强制。**
|
||||
*/
|
||||
export type AgentPort = {
|
||||
decide(input: {
|
||||
taskId: TaskId;
|
||||
/** 叙事面:说明书 + 活动记忆选摘 + 任务记忆窗口 */
|
||||
narrative: string;
|
||||
enabled: EnabledSet;
|
||||
scope: Record<string, unknown>;
|
||||
}): Promise<AgentAction>;
|
||||
};
|
||||
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
|
||||
export type ToolGateway = {
|
||||
invoke(input: {
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
activityId: string;
|
||||
taskId: TaskId;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
output?: Record<string, unknown>;
|
||||
error?: {
|
||||
message: string;
|
||||
};
|
||||
}>;
|
||||
isWriteEffect(tool: string): Promise<boolean>;
|
||||
registerIntent(input: {
|
||||
taskId: TaskId;
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
label: string;
|
||||
}): Promise<void>;
|
||||
};
|
||||
export type CardGateway = {
|
||||
issue(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
workOrder: 'execution' | 'acceptance';
|
||||
card: Record<string, unknown>;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
export type ListenerRegistrar = {
|
||||
register(input: {
|
||||
taskId: TaskId;
|
||||
eventType: 'data' | 'time' | 'human';
|
||||
keyFilter: Record<string, string>;
|
||||
deadline: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
/** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */
|
||||
loadActivityMemory(activityId: string, limit: number): Promise<Array<{
|
||||
layer: string;
|
||||
statement: string;
|
||||
}>>;
|
||||
};
|
||||
export type TaskContext = {
|
||||
of(taskId: TaskId): Promise<{
|
||||
activityId: string;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
}>;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* module: skill-engine —— 对外类型(编码规范 §10.1;语义出处 AM 08)。
|
||||
*
|
||||
* 技能不是独立范式:没有自己的约束体系 —— 留痕、治理、权力边界与生命周期全部继承本规范。
|
||||
* **内容软**(方法正文自由,用不用由 AI 决断),**边框硬**(装配入信封 · 脚本注册留痕 ·
|
||||
* 不得扩权 · 版本过进化关口)。
|
||||
*/
|
||||
import type { Skill, TaskId } from '@contracts';
|
||||
import type { AssemblyContext, CrystallizedStep, SkillIndexEntry, SkillStamp, SkillViolation } from './internal/domain/skill-assembly.ts';
|
||||
export type { AssemblyContext, CrystallizedStep, Skill, SkillIndexEntry, SkillStamp, SkillViolation };
|
||||
export declare class SkillPowerEscalationError extends Error {
|
||||
readonly violations: readonly SkillViolation[];
|
||||
constructor(violations: readonly SkillViolation[]);
|
||||
}
|
||||
export type RunScriptInput = {
|
||||
readonly taskId: TaskId;
|
||||
readonly activityId: string;
|
||||
readonly skillId: string;
|
||||
/** 任务数据 —— 结晶片段的入参插值面 */
|
||||
readonly scope: Readonly<Record<string, unknown>>;
|
||||
readonly stepNo?: number;
|
||||
};
|
||||
export type ScriptOutcome = {
|
||||
readonly ok: boolean;
|
||||
readonly steps: ReadonlyArray<{
|
||||
tool: string;
|
||||
ok: boolean;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}>;
|
||||
readonly stamp: readonly SkillStamp[];
|
||||
};
|
||||
export type SkillEngineService = {
|
||||
/** 常驻上下文只有目录 —— 渐进披露 */
|
||||
index(activityId: string): Promise<SkillIndexEntry[]>;
|
||||
/** 按事加载正文;装配前过扩权检查 */
|
||||
load(activityId: string, skillIds: readonly string[]): Promise<Skill[]>;
|
||||
/** 结晶片段脚本 = 确定性动作链;经 ToolInvoker 调用而留痕,不得内联绕过 */
|
||||
runScript(input: RunScriptInput): Promise<ScriptOutcome>;
|
||||
/** 装配入信封 —— 供任务记忆盖戳 */
|
||||
stamp(activityId: string, skillIds: readonly string[]): Promise<SkillStamp[]>;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* module: skill-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { AssemblyContext, CrystallizedStep, RunScriptInput, ScriptOutcome, Skill, SkillEngineService, SkillIndexEntry, SkillStamp, SkillViolation, } from './contract.ts';
|
||||
export { SkillPowerEscalationError } from './contract.ts';
|
||||
export type { Clock, MemoryRecorder, ScriptToolInvoker, SkillCatalog } from './port.ts';
|
||||
export type { SkillEngineDeps } from './internal/usecase/skill-engine-service.ts';
|
||||
export { createSkillEngineService } from './internal/usecase/skill-engine-service.ts';
|
||||
export { checkCatalog, checkSkillPower, crystallizedChain, interpolate, skillIndex, stampOf, } from './internal/domain/skill-assembly.ts';
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* module: skill-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { Skill, TaskId } from '@contracts';
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
/** 技能目录 —— app 注入 `activity-registry`(L4 同层):技能是活动的资产之一 */
|
||||
export type SkillCatalog = {
|
||||
skillsOf(activityId: string): Promise<Skill[]>;
|
||||
allowlistOf(activityId: string): Promise<string[]>;
|
||||
};
|
||||
/**
|
||||
* 工具调用 —— app 注入 `toolbox`(L2)的 `ToolInvoker`。
|
||||
* 脚本引用的工具**仍过本活动白名单与调用契约** —— 载方法不载权力。
|
||||
*/
|
||||
export type ScriptToolInvoker = {
|
||||
invoke(input: {
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
activityId: string;
|
||||
taskId: TaskId;
|
||||
stepNo?: number;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
output?: Record<string, unknown>;
|
||||
error?: {
|
||||
message: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'action' | 'result';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* module: task —— 对外类型(编码规范 §10.1;语义出处 OS 04,AM 01/10)。
|
||||
*
|
||||
* 任务运行时:生命周期与**终态五名裁定**、Harness 预算执行器 / 停滞熔断、
|
||||
* 控制三语义、改派、断点续跑。范式(loop / flow)在此汇流 —— 对流程层与交互层不可区分。
|
||||
*/
|
||||
import type { BudgetCaps, ControlSemantic, Paradigm, StaffId, TaskId, TaskRuntimeState, TerminalState } from '@contracts';
|
||||
import type { BudgetBreach, Consumption, HarnessVerdict } from './internal/domain/harness.ts';
|
||||
import type { ControlOutcome, ResumeReadiness, TaskSnapshot, TerminalCause } from './internal/domain/lifecycle.ts';
|
||||
export type { BudgetBreach, BudgetCaps, Consumption, ControlOutcome, ControlSemantic, HarnessVerdict, Paradigm, ResumeReadiness, TaskRuntimeState, TaskSnapshot, TerminalCause, TerminalState, };
|
||||
export declare class TaskNotFoundError extends Error {
|
||||
readonly taskId: TaskId;
|
||||
constructor(taskId: TaskId);
|
||||
}
|
||||
export declare class TaskAlreadyTerminalError extends Error {
|
||||
readonly taskId: TaskId;
|
||||
readonly terminal: TerminalState;
|
||||
constructor(taskId: TaskId, terminal: TerminalState);
|
||||
}
|
||||
/** 一单任务 —— 活动的运行时实例;**恰有一名负责人** */
|
||||
export type Task = {
|
||||
readonly id: TaskId;
|
||||
readonly activityId: string;
|
||||
readonly paradigm: Paradigm;
|
||||
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
readonly ownerId: StaffId;
|
||||
readonly title: string;
|
||||
readonly runtime: TaskRuntimeState;
|
||||
readonly terminal?: TerminalState;
|
||||
readonly reason?: string;
|
||||
/** 装载信封:包版本 + 各资产生效版 —— 行为可归因 */
|
||||
readonly envelope: Readonly<Record<string, unknown>>;
|
||||
readonly caps: BudgetCaps;
|
||||
readonly used: Consumption;
|
||||
readonly startedAt: string;
|
||||
readonly finishedAt?: string;
|
||||
};
|
||||
export type StartTaskInput = {
|
||||
readonly activityId: string;
|
||||
readonly ownerId: StaffId;
|
||||
readonly title: string;
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
|
||||
readonly data: Record<string, unknown>;
|
||||
readonly message?: string;
|
||||
};
|
||||
export type TickOutcome = {
|
||||
readonly task: Task;
|
||||
readonly verdict: HarnessVerdict;
|
||||
readonly progressed: boolean;
|
||||
};
|
||||
export type TaskService = {
|
||||
/** 起单:装载 → 校验输入 → 建任务记忆 → 首个回合 */
|
||||
start(input: StartTaskInput): Promise<Task>;
|
||||
get(taskId: TaskId): Promise<Task>;
|
||||
listByOwner(ownerId: StaffId, openOnly?: boolean): Promise<Task[]>;
|
||||
/** org 停用前置检查要问的「在途任务」 */
|
||||
openTasksOf(ownerId: StaffId): Promise<Array<{
|
||||
taskId: string;
|
||||
title: string;
|
||||
}>>;
|
||||
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
|
||||
tick(taskId: TaskId): Promise<TickOutcome>;
|
||||
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
|
||||
deliver(taskId: TaskId, event: {
|
||||
kind: 'data' | 'time' | 'human' | 'control';
|
||||
ref: string;
|
||||
payload: Record<string, unknown>;
|
||||
}): Promise<TickOutcome>;
|
||||
/** 控制三语义:暂停 / 叫停 / 终止 */
|
||||
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
||||
resume(taskId: TaskId, by: StaffId): Promise<Task>;
|
||||
/** 改派:在途任务的负责人变更,**留痕并通知双方** */
|
||||
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
|
||||
/** 终态裁定 —— 完成由框架裁定,终态由系统命名 */
|
||||
finish(taskId: TaskId, cause: TerminalCause): Promise<Task>;
|
||||
/** 断点续跑的三份持久状态是否齐备 */
|
||||
resumeReadiness(taskId: TaskId): Promise<ResumeReadiness>;
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* module: task —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { BudgetBreach, BudgetCaps, Consumption, ControlOutcome, ControlSemantic, HarnessVerdict, Paradigm, ResumeReadiness, StartTaskInput, Task, TaskRuntimeState, TaskService, TaskSnapshot, TerminalCause, TerminalState, TickOutcome, } from './contract.ts';
|
||||
export { TaskAlreadyTerminalError, TaskNotFoundError } from './contract.ts';
|
||||
export type { ActivityLoader, Clock, IdGenerator, InteractionPhaseSetter, ListenerProbe, MemoryRecorder, ParadigmEngine, PersistenceProbe, ReassignNotifier, SchemaValidator, TaskEventPublisher, TaskStore, } from './port.ts';
|
||||
export type { TaskServiceDeps } from './internal/usecase/task-service.ts';
|
||||
export { createTaskService } from './internal/usecase/task-service.ts';
|
||||
export { budgetBreaches, durationMs, isStalled, judge, terminalOf, ZERO_CONSUMPTION, } from './internal/domain/harness.ts';
|
||||
export { applyControl, decideTerminal, isPassiveOpen, isSuccess, isTerminalControl, landingBlocked, RESUME_SOURCES, rejectionKeepsRunning, resumeReadiness, triggersFailureDisposition, } from './internal/domain/lifecycle.ts';
|
||||
export { createTaskStore } from './internal/adapter/task-store.ts';
|
||||
export { taskTable } from './internal/adapter/task-schema.ts';
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* module: task —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { BudgetCaps, Paradigm, StaffId, TaskId, TerminalState } from '@contracts';
|
||||
import type { Consumption } from './internal/domain/harness.ts';
|
||||
import type { Task } from './contract.ts';
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
export type IdGenerator = {
|
||||
next(): string;
|
||||
};
|
||||
export type TaskStore = {
|
||||
insert(task: Task): Promise<void>;
|
||||
get(taskId: TaskId): Promise<Task | undefined>;
|
||||
listByOwner(ownerId: StaffId, openOnly: boolean): Promise<Task[]>;
|
||||
update(taskId: TaskId, patch: Partial<Task>): Promise<Task>;
|
||||
addConsumption(taskId: TaskId, delta: Partial<Consumption>): Promise<Consumption>;
|
||||
lastProgressAt(taskId: TaskId): Promise<Date | undefined>;
|
||||
markProgress(taskId: TaskId, at: Date): Promise<void>;
|
||||
};
|
||||
/** 活动装载 —— app 注入 `activity-registry`(L4) */
|
||||
export type ActivityLoader = {
|
||||
load(activityId: string): Promise<{
|
||||
paradigm: Paradigm;
|
||||
caps: BudgetCaps;
|
||||
envelope: Record<string, unknown>;
|
||||
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
|
||||
inputSchema: Record<string, unknown>;
|
||||
businessModel: unknown;
|
||||
stallAfter: string;
|
||||
}>;
|
||||
};
|
||||
export type SchemaValidator = {
|
||||
validate(schema: Record<string, unknown>, value: unknown): {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
};
|
||||
/** 范式引擎 —— loop / flow 在此汇流;对上层不可区分(§9 双层边界) */
|
||||
export type ParadigmEngine = {
|
||||
load(taskId: TaskId, model: unknown): Promise<void>;
|
||||
advance(taskId: TaskId): Promise<{
|
||||
status: 'running' | 'awaiting' | 'submitted' | 'finished' | 'blocked';
|
||||
reason?: string;
|
||||
}>;
|
||||
onEvent(taskId: TaskId, event: {
|
||||
kind: 'data' | 'time' | 'human' | 'control';
|
||||
ref: string;
|
||||
payload: Record<string, unknown>;
|
||||
}): Promise<{
|
||||
status: 'running' | 'awaiting' | 'submitted' | 'finished' | 'blocked';
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'engine-transition' | 'terminal' | 'observation';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
seal(taskId: TaskId): Promise<void>;
|
||||
/** 终态封卷触发蒸馏钩子 —— 产出活动记忆候选条目 */
|
||||
distill(input: {
|
||||
taskId: TaskId;
|
||||
activityId: string;
|
||||
trigger: 'terminal';
|
||||
prompt: string;
|
||||
}): Promise<unknown>;
|
||||
hasHistory(taskId: TaskId): Promise<boolean>;
|
||||
};
|
||||
/** 监听在等?—— app 注入 `event`(L1):监听中是健康态,与停滞分别对待 */
|
||||
export type ListenerProbe = {
|
||||
hasArmedListener(taskId: TaskId): Promise<boolean>;
|
||||
};
|
||||
/** 草稿层与运行时状态是否在 —— 断点续跑的三份持久状态 */
|
||||
export type PersistenceProbe = {
|
||||
hasDrafts(taskId: TaskId): Promise<boolean>;
|
||||
hasRuntimeState(taskId: TaskId): Promise<boolean>;
|
||||
};
|
||||
/** 控制事件与终态事件 —— app 注入 `event`(L1) */
|
||||
export type TaskEventPublisher = {
|
||||
publishControl(taskId: TaskId, semantic: string, by: StaffId, reason?: string): Promise<void>;
|
||||
publishTerminal(taskId: TaskId, terminal: TerminalState, reason: string): Promise<void>;
|
||||
};
|
||||
/** 改派通知 —— app 注入 `message`(L2):改派留痕并**通知双方** */
|
||||
export type ReassignNotifier = {
|
||||
notify(input: {
|
||||
from: StaffId;
|
||||
to: StaffId;
|
||||
taskId: TaskId;
|
||||
title: string;
|
||||
by: StaffId;
|
||||
}): Promise<void>;
|
||||
};
|
||||
/** 交互态 —— app 注入 `interaction`(L3):控制权按态转交 */
|
||||
export type InteractionPhaseSetter = {
|
||||
setPhase(taskId: TaskId, phase: 'activating' | 'active' | 'awaiting-human' | 'awaiting-event' | 'transitioning' | 'terminal'): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
# module: flow-engine
|
||||
|
||||
职责: flow 范式:步序声明的装载与静态检查、四种步型的执行、条件跳步、
|
||||
重试与声明给定的兜底、异步步的监听登记与事件续跑、步序位置的持久
|
||||
不负责: 任务生命周期与终态裁定(见 task)、放行与落地(见 release)、
|
||||
复杂路由(**不得在 flow 内表达** —— 上升为流程层网关或改用 loop)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`FlowEngineService` `FlowPosition` `StepRuntime` `AdvanceOutcome` `FailureAction` `NextAction`
|
||||
- 错误:`FlowDeclarationInvalidError`
|
||||
- 端口类型:`FlowStateStore` `PredicateEvaluator` `TaskDataGateway` `ToolStepInvoker`
|
||||
`LlmStepGateway` `CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
|
||||
- 用例工厂:`createFlowRunner(deps): FlowEngineService`
|
||||
- 纯函数:`nextAction` `markStep` `failureAction` `checkDeclaration` `initialPosition`
|
||||
`lastStepIsAcceptance` `parallelGroupComplete`
|
||||
- adapter:`createFlowStateStore`;Drizzle 表:`flowStateTable`
|
||||
|
||||
层级: L5(编码规范 §4)
|
||||
依赖: @contracts;@shared/db;skill-engine(L4,步引用结晶片段);
|
||||
port 注入:ledger / toolbox / llm / human-tool / event / memory
|
||||
被依赖: task
|
||||
|
||||
不变量(AM 05 / 12.3):
|
||||
- **零临场调度**:下一步由 `nextAction` 从声明算出,LLM 只以「被调用的工具」出现
|
||||
- **条件必谓词**:跳步条件经 `PredicateEvaluator`(@sfeel)确定性求值,零 LLM
|
||||
- **失败路径声明给定**:`failureAction` 只从声明读重试次数与兜底,不临场发明
|
||||
- **异步步 = 监听**:人类工具步登记人事件监听,**时限必配**,无死等
|
||||
- **出参落草稿层**:步的产出是草稿态,放行前不落事实
|
||||
- **写类工具登记为动作意向**:flow 里同样不即时触达世界(除契约显式预授权)
|
||||
- **谱全程可静态检查**:`checkDeclaration` 不过即 `FlowDeclarationInvalidError`,不得上架
|
||||
- 步序位置持久于 `flowStateTable` —— 断点续跑的三份持久状态之一
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* module: flow-engine —— 对外类型(编码规范 §10.1;语义出处 AM 05)。
|
||||
*
|
||||
* flow 范式:步序声明运行器(四种步型、重试与兜底、事件即步条件)。
|
||||
* **零临场调度** —— 运行中不得由 LLM 决定步序、跳步或兜底。
|
||||
*/
|
||||
import type { FlowModel, FlowStep, TaskId } from '@contracts';
|
||||
import type { FailureAction, FlowIssue, FlowPosition, NextAction, StepRuntime, StepState } from './internal/domain/step-sequence.ts';
|
||||
|
||||
export type { FailureAction, FlowIssue, FlowModel, FlowPosition, FlowStep, NextAction, StepRuntime, StepState };
|
||||
|
||||
export class FlowDeclarationInvalidError extends Error {
|
||||
constructor(readonly issues: readonly FlowIssue[]) {
|
||||
super(`步序声明不完整,不得上架:${issues.map((i) => `${i.step}:${i.message}`).join('; ')}`);
|
||||
this.name = 'FlowDeclarationInvalidError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次推进的结果 —— 由 task 驱动,不自转 */
|
||||
export type AdvanceOutcome = {
|
||||
readonly position: FlowPosition;
|
||||
readonly status: 'running' | 'awaiting' | 'finished' | 'blocked';
|
||||
/** awaiting 时:在等什么 */
|
||||
readonly waitingFor?: { readonly kind: 'human' | 'event' | 'time'; readonly ref: string; readonly deadline: string };
|
||||
/** finished 时:末步验收工单的裁决决定终态 */
|
||||
readonly released?: boolean;
|
||||
readonly blockedReason?: string;
|
||||
};
|
||||
|
||||
export type FlowEngineService = {
|
||||
/** 装载步序声明并校验 —— 谱必须全程可静态检查 */
|
||||
load(taskId: TaskId, model: FlowModel): Promise<FlowPosition>;
|
||||
position(taskId: TaskId): Promise<FlowPosition | undefined>;
|
||||
/** 推进一步(或若干确定步);异步步进入 awaiting 并登记监听 */
|
||||
advance(taskId: TaskId): Promise<AdvanceOutcome>;
|
||||
/** 事件即步的完成条件:人事件 / 数据事件 / 时间事件到达即续跑 */
|
||||
resume(taskId: TaskId, event: { kind: 'human' | 'data' | 'time'; ref: string; payload: Record<string, unknown> }): Promise<AdvanceOutcome>;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* module: flow-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
AdvanceOutcome,
|
||||
FailureAction,
|
||||
FlowEngineService,
|
||||
FlowIssue,
|
||||
FlowModel,
|
||||
FlowPosition,
|
||||
FlowStep,
|
||||
NextAction,
|
||||
StepRuntime,
|
||||
StepState,
|
||||
} from './contract.ts';
|
||||
export { FlowDeclarationInvalidError } from './contract.ts';
|
||||
|
||||
export type {
|
||||
CardGateway,
|
||||
Clock,
|
||||
FlowStateStore,
|
||||
IdGenerator,
|
||||
ListenerRegistrar,
|
||||
LlmStepGateway,
|
||||
MemoryRecorder,
|
||||
PredicateEvaluator,
|
||||
TaskContext,
|
||||
TaskDataGateway,
|
||||
ToolStepInvoker,
|
||||
} from './port.ts';
|
||||
|
||||
export type { FlowRunnerDeps } from './internal/usecase/flow-runner.ts';
|
||||
export { createFlowRunner } from './internal/usecase/flow-runner.ts';
|
||||
|
||||
export {
|
||||
checkDeclaration,
|
||||
failureAction,
|
||||
initialPosition,
|
||||
lastStepIsAcceptance,
|
||||
markStep,
|
||||
nextAction,
|
||||
parallelGroupComplete,
|
||||
STEP_STATES,
|
||||
} from './internal/domain/step-sequence.ts';
|
||||
|
||||
export { createFlowStateStore } from './internal/adapter/flow-state-store.ts';
|
||||
export { flowStateTable } from './internal/adapter/flow-state-schema.ts';
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* flow-engine 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||
* 运行时实例状态 —— 断点续跑的三份持久状态之一(OS 09)。
|
||||
*/
|
||||
import { jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const flowStateTable = sys.table('flow_state', {
|
||||
taskId: text('task_id').primaryKey(),
|
||||
model: jsonb('model').notNull(),
|
||||
/** 步序位置 */
|
||||
position: jsonb('position').notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* flow-engine 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { FlowModel, TaskId } from '@contracts';
|
||||
import { flowModelSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { FlowStateStore } from '../../port.ts';
|
||||
import type { FlowPosition } from '../domain/step-sequence.ts';
|
||||
import { flowStateTable } from './flow-state-schema.ts';
|
||||
|
||||
export function createFlowStateStore(db: Db): FlowStateStore {
|
||||
return {
|
||||
async save(taskId: TaskId, model: FlowModel, position: FlowPosition) {
|
||||
await db
|
||||
.insert(flowStateTable)
|
||||
.values({ taskId, model, position, updatedAt: new Date() })
|
||||
.onConflictDoUpdate({
|
||||
target: flowStateTable.taskId,
|
||||
set: { model, position, updatedAt: new Date() },
|
||||
});
|
||||
},
|
||||
async load(taskId: TaskId) {
|
||||
const rows = await db.select().from(flowStateTable).where(eq(flowStateTable.taskId, taskId)).limit(1);
|
||||
const row = rows[0];
|
||||
if (row === undefined) return undefined;
|
||||
return { model: flowModelSchema.parse(row.model), position: row.position as FlowPosition };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* flow 步序推进 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 05)。
|
||||
*
|
||||
* 「flow 是活动内的**确定性范式**:业务模型 = 步序声明 —— 一条谱;
|
||||
* 引擎照谱执行,**零临场调度**,调度权归系统。
|
||||
* 差异只有一处:下一步由**声明给定**,不由 AI 决断。
|
||||
* LLM 在 flow 里可以出现,但只以『被调用的工具』出现,不是调度者。」
|
||||
*/
|
||||
import type { FlowStep } from '@contracts';
|
||||
|
||||
export const STEP_STATES = ['pending', 'running', 'awaiting', 'done', 'skipped', 'failed'] as const;
|
||||
export type StepState = (typeof STEP_STATES)[number];
|
||||
|
||||
export type StepRuntime = {
|
||||
readonly name: string;
|
||||
readonly state: StepState;
|
||||
readonly attempt: number;
|
||||
readonly output?: Record<string, unknown>;
|
||||
readonly error?: string;
|
||||
};
|
||||
|
||||
export type FlowPosition = {
|
||||
/** 步序位置 —— 三份持久状态之一(OS 09 断点续跑) */
|
||||
readonly index: number;
|
||||
readonly steps: readonly StepRuntime[];
|
||||
};
|
||||
|
||||
export function initialPosition(steps: readonly FlowStep[]): FlowPosition {
|
||||
return { index: 0, steps: steps.map((s) => ({ name: s.name, state: 'pending', attempt: 0 })) };
|
||||
}
|
||||
|
||||
/** 条件跳步:条件必须是任务数据上的**确定性谓词**,由引擎求值,零 LLM(AM 5.2) */
|
||||
export type PredicateEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||
|
||||
export type NextAction =
|
||||
| { readonly kind: 'run'; readonly step: FlowStep; readonly index: number }
|
||||
| { readonly kind: 'skip'; readonly step: FlowStep; readonly index: number; readonly reason: string }
|
||||
| { readonly kind: 'await'; readonly step: FlowStep; readonly index: number }
|
||||
| { readonly kind: 'finished' };
|
||||
|
||||
/**
|
||||
* 下一步由声明给定。异步步(人类工具步 / 等事件的步)进入 `await` —— 完成条件 = 监听。
|
||||
*/
|
||||
export function nextAction(
|
||||
declaration: readonly FlowStep[],
|
||||
position: FlowPosition,
|
||||
scope: Readonly<Record<string, unknown>>,
|
||||
evaluate: PredicateEvaluator,
|
||||
): NextAction {
|
||||
for (let i = position.index; i < declaration.length; i += 1) {
|
||||
const step = declaration[i]!;
|
||||
const runtime = position.steps[i];
|
||||
if (runtime?.state === 'done' || runtime?.state === 'skipped') continue;
|
||||
if (runtime?.state === 'awaiting') return { kind: 'await', step, index: i };
|
||||
|
||||
if (step.when !== undefined && !evaluate(step.when, scope)) {
|
||||
return { kind: 'skip', step, index: i, reason: `条件不成立:${step.when}` };
|
||||
}
|
||||
return { kind: 'run', step, index: i };
|
||||
}
|
||||
return { kind: 'finished' };
|
||||
}
|
||||
|
||||
export function markStep(position: FlowPosition, index: number, patch: Partial<StepRuntime>): FlowPosition {
|
||||
const steps = position.steps.map((s, i) => (i === index ? { ...s, ...patch } : s));
|
||||
const advanced = patch.state === 'done' || patch.state === 'skipped';
|
||||
return { index: advanced ? index + 1 : index, steps };
|
||||
}
|
||||
|
||||
/* ── 失败与缺口:声明给定,不临场(AM 5.3) ────────────────────────── */
|
||||
|
||||
export type FailureAction =
|
||||
| { readonly kind: 'retry'; readonly attempt: number; readonly backoff: string }
|
||||
/** 转人兜底:改发人类工具卡,卡内容随声明 */
|
||||
| { readonly kind: 'human'; readonly workOrder: 'execution' | 'acceptance' }
|
||||
/** 受阻待人:挂起,携阻因 */
|
||||
| { readonly kind: 'blocked' };
|
||||
|
||||
/**
|
||||
* 「每步声明重试次数与间隔;重试仍败,按声明走下一步。
|
||||
* **flow 内不得出现『由 AI 决定怎么办』的失败处理** —— 需要临场,即说明该活动不该是 flow。」
|
||||
*/
|
||||
export function failureAction(step: FlowStep, attempt: number): FailureAction {
|
||||
if (attempt < step.failure.retry.times) {
|
||||
return { kind: 'retry', attempt: attempt + 1, backoff: step.failure.retry.backoff };
|
||||
}
|
||||
return step.failure.fallback.kind === 'human'
|
||||
? { kind: 'human', workOrder: step.failure.fallback.workOrder }
|
||||
: { kind: 'blocked' };
|
||||
}
|
||||
|
||||
/* ── 静态检查:谱必须全程可静态检查(AM 12.3) ────────────────────── */
|
||||
|
||||
export type FlowIssue = { readonly step: string; readonly message: string };
|
||||
|
||||
export function checkDeclaration(steps: readonly FlowStep[]): FlowIssue[] {
|
||||
const out: FlowIssue[] = [];
|
||||
const names = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if (names.has(step.name)) out.push({ step: step.name, message: '步名重复' });
|
||||
names.add(step.name);
|
||||
// 步序声明完整:步型 / 入参映射 / 出参写入 / 完成条件 / 失败策略齐备
|
||||
if (step.failure.retry.times < 0) out.push({ step: step.name, message: '重试次数不得为负' });
|
||||
if (step.kind === 'human' && step.deadline.trim() === '') {
|
||||
out.push({ step: step.name, message: '人类工具步必配时限 —— 无死等' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 终态映射(AM 5.3):放行完成 = 末步验收工单「放行通过」 */
|
||||
export function lastStepIsAcceptance(steps: readonly FlowStep[]): boolean {
|
||||
const last = steps.at(-1);
|
||||
return last !== undefined && last.kind === 'human' && last.workOrder === 'acceptance';
|
||||
}
|
||||
|
||||
/** 并行组:组内步无依赖、**全完成再继续** */
|
||||
export function parallelGroupComplete(position: FlowPosition, members: readonly string[]): boolean {
|
||||
return members.every((name) => {
|
||||
const runtime = position.steps.find((s) => s.name === name);
|
||||
return runtime?.state === 'done' || runtime?.state === 'skipped';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* flow 运行器 —— 照谱执行,零临场调度(编码规范 §5.3;语义出处 AM 05)。
|
||||
*/
|
||||
import type { FlowStep, TaskId } from '@contracts';
|
||||
import type { AdvanceOutcome, FlowEngineService } from '../../contract.ts';
|
||||
import { FlowDeclarationInvalidError } from '../../contract.ts';
|
||||
import type {
|
||||
CardGateway,
|
||||
Clock,
|
||||
FlowStateStore,
|
||||
ListenerRegistrar,
|
||||
LlmStepGateway,
|
||||
MemoryRecorder,
|
||||
PredicateEvaluator,
|
||||
TaskContext,
|
||||
TaskDataGateway,
|
||||
ToolStepInvoker,
|
||||
} from '../../port.ts';
|
||||
import type { FlowPosition } from '../domain/step-sequence.ts';
|
||||
import {
|
||||
checkDeclaration,
|
||||
failureAction,
|
||||
initialPosition,
|
||||
markStep,
|
||||
nextAction,
|
||||
} from '../domain/step-sequence.ts';
|
||||
|
||||
export type FlowRunnerDeps = {
|
||||
readonly state: FlowStateStore;
|
||||
readonly evaluate: PredicateEvaluator;
|
||||
readonly data: TaskDataGateway;
|
||||
readonly tools: ToolStepInvoker;
|
||||
readonly llm: LlmStepGateway;
|
||||
readonly cards: CardGateway;
|
||||
readonly listeners: ListenerRegistrar;
|
||||
readonly memory: MemoryRecorder;
|
||||
readonly tasks: TaskContext;
|
||||
readonly clock: Clock;
|
||||
};
|
||||
|
||||
export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
|
||||
async function loaded(taskId: TaskId) {
|
||||
const found = await deps.state.load(taskId);
|
||||
if (found === undefined) throw new Error(`flow 未装载:${taskId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 出参写入落**草稿层**(草稿态);放行前不落事实 */
|
||||
async function writeOutput(taskId: TaskId, step: FlowStep, output: Record<string, unknown>): Promise<void> {
|
||||
if (step.output === undefined) return;
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [field, path] of Object.entries(step.output.fields)) {
|
||||
patch[field] = output[path] ?? output[field];
|
||||
}
|
||||
await deps.data.draftWrite({
|
||||
taskId,
|
||||
domain: step.output.domain,
|
||||
object: step.output.object,
|
||||
key: {},
|
||||
patch,
|
||||
});
|
||||
}
|
||||
|
||||
function inputOf(step: FlowStep, scope: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, path] of Object.entries(step.input)) {
|
||||
out[key] = path.split('.').reduce<unknown>((acc, seg) => {
|
||||
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
|
||||
return (acc as Record<string, unknown>)[seg];
|
||||
}, scope);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runOne(
|
||||
taskId: TaskId,
|
||||
step: FlowStep,
|
||||
index: number,
|
||||
position: FlowPosition,
|
||||
): Promise<AdvanceOutcome> {
|
||||
const context = await deps.tasks.of(taskId);
|
||||
const scope = await deps.data.view(taskId);
|
||||
const payload = inputOf(step, scope);
|
||||
const attempt = (position.steps[index]?.attempt ?? 0) + 1;
|
||||
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'action',
|
||||
title: `步 ${index + 1} · ${step.label}(${step.kind})`,
|
||||
detail: { step: step.name, attempt, input: payload },
|
||||
});
|
||||
|
||||
switch (step.kind) {
|
||||
case 'tool':
|
||||
case 'pure-tool': {
|
||||
// 写类工具在 flow 里同样登记为动作意向 —— 触达世界的动作集中在放行时落地
|
||||
if (step.kind === 'tool' && (await deps.tools.isWriteEffect(step.tool))) {
|
||||
await deps.tools.registerIntent({ taskId, tool: step.tool, input: payload, label: step.label });
|
||||
const next = markStep(position, index, { state: 'done', attempt });
|
||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||
return { position: next, status: 'running' };
|
||||
}
|
||||
const result = await deps.tools.invoke({
|
||||
tool: step.tool,
|
||||
input: payload,
|
||||
activityId: context.activityId,
|
||||
taskId,
|
||||
stepNo: index,
|
||||
});
|
||||
if (!result.ok) return handleFailure(taskId, step, index, position, attempt, result.error?.message ?? '工具失败');
|
||||
if (result.output !== undefined) await writeOutput(taskId, step, result.output);
|
||||
const next = markStep(position, index, { state: 'done', attempt, ...(result.output === undefined ? {} : { output: result.output }) });
|
||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||
return { position: next, status: 'running' };
|
||||
}
|
||||
|
||||
case 'llm': {
|
||||
// LLM 只以「被调用的工具」出现,不是调度者;模板与出参 schema 随声明版本冻结
|
||||
const prompt = step.template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => String(payload[path] ?? ''));
|
||||
const result = await deps.llm.generate({
|
||||
model: step.model,
|
||||
prompt,
|
||||
outputSchema: step.outputSchema,
|
||||
taskId,
|
||||
stepNo: index,
|
||||
});
|
||||
if (!result.ok) return handleFailure(taskId, step, index, position, attempt, result.error ?? 'LLM 步失败');
|
||||
const output = (result.object ?? {}) as Record<string, unknown>;
|
||||
await writeOutput(taskId, step, output);
|
||||
const next = markStep(position, index, { state: 'done', attempt, output });
|
||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||
return { position: next, status: 'running' };
|
||||
}
|
||||
|
||||
case 'human': {
|
||||
// 异步步:完成条件 = 人事件;时限必配
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
humanCallCap: context.humanCallCap,
|
||||
workOrder: step.workOrder,
|
||||
card: step.card,
|
||||
});
|
||||
await deps.listeners.register({
|
||||
taskId,
|
||||
eventType: 'human',
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: step.deadline,
|
||||
});
|
||||
const next = markStep(position, index, { state: 'awaiting', attempt });
|
||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||
return {
|
||||
position: next,
|
||||
status: 'awaiting',
|
||||
waitingFor: { kind: 'human', ref: card.id, deadline: step.deadline },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 失败路径**声明给定**,不临场发明 */
|
||||
async function handleFailure(
|
||||
taskId: TaskId,
|
||||
step: FlowStep,
|
||||
index: number,
|
||||
position: FlowPosition,
|
||||
attempt: number,
|
||||
error: string,
|
||||
): Promise<AdvanceOutcome> {
|
||||
const action = failureAction(step, attempt);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `步 ${index + 1} 失败:${error} → ${action.kind}`,
|
||||
detail: { step: step.name, attempt, action },
|
||||
});
|
||||
|
||||
if (action.kind === 'retry') {
|
||||
const next = markStep(position, index, { state: 'pending', attempt, error });
|
||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||
return { position: next, status: 'running' };
|
||||
}
|
||||
|
||||
if (action.kind === 'human') {
|
||||
const context = await deps.tasks.of(taskId);
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
humanCallCap: context.humanCallCap,
|
||||
workOrder: action.workOrder,
|
||||
card: { title: `步「${step.label}」重试仍败,转人兜底`, reason: error },
|
||||
});
|
||||
await deps.listeners.register({
|
||||
taskId,
|
||||
eventType: 'human',
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
const next = markStep(position, index, { state: 'awaiting', attempt, error });
|
||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||
return { position: next, status: 'awaiting', waitingFor: { kind: 'human', ref: card.id, deadline: 'P3D' } };
|
||||
}
|
||||
|
||||
const next = markStep(position, index, { state: 'failed', attempt, error });
|
||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||
return { position: next, status: 'blocked', blockedReason: error };
|
||||
}
|
||||
|
||||
return {
|
||||
async load(taskId, model) {
|
||||
const issues = checkDeclaration(model.steps);
|
||||
if (issues.length > 0) throw new FlowDeclarationInvalidError(issues);
|
||||
const position = initialPosition(model.steps);
|
||||
await deps.state.save(taskId, model, position);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `装载步序声明:${model.steps.length} 步`,
|
||||
});
|
||||
return position;
|
||||
},
|
||||
|
||||
async position(taskId) {
|
||||
return (await deps.state.load(taskId))?.position;
|
||||
},
|
||||
|
||||
async advance(taskId) {
|
||||
const { model, position } = await loaded(taskId);
|
||||
const scope = await deps.data.view(taskId);
|
||||
const action = nextAction(model.steps, position, scope, deps.evaluate);
|
||||
|
||||
switch (action.kind) {
|
||||
case 'finished': {
|
||||
// 末步是验收工单时,终态由其裁决给出;此处只报「谱走完」
|
||||
return { position, status: 'finished', released: false };
|
||||
}
|
||||
case 'skip': {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `跳步:${action.step.label} —— ${action.reason}`,
|
||||
});
|
||||
const next = markStep(position, action.index, { state: 'skipped' });
|
||||
await deps.state.save(taskId, model, next);
|
||||
return { position: next, status: 'running' };
|
||||
}
|
||||
case 'await':
|
||||
return {
|
||||
position,
|
||||
status: 'awaiting',
|
||||
waitingFor: { kind: 'event', ref: action.step.name, deadline: 'P3D' },
|
||||
};
|
||||
case 'run':
|
||||
return runOne(taskId, action.step, action.index, position);
|
||||
}
|
||||
},
|
||||
|
||||
/** 事件即步的完成条件 —— 到达即续跑,挂起期间事件不丢 */
|
||||
async resume(taskId, event) {
|
||||
const { model, position } = await loaded(taskId);
|
||||
const index = position.steps.findIndex((s) => s.state === 'awaiting');
|
||||
if (index < 0) return { position, status: 'running' };
|
||||
|
||||
const step = model.steps[index];
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `事件到达,步 ${index + 1} 完成条件满足(${event.kind})`,
|
||||
detail: { ref: event.ref },
|
||||
});
|
||||
if (step !== undefined && event.kind === 'human') {
|
||||
await writeOutput(taskId, step, event.payload);
|
||||
}
|
||||
const next = markStep(position, index, { state: 'done' });
|
||||
await deps.state.save(taskId, model, next);
|
||||
return { position: next, status: 'running' };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/flow-engine",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* module: flow-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { FlowModel, StaffId, TaskId } from '@contracts';
|
||||
import type { FlowPosition } from './internal/domain/step-sequence.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
/** 运行时实例状态 —— 三份持久状态之一(OS 09) */
|
||||
export type FlowStateStore = {
|
||||
save(taskId: TaskId, model: FlowModel, position: FlowPosition): Promise<void>;
|
||||
load(taskId: TaskId): Promise<{ model: FlowModel; position: FlowPosition } | undefined>;
|
||||
};
|
||||
|
||||
/** 条件谓词求值 —— app 注入 `@sfeel`;零 LLM */
|
||||
export type PredicateEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||
|
||||
/** 任务数据 —— app 注入 `ledger`(L1):读任务视图 = 正式版 + 本任务草稿层 */
|
||||
export type TaskDataGateway = {
|
||||
view(taskId: TaskId): Promise<Record<string, unknown>>;
|
||||
draftWrite(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/** 工具步与纯工具步 —— app 注入 `toolbox`(L2) */
|
||||
export type ToolStepInvoker = {
|
||||
invoke(input: {
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
activityId: string;
|
||||
taskId: TaskId;
|
||||
stepNo: number;
|
||||
}): Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { message: string; retryable: boolean } }>;
|
||||
/** 写类工具在 flow 里也走「登记动作意向」,除非契约显式预授权 */
|
||||
registerIntent(input: {
|
||||
taskId: TaskId;
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
label: string;
|
||||
}): Promise<void>;
|
||||
isWriteEffect(tool: string): Promise<boolean>;
|
||||
};
|
||||
|
||||
/** LLM 步 —— app 注入 `llm`(L1)的 ModelGateway;模板与出参 schema 随声明版本冻结 */
|
||||
export type LlmStepGateway = {
|
||||
generate(input: {
|
||||
model: string;
|
||||
prompt: string;
|
||||
outputSchema: Record<string, unknown>;
|
||||
taskId: TaskId;
|
||||
stepNo: number;
|
||||
}): Promise<{ ok: boolean; object?: unknown; error?: string }>;
|
||||
};
|
||||
|
||||
/** 人类工具步 —— app 注入 `human-tool`(L3) */
|
||||
export type CardGateway = {
|
||||
issue(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
workOrder: 'execution' | 'acceptance';
|
||||
card: Record<string, unknown>;
|
||||
}): Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
/** 监听登记 —— app 注入 `event`(L1):异步步的完成条件 = 监听,时限必配 */
|
||||
export type ListenerRegistrar = {
|
||||
register(input: {
|
||||
taskId: TaskId;
|
||||
eventType: 'data' | 'time' | 'human';
|
||||
keyFilter: Record<string, string>;
|
||||
deadline: string;
|
||||
}): Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'action' | 'result' | 'engine-transition';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/** 任务上下文 —— 活动 ID、负责人、预算 */
|
||||
export type TaskContext = {
|
||||
of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
# module: loop-engine
|
||||
|
||||
职责: loop 范式:CMMN 子集解析、标准生命周期推进、前提求值与 Enabled 集发布、
|
||||
DMN 确定性求值与命中行留痕、双向驱动(前推 × 后拉 × 交集导航)、
|
||||
Agent 回路的回合驱动(三型行动)
|
||||
不负责: Agent 的推理本身(经 `AgentPort`,Mastra 在 app/adapter)、放行与落地(见 release)、
|
||||
终态命名(见 task)、活动记忆的存取(经 `MemoryRecorder`)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`LoopEngineService` `CaseModel` `CaseRuntime` `EnabledSet` `Navigation` `AgentAction`
|
||||
`TurnOutcome` `DecisionResult` `DecisionTrace` `Gap` `ItemState` `CaseState`
|
||||
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
|
||||
`AutomaticOnWorldTouchingError`
|
||||
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway` `AgentPort` `ToolGateway`
|
||||
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
|
||||
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
|
||||
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
|
||||
`transitionItem` `occur` `reenter` `autoCompleteReady` `canTransitionItem`
|
||||
`mayCompleteCase` `mayTerminateCase` `evaluateTable` `evaluationOrder` `deduceGaps`
|
||||
- adapter:`createLoopStateStore`;Drizzle 表:`loopStateTable`
|
||||
|
||||
层级: L5(编码规范 §4)
|
||||
依赖: @contracts;@shared/db;skill-engine(L4);port 注入:@sfeel / ledger / toolbox /
|
||||
human-tool / event / memory
|
||||
被依赖: task
|
||||
|
||||
不变量(AM 04 / 12.2):
|
||||
- **CMMN 子集封顶**:`parseCmmn` 遇到表外构件**直接报错**,不静默忽略 ——
|
||||
扩构件 = 改编码规范 §4 那一行 + 建 ADR
|
||||
- **语义忠实**:迁移拓扑写死在 `ITEM_TRANSITIONS` / `CASE_TRANSITIONS`,
|
||||
引擎不得私改;活动与模型不得发明状态
|
||||
- **Enabled ≠ Active**:引擎推进到 Enabled 为止;进入 Active 永远出自 Agent 的 manualStart 并留痕
|
||||
- **引擎零 LLM**:前提求值与 DMN 都走 `@sfeel`;语义性前提标「待裁量评估」交上层,
|
||||
引擎**不代裁量、不代把关**
|
||||
- **能算的不问 LLM**:DMN 输入齐备即确定性求值,同输入必同结论;命中规则行必留痕
|
||||
- **结论不可静默覆写**:`isBindingConclusion` 标出约束性输入
|
||||
- **三型行动**:每步行动属于且仅属于「调工具 / 调人 / 立监听」之一;
|
||||
调人 = 立监听听人;AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势
|
||||
- **终局唯放行与 Harness**:引擎不把案计划置 Completed —— 那是 release 的事;
|
||||
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
|
||||
- **驳回是迁移不是状态**:`reenter` 以 repetitionRule 新实例重新入场
|
||||
@@ -0,0 +1,288 @@
|
||||
/** CMMN 子集解析、生命周期与 DMN —— domain 测试零 mock(AM 03 / 04) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DmnDecisionTable, DmnModel } from '@contracts';
|
||||
import { CmmnParseError, parseCmmn } from '../internal/domain/cmmn-parse.ts';
|
||||
import {
|
||||
autoCompleteReady,
|
||||
canTransitionItem,
|
||||
IllegalTransitionError,
|
||||
initialRuntime,
|
||||
mayCompleteCase,
|
||||
mayTerminateCase,
|
||||
navigate,
|
||||
occur,
|
||||
publishEnabled,
|
||||
reenter,
|
||||
transitionItem,
|
||||
} from '../internal/domain/lifecycle.ts';
|
||||
import { deduceGaps, evaluateTable, evaluationOrder } from '../internal/domain/dmn.ts';
|
||||
|
||||
/** 渠道寻源 A3 的极简案例模型 —— 取自 MVP 示例(S1 接需核史 → S2 选渠定策 → S3 发询比价) */
|
||||
const XML = `<?xml version="1.0"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||
<case id="sourcing">
|
||||
<caseFileModel>
|
||||
<caseFileItem id="inquiry" name="询价请求"/>
|
||||
<caseFileItem id="quotes" name="供应商回价单"/>
|
||||
</caseFileModel>
|
||||
<casePlanModel id="plan" name="渠道寻源" autoComplete="true">
|
||||
<sentry id="s_after_inquiry">
|
||||
<onPart id="op1" caseFileItemRef="inquiry"><standardEvent>create</standardEvent></onPart>
|
||||
</sentry>
|
||||
<sentry id="s_semantic">
|
||||
<onPart id="op2" caseFileItemRef="quotes"><standardEvent>create</standardEvent></onPart>
|
||||
<ifPart id="ip1"><condition>semantic: 回价是否可信</condition></ifPart>
|
||||
</sentry>
|
||||
<stage id="S1" name="接需核史" autoComplete="true">
|
||||
<entryCriterion id="ec1" sentryRef="s_after_inquiry"/>
|
||||
<humanTask id="T1" name="人工查历史报价">
|
||||
<itemControl><manualActivationRule id="m1"/></itemControl>
|
||||
</humanTask>
|
||||
<decisionTask id="D1" name="判定采用策略" decisionRef="strategy"/>
|
||||
<planningTable id="pt1">
|
||||
<humanTask id="T2" name="人工渠道资质核实"/>
|
||||
</planningTable>
|
||||
</stage>
|
||||
<milestone id="M1" name="策略已定"/>
|
||||
<timerEventListener id="TL1" name="时限预警">
|
||||
<timerExpression>P4D</timerExpression>
|
||||
</timerEventListener>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
describe('parseCmmn —— 子集封顶', () => {
|
||||
const model = parseCmmn(XML);
|
||||
|
||||
it('读出案计划与自动完成', () => {
|
||||
expect(model.name).toBe('渠道寻源');
|
||||
expect(model.autoComplete).toBe(true);
|
||||
});
|
||||
|
||||
it('读出任务数据(CaseFileItem)', () => {
|
||||
expect(model.fileItems.map((f) => f.id)).toEqual(['inquiry', 'quotes']);
|
||||
});
|
||||
|
||||
it('读出全部计划项与类型', () => {
|
||||
expect(model.items.map((i) => `${i.id}:${i.type}`)).toEqual([
|
||||
'S1:stage',
|
||||
'T1:humanTask',
|
||||
'D1:decisionTask',
|
||||
'T2:humanTask',
|
||||
'M1:milestone',
|
||||
'TL1:timerEventListener',
|
||||
]);
|
||||
});
|
||||
|
||||
it('manualActivationRule 被识别 —— Enabled ≠ Active', () => {
|
||||
expect(model.items.find((i) => i.id === 'T1')?.manualActivation).toBe(true);
|
||||
expect(model.items.find((i) => i.id === 'D1')?.manualActivation).toBe(false);
|
||||
});
|
||||
|
||||
it('planning table 里的项标为裁量项', () => {
|
||||
expect(model.items.find((i) => i.id === 'T2')?.discretionary).toBe(true);
|
||||
expect(model.items.find((i) => i.id === 'T1')?.discretionary).toBe(false);
|
||||
});
|
||||
|
||||
it('entryCriterion 关到 sentry 的 onPart', () => {
|
||||
const stage = model.items.find((i) => i.id === 'S1');
|
||||
expect(stage?.entryCriteria[0]?.onParts[0]).toEqual({ kind: 'file', ref: 'inquiry', event: 'create' });
|
||||
});
|
||||
|
||||
it('decisionRef 与 timerExpression', () => {
|
||||
expect(model.items.find((i) => i.id === 'D1')?.decisionRef).toBe('strategy');
|
||||
expect(model.items.find((i) => i.id === 'TL1')?.timerExpression).toBe('P4D');
|
||||
});
|
||||
|
||||
it('表外构件直接报错 —— 不静默忽略', () => {
|
||||
const bad = XML.replace('<milestone id="M1" name="策略已定"/>', '<eventListener id="X"/>');
|
||||
expect(() => parseCmmn(bad)).toThrow(CmmnParseError);
|
||||
});
|
||||
|
||||
it('标签不匹配报错', () => {
|
||||
expect(() => parseCmmn('<definitions><case id="a"></definitions>')).toThrow(CmmnParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('生命周期 —— 语义忠实,引擎不得私改迁移拓扑', () => {
|
||||
it('计划项八态的合法迁移', () => {
|
||||
expect(canTransitionItem('Available', 'Enabled')).toBe(true);
|
||||
expect(canTransitionItem('Enabled', 'Active')).toBe(true);
|
||||
expect(canTransitionItem('Active', 'Completed')).toBe(true);
|
||||
expect(canTransitionItem('Enabled', 'Completed')).toBe(false);
|
||||
expect(canTransitionItem('Completed', 'Active')).toBe(false);
|
||||
});
|
||||
|
||||
it('非法迁移抛错', () => {
|
||||
const model = parseCmmn(XML);
|
||||
const runtime = initialRuntime(model);
|
||||
expect(() => transitionItem(runtime, 'T1', 'Completed')).toThrow(IllegalTransitionError);
|
||||
});
|
||||
|
||||
it('里程碑只有一条主迁移 Available →(occur)→ Completed', () => {
|
||||
const model = parseCmmn(XML);
|
||||
const next = occur(initialRuntime(model), 'M1');
|
||||
expect(next.items.find((i) => i.id === 'M1')?.state).toBe('Completed');
|
||||
expect(next.milestones).toEqual(['M1']);
|
||||
});
|
||||
|
||||
it('驳回是迁移不是状态:新实例重新入场', () => {
|
||||
const model = parseCmmn(XML);
|
||||
let runtime = transitionItem(initialRuntime(model), 'T1', 'Enabled');
|
||||
runtime = transitionItem(runtime, 'T1', 'Active');
|
||||
const next = reenter(runtime, 'T1');
|
||||
expect(next.items.find((i) => i.id === 'T1')).toEqual({ id: 'T1', state: 'Available', instance: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('终局唯放行与 Harness —— AI 不得产生终局迁移', () => {
|
||||
it('Completed 只出自放行', () => {
|
||||
expect(mayCompleteCase('release')).toBe(true);
|
||||
expect(mayCompleteCase('harness')).toBe(false);
|
||||
expect(mayCompleteCase('owner')).toBe(false);
|
||||
});
|
||||
it('Terminated 只出自 Harness / 负责人 / 治理台', () => {
|
||||
expect(mayTerminateCase('harness')).toBe(true);
|
||||
expect(mayTerminateCase('owner')).toBe(true);
|
||||
expect(mayTerminateCase('governance')).toBe(true);
|
||||
expect(mayTerminateCase('release')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishEnabled —— 引擎推进到 Enabled 为止', () => {
|
||||
const model = parseCmmn(XML);
|
||||
|
||||
it('前提未满足时不发布', () => {
|
||||
const enabled = publishEnabled(model, initialRuntime(model), {}, new Set(), () => true);
|
||||
expect(enabled.entries.map((e) => e.id)).not.toContain('S1');
|
||||
});
|
||||
|
||||
it('onPart 件到 + 无 ifPart → Enabled', () => {
|
||||
const enabled = publishEnabled(model, initialRuntime(model), { inquiry: {} }, new Set(['inquiry']), () => true);
|
||||
expect(enabled.entries.map((e) => e.id)).toContain('S1');
|
||||
});
|
||||
|
||||
it('父阶段未激活的子项不入场', () => {
|
||||
const enabled = publishEnabled(model, initialRuntime(model), { inquiry: {} }, new Set(['inquiry']), () => true);
|
||||
expect(enabled.entries.map((e) => e.id)).not.toContain('T1');
|
||||
});
|
||||
|
||||
it('manualActivation 的项标出「须 Agent 启动」', () => {
|
||||
let runtime = initialRuntime(model);
|
||||
runtime = transitionItem(runtime, 'S1', 'Enabled');
|
||||
runtime = transitionItem(runtime, 'S1', 'Active');
|
||||
const enabled = publishEnabled(model, runtime, { inquiry: {} }, new Set(['inquiry']), () => true);
|
||||
expect(enabled.entries.find((e) => e.id === 'T1')?.requiresManualStart).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigate —— 导航即交集,断点即人环', () => {
|
||||
it('有交集即择优推进', () => {
|
||||
const nav = navigate(
|
||||
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], judgements: [], gaps: ['缺报价'], activeStages: [] },
|
||||
new Set(['a']),
|
||||
);
|
||||
expect(nav.kind).toBe('proceed');
|
||||
});
|
||||
|
||||
it('交集为空即断点 —— 调人或立监听的判定时机', () => {
|
||||
const nav = navigate({ entries: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
|
||||
expect(nav).toEqual({ kind: 'breakpoint', gaps: ['缺客户信用'] });
|
||||
});
|
||||
|
||||
it('无缺口即收敛', () => {
|
||||
expect(navigate({ entries: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoCompleteReady —— 齐了即收', () => {
|
||||
it('必做项未完不收', () => {
|
||||
const model = parseCmmn(XML);
|
||||
expect(autoCompleteReady(model, initialRuntime(model))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── DMN:三分类规则 P-03 ─────────────────────────────────────────── */
|
||||
|
||||
const p03: DmnDecisionTable = {
|
||||
id: 'p03',
|
||||
label: '三分类规则 P-03',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [
|
||||
{ name: 'days', expression: 'historyDays' },
|
||||
{ name: 'stable', expression: 'marketStable' },
|
||||
],
|
||||
outputs: [{ name: 'type' }, { name: 'play' }],
|
||||
rules: [
|
||||
{ id: 'r1', when: ['<= 90', 'true'], then: ['A', '直接快答'] },
|
||||
{ id: 'r2', when: ['-', '-'], then: ['B', '重估后报'] },
|
||||
],
|
||||
};
|
||||
|
||||
const evalExpr = (expr: string, scope: Readonly<Record<string, unknown>>): unknown => scope[expr];
|
||||
const evalTest = (cell: string, value: unknown): boolean => {
|
||||
const trimmed = cell.trim();
|
||||
if (trimmed === '-' || trimmed === '') return true;
|
||||
if (trimmed.startsWith('<=')) return Number(value) <= Number(trimmed.slice(2));
|
||||
if (trimmed === 'true') return value === true;
|
||||
return String(value) === trimmed.replace(/^"|"$/g, '');
|
||||
};
|
||||
|
||||
describe('evaluateTable —— 能算的不问 LLM', () => {
|
||||
it('命中首行,留痕带命中规则行', () => {
|
||||
const result = evaluateTable(p03, { historyDays: 30, marketStable: true }, evalExpr, evalTest);
|
||||
expect(result.kind).toBe('decided');
|
||||
if (result.kind === 'decided') {
|
||||
expect(result.trace.hitRules).toEqual(['r1']);
|
||||
expect(result.trace.outputs).toEqual({ type: 'A', play: '直接快答' });
|
||||
expect(result.trace.inputs).toEqual({ days: 30, stable: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('同输入必同结论 —— 零方差', () => {
|
||||
const a = evaluateTable(p03, { historyDays: 30, marketStable: true }, evalExpr, evalTest);
|
||||
const b = evaluateTable(p03, { historyDays: 30, marketStable: true }, evalExpr, evalTest);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('落到兜底行', () => {
|
||||
const result = evaluateTable(p03, { historyDays: 200, marketStable: false }, evalExpr, evalTest);
|
||||
if (result.kind === 'decided') expect(result.trace.outputs['type']).toBe('B');
|
||||
});
|
||||
|
||||
it('输入不齐即 incomplete —— DRD 指出缺什么', () => {
|
||||
const result = evaluateTable(p03, { historyDays: 30 }, evalExpr, evalTest);
|
||||
expect(result).toEqual({ kind: 'incomplete', missing: ['stable'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DRD —— 缺口演绎与拓扑序', () => {
|
||||
const model: DmnModel = {
|
||||
drd: [
|
||||
{ decision: 'price', requires: ['margin'], inputs: ['basePrice'] },
|
||||
{ decision: 'margin', requires: [], inputs: ['customerGrade', 'materialType'] },
|
||||
],
|
||||
tables: [],
|
||||
};
|
||||
|
||||
it('反向遍历给出缺口', () => {
|
||||
expect(deduceGaps(model, 'price', new Set(['basePrice']))).toEqual([
|
||||
{ kind: 'decision', ref: 'margin' },
|
||||
{ kind: 'input', ref: 'customerGrade' },
|
||||
{ kind: 'input', ref: 'materialType' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('已知项不再算缺口', () => {
|
||||
expect(deduceGaps(model, 'price', new Set(['basePrice', 'margin']))).toEqual([]);
|
||||
});
|
||||
|
||||
it('拓扑序:先算上游决策', () => {
|
||||
expect(evaluationOrder(model, 'price')).toEqual(['margin', 'price']);
|
||||
});
|
||||
|
||||
it('成环即报错', () => {
|
||||
const cyclic: DmnModel = { drd: [{ decision: 'a', requires: ['b'], inputs: [] }, { decision: 'b', requires: ['a'], inputs: [] }], tables: [] };
|
||||
expect(() => evaluationOrder(cyclic, 'a')).toThrow(/成环/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* module: loop-engine —— 对外类型(编码规范 §10.1;语义出处 AM 03 / 04)。
|
||||
*
|
||||
* loop 的运行 = **CMMN 语义引擎(下层,零 LLM)× Agent 回路(上层,临场裁量)**。
|
||||
* 两层的耦合点是任务数据(驱动耦合)与任务记忆(记录同轴)。
|
||||
*/
|
||||
import type { LoopModel, TaskId } from '@contracts';
|
||||
import type { CaseModel, PlanItem, PlanItemType } from './internal/domain/cmmn-parse.ts';
|
||||
import type {
|
||||
CaseRuntime,
|
||||
CaseState,
|
||||
EnabledEntry,
|
||||
EnabledSet,
|
||||
ItemRuntime,
|
||||
ItemState,
|
||||
Navigation,
|
||||
TerminalAuthority,
|
||||
} from './internal/domain/lifecycle.ts';
|
||||
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts';
|
||||
|
||||
export type {
|
||||
CaseModel,
|
||||
CaseRuntime,
|
||||
CaseState,
|
||||
DecisionResult,
|
||||
DecisionTrace,
|
||||
EnabledEntry,
|
||||
EnabledSet,
|
||||
Gap,
|
||||
ItemRuntime,
|
||||
ItemState,
|
||||
LoopModel,
|
||||
Navigation,
|
||||
PlanItem,
|
||||
PlanItemType,
|
||||
TerminalAuthority,
|
||||
};
|
||||
|
||||
export { CmmnParseError } from './internal/domain/cmmn-parse.ts';
|
||||
export { IllegalTransitionError } from './internal/domain/lifecycle.ts';
|
||||
|
||||
export class ForbiddenTerminalError extends Error {
|
||||
constructor(readonly by: TerminalAuthority) {
|
||||
super(`终局迁移不得由 ${by} 产生:Completed 必经放行,Terminated 只出自 Harness 与负责人(AM 4.1)`);
|
||||
this.name = 'ForbiddenTerminalError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AutomaticOnWorldTouchingError extends Error {
|
||||
constructor(readonly itemId: string) {
|
||||
super(`计划项 ${itemId} 触达世界却未声明 manualActivationRule —— Enabled ≠ Active(AM 12.2)`);
|
||||
this.name = 'AutomaticOnWorldTouchingError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Agent 的三型行动 —— 每一步行动属于且仅属于其一(AM 4.2) */
|
||||
export type AgentAction =
|
||||
/** 调工具:系统调用与 LLM 工具 */
|
||||
| { readonly kind: 'tool'; readonly itemId: string; readonly tool: string; readonly input: Record<string, unknown> }
|
||||
/** 调人:四卡之一 —— 调人 = 立监听听人 */
|
||||
| { readonly kind: 'human'; readonly itemId: string; readonly card: Record<string, unknown>; readonly workOrder: 'execution' | 'acceptance' }
|
||||
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
|
||||
| { readonly kind: 'listen'; readonly itemId: string; readonly eventType: 'data' | 'time'; readonly keyFilter: Record<string, string>; readonly deadline: string }
|
||||
/** 启动一个 Enabled 项(manualStart,留痕) */
|
||||
| { readonly kind: 'start'; readonly itemId: string }
|
||||
/** 规划裁量项入场 */
|
||||
| { readonly kind: 'plan'; readonly itemId: string }
|
||||
/** 弃用某项(此单不走这条) */
|
||||
| { readonly kind: 'disable'; readonly itemId: string; readonly reason: string }
|
||||
/** 评估语义性前提(评估留痕,可回放可归因) */
|
||||
| { readonly kind: 'judge'; readonly criterion: string; readonly satisfied: boolean; readonly rationale: string }
|
||||
/** 判定成果已齐,提交放行 */
|
||||
| { readonly kind: 'submit'; readonly rationale: string };
|
||||
|
||||
export type TurnOutcome = {
|
||||
readonly runtime: CaseRuntime;
|
||||
readonly enabled: EnabledSet;
|
||||
readonly navigation: Navigation;
|
||||
readonly action?: AgentAction;
|
||||
readonly status: 'running' | 'awaiting' | 'submitted' | 'blocked';
|
||||
readonly blockedReason?: string;
|
||||
};
|
||||
|
||||
export type LoopEngineService = {
|
||||
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
|
||||
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
|
||||
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
|
||||
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
|
||||
publish(taskId: TaskId): Promise<EnabledSet>;
|
||||
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
|
||||
runTurn(taskId: TaskId): Promise<TurnOutcome>;
|
||||
/** 事件驱动续跑:数据 / 时间 / 人 / 控制 */
|
||||
onEvent(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> }): Promise<TurnOutcome>;
|
||||
/** DMN 确定性求值 —— 能算的不问 LLM */
|
||||
decide(taskId: TaskId, decision: string): Promise<DecisionResult>;
|
||||
/** 驳回:相关计划项以新实例重新入场(迁移不是状态) */
|
||||
reenter(taskId: TaskId, itemId: string): Promise<CaseRuntime>;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* module: loop-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
AgentAction,
|
||||
CaseModel,
|
||||
CaseRuntime,
|
||||
CaseState,
|
||||
DecisionResult,
|
||||
DecisionTrace,
|
||||
EnabledEntry,
|
||||
EnabledSet,
|
||||
Gap,
|
||||
ItemRuntime,
|
||||
ItemState,
|
||||
LoopEngineService,
|
||||
LoopModel,
|
||||
Navigation,
|
||||
PlanItem,
|
||||
PlanItemType,
|
||||
TerminalAuthority,
|
||||
TurnOutcome,
|
||||
} from './contract.ts';
|
||||
export {
|
||||
AutomaticOnWorldTouchingError,
|
||||
CmmnParseError,
|
||||
ForbiddenTerminalError,
|
||||
IllegalTransitionError,
|
||||
} from './contract.ts';
|
||||
|
||||
export type {
|
||||
AgentPort,
|
||||
CardGateway,
|
||||
Clock,
|
||||
ListenerRegistrar,
|
||||
LoopStateStore,
|
||||
MemoryRecorder,
|
||||
Sfeel,
|
||||
TaskContext,
|
||||
TaskDataGateway,
|
||||
ToolGateway,
|
||||
} from './port.ts';
|
||||
|
||||
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
||||
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
|
||||
|
||||
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
|
||||
export {
|
||||
assertItemTransition,
|
||||
autoCompleteReady,
|
||||
canTransitionCase,
|
||||
canTransitionItem,
|
||||
CASE_STATES,
|
||||
evaluateCriterion,
|
||||
initialRuntime,
|
||||
ITEM_STATES,
|
||||
mayCompleteCase,
|
||||
mayTerminateCase,
|
||||
navigate,
|
||||
occur,
|
||||
publishEnabled,
|
||||
reenter,
|
||||
transitionItem,
|
||||
} from './internal/domain/lifecycle.ts';
|
||||
export { deduceGaps, evaluateTable, evaluationOrder, isBindingConclusion } from './internal/domain/dmn.ts';
|
||||
|
||||
export { createLoopStateStore } from './internal/adapter/loop-state-store.ts';
|
||||
export { loopStateTable } from './internal/adapter/loop-state-schema.ts';
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* loop-engine 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||
* 运行时实例状态:loop 的**模型定位与就绪集** —— 断点续跑的三份持久状态之一(OS 09)。
|
||||
*/
|
||||
import { jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const loopStateTable = sys.table('loop_state', {
|
||||
taskId: text('task_id').primaryKey(),
|
||||
model: jsonb('model').notNull(),
|
||||
/** 解析后的 CMMN 子集结构 —— 免去每回合重解析 */
|
||||
parsed: jsonb('parsed').notNull(),
|
||||
runtime: jsonb('runtime').notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* loop-engine 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LoopModel, TaskId } from '@contracts';
|
||||
import { loopModelSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { LoopStateStore } from '../../port.ts';
|
||||
import type { CaseModel } from '../domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime } from '../domain/lifecycle.ts';
|
||||
import { loopStateTable } from './loop-state-schema.ts';
|
||||
|
||||
export function createLoopStateStore(db: Db): LoopStateStore {
|
||||
return {
|
||||
async save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime) {
|
||||
const values = { taskId, model, parsed, runtime, updatedAt: new Date() };
|
||||
await db
|
||||
.insert(loopStateTable)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: loopStateTable.taskId,
|
||||
set: { model, parsed, runtime, updatedAt: values.updatedAt },
|
||||
});
|
||||
},
|
||||
async load(taskId: TaskId) {
|
||||
const rows = await db.select().from(loopStateTable).where(eq(loopStateTable.taskId, taskId)).limit(1);
|
||||
const row = rows[0];
|
||||
if (row === undefined) return undefined;
|
||||
return {
|
||||
model: loopModelSchema.parse(row.model),
|
||||
parsed: row.parsed as CaseModel,
|
||||
runtime: row.runtime as CaseRuntime,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* CMMN 子集解析 —— 纯函数,零 IO、零依赖(编码规范 §5.3 / §4)。
|
||||
*
|
||||
* **CMMN 子集封顶**:构件与生命周期以 AM 03 构件表 + AM 4.1 两副状态表为全集,
|
||||
* 不实现 CMMN 1.1 其余构件 —— 扩构件 = 改 §4 那一行 + ADR。
|
||||
*
|
||||
* 子集构件:CasePlanModel / Stage / HumanTask / DecisionTask / ProcessTask / CaseTask /
|
||||
* Milestone / TimerEventListener / UserEventListener / PlanningTable + DiscretionaryItem /
|
||||
* EntryCriterion + ExitCriterion(onPart + ifPart)/ repetitionRule / manualActivationRule / autoComplete。
|
||||
*
|
||||
* 自研极小 XML 读取器:只认标签、属性与嵌套 —— 不支持实体扩展、CDATA 之外的特性,
|
||||
* 遇到不认识的构件**直接报错**,不静默忽略(子集封顶要能被机器证明)。
|
||||
*/
|
||||
|
||||
export class CmmnParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`CMMN 解析失败:${message}`);
|
||||
this.name = 'CmmnParseError';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 极小 XML 读取器 ──────────────────────────────────────────────── */
|
||||
|
||||
export type XmlNode = {
|
||||
readonly tag: string;
|
||||
readonly attrs: Readonly<Record<string, string>>;
|
||||
readonly children: readonly XmlNode[];
|
||||
readonly text: string;
|
||||
};
|
||||
|
||||
const TAG = /<(\/?)([A-Za-z_][\w.:-]*)((?:\s+[\w.:-]+\s*=\s*"[^"]*")*)\s*(\/?)>/g;
|
||||
const ATTR = /([\w.:-]+)\s*=\s*"([^"]*)"/g;
|
||||
|
||||
export function parseXml(source: string): XmlNode {
|
||||
const cleaned = source.replace(/<\?[\s\S]*?\?>/g, '').replace(/<!--[\s\S]*?-->/g, '');
|
||||
const stack: Array<{ tag: string; attrs: Record<string, string>; children: XmlNode[]; text: string }> = [];
|
||||
let root: XmlNode | undefined;
|
||||
let lastIndex = 0;
|
||||
TAG.lastIndex = 0;
|
||||
|
||||
for (let m = TAG.exec(cleaned); m !== null; m = TAG.exec(cleaned)) {
|
||||
const [full, closing, rawTag, rawAttrs, selfClosing] = m;
|
||||
const between = cleaned.slice(lastIndex, m.index).trim();
|
||||
lastIndex = m.index + full.length;
|
||||
const top = stack.at(-1);
|
||||
if (between !== '' && top !== undefined) top.text += unescapeXml(between);
|
||||
|
||||
const tag = stripNamespace(rawTag!);
|
||||
|
||||
if (closing === '/') {
|
||||
const finished = stack.pop();
|
||||
if (finished === undefined) throw new CmmnParseError(`多余的结束标签 </${tag}>`);
|
||||
if (finished.tag !== tag) throw new CmmnParseError(`标签不匹配:<${finished.tag}> 与 </${tag}>`);
|
||||
const node: XmlNode = { ...finished, children: finished.children };
|
||||
const parent = stack.at(-1);
|
||||
if (parent === undefined) root = node;
|
||||
else parent.children.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attrs: Record<string, string> = {};
|
||||
ATTR.lastIndex = 0;
|
||||
for (let a = ATTR.exec(rawAttrs ?? ''); a !== null; a = ATTR.exec(rawAttrs ?? '')) {
|
||||
attrs[stripNamespace(a[1]!)] = unescapeXml(a[2]!);
|
||||
}
|
||||
|
||||
if (selfClosing === '/') {
|
||||
const node: XmlNode = { tag, attrs, children: [], text: '' };
|
||||
const parent = stack.at(-1);
|
||||
if (parent === undefined) root = node;
|
||||
else parent.children.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
stack.push({ tag, attrs, children: [], text: '' });
|
||||
}
|
||||
|
||||
if (stack.length > 0) throw new CmmnParseError(`标签未闭合:<${stack.at(-1)!.tag}>`);
|
||||
if (root === undefined) throw new CmmnParseError('没有根元素');
|
||||
return root;
|
||||
}
|
||||
|
||||
function stripNamespace(name: string): string {
|
||||
const idx = name.indexOf(':');
|
||||
return idx < 0 ? name : name.slice(idx + 1);
|
||||
}
|
||||
|
||||
function unescapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/* ── 子集构件模型 ───────────────────────────────────────────────────── */
|
||||
|
||||
/** 计划项类型 —— 子集全集(AM 03 构件表) */
|
||||
export const PLAN_ITEM_TYPES = [
|
||||
'humanTask',
|
||||
'decisionTask',
|
||||
'processTask',
|
||||
'caseTask',
|
||||
'stage',
|
||||
'milestone',
|
||||
'timerEventListener',
|
||||
'userEventListener',
|
||||
] as const;
|
||||
export type PlanItemType = (typeof PLAN_ITEM_TYPES)[number];
|
||||
|
||||
/** 前提:onPart(听什么)+ ifPart(内容条件,S-FEEL) */
|
||||
export type Criterion = {
|
||||
readonly id: string;
|
||||
/** 监听哪个任务数据项的写入/变更,或哪个计划项的迁移 */
|
||||
readonly onParts: ReadonlyArray<{ kind: 'file' | 'plan-item'; ref: string; event: string }>;
|
||||
/** 内容条件 —— 不可机算的标记为语义性前提,交 Agent 评估 */
|
||||
readonly ifPart?: string;
|
||||
/** 语义性前提:引擎标「待裁量评估」,不自行裁决 */
|
||||
readonly semantic: boolean;
|
||||
};
|
||||
|
||||
export type PlanItem = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly type: PlanItemType;
|
||||
/** 所属阶段;顶层为 undefined */
|
||||
readonly parent?: string;
|
||||
/** 触达世界与关键交付者**必须** manualActivationRule = true(AM 4.1 原理一) */
|
||||
readonly manualActivation: boolean;
|
||||
readonly repetition: boolean;
|
||||
/** 裁量项:从 planning table 由 Agent 临场规划入场 */
|
||||
readonly discretionary: boolean;
|
||||
readonly entryCriteria: readonly Criterion[];
|
||||
readonly exitCriteria: readonly Criterion[];
|
||||
/** DecisionTask 引用的决策 */
|
||||
readonly decisionRef?: string;
|
||||
/** TimerEventListener 的时限 */
|
||||
readonly timerExpression?: string;
|
||||
/** 阶段的「齐了即收」 */
|
||||
readonly autoComplete: boolean;
|
||||
/** 调人时投递到哪个工位 */
|
||||
readonly roleRef?: string;
|
||||
};
|
||||
|
||||
export type CaseModel = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly autoComplete: boolean;
|
||||
readonly items: readonly PlanItem[];
|
||||
/** CaseFileItem:任务数据 —— 事的数据中心 */
|
||||
readonly fileItems: ReadonlyArray<{ id: string; name: string }>;
|
||||
};
|
||||
|
||||
const ITEM_TAGS: Readonly<Record<string, PlanItemType>> = {
|
||||
humanTask: 'humanTask',
|
||||
decisionTask: 'decisionTask',
|
||||
processTask: 'processTask',
|
||||
caseTask: 'caseTask',
|
||||
stage: 'stage',
|
||||
milestone: 'milestone',
|
||||
timerEventListener: 'timerEventListener',
|
||||
userEventListener: 'userEventListener',
|
||||
};
|
||||
|
||||
/** 结构性标签:允许出现但本身不是计划项 */
|
||||
const STRUCTURAL = new Set([
|
||||
'definitions',
|
||||
'case',
|
||||
'casePlanModel',
|
||||
'caseFileModel',
|
||||
'caseFileItem',
|
||||
'planItem',
|
||||
'planningTable',
|
||||
'discretionaryItem',
|
||||
'entryCriterion',
|
||||
'exitCriterion',
|
||||
'sentry',
|
||||
'onPart',
|
||||
'ifPart',
|
||||
'condition',
|
||||
'standardEvent',
|
||||
'itemControl',
|
||||
'repetitionRule',
|
||||
'manualActivationRule',
|
||||
'requiredRule',
|
||||
'timerExpression',
|
||||
'caseRoles',
|
||||
'role',
|
||||
'extensionElements',
|
||||
'documentation',
|
||||
'CMMNDI',
|
||||
'CMMNDiagram',
|
||||
'CMMNShape',
|
||||
'CMMNEdge',
|
||||
'Bounds',
|
||||
'waypoint',
|
||||
'CMMNLabel',
|
||||
]);
|
||||
|
||||
export function parseCmmn(xml: string): CaseModel {
|
||||
const root = parseXml(xml);
|
||||
const plan = findFirst(root, 'casePlanModel');
|
||||
if (plan === undefined) throw new CmmnParseError('缺 casePlanModel');
|
||||
|
||||
assertSubset(plan);
|
||||
|
||||
const sentries = new Map<string, XmlNode>();
|
||||
collect(plan, 'sentry', sentries);
|
||||
|
||||
const items: PlanItem[] = [];
|
||||
walkItems(plan, undefined, sentries, items, false);
|
||||
|
||||
const fileModel = findFirst(root, 'caseFileModel');
|
||||
const fileItems =
|
||||
fileModel === undefined
|
||||
? []
|
||||
: fileModel.children
|
||||
.filter((c) => c.tag === 'caseFileItem')
|
||||
.map((c) => ({ id: c.attrs['id'] ?? '', name: c.attrs['name'] ?? c.attrs['id'] ?? '' }));
|
||||
|
||||
return {
|
||||
id: plan.attrs['id'] ?? 'case',
|
||||
name: plan.attrs['name'] ?? '案例',
|
||||
autoComplete: plan.attrs['autoComplete'] === 'true',
|
||||
items,
|
||||
fileItems,
|
||||
};
|
||||
}
|
||||
|
||||
/** 子集封顶的机器证明:出现表外构件即报错,不静默忽略 */
|
||||
function assertSubset(node: XmlNode): void {
|
||||
for (const child of node.children) {
|
||||
if (ITEM_TAGS[child.tag] === undefined && !STRUCTURAL.has(child.tag)) {
|
||||
throw new CmmnParseError(`构件 <${child.tag}> 不在 CMMN 子集内(子集封顶,扩构件须改 §4 并建 ADR)`);
|
||||
}
|
||||
assertSubset(child);
|
||||
}
|
||||
}
|
||||
|
||||
function walkItems(
|
||||
node: XmlNode,
|
||||
parent: string | undefined,
|
||||
sentries: Map<string, XmlNode>,
|
||||
out: PlanItem[],
|
||||
discretionary: boolean,
|
||||
): void {
|
||||
for (const child of node.children) {
|
||||
const type = ITEM_TAGS[child.tag];
|
||||
if (type !== undefined) {
|
||||
const id = child.attrs['id'] ?? '';
|
||||
const control = findFirst(child, 'itemControl');
|
||||
const item: PlanItem = {
|
||||
id,
|
||||
name: child.attrs['name'] ?? id,
|
||||
type,
|
||||
...(parent === undefined ? {} : { parent }),
|
||||
manualActivation: hasRule(control, 'manualActivationRule') || child.attrs['manualActivation'] === 'true',
|
||||
repetition: hasRule(control, 'repetitionRule') || child.attrs['repetition'] === 'true',
|
||||
discretionary,
|
||||
entryCriteria: criteriaOf(child, 'entryCriterion', sentries),
|
||||
exitCriteria: criteriaOf(child, 'exitCriterion', sentries),
|
||||
...(child.attrs['decisionRef'] === undefined ? {} : { decisionRef: child.attrs['decisionRef'] }),
|
||||
...timerOf(child),
|
||||
autoComplete: child.attrs['autoComplete'] === 'true',
|
||||
...(child.attrs['roleRef'] === undefined ? {} : { roleRef: child.attrs['roleRef'] }),
|
||||
};
|
||||
out.push(item);
|
||||
// 阶段展开即 create 子项;planningTable 由下面的分支统一按裁量项处理,此处不重复收集
|
||||
if (type === 'stage') walkItems(child, id, sentries, out, false);
|
||||
continue;
|
||||
}
|
||||
if (child.tag === 'planningTable') {
|
||||
walkItems(child, parent, sentries, out, true);
|
||||
continue;
|
||||
}
|
||||
if (child.tag === 'discretionaryItem') {
|
||||
walkItems({ ...child, children: child.children }, parent, sentries, out, true);
|
||||
continue;
|
||||
}
|
||||
if (STRUCTURAL.has(child.tag)) walkItems(child, parent, sentries, out, discretionary);
|
||||
}
|
||||
}
|
||||
|
||||
function criteriaOf(node: XmlNode, tag: 'entryCriterion' | 'exitCriterion', sentries: Map<string, XmlNode>): Criterion[] {
|
||||
return node.children
|
||||
.filter((c) => c.tag === tag)
|
||||
.map((c) => {
|
||||
const sentryRef = c.attrs['sentryRef'];
|
||||
const sentry = sentryRef === undefined ? undefined : sentries.get(sentryRef);
|
||||
const onParts = (sentry?.children ?? [])
|
||||
.filter((s) => s.tag === 'onPart')
|
||||
.map((s) => ({
|
||||
kind: (s.attrs['caseFileItemRef'] !== undefined ? 'file' : 'plan-item') as 'file' | 'plan-item',
|
||||
ref: s.attrs['caseFileItemRef'] ?? s.attrs['sourceRef'] ?? '',
|
||||
event: textOf(s, 'standardEvent') ?? s.attrs['standardEvent'] ?? 'create',
|
||||
}));
|
||||
const ifPartNode = sentry === undefined ? undefined : findFirst(sentry, 'ifPart');
|
||||
const condition = ifPartNode === undefined ? undefined : textOf(ifPartNode, 'condition') ?? ifPartNode.text.trim();
|
||||
const semantic = condition !== undefined && condition.startsWith('semantic:');
|
||||
return {
|
||||
id: c.attrs['id'] ?? sentryRef ?? '',
|
||||
onParts,
|
||||
...(condition === undefined || condition === ''
|
||||
? {}
|
||||
: { ifPart: semantic ? condition.slice('semantic:'.length).trim() : condition }),
|
||||
semantic,
|
||||
} satisfies Criterion;
|
||||
});
|
||||
}
|
||||
|
||||
function timerOf(node: XmlNode): { timerExpression?: string } {
|
||||
const value = textOf(node, 'timerExpression');
|
||||
return value === undefined ? {} : { timerExpression: value };
|
||||
}
|
||||
|
||||
function hasRule(control: XmlNode | undefined, rule: string): boolean {
|
||||
if (control === undefined) return false;
|
||||
const node = findFirst(control, rule);
|
||||
if (node === undefined) return false;
|
||||
const condition = textOf(node, 'condition');
|
||||
return condition === undefined || condition.trim() === '' || condition.trim() === 'true';
|
||||
}
|
||||
|
||||
function findFirst(node: XmlNode, tag: string): XmlNode | undefined {
|
||||
if (node.tag === tag) return node;
|
||||
for (const child of node.children) {
|
||||
const found = findFirst(child, tag);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collect(node: XmlNode, tag: string, out: Map<string, XmlNode>): void {
|
||||
if (node.tag === tag) {
|
||||
const id = node.attrs['id'];
|
||||
if (id !== undefined) out.set(id, node);
|
||||
}
|
||||
for (const child of node.children) collect(child, tag, out);
|
||||
}
|
||||
|
||||
function textOf(node: XmlNode, tag: string): string | undefined {
|
||||
const found = node.children.find((c) => c.tag === tag);
|
||||
if (found === undefined) return undefined;
|
||||
const text = found.text.trim();
|
||||
return text === '' ? undefined : text;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* DMN 求值 —— 纯逻辑,零 IO(编码规范 §5.3 / §4:DMN 落点在本模块 domain,
|
||||
* 单元格谓词复用 `@sfeel`;语义出处 AM 3.3)。
|
||||
*
|
||||
* 「DMN 承担一件事:把装得下的判断**从 LLM 手里拿走**。
|
||||
* 能算的不问 LLM(必须)—— 输入齐备的决策表由语义引擎确定性求值,同输入必同结论,零方差零 token;
|
||||
* DRD 即缺口演绎(应)—— 输入不齐时,DRD 指出缺什么;
|
||||
* 决策留痕(必须)—— 输入快照 + 决策表版本 + **命中规则行**;
|
||||
* 结论不可静默覆写(必须)。」
|
||||
*/
|
||||
import type { DmnDecisionTable, DmnModel } from '@contracts';
|
||||
|
||||
export type UnaryTestEvaluator = (cell: string, value: unknown, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||
export type ExpressionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => unknown;
|
||||
|
||||
/** 决策留痕:输入快照 + 决策表版本 + 命中规则行 —— 决策可回放、可归因 */
|
||||
export type DecisionTrace = {
|
||||
readonly decision: string;
|
||||
readonly tableId: string;
|
||||
readonly inputs: Readonly<Record<string, unknown>>;
|
||||
readonly hitRules: readonly string[];
|
||||
readonly outputs: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type DecisionResult =
|
||||
| { readonly kind: 'decided'; readonly trace: DecisionTrace }
|
||||
/** 输入不齐 —— DRD 指出缺什么(缺口演绎的后拉遍历) */
|
||||
| { readonly kind: 'incomplete'; readonly missing: readonly string[] }
|
||||
/** 无规则命中 */
|
||||
| { readonly kind: 'no-match'; readonly inputs: Readonly<Record<string, unknown>> };
|
||||
|
||||
/** 求值一张决策表 */
|
||||
export function evaluateTable(
|
||||
table: DmnDecisionTable,
|
||||
scope: Readonly<Record<string, unknown>>,
|
||||
evaluateExpression: ExpressionEvaluator,
|
||||
evaluateUnaryTest: UnaryTestEvaluator,
|
||||
): DecisionResult {
|
||||
const inputs: Record<string, unknown> = {};
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const input of table.inputs) {
|
||||
const value = evaluateExpression(input.expression, scope);
|
||||
if (value === undefined) missing.push(input.name);
|
||||
inputs[input.name] = value;
|
||||
}
|
||||
if (missing.length > 0) return { kind: 'incomplete', missing };
|
||||
|
||||
const hitRules: string[] = [];
|
||||
const outputs: Record<string, unknown> = {};
|
||||
|
||||
for (const rule of table.rules) {
|
||||
const matches = table.inputs.every((input, index) => {
|
||||
const cell = rule.when[index] ?? '-';
|
||||
return evaluateUnaryTest(cell, inputs[input.name], scope);
|
||||
});
|
||||
if (!matches) continue;
|
||||
|
||||
hitRules.push(rule.id);
|
||||
for (const [index, output] of table.outputs.entries()) {
|
||||
const value = rule.then[index];
|
||||
if (table.hitPolicy === 'COLLECT') {
|
||||
const bucket = (outputs[output.name] as unknown[] | undefined) ?? [];
|
||||
bucket.push(value);
|
||||
outputs[output.name] = bucket;
|
||||
} else {
|
||||
// UNIQUE / FIRST / PRIORITY:先命中者定结论
|
||||
if (!(output.name in outputs)) outputs[output.name] = value;
|
||||
}
|
||||
}
|
||||
if (table.hitPolicy === 'FIRST' || table.hitPolicy === 'UNIQUE') break;
|
||||
}
|
||||
|
||||
if (hitRules.length === 0) return { kind: 'no-match', inputs };
|
||||
return { kind: 'decided', trace: { decision: table.id, tableId: table.id, inputs, hitRules, outputs } };
|
||||
}
|
||||
|
||||
/* ── DRD:缺口演绎 ─────────────────────────────────────────────────── */
|
||||
|
||||
export type Gap =
|
||||
/** 可算:先算上游决策 */
|
||||
| { readonly kind: 'decision'; readonly ref: string }
|
||||
/** 可查 / 只有人知道 / 在世界侧 —— 由 Agent 按断点判定去处 */
|
||||
| { readonly kind: 'input'; readonly ref: string };
|
||||
|
||||
/**
|
||||
* 沿 DRD 反向遍历:目标决策缺什么 → 缺口由什么推出。
|
||||
* 「主要就是走 DRD」—— 双向驱动的后拉遍历。
|
||||
*/
|
||||
export function deduceGaps(model: DmnModel, decision: string, known: ReadonlySet<string>): Gap[] {
|
||||
const node = model.drd.find((d) => d.decision === decision);
|
||||
if (node === undefined) return [];
|
||||
const gaps: Gap[] = [];
|
||||
for (const required of node.requires) {
|
||||
if (known.has(required)) continue;
|
||||
gaps.push({ kind: 'decision', ref: required });
|
||||
gaps.push(...deduceGaps(model, required, known));
|
||||
}
|
||||
for (const input of node.inputs) {
|
||||
if (!known.has(input)) gaps.push({ kind: 'input', ref: input });
|
||||
}
|
||||
// 去重,保持首次出现次序
|
||||
const seen = new Set<string>();
|
||||
return gaps.filter((g) => {
|
||||
const key = `${g.kind}:${g.ref}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 拓扑序:先算上游决策,再算目标 */
|
||||
export function evaluationOrder(model: DmnModel, target: string): string[] {
|
||||
const order: string[] = [];
|
||||
const visiting = new Set<string>();
|
||||
|
||||
const visit = (decision: string): void => {
|
||||
if (order.includes(decision)) return;
|
||||
if (visiting.has(decision)) throw new Error(`DRD 成环:${decision}`);
|
||||
visiting.add(decision);
|
||||
const node = model.drd.find((d) => d.decision === decision);
|
||||
for (const required of node?.requires ?? []) visit(required);
|
||||
visiting.delete(decision);
|
||||
order.push(decision);
|
||||
};
|
||||
|
||||
visit(target);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结论不可静默覆写(必须):
|
||||
* DMN 结论对 Agent 是**约束性输入** —— 认为不合理时只有两条路:
|
||||
* 超出授权走「人 · 决策」,规则本身有病提迭代提案。
|
||||
*/
|
||||
export function isBindingConclusion(result: DecisionResult): result is Extract<DecisionResult, { kind: 'decided' }> {
|
||||
return result.kind === 'decided';
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* CMMN 标准生命周期与双向驱动 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 04)。
|
||||
*
|
||||
* 「状态的权威是 CMMN 规范的标准生命周期:**活动不定义状态,模型不发明状态**;
|
||||
* 引擎按规范推进,守卫(时限 / 预算 / 预授权 / 监听)仅可参数化。」
|
||||
*/
|
||||
import type { CaseModel, Criterion, PlanItem } from './cmmn-parse.ts';
|
||||
|
||||
/* ── 两副状态表(AM 4.1) ────────────────────────────────────────────── */
|
||||
|
||||
/** 案计划(整案)五态 */
|
||||
export const CASE_STATES = ['Active', 'Completed', 'Suspended', 'Terminated', 'Failed'] as const;
|
||||
export type CaseState = (typeof CASE_STATES)[number];
|
||||
|
||||
/** 计划项八态 */
|
||||
export const ITEM_STATES = [
|
||||
'Available',
|
||||
'Enabled',
|
||||
'Disabled',
|
||||
'Active',
|
||||
'Suspended',
|
||||
'Failed',
|
||||
'Completed',
|
||||
'Terminated',
|
||||
] as const;
|
||||
export type ItemState = (typeof ITEM_STATES)[number];
|
||||
|
||||
/** 迁移拓扑 —— 引擎不得私改 */
|
||||
const ITEM_TRANSITIONS: Readonly<Record<ItemState, readonly ItemState[]>> = {
|
||||
Available: ['Enabled', 'Active', 'Terminated', 'Suspended'],
|
||||
Enabled: ['Active', 'Disabled', 'Terminated', 'Suspended'],
|
||||
Disabled: ['Enabled', 'Terminated'],
|
||||
Active: ['Completed', 'Failed', 'Suspended', 'Terminated'],
|
||||
Suspended: ['Available', 'Enabled', 'Active', 'Terminated'],
|
||||
Failed: ['Active', 'Terminated'],
|
||||
Completed: [],
|
||||
Terminated: [],
|
||||
};
|
||||
|
||||
const CASE_TRANSITIONS: Readonly<Record<CaseState, readonly CaseState[]>> = {
|
||||
Active: ['Completed', 'Suspended', 'Terminated', 'Failed'],
|
||||
Suspended: ['Active', 'Terminated'],
|
||||
Failed: ['Active', 'Terminated'],
|
||||
Completed: [],
|
||||
Terminated: [],
|
||||
};
|
||||
|
||||
export class IllegalTransitionError extends Error {
|
||||
constructor(
|
||||
readonly from: string,
|
||||
readonly to: string,
|
||||
) {
|
||||
super(`生命周期迁移非法:${from} → ${to}(语义忠实:引擎不得私改迁移拓扑)`);
|
||||
this.name = 'IllegalTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export function canTransitionItem(from: ItemState, to: ItemState): boolean {
|
||||
return ITEM_TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
export function assertItemTransition(from: ItemState, to: ItemState): void {
|
||||
if (!canTransitionItem(from, to)) throw new IllegalTransitionError(from, to);
|
||||
}
|
||||
|
||||
export function canTransitionCase(from: CaseState, to: CaseState): boolean {
|
||||
return CASE_TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
/* ── 运行时快照 ─────────────────────────────────────────────────────── */
|
||||
|
||||
export type ItemRuntime = {
|
||||
readonly id: string;
|
||||
readonly state: ItemState;
|
||||
/** repetitionRule 的实例序号 */
|
||||
readonly instance: number;
|
||||
/** 语义性前提待裁量评估的标记 */
|
||||
readonly awaitingJudgement?: boolean;
|
||||
};
|
||||
|
||||
export type CaseRuntime = {
|
||||
readonly state: CaseState;
|
||||
readonly items: readonly ItemRuntime[];
|
||||
/** 已达成的里程碑 */
|
||||
readonly milestones: readonly string[];
|
||||
};
|
||||
|
||||
export function initialRuntime(model: CaseModel): CaseRuntime {
|
||||
return {
|
||||
state: 'Active',
|
||||
items: model.items.map((item) => ({
|
||||
id: item.id,
|
||||
// 裁量项不随阶段自动入场:从 planning table 由 Agent 临场规划
|
||||
state: item.discretionary ? 'Available' : 'Available',
|
||||
instance: 1,
|
||||
})),
|
||||
milestones: [],
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 前提求值 ───────────────────────────────────────────────────────── */
|
||||
|
||||
export type ConditionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||
|
||||
export type CriterionVerdict =
|
||||
| { readonly kind: 'satisfied' }
|
||||
| { readonly kind: 'unsatisfied' }
|
||||
/** 语义性前提不可机算 —— 标「待裁量评估」,交上层 Agent,引擎**不代裁量** */
|
||||
| { readonly kind: 'needs-judgement'; readonly statement: string };
|
||||
|
||||
export function evaluateCriterion(
|
||||
criterion: Criterion,
|
||||
scope: Readonly<Record<string, unknown>>,
|
||||
changedRefs: ReadonlySet<string>,
|
||||
evaluate: ConditionEvaluator,
|
||||
): CriterionVerdict {
|
||||
// onPart:监听的数据项写入 / 计划项迁移发生过才算「件到」
|
||||
if (criterion.onParts.length > 0) {
|
||||
const allArrived = criterion.onParts.every((part) => changedRefs.has(part.ref));
|
||||
if (!allArrived) return { kind: 'unsatisfied' };
|
||||
}
|
||||
if (criterion.ifPart === undefined) return { kind: 'satisfied' };
|
||||
if (criterion.semantic) return { kind: 'needs-judgement', statement: criterion.ifPart };
|
||||
return evaluate(criterion.ifPart, scope) ? { kind: 'satisfied' } : { kind: 'unsatisfied' };
|
||||
}
|
||||
|
||||
/* ── Enabled 集发布:引擎推进的终点,Agent 裁量的起点 ────────────────── */
|
||||
|
||||
export type EnabledEntry = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly type: PlanItem['type'];
|
||||
/** manualActivationRule = true:进入 Active 永远出自 Agent 的裁量启动 */
|
||||
readonly requiresManualStart: boolean;
|
||||
readonly discretionary: boolean;
|
||||
};
|
||||
|
||||
export type Redlight = { readonly criterion: string; readonly statement: string };
|
||||
|
||||
export type EnabledSet = {
|
||||
readonly entries: readonly EnabledEntry[];
|
||||
/** 待裁量评估的语义性前提 */
|
||||
readonly judgements: readonly Redlight[];
|
||||
/** 目标缺口:未绿的判据 */
|
||||
readonly gaps: readonly string[];
|
||||
/** 阶段位置:Active 的阶段 */
|
||||
readonly activeStages: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 前提前推(开展):任务视图变化 → 前提满足 → 新计划项 Enabled。
|
||||
* 「**Enabled ≠ Active**」—— 引擎按规范推进到 Enabled 为止;
|
||||
* 凡触达世界或产出关键交付的计划项必须 manualActivationRule = true。
|
||||
*/
|
||||
export function publishEnabled(
|
||||
model: CaseModel,
|
||||
runtime: CaseRuntime,
|
||||
scope: Readonly<Record<string, unknown>>,
|
||||
changedRefs: ReadonlySet<string>,
|
||||
evaluate: ConditionEvaluator,
|
||||
unmetCriteria: readonly string[] = [],
|
||||
): EnabledSet {
|
||||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||||
const entries: EnabledEntry[] = [];
|
||||
const judgements: Redlight[] = [];
|
||||
|
||||
for (const item of model.items) {
|
||||
const state = byId.get(item.id)?.state ?? 'Available';
|
||||
if (state !== 'Available' && state !== 'Enabled') continue;
|
||||
// 父阶段未激活的项不入场
|
||||
if (item.parent !== undefined && byId.get(item.parent)?.state !== 'Active') continue;
|
||||
|
||||
let satisfied = true;
|
||||
for (const criterion of item.entryCriteria) {
|
||||
const verdict = evaluateCriterion(criterion, scope, changedRefs, evaluate);
|
||||
if (verdict.kind === 'needs-judgement') {
|
||||
judgements.push({ criterion: criterion.id, statement: verdict.statement });
|
||||
satisfied = false;
|
||||
break;
|
||||
}
|
||||
if (verdict.kind === 'unsatisfied') {
|
||||
satisfied = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!satisfied) continue;
|
||||
|
||||
entries.push({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
requiresManualStart: item.manualActivation,
|
||||
discretionary: item.discretionary,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
judgements,
|
||||
gaps: unmetCriteria,
|
||||
activeStages: runtime.items
|
||||
.filter((i) => i.state === 'Active' && model.items.find((m) => m.id === i.id)?.type === 'stage')
|
||||
.map((i) => i.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判据后拉(完成):目标缺口 → 由什么推出 → 落到一个 Enabled 的计划项。
|
||||
* 「导航即交集,断点即人环:下一步 = 当下可为(Enabled 集)∩ 目标所需;
|
||||
* **交集为空即断点** —— 调人或立监听的判定时机。」
|
||||
*/
|
||||
export type Navigation =
|
||||
| { readonly kind: 'proceed'; readonly candidates: readonly EnabledEntry[] }
|
||||
/** 断点:目标所需的缺口无法由任何 Enabled 项补足 */
|
||||
| { readonly kind: 'breakpoint'; readonly gaps: readonly string[] }
|
||||
| { readonly kind: 'converged' };
|
||||
|
||||
export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Navigation {
|
||||
if (enabled.gaps.length === 0) return { kind: 'converged' };
|
||||
const intersection =
|
||||
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
|
||||
if (intersection.length === 0) return { kind: 'breakpoint', gaps: enabled.gaps };
|
||||
return { kind: 'proceed', candidates: intersection };
|
||||
}
|
||||
|
||||
/* ── 自动完成与终局 ─────────────────────────────────────────────────── */
|
||||
|
||||
/** 「齐了即收」(AutoComplete):必做项完成且无进行中项即结案 */
|
||||
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
|
||||
if (!model.autoComplete) return false;
|
||||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||||
const required = model.items.filter((i) => !i.discretionary && i.type !== 'timerEventListener');
|
||||
const allDone = required.every((i) => {
|
||||
const state = byId.get(i.id)?.state;
|
||||
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
|
||||
});
|
||||
const noneRunning = runtime.items.every((i) => i.state !== 'Active');
|
||||
return allDone && noneRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* 终局唯放行与 Harness(必须):
|
||||
* 案计划 Completed **必经放行卡**;Terminated 只出自 Harness 与负责人;
|
||||
* **AI 不得产生终局迁移**。
|
||||
*/
|
||||
export const TERMINAL_AUTHORITIES = ['release', 'harness', 'owner', 'governance'] as const;
|
||||
export type TerminalAuthority = (typeof TERMINAL_AUTHORITIES)[number];
|
||||
|
||||
export function mayCompleteCase(by: TerminalAuthority): boolean {
|
||||
return by === 'release';
|
||||
}
|
||||
|
||||
export function mayTerminateCase(by: TerminalAuthority): boolean {
|
||||
return by === 'harness' || by === 'owner' || by === 'governance';
|
||||
}
|
||||
|
||||
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
|
||||
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
return {
|
||||
...runtime,
|
||||
items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: 'Completed' } : i)),
|
||||
milestones: runtime.milestones.includes(itemId) ? runtime.milestones : [...runtime.milestones, itemId],
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemState): CaseRuntime {
|
||||
const current = runtime.items.find((i) => i.id === itemId);
|
||||
if (current === undefined) throw new IllegalTransitionError('(不存在)', to);
|
||||
assertItemTransition(current.state, to);
|
||||
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) };
|
||||
}
|
||||
|
||||
/** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */
|
||||
export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
return {
|
||||
...runtime,
|
||||
items: runtime.items.map((i) =>
|
||||
i.id === itemId ? { id: i.id, state: 'Available', instance: i.instance + 1 } : i,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* loop 回合驱动 —— 语义引擎 × Agent 回路(编码规范 §5.3 / §6.3;语义出处 AM 04)。
|
||||
*
|
||||
* 定位 → 择向 → 决断 → 行动 → 写数据 → 回到定位。
|
||||
* **回合由 usecase 驱动,不放开自由 maxSteps;预算在环外由 Harness 强制。**
|
||||
*/
|
||||
import type { LoopModel, TaskId } from '@contracts';
|
||||
import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract.ts';
|
||||
import { ForbiddenTerminalError } from '../../contract.ts';
|
||||
import type {
|
||||
AgentPort,
|
||||
CardGateway,
|
||||
Clock,
|
||||
ListenerRegistrar,
|
||||
LoopStateStore,
|
||||
MemoryRecorder,
|
||||
Sfeel,
|
||||
TaskContext,
|
||||
TaskDataGateway,
|
||||
ToolGateway,
|
||||
} from '../../port.ts';
|
||||
import { parseCmmn } from '../domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime } from '../domain/lifecycle.ts';
|
||||
import {
|
||||
autoCompleteReady,
|
||||
initialRuntime,
|
||||
navigate,
|
||||
occur,
|
||||
publishEnabled,
|
||||
reenter as reenterItem,
|
||||
transitionItem,
|
||||
} from '../domain/lifecycle.ts';
|
||||
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
|
||||
|
||||
export type TurnRunnerDeps = {
|
||||
readonly state: LoopStateStore;
|
||||
readonly sfeel: Sfeel;
|
||||
readonly data: TaskDataGateway;
|
||||
readonly agent: AgentPort;
|
||||
readonly tools: ToolGateway;
|
||||
readonly cards: CardGateway;
|
||||
readonly listeners: ListenerRegistrar;
|
||||
readonly memory: MemoryRecorder;
|
||||
readonly tasks: TaskContext;
|
||||
readonly clock: Clock;
|
||||
};
|
||||
|
||||
export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
async function loaded(taskId: TaskId) {
|
||||
const found = await deps.state.load(taskId);
|
||||
if (found === undefined) throw new Error(`loop 未装载:${taskId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 变化面:任务视图里有值的键 —— onPart 的「件到」判据 */
|
||||
function changedRefs(scope: Record<string, unknown>): Set<string> {
|
||||
return new Set(Object.keys(scope).filter((k) => scope[k] !== undefined && scope[k] !== null));
|
||||
}
|
||||
|
||||
async function publishFor(taskId: TaskId) {
|
||||
const { model, parsed, runtime } = await loaded(taskId);
|
||||
const scope = await deps.data.view(taskId);
|
||||
const gaps = parsed.items
|
||||
.filter((i) => !i.discretionary && runtime.items.find((r) => r.id === i.id)?.state !== 'Completed')
|
||||
.map((i) => i.name);
|
||||
return {
|
||||
model,
|
||||
parsed,
|
||||
runtime,
|
||||
scope,
|
||||
enabled: publishEnabled(parsed, runtime, scope, changedRefs(scope), deps.sfeel.condition, gaps),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyAction(taskId: TaskId, action: AgentAction, runtime: CaseRuntime): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status']; reason?: string }> {
|
||||
const context = await deps.tasks.of(taskId);
|
||||
|
||||
switch (action.kind) {
|
||||
case 'start': {
|
||||
// manualStart —— 进入 Active 永远出自 Agent 的裁量启动,留痕
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `manualStart:${action.itemId}`,
|
||||
detail: { by: 'agent' },
|
||||
});
|
||||
return { runtime: transitionItem(runtime, action.itemId, 'Active'), status: 'running' };
|
||||
}
|
||||
|
||||
case 'plan': {
|
||||
await deps.memory.append({ taskId, kind: 'engine-transition', title: `规划裁量项入场:${action.itemId}` });
|
||||
return { runtime: transitionItem(runtime, action.itemId, 'Enabled'), status: 'running' };
|
||||
}
|
||||
|
||||
case 'disable': {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `弃用计划项:${action.itemId}`,
|
||||
detail: { reason: action.reason },
|
||||
});
|
||||
return { runtime: transitionItem(runtime, action.itemId, 'Disabled'), status: 'running' };
|
||||
}
|
||||
|
||||
case 'judge': {
|
||||
// 语义性前提的裁量评估 —— 评估留痕,可回放可归因
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'evaluation',
|
||||
title: `语义性前提评估:${action.criterion} → ${action.satisfied ? '满足' : '未满足'}`,
|
||||
detail: { rationale: action.rationale },
|
||||
});
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
|
||||
case 'tool': {
|
||||
// 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地
|
||||
if (await deps.tools.isWriteEffect(action.tool)) {
|
||||
await deps.tools.registerIntent({
|
||||
taskId,
|
||||
tool: action.tool,
|
||||
input: action.input,
|
||||
label: `${action.itemId}:${action.tool}`,
|
||||
});
|
||||
return { runtime: transitionItem(runtime, action.itemId, 'Completed'), status: 'running' };
|
||||
}
|
||||
const result = await deps.tools.invoke({
|
||||
tool: action.tool,
|
||||
input: action.input,
|
||||
activityId: context.activityId,
|
||||
taskId,
|
||||
});
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `${action.tool} ${result.ok ? '返回' : '失败'}`,
|
||||
detail: { output: result.output, error: result.error?.message },
|
||||
});
|
||||
return {
|
||||
runtime: transitionItem(runtime, action.itemId, result.ok ? 'Completed' : 'Failed'),
|
||||
status: 'running',
|
||||
};
|
||||
}
|
||||
|
||||
case 'human': {
|
||||
// 调人 = 立监听听人:调用即 Active(阻塞),回传驱动前提
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
humanCallCap: context.humanCallCap,
|
||||
workOrder: action.workOrder,
|
||||
card: action.card,
|
||||
});
|
||||
await deps.listeners.register({
|
||||
taskId,
|
||||
eventType: 'human',
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: transitionItem(runtime, action.itemId, 'Active'), status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'listen': {
|
||||
// AI 不能调用世界:对世界只有两种合法姿势 —— 触达它(动作)与等它(监听)
|
||||
await deps.listeners.register({
|
||||
taskId,
|
||||
eventType: action.eventType,
|
||||
keyFilter: action.keyFilter,
|
||||
deadline: action.deadline,
|
||||
});
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `立监听:${action.eventType}(时限 ${action.deadline})`,
|
||||
detail: action.keyFilter,
|
||||
});
|
||||
return { runtime, status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'submit': {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'thought',
|
||||
title: '判定成果已齐,提交放行',
|
||||
detail: { rationale: action.rationale },
|
||||
});
|
||||
// 终局唯放行:引擎不把案计划置为 Completed —— 那是 release 的事
|
||||
return { runtime, status: 'submitted' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async load(taskId, model: LoopModel) {
|
||||
const parsed = parseCmmn(model.cmmnXml);
|
||||
const runtime = initialRuntime(parsed);
|
||||
await deps.state.save(taskId, model, parsed, runtime);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `装载案例模型:${parsed.name}(${parsed.items.length} 个计划项)`,
|
||||
});
|
||||
return runtime;
|
||||
},
|
||||
|
||||
async runtime(taskId) {
|
||||
return (await deps.state.load(taskId))?.runtime;
|
||||
},
|
||||
|
||||
async publish(taskId) {
|
||||
return (await publishFor(taskId)).enabled;
|
||||
},
|
||||
|
||||
/** 一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
|
||||
async runTurn(taskId): Promise<TurnOutcome> {
|
||||
const { model, parsed, runtime, scope, enabled } = await publishFor(taskId);
|
||||
|
||||
// 齐了即收:必做项完成且无进行中项 → 提交放行(仍必经放行卡)
|
||||
if (autoCompleteReady(parsed, runtime)) {
|
||||
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'submitted' };
|
||||
}
|
||||
|
||||
// 导航即交集:当下可为 ∩ 目标所需
|
||||
const navigation = navigate(enabled, new Set(enabled.entries.map((e) => e.id)));
|
||||
if (navigation.kind === 'breakpoint') {
|
||||
// 断点即人环 —— 由 Agent 定夺调人还是立监听,判定留痕
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'thought',
|
||||
title: '依赖断点:当下可为 ∩ 目标所需 = 空',
|
||||
detail: { gaps: navigation.gaps },
|
||||
});
|
||||
}
|
||||
|
||||
const narrative = [
|
||||
model.narrative,
|
||||
...(await deps.memory.loadActivityMemory((await deps.tasks.of(taskId)).activityId, 20)).map(
|
||||
(m) => `[${m.layer}] ${m.statement}`,
|
||||
),
|
||||
].join('\n\n');
|
||||
|
||||
const action = await deps.agent.decide({ taskId, narrative, enabled, scope });
|
||||
|
||||
// AI 不得产生终局迁移
|
||||
if (action.kind === 'start') {
|
||||
const item = parsed.items.find((i) => i.id === action.itemId);
|
||||
if (item === undefined) throw new ForbiddenTerminalError('owner');
|
||||
}
|
||||
|
||||
const applied = await applyAction(taskId, action, runtime);
|
||||
await deps.state.save(taskId, model, parsed, applied.runtime);
|
||||
|
||||
return {
|
||||
runtime: applied.runtime,
|
||||
enabled,
|
||||
navigation,
|
||||
action,
|
||||
status: applied.status,
|
||||
...(applied.reason === undefined ? {} : { blockedReason: applied.reason }),
|
||||
};
|
||||
},
|
||||
|
||||
/** 事件驱动续跑:挂起期间事件不丢,恢复即补投 */
|
||||
async onEvent(taskId, event) {
|
||||
const { model, parsed, runtime } = await loaded(taskId);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `事件到达(${event.kind}):${event.ref}`,
|
||||
detail: event.payload,
|
||||
});
|
||||
|
||||
let next = runtime;
|
||||
// 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed
|
||||
const listener = parsed.items.find((i) => i.id === event.ref && i.type.endsWith('EventListener'));
|
||||
if (listener !== undefined) next = occur(runtime, listener.id);
|
||||
|
||||
await deps.state.save(taskId, model, parsed, next);
|
||||
// 前提前推:事件写数据 → 任务视图变化 → 前提求值 → 新项 Enabled
|
||||
return this.runTurn(taskId);
|
||||
},
|
||||
|
||||
/** 能算的不问 LLM:输入齐备的决策表确定性求值,命中行留痕 */
|
||||
async decide(taskId, decision) {
|
||||
const { model } = await loaded(taskId);
|
||||
const scope = await deps.data.view(taskId);
|
||||
const order = evaluationOrder(model.dmn, decision);
|
||||
const enriched: Record<string, unknown> = { ...scope };
|
||||
let last = undefined as ReturnType<typeof evaluateTable> | undefined;
|
||||
|
||||
for (const step of order) {
|
||||
const table = model.dmn.tables.find((t) => t.id === step);
|
||||
if (table === undefined) continue;
|
||||
const result = evaluateTable(table, enriched, deps.sfeel.expression, deps.sfeel.unaryTest);
|
||||
last = result;
|
||||
if (result.kind !== 'decided') break;
|
||||
Object.assign(enriched, result.trace.outputs);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'evaluation',
|
||||
title: `DMN 求值:${table.label} → 命中 ${result.trace.hitRules.join('、')}`,
|
||||
detail: { inputs: result.trace.inputs, outputs: result.trace.outputs, table: table.id },
|
||||
});
|
||||
}
|
||||
|
||||
return last ?? { kind: 'no-match', inputs: {} };
|
||||
},
|
||||
|
||||
/** 驳回:相关计划项以新实例重新入场(repetitionRule),记为纠正信号 */
|
||||
async reenter(taskId, itemId) {
|
||||
const { model, parsed, runtime } = await loaded(taskId);
|
||||
const next = reenterItem(runtime, itemId);
|
||||
await deps.state.save(taskId, model, parsed, next);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `驳回重入:${itemId}(新实例)`,
|
||||
});
|
||||
return next;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/loop-engine",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* module: loop-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { LoopModel, StaffId, TaskId } from '@contracts';
|
||||
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts';
|
||||
import type { AgentAction } from './contract.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
|
||||
/** 运行时实例状态 —— loop 的模型定位与就绪集(OS 09 三份持久状态之一) */
|
||||
export type LoopStateStore = {
|
||||
save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime): Promise<void>;
|
||||
load(taskId: TaskId): Promise<{ model: LoopModel; parsed: CaseModel; runtime: CaseRuntime } | undefined>;
|
||||
};
|
||||
|
||||
/** S-FEEL —— app 注入 `@sfeel`;引擎零 LLM */
|
||||
export type Sfeel = {
|
||||
condition(expression: string, scope: Readonly<Record<string, unknown>>): boolean;
|
||||
expression(expression: string, scope: Readonly<Record<string, unknown>>): unknown;
|
||||
unaryTest(cell: string, value: unknown, scope: Readonly<Record<string, unknown>>): boolean;
|
||||
};
|
||||
|
||||
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
|
||||
export type TaskDataGateway = {
|
||||
view(taskId: TaskId): Promise<Record<string, unknown>>;
|
||||
draftWrite(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。
|
||||
* **回合由 usecase 驱动,不放开自由 maxSteps,预算在环外由 Harness 强制。**
|
||||
*/
|
||||
export type AgentPort = {
|
||||
decide(input: {
|
||||
taskId: TaskId;
|
||||
/** 叙事面:说明书 + 活动记忆选摘 + 任务记忆窗口 */
|
||||
narrative: string;
|
||||
enabled: EnabledSet;
|
||||
scope: Record<string, unknown>;
|
||||
}): Promise<AgentAction>;
|
||||
};
|
||||
|
||||
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
|
||||
export type ToolGateway = {
|
||||
invoke(input: { tool: string; input: Record<string, unknown>; activityId: string; taskId: TaskId }): Promise<{
|
||||
ok: boolean;
|
||||
output?: Record<string, unknown>;
|
||||
error?: { message: string };
|
||||
}>;
|
||||
isWriteEffect(tool: string): Promise<boolean>;
|
||||
registerIntent(input: { taskId: TaskId; tool: string; input: Record<string, unknown>; label: string }): Promise<void>;
|
||||
};
|
||||
|
||||
export type CardGateway = {
|
||||
issue(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
workOrder: 'execution' | 'acceptance';
|
||||
card: Record<string, unknown>;
|
||||
}): Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export type ListenerRegistrar = {
|
||||
register(input: {
|
||||
taskId: TaskId;
|
||||
eventType: 'data' | 'time' | 'human';
|
||||
keyFilter: Record<string, string>;
|
||||
deadline: string;
|
||||
}): Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
/** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */
|
||||
loadActivityMemory(activityId: string, limit: number): Promise<Array<{ layer: string; statement: string }>>;
|
||||
};
|
||||
|
||||
export type TaskContext = {
|
||||
of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
# module: skill-engine
|
||||
|
||||
职责: skill 范式:技能目录的渐进披露、装配前的扩权检查、结晶片段脚本(确定性动作链)的运行、
|
||||
装配信封的戳
|
||||
不负责: 技能的存放与版本(见 activity-registry,技能是活动的资产之一)、
|
||||
工具执行(见 toolbox 的 ToolInvoker)、何时装配哪些技能(loop 临场 / flow 步引用)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`SkillEngineService` `RunScriptInput` `ScriptOutcome` `SkillIndexEntry` `SkillStamp`
|
||||
- 错误:`SkillPowerEscalationError`
|
||||
- 端口类型:`SkillCatalog` `ScriptToolInvoker` `MemoryRecorder` `Clock`
|
||||
- 用例工厂:`createSkillEngineService(deps): SkillEngineService`
|
||||
- 纯函数:`checkSkillPower` `checkCatalog` `skillIndex` `stampOf` `crystallizedChain` `interpolate`
|
||||
|
||||
层级: L4(编码规范 §4;置于 L4 因 loop/flow 两范式依赖它 —— 临场装配 / 步引用,
|
||||
import 恒向下、免 port)
|
||||
依赖: @contracts;port:`SkillCatalog`(app 注入 activity-registry)、
|
||||
`ScriptToolInvoker`(app 注入 toolbox)、`MemoryRecorder`(app 注入 memory)
|
||||
被依赖: loop-engine, flow-engine, task
|
||||
|
||||
不变量(AM 08):
|
||||
- **载方法,不载权力**:结晶片段引用白名单外的工具即 `SkillPowerEscalationError`;
|
||||
技能不得扩大白名单、授权边界与禁区
|
||||
- **脚本按纯工具规矩**:每步经 `ToolInvoker` 调用而留痕 —— 不得内联绕过,不得成为绕过留痕的暗道
|
||||
- **装配入信封**:`stamp()` 给出技能 ID × 版本,记入任务记忆 —— 回放与归因依赖之
|
||||
- **渐进披露**:常驻上下文只有 `index()` 的目录;正文按事 `load()`
|
||||
- 技能**不是独立范式**:无自己的约束体系,一切继承本规范
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* module: skill-engine —— 对外类型(编码规范 §10.1;语义出处 AM 08)。
|
||||
*
|
||||
* 技能不是独立范式:没有自己的约束体系 —— 留痕、治理、权力边界与生命周期全部继承本规范。
|
||||
* **内容软**(方法正文自由,用不用由 AI 决断),**边框硬**(装配入信封 · 脚本注册留痕 ·
|
||||
* 不得扩权 · 版本过进化关口)。
|
||||
*/
|
||||
import type { Skill, TaskId } from '@contracts';
|
||||
import type {
|
||||
AssemblyContext,
|
||||
CrystallizedStep,
|
||||
SkillIndexEntry,
|
||||
SkillStamp,
|
||||
SkillViolation,
|
||||
} from './internal/domain/skill-assembly.ts';
|
||||
|
||||
export type { AssemblyContext, CrystallizedStep, Skill, SkillIndexEntry, SkillStamp, SkillViolation };
|
||||
|
||||
export class SkillPowerEscalationError extends Error {
|
||||
constructor(readonly violations: readonly SkillViolation[]) {
|
||||
super(`技能试图扩权:${violations.map((v) => v.message).join('; ')}`);
|
||||
this.name = 'SkillPowerEscalationError';
|
||||
}
|
||||
}
|
||||
|
||||
export type RunScriptInput = {
|
||||
readonly taskId: TaskId;
|
||||
readonly activityId: string;
|
||||
readonly skillId: string;
|
||||
/** 任务数据 —— 结晶片段的入参插值面 */
|
||||
readonly scope: Readonly<Record<string, unknown>>;
|
||||
readonly stepNo?: number;
|
||||
};
|
||||
|
||||
export type ScriptOutcome = {
|
||||
readonly ok: boolean;
|
||||
readonly steps: ReadonlyArray<{ tool: string; ok: boolean; output?: Record<string, unknown>; error?: string }>;
|
||||
readonly stamp: readonly SkillStamp[];
|
||||
};
|
||||
|
||||
export type SkillEngineService = {
|
||||
/** 常驻上下文只有目录 —— 渐进披露 */
|
||||
index(activityId: string): Promise<SkillIndexEntry[]>;
|
||||
/** 按事加载正文;装配前过扩权检查 */
|
||||
load(activityId: string, skillIds: readonly string[]): Promise<Skill[]>;
|
||||
/** 结晶片段脚本 = 确定性动作链;经 ToolInvoker 调用而留痕,不得内联绕过 */
|
||||
runScript(input: RunScriptInput): Promise<ScriptOutcome>;
|
||||
/** 装配入信封 —— 供任务记忆盖戳 */
|
||||
stamp(activityId: string, skillIds: readonly string[]): Promise<SkillStamp[]>;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* module: skill-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
AssemblyContext,
|
||||
CrystallizedStep,
|
||||
RunScriptInput,
|
||||
ScriptOutcome,
|
||||
Skill,
|
||||
SkillEngineService,
|
||||
SkillIndexEntry,
|
||||
SkillStamp,
|
||||
SkillViolation,
|
||||
} from './contract.ts';
|
||||
export { SkillPowerEscalationError } from './contract.ts';
|
||||
|
||||
export type { Clock, MemoryRecorder, ScriptToolInvoker, SkillCatalog } from './port.ts';
|
||||
|
||||
export type { SkillEngineDeps } from './internal/usecase/skill-engine-service.ts';
|
||||
export { createSkillEngineService } from './internal/usecase/skill-engine-service.ts';
|
||||
|
||||
export {
|
||||
checkCatalog,
|
||||
checkSkillPower,
|
||||
crystallizedChain,
|
||||
interpolate,
|
||||
skillIndex,
|
||||
stampOf,
|
||||
} from './internal/domain/skill-assembly.ts';
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 技能装配 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 08)。
|
||||
*
|
||||
* 「白名单回答『能用什么』(权力),技能回答『怎么用好』(方法)—— 棋子确定,棋谱是资产。」
|
||||
*
|
||||
* 三层软硬:
|
||||
* 工具白名单 = 硬约束(权力,契约定)—— 不在名单调不动
|
||||
* 技能清单 = 软资源(方法,包声明)—— AI 可用可不用,**可以越谱,不可越权**
|
||||
* 临场路径 = 自由(由事决定,loop)—— 受上两层约束
|
||||
*/
|
||||
import type { Skill } from '@contracts';
|
||||
|
||||
export type AssemblyContext = {
|
||||
/** 本活动的工具白名单 —— 硬约束 */
|
||||
readonly allowlist: readonly string[];
|
||||
/** 本活动可装配的技能集合 —— 软资源 */
|
||||
readonly catalog: readonly Skill[];
|
||||
};
|
||||
|
||||
export type SkillViolation = { readonly skillId: string; readonly tool: string; readonly message: string };
|
||||
|
||||
/**
|
||||
* 载方法,不载权力(必须)——
|
||||
* 「技能不得扩大白名单、授权边界与禁区;技能提及的工具不在本活动白名单者,**不可调**。」
|
||||
*/
|
||||
export function checkSkillPower(skill: Skill, allowlist: readonly string[]): SkillViolation[] {
|
||||
return skill.crystallized
|
||||
.filter((step) => !allowlist.includes(step.tool))
|
||||
.map((step) => ({
|
||||
skillId: skill.id,
|
||||
tool: step.tool,
|
||||
message: `技能 ${skill.label} 的结晶片段引用了白名单外的工具 ${step.tool} —— 载方法不载权力`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function checkCatalog(context: AssemblyContext): SkillViolation[] {
|
||||
return context.catalog.flatMap((skill) => checkSkillPower(skill, context.allowlist));
|
||||
}
|
||||
|
||||
/** 技能目录:常驻上下文里只有它 —— 正文按事加载,引用按需下钻(渐进披露) */
|
||||
export type SkillIndexEntry = { readonly id: string; readonly label: string; readonly applicability: string };
|
||||
|
||||
export function skillIndex(catalog: readonly Skill[]): SkillIndexEntry[] {
|
||||
return catalog.map((s) => ({ id: s.id, label: s.label, applicability: s.applicability }));
|
||||
}
|
||||
|
||||
/** 装配入信封:每步装配了哪些技能及其版本,记入任务记忆 —— 回放与归因依赖之 */
|
||||
export type SkillStamp = { readonly id: string; readonly version: string };
|
||||
|
||||
export function stampOf(skills: readonly Skill[]): SkillStamp[] {
|
||||
return skills.map((s) => ({ id: s.id, version: s.version }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 结晶片段脚本 = 确定性动作链。
|
||||
* 「脚本必须**注册进工具表、经运行时调用而留痕**、无环境能力;不得成为绕过留痕的暗道。」
|
||||
*/
|
||||
export type CrystallizedStep = { readonly tool: string; readonly input: Readonly<Record<string, string>> };
|
||||
|
||||
export function crystallizedChain(skill: Skill): CrystallizedStep[] {
|
||||
return skill.crystallized.map((s) => ({ tool: s.tool, input: s.input }));
|
||||
}
|
||||
|
||||
/** 入参插值:`{{path}}` 从任务数据取值 —— 纯字符串替换,零求值 */
|
||||
export function interpolate(
|
||||
input: Readonly<Record<string, string>>,
|
||||
scope: Readonly<Record<string, unknown>>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, template] of Object.entries(input)) {
|
||||
const exact = /^\{\{\s*([\w.]+)\s*\}\}$/.exec(template);
|
||||
if (exact !== null) {
|
||||
out[key] = resolvePath(scope, exact[1]!);
|
||||
continue;
|
||||
}
|
||||
out[key] = template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => {
|
||||
const value = resolvePath(scope, path);
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolvePath(scope: Readonly<Record<string, unknown>>, path: string): unknown {
|
||||
let current: unknown = scope;
|
||||
for (const segment of path.split('.')) {
|
||||
if (current === null || current === undefined || typeof current !== 'object') return undefined;
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* skill-engine 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
|
||||
*/
|
||||
import type { Skill } from '@contracts';
|
||||
import type { RunScriptInput, ScriptOutcome, SkillEngineService } from '../../contract.ts';
|
||||
import { SkillPowerEscalationError } from '../../contract.ts';
|
||||
import type { Clock, MemoryRecorder, ScriptToolInvoker, SkillCatalog } from '../../port.ts';
|
||||
import { checkSkillPower, crystallizedChain, interpolate, skillIndex, stampOf } from '../domain/skill-assembly.ts';
|
||||
|
||||
export type SkillEngineDeps = {
|
||||
readonly catalog: SkillCatalog;
|
||||
readonly tools: ScriptToolInvoker;
|
||||
readonly memory: MemoryRecorder;
|
||||
readonly clock: Clock;
|
||||
};
|
||||
|
||||
export function createSkillEngineService(deps: SkillEngineDeps): SkillEngineService {
|
||||
async function assemble(activityId: string, skillIds: readonly string[]): Promise<Skill[]> {
|
||||
const [all, allowlist] = await Promise.all([
|
||||
deps.catalog.skillsOf(activityId),
|
||||
deps.catalog.allowlistOf(activityId),
|
||||
]);
|
||||
const picked = all.filter((s) => skillIds.includes(s.id));
|
||||
// 载方法不载权力:装配前先查扩权
|
||||
const violations = picked.flatMap((s) => checkSkillPower(s, allowlist));
|
||||
if (violations.length > 0) throw new SkillPowerEscalationError(violations);
|
||||
return picked;
|
||||
}
|
||||
|
||||
return {
|
||||
async index(activityId) {
|
||||
return skillIndex(await deps.catalog.skillsOf(activityId));
|
||||
},
|
||||
|
||||
async load(activityId, skillIds) {
|
||||
return assemble(activityId, skillIds);
|
||||
},
|
||||
|
||||
async stamp(activityId, skillIds) {
|
||||
return stampOf(await assemble(activityId, skillIds));
|
||||
},
|
||||
|
||||
/** 结晶片段脚本照谱执行 —— 每步经 ToolInvoker,留痕不可绕过 */
|
||||
async runScript(input: RunScriptInput): Promise<ScriptOutcome> {
|
||||
const [skill] = await assemble(input.activityId, [input.skillId]);
|
||||
if (skill === undefined) {
|
||||
return { ok: false, steps: [], stamp: [] };
|
||||
}
|
||||
|
||||
const chain = crystallizedChain(skill);
|
||||
const steps: Array<{ tool: string; ok: boolean; output?: Record<string, unknown>; error?: string }> = [];
|
||||
let ok = true;
|
||||
|
||||
for (const [index, step] of chain.entries()) {
|
||||
const payload = interpolate(step.input, input.scope);
|
||||
await deps.memory.append({
|
||||
taskId: input.taskId,
|
||||
kind: 'action',
|
||||
title: `技能 ${skill.label} · 结晶片段 ${index + 1}:${step.tool}`,
|
||||
detail: { skillId: skill.id, version: skill.version, input: payload },
|
||||
});
|
||||
const result = await deps.tools.invoke({
|
||||
tool: step.tool,
|
||||
input: payload,
|
||||
activityId: input.activityId,
|
||||
taskId: input.taskId,
|
||||
...(input.stepNo === undefined ? {} : { stepNo: input.stepNo * 100 + index }),
|
||||
});
|
||||
steps.push({
|
||||
tool: step.tool,
|
||||
ok: result.ok,
|
||||
...(result.output === undefined ? {} : { output: result.output }),
|
||||
...(result.error === undefined ? {} : { error: result.error.message }),
|
||||
});
|
||||
await deps.memory.append({
|
||||
taskId: input.taskId,
|
||||
kind: 'result',
|
||||
title: `${step.tool} ${result.ok ? '成功' : '失败'}`,
|
||||
detail: { output: result.output, error: result.error?.message },
|
||||
});
|
||||
if (!result.ok) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok, steps, stamp: stampOf([skill]) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/skill-engine",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* module: skill-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { Skill, TaskId } from '@contracts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
|
||||
/** 技能目录 —— app 注入 `activity-registry`(L4 同层):技能是活动的资产之一 */
|
||||
export type SkillCatalog = {
|
||||
skillsOf(activityId: string): Promise<Skill[]>;
|
||||
allowlistOf(activityId: string): Promise<string[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具调用 —— app 注入 `toolbox`(L2)的 `ToolInvoker`。
|
||||
* 脚本引用的工具**仍过本活动白名单与调用契约** —— 载方法不载权力。
|
||||
*/
|
||||
export type ScriptToolInvoker = {
|
||||
invoke(input: {
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
activityId: string;
|
||||
taskId: TaskId;
|
||||
stepNo?: number;
|
||||
}): Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { message: string } }>;
|
||||
};
|
||||
|
||||
export type MemoryRecorder = {
|
||||
append(input: { taskId: TaskId; kind: 'action' | 'result'; title: string; detail?: Record<string, unknown> }): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
# module: task
|
||||
|
||||
职责: 任务运行时:生命周期与**终态五名裁定**、Harness 预算执行器 / 停滞熔断、
|
||||
控制三语义、改派、断点续跑的就绪判定、封卷与蒸馏钩子的触发
|
||||
不负责: 范式的推进逻辑(见 loop-engine / flow-engine,经 `ParadigmEngine` 端口)、
|
||||
放行与落地(见 release)、工作流的编排与聚合(见 process)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`TaskService` `Task` `StartTaskInput` `TickOutcome` `Consumption` `HarnessVerdict`
|
||||
`ControlOutcome` `TerminalCause` `ResumeReadiness`
|
||||
- 错误:`TaskNotFoundError` `TaskAlreadyTerminalError`
|
||||
- 端口类型:`TaskStore` `ActivityLoader` `SchemaValidator` `ParadigmEngine` `MemoryRecorder`
|
||||
`ListenerProbe` `PersistenceProbe` `TaskEventPublisher` `ReassignNotifier`
|
||||
`InteractionPhaseSetter`
|
||||
- 用例工厂:`createTaskService(deps): TaskService`
|
||||
- 纯函数:`judge` `budgetBreaches` `isStalled` `terminalOf` `durationMs`
|
||||
`applyControl` `decideTerminal` `isSuccess` `triggersFailureDisposition`
|
||||
`resumeReadiness` `isPassiveOpen` `landingBlocked` `rejectionKeepsRunning`
|
||||
- adapter:`createTaskStore`;Drizzle 表:`taskTable`
|
||||
|
||||
层级: L6(编码规范 §4)
|
||||
依赖: @contracts;@shared/db;loop-engine / flow-engine / activity-registry(L5/L4);
|
||||
port 注入:memory / event / message / interaction / ledger
|
||||
被依赖: process, org(停用前置检查的在途任务), report, app
|
||||
|
||||
不变量(OS 04 / 4.3,AM 10 / 12.1):
|
||||
- **恰一负责人**:任务必绑定恰一个 `ownerId`;改派**留痕并通知双方**
|
||||
- **输入必过 schema**:`data` 缺项**不得启动**;`message` 计入初始观察但不得扩大授权
|
||||
- **预算环外强制**:每一拍先 `judge` 再交引擎 —— 活动代码无从自查自守;
|
||||
任一项触顶即 `exhausted`,空转即 `stalled`
|
||||
- **监听中是健康态**:有监听在等或等人回传一律**不算停滞**
|
||||
- **终态五名封闭**:`decideTerminal` 是唯一命名口;`released` 是唯一成功终态
|
||||
- **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` ——
|
||||
流级重做一律重开新实例,不复活旧任务
|
||||
- **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号**
|
||||
- **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目
|
||||
- **断点续跑靠三份持久状态**:任务记忆 / 草稿层 / 运行时实例状态,缺一即不可续
|
||||
@@ -0,0 +1,158 @@
|
||||
/** Harness 与终态五名裁定 —— domain 测试零 mock(OS 04 / 4.3,AM 10) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BudgetCaps } from '@contracts';
|
||||
import { budgetBreaches, durationMs, isStalled, judge, terminalOf, ZERO_CONSUMPTION } from '../internal/domain/harness.ts';
|
||||
import {
|
||||
applyControl,
|
||||
decideTerminal,
|
||||
isPassiveOpen,
|
||||
isSuccess,
|
||||
isTerminalControl,
|
||||
landingBlocked,
|
||||
rejectionKeepsRunning,
|
||||
resumeReadiness,
|
||||
triggersFailureDisposition,
|
||||
} from '../internal/domain/lifecycle.ts';
|
||||
|
||||
const caps: BudgetCaps = { steps: 50, tokens: 100_000, activeMinutes: 120, humanCalls: 5 };
|
||||
|
||||
describe('预算执行器 —— 环外强制,活动不自查自守', () => {
|
||||
it('未越限即无 breach', () => {
|
||||
expect(budgetBreaches({ steps: 10, tokens: 100, activeMs: 1000, humanCalls: 1 }, caps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('任一项触顶即熔断', () => {
|
||||
expect(budgetBreaches({ ...ZERO_CONSUMPTION, steps: 50 }, caps).map((b) => b.item)).toEqual(['steps']);
|
||||
expect(budgetBreaches({ ...ZERO_CONSUMPTION, tokens: 100_000 }, caps).map((b) => b.item)).toEqual(['tokens']);
|
||||
expect(budgetBreaches({ ...ZERO_CONSUMPTION, humanCalls: 5 }, caps).map((b) => b.item)).toEqual(['humanCalls']);
|
||||
});
|
||||
|
||||
it('活跃时长按分钟折算', () => {
|
||||
expect(budgetBreaches({ ...ZERO_CONSUMPTION, activeMs: 120 * 60_000 }, caps).map((b) => b.item)).toEqual(['activeMs']);
|
||||
expect(budgetBreaches({ ...ZERO_CONSUMPTION, activeMs: 119 * 60_000 }, caps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('多项同时越限全部报出', () => {
|
||||
expect(budgetBreaches({ steps: 99, tokens: 999_999, activeMs: 0, humanCalls: 0 }, caps)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('停滞检测 —— 监听中是健康态', () => {
|
||||
const base = { sinceLastProgressMs: 60 * 60_000, stallAfterMs: 30 * 60_000, hasArmedListener: false, awaitingHumanReply: false };
|
||||
|
||||
it('该动未动、超限仍无推进即停滞', () => {
|
||||
expect(isStalled(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('有监听在等一律不算停滞 —— 与停滞分别对待', () => {
|
||||
expect(isStalled({ ...base, hasArmedListener: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('等人回传也不算停滞', () => {
|
||||
expect(isStalled({ ...base, awaitingHumanReply: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('未到阈值不算停滞', () => {
|
||||
expect(isStalled({ ...base, sinceLastProgressMs: 10 * 60_000 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('judge —— 预算优先于停滞', () => {
|
||||
const stall = { sinceLastProgressMs: 999_999_999, stallAfterMs: 1000, hasArmedListener: false, awaitingHumanReply: false };
|
||||
|
||||
it('越限即 exhausted', () => {
|
||||
const verdict = judge({ ...ZERO_CONSUMPTION, steps: 50 }, caps, stall);
|
||||
expect(verdict.kind).toBe('exhausted');
|
||||
expect(terminalOf(verdict)).toBe('exhausted');
|
||||
});
|
||||
|
||||
it('未越限但空转即 stalled', () => {
|
||||
const verdict = judge(ZERO_CONSUMPTION, caps, stall);
|
||||
expect(verdict.kind).toBe('stalled');
|
||||
expect(terminalOf(verdict)).toBe('stalled');
|
||||
});
|
||||
|
||||
it('都没事即 ok,不产生终态', () => {
|
||||
const verdict = judge(ZERO_CONSUMPTION, caps, { ...stall, hasArmedListener: true });
|
||||
expect(verdict).toEqual({ kind: 'ok' });
|
||||
expect(terminalOf(verdict)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('控制三语义', () => {
|
||||
it('暂停 = 可恢复冻结', () => {
|
||||
expect(applyControl('pause')).toEqual({ kind: 'suspended', resumable: true });
|
||||
});
|
||||
|
||||
it('叫停 = 挂起 + 最重档纠正信号,恢复归负责人', () => {
|
||||
expect(applyControl('halt')).toEqual({ kind: 'halted', resumable: true, correctionSignal: 'heaviest' });
|
||||
});
|
||||
|
||||
it('终止 = 不可恢复关闭', () => {
|
||||
expect(applyControl('terminate')).toEqual({ kind: 'terminated', resumable: false, terminal: 'terminated' });
|
||||
});
|
||||
|
||||
it('只有终止是终态 —— 叫停不是', () => {
|
||||
expect(isTerminalControl('halt')).toBe(false);
|
||||
expect(isTerminalControl('pause')).toBe(false);
|
||||
expect(isTerminalControl('terminate')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('终态五名 —— 系统命名,封闭枚举', () => {
|
||||
it('放行完成是唯一成功终态', () => {
|
||||
expect(decideTerminal({ kind: 'released' }).terminal).toBe('released');
|
||||
expect(isSuccess('released')).toBe(true);
|
||||
for (const t of ['blocked', 'stalled', 'exhausted', 'terminated'] as const) expect(isSuccess(t)).toBe(false);
|
||||
});
|
||||
|
||||
it('五名各有其因', () => {
|
||||
expect(decideTerminal({ kind: 'blocked', reason: '监听时限走完' }).terminal).toBe('blocked');
|
||||
expect(decideTerminal({ kind: 'stalled', evidence: '空转' }).terminal).toBe('stalled');
|
||||
expect(decideTerminal({ kind: 'exhausted', detail: 'token' }).terminal).toBe('exhausted');
|
||||
expect(decideTerminal({ kind: 'terminated', reason: '治理台终止' }).terminal).toBe('terminated');
|
||||
});
|
||||
|
||||
it('非完成终态触发流程层失败走向', () => {
|
||||
expect(triggersFailureDisposition('released')).toBe(false);
|
||||
expect(triggersFailureDisposition('blocked')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('「不是终态」的三样', () => {
|
||||
it('驳回是环内回转,任务继续', () => {
|
||||
expect(rejectionKeepsRunning()).toEqual({ runtime: 'running' });
|
||||
});
|
||||
it('落地受阻是运行态', () => {
|
||||
expect(landingBlocked('连接超时')).toEqual({ runtime: 'landing-blocked', reason: '连接超时' });
|
||||
});
|
||||
it('打开任务只是打开呈现', () => {
|
||||
expect(isPassiveOpen('running')).toBe(false);
|
||||
expect(isPassiveOpen('awaiting-event')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('断点续跑 —— 三份持久状态共同保证', () => {
|
||||
it('三样齐备才算可续', () => {
|
||||
expect(resumeReadiness({ 'task-memory': true, 'draft-layer': true, 'runtime-state': true })).toEqual({
|
||||
ready: true,
|
||||
missing: [],
|
||||
});
|
||||
});
|
||||
it('缺一即不可续,并指出缺谁', () => {
|
||||
expect(resumeReadiness({ 'task-memory': true, 'draft-layer': false, 'runtime-state': true })).toEqual({
|
||||
ready: false,
|
||||
missing: ['draft-layer'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('durationMs', () => {
|
||||
it('解析 ISO-8601', () => {
|
||||
expect(durationMs('PT30M')).toBe(30 * 60_000);
|
||||
expect(durationMs('P1D')).toBe(86_400_000);
|
||||
});
|
||||
it('非法即报错', () => {
|
||||
expect(() => durationMs('30分钟')).toThrow(/非法时长/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* module: task —— 对外类型(编码规范 §10.1;语义出处 OS 04,AM 01/10)。
|
||||
*
|
||||
* 任务运行时:生命周期与**终态五名裁定**、Harness 预算执行器 / 停滞熔断、
|
||||
* 控制三语义、改派、断点续跑。范式(loop / flow)在此汇流 —— 对流程层与交互层不可区分。
|
||||
*/
|
||||
import type { BudgetCaps, ControlSemantic, Paradigm, StaffId, TaskId, TaskRuntimeState, TerminalState } from '@contracts';
|
||||
import type { BudgetBreach, Consumption, HarnessVerdict } from './internal/domain/harness.ts';
|
||||
import type { ControlOutcome, ResumeReadiness, TaskSnapshot, TerminalCause } from './internal/domain/lifecycle.ts';
|
||||
|
||||
export type {
|
||||
BudgetBreach,
|
||||
BudgetCaps,
|
||||
Consumption,
|
||||
ControlOutcome,
|
||||
ControlSemantic,
|
||||
HarnessVerdict,
|
||||
Paradigm,
|
||||
ResumeReadiness,
|
||||
TaskRuntimeState,
|
||||
TaskSnapshot,
|
||||
TerminalCause,
|
||||
TerminalState,
|
||||
};
|
||||
|
||||
export class TaskNotFoundError extends Error {
|
||||
constructor(readonly taskId: TaskId) {
|
||||
super(`任务不存在:${taskId}`);
|
||||
this.name = 'TaskNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskAlreadyTerminalError extends Error {
|
||||
constructor(
|
||||
readonly taskId: TaskId,
|
||||
readonly terminal: TerminalState,
|
||||
) {
|
||||
super(`任务已终结(${terminal}),不得再推进:${taskId} —— 流级重做一律重开新实例`);
|
||||
this.name = 'TaskAlreadyTerminalError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 一单任务 —— 活动的运行时实例;**恰有一名负责人** */
|
||||
export type Task = {
|
||||
readonly id: TaskId;
|
||||
readonly activityId: string;
|
||||
readonly paradigm: Paradigm;
|
||||
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
readonly ownerId: StaffId;
|
||||
readonly title: string;
|
||||
readonly runtime: TaskRuntimeState;
|
||||
readonly terminal?: TerminalState;
|
||||
readonly reason?: string;
|
||||
/** 装载信封:包版本 + 各资产生效版 —— 行为可归因 */
|
||||
readonly envelope: Readonly<Record<string, unknown>>;
|
||||
readonly caps: BudgetCaps;
|
||||
readonly used: Consumption;
|
||||
readonly startedAt: string;
|
||||
readonly finishedAt?: string;
|
||||
};
|
||||
|
||||
export type StartTaskInput = {
|
||||
readonly activityId: string;
|
||||
readonly ownerId: StaffId;
|
||||
readonly title: string;
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
|
||||
readonly data: Record<string, unknown>;
|
||||
readonly message?: string;
|
||||
};
|
||||
|
||||
export type TickOutcome = {
|
||||
readonly task: Task;
|
||||
readonly verdict: HarnessVerdict;
|
||||
readonly progressed: boolean;
|
||||
};
|
||||
|
||||
export type TaskService = {
|
||||
/** 起单:装载 → 校验输入 → 建任务记忆 → 首个回合 */
|
||||
start(input: StartTaskInput): Promise<Task>;
|
||||
get(taskId: TaskId): Promise<Task>;
|
||||
listByOwner(ownerId: StaffId, openOnly?: boolean): Promise<Task[]>;
|
||||
/** org 停用前置检查要问的「在途任务」 */
|
||||
openTasksOf(ownerId: StaffId): Promise<Array<{ taskId: string; title: string }>>;
|
||||
|
||||
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
|
||||
tick(taskId: TaskId): Promise<TickOutcome>;
|
||||
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
|
||||
deliver(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> }): Promise<TickOutcome>;
|
||||
|
||||
/** 控制三语义:暂停 / 叫停 / 终止 */
|
||||
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
||||
resume(taskId: TaskId, by: StaffId): Promise<Task>;
|
||||
/** 改派:在途任务的负责人变更,**留痕并通知双方** */
|
||||
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
|
||||
|
||||
/** 终态裁定 —— 完成由框架裁定,终态由系统命名 */
|
||||
finish(taskId: TaskId, cause: TerminalCause): Promise<Task>;
|
||||
/** 断点续跑的三份持久状态是否齐备 */
|
||||
resumeReadiness(taskId: TaskId): Promise<ResumeReadiness>;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* module: task —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
BudgetBreach,
|
||||
BudgetCaps,
|
||||
Consumption,
|
||||
ControlOutcome,
|
||||
ControlSemantic,
|
||||
HarnessVerdict,
|
||||
Paradigm,
|
||||
ResumeReadiness,
|
||||
StartTaskInput,
|
||||
Task,
|
||||
TaskRuntimeState,
|
||||
TaskService,
|
||||
TaskSnapshot,
|
||||
TerminalCause,
|
||||
TerminalState,
|
||||
TickOutcome,
|
||||
} from './contract.ts';
|
||||
export { TaskAlreadyTerminalError, TaskNotFoundError } from './contract.ts';
|
||||
|
||||
export type {
|
||||
ActivityLoader,
|
||||
Clock,
|
||||
IdGenerator,
|
||||
InteractionPhaseSetter,
|
||||
ListenerProbe,
|
||||
MemoryRecorder,
|
||||
ParadigmEngine,
|
||||
PersistenceProbe,
|
||||
ReassignNotifier,
|
||||
SchemaValidator,
|
||||
TaskEventPublisher,
|
||||
TaskStore,
|
||||
} from './port.ts';
|
||||
|
||||
export type { TaskServiceDeps } from './internal/usecase/task-service.ts';
|
||||
export { createTaskService } from './internal/usecase/task-service.ts';
|
||||
|
||||
export {
|
||||
budgetBreaches,
|
||||
durationMs,
|
||||
isStalled,
|
||||
judge,
|
||||
terminalOf,
|
||||
ZERO_CONSUMPTION,
|
||||
} from './internal/domain/harness.ts';
|
||||
export {
|
||||
applyControl,
|
||||
decideTerminal,
|
||||
isPassiveOpen,
|
||||
isSuccess,
|
||||
isTerminalControl,
|
||||
landingBlocked,
|
||||
RESUME_SOURCES,
|
||||
rejectionKeepsRunning,
|
||||
resumeReadiness,
|
||||
triggersFailureDisposition,
|
||||
} from './internal/domain/lifecycle.ts';
|
||||
|
||||
export { createTaskStore } from './internal/adapter/task-store.ts';
|
||||
export { taskTable } from './internal/adapter/task-schema.ts';
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* task 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||
*/
|
||||
import { index, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const taskTable = sys.table(
|
||||
'task',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
activityId: text('activity_id').notNull(),
|
||||
paradigm: text('paradigm').notNull(),
|
||||
workflowId: text('workflow_id'),
|
||||
nodeId: text('node_id'),
|
||||
/** 恰有一名负责人 */
|
||||
ownerId: text('owner_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
runtime: text('runtime').notNull().default('running'),
|
||||
/** 终态五名 —— 封闭枚举 */
|
||||
terminal: text('terminal'),
|
||||
reason: text('reason'),
|
||||
/** 装载信封:包版本 + 各资产生效版 —— 行为可归因 */
|
||||
envelope: jsonb('envelope').notNull(),
|
||||
caps: jsonb('caps').notNull(),
|
||||
usedSteps: integer('used_steps').notNull().default(0),
|
||||
usedTokens: integer('used_tokens').notNull().default(0),
|
||||
usedActiveMs: integer('used_active_ms').notNull().default(0),
|
||||
usedHumanCalls: integer('used_human_calls').notNull().default(0),
|
||||
lastProgressAt: timestamp('last_progress_at', { withTimezone: true }),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
index('task_owner_idx').on(t.ownerId, t.runtime),
|
||||
index('task_workflow_idx').on(t.workflowId),
|
||||
index('task_terminal_idx').on(t.terminal),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* task 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { BudgetCaps, Paradigm, StaffId, TaskId, TaskRuntimeState, TerminalState } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { TaskStore } from '../../port.ts';
|
||||
import type { Task } from '../../contract.ts';
|
||||
import type { Consumption } from '../domain/harness.ts';
|
||||
import { taskTable } from './task-schema.ts';
|
||||
|
||||
type Row = typeof taskTable.$inferSelect;
|
||||
|
||||
function toTask(row: Row): Task {
|
||||
return {
|
||||
id: row.id as TaskId,
|
||||
activityId: row.activityId,
|
||||
paradigm: row.paradigm as Paradigm,
|
||||
...(row.workflowId === null ? {} : { workflowId: row.workflowId }),
|
||||
...(row.nodeId === null ? {} : { nodeId: row.nodeId }),
|
||||
ownerId: row.ownerId as StaffId,
|
||||
title: row.title,
|
||||
runtime: row.runtime as TaskRuntimeState,
|
||||
...(row.terminal === null ? {} : { terminal: row.terminal as TerminalState }),
|
||||
...(row.reason === null ? {} : { reason: row.reason }),
|
||||
envelope: row.envelope as Record<string, unknown>,
|
||||
caps: row.caps as BudgetCaps,
|
||||
used: {
|
||||
steps: row.usedSteps,
|
||||
tokens: row.usedTokens,
|
||||
activeMs: row.usedActiveMs,
|
||||
humanCalls: row.usedHumanCalls,
|
||||
},
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
...(row.finishedAt === null ? {} : { finishedAt: row.finishedAt.toISOString() }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTaskStore(db: Db): TaskStore {
|
||||
return {
|
||||
async insert(task) {
|
||||
await db.insert(taskTable).values({
|
||||
id: task.id,
|
||||
activityId: task.activityId,
|
||||
paradigm: task.paradigm,
|
||||
workflowId: task.workflowId ?? null,
|
||||
nodeId: task.nodeId ?? null,
|
||||
ownerId: task.ownerId,
|
||||
title: task.title,
|
||||
runtime: task.runtime,
|
||||
envelope: task.envelope,
|
||||
caps: task.caps,
|
||||
startedAt: new Date(task.startedAt),
|
||||
});
|
||||
},
|
||||
|
||||
async get(taskId: TaskId) {
|
||||
const rows = await db.select().from(taskTable).where(eq(taskTable.id, taskId)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toTask(rows[0]);
|
||||
},
|
||||
|
||||
async listByOwner(ownerId: StaffId, openOnly) {
|
||||
const conditions = [eq(taskTable.ownerId, ownerId)];
|
||||
if (openOnly) conditions.push(isNull(taskTable.terminal));
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(taskTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(taskTable.startedAt));
|
||||
return rows.map(toTask);
|
||||
},
|
||||
|
||||
async update(taskId: TaskId, patch) {
|
||||
const values: Record<string, unknown> = {};
|
||||
if (patch.runtime !== undefined) values['runtime'] = patch.runtime;
|
||||
if (patch.terminal !== undefined) values['terminal'] = patch.terminal;
|
||||
if (patch.reason !== undefined) values['reason'] = patch.reason;
|
||||
if (patch.ownerId !== undefined) values['ownerId'] = patch.ownerId;
|
||||
if (patch.finishedAt !== undefined) values['finishedAt'] = new Date(patch.finishedAt);
|
||||
if (patch.used !== undefined) {
|
||||
values['usedSteps'] = patch.used.steps;
|
||||
values['usedTokens'] = patch.used.tokens;
|
||||
values['usedActiveMs'] = patch.used.activeMs;
|
||||
values['usedHumanCalls'] = patch.used.humanCalls;
|
||||
}
|
||||
const [row] = await db.update(taskTable).set(values).where(eq(taskTable.id, taskId)).returning();
|
||||
if (row === undefined) throw new Error(`任务不存在:${taskId}`);
|
||||
return toTask(row);
|
||||
},
|
||||
|
||||
async addConsumption(taskId: TaskId, delta): Promise<Consumption> {
|
||||
const [row] = await db
|
||||
.update(taskTable)
|
||||
.set({
|
||||
usedSteps: sql`${taskTable.usedSteps} + ${delta.steps ?? 0}`,
|
||||
usedTokens: sql`${taskTable.usedTokens} + ${delta.tokens ?? 0}`,
|
||||
usedActiveMs: sql`${taskTable.usedActiveMs} + ${delta.activeMs ?? 0}`,
|
||||
usedHumanCalls: sql`${taskTable.usedHumanCalls} + ${delta.humanCalls ?? 0}`,
|
||||
})
|
||||
.where(eq(taskTable.id, taskId))
|
||||
.returning();
|
||||
if (row === undefined) throw new Error(`任务不存在:${taskId}`);
|
||||
return {
|
||||
steps: row.usedSteps,
|
||||
tokens: row.usedTokens,
|
||||
activeMs: row.usedActiveMs,
|
||||
humanCalls: row.usedHumanCalls,
|
||||
};
|
||||
},
|
||||
|
||||
async lastProgressAt(taskId: TaskId) {
|
||||
const rows = await db
|
||||
.select({ at: taskTable.lastProgressAt })
|
||||
.from(taskTable)
|
||||
.where(eq(taskTable.id, taskId))
|
||||
.limit(1);
|
||||
return rows[0]?.at ?? undefined;
|
||||
},
|
||||
|
||||
async markProgress(taskId: TaskId, at: Date) {
|
||||
await db.update(taskTable).set({ lastProgressAt: at }).where(eq(taskTable.id, taskId));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Harness:预算执行器与停滞检测 —— 纯判定,零 IO(编码规范 §5.3;语义出处 OS 04,AM 10.1)。
|
||||
*
|
||||
* 「预算与熔断由活动基座**在环外强制执行** —— 活动仅在 Harness 配置中申报参数,
|
||||
* **不自查自守**。任一项越限,基座抛控制事件并挂起任务。」
|
||||
*/
|
||||
import type { BudgetCaps, TerminalState } from '@contracts';
|
||||
|
||||
/** 计量口径(OS 04):步 / token / 活跃时长 / 调人次数 */
|
||||
export type Consumption = {
|
||||
/** loop 的一次决策循环 / flow 的一个声明步 */
|
||||
readonly steps: number;
|
||||
/** LLM 调用出入 token 累计 */
|
||||
readonly tokens: number;
|
||||
/** **活跃时长**:Agent 机制运行与工具执行的时间;挂起、监听等待、待卡回传**不计入** */
|
||||
readonly activeMs: number;
|
||||
/** 调人次数按卡计 */
|
||||
readonly humanCalls: number;
|
||||
};
|
||||
|
||||
export const ZERO_CONSUMPTION: Consumption = { steps: 0, tokens: 0, activeMs: 0, humanCalls: 0 };
|
||||
|
||||
export type BudgetBreach = { readonly item: keyof Consumption; readonly used: number; readonly cap: number };
|
||||
|
||||
/** 任一项越限即熔断 —— 预算耗尽 */
|
||||
export function budgetBreaches(used: Consumption, caps: BudgetCaps): BudgetBreach[] {
|
||||
const out: BudgetBreach[] = [];
|
||||
if (used.steps >= caps.steps) out.push({ item: 'steps', used: used.steps, cap: caps.steps });
|
||||
if (used.tokens >= caps.tokens) out.push({ item: 'tokens', used: used.tokens, cap: caps.tokens });
|
||||
if (used.activeMs >= caps.activeMinutes * 60_000) {
|
||||
out.push({ item: 'activeMs', used: used.activeMs, cap: caps.activeMinutes * 60_000 });
|
||||
}
|
||||
if (used.humanCalls >= caps.humanCalls) out.push({ item: 'humanCalls', used: used.humanCalls, cap: caps.humanCalls });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停滞检测:loop = Enabled 集与任务数据皆无变化、且**无监听在等**的空转;
|
||||
* flow = 当前步长期无进展且无监听在等。
|
||||
* 「**监听中是健康态**,与停滞分别对待」—— 有监听在等一律不算停滞。
|
||||
*/
|
||||
export type StallInput = {
|
||||
readonly sinceLastProgressMs: number;
|
||||
readonly stallAfterMs: number;
|
||||
readonly hasArmedListener: boolean;
|
||||
readonly awaitingHumanReply: boolean;
|
||||
};
|
||||
|
||||
export function isStalled(input: StallInput): boolean {
|
||||
if (input.hasArmedListener || input.awaitingHumanReply) return false;
|
||||
return input.sinceLastProgressMs >= input.stallAfterMs;
|
||||
}
|
||||
|
||||
/** Harness 的裁定:什么都不做 / 熔断 / 停滞 */
|
||||
export type HarnessVerdict =
|
||||
| { readonly kind: 'ok' }
|
||||
| { readonly kind: 'exhausted'; readonly breaches: readonly BudgetBreach[] }
|
||||
| { readonly kind: 'stalled' };
|
||||
|
||||
export function judge(used: Consumption, caps: BudgetCaps, stall: StallInput): HarnessVerdict {
|
||||
const breaches = budgetBreaches(used, caps);
|
||||
if (breaches.length > 0) return { kind: 'exhausted', breaches };
|
||||
if (isStalled(stall)) return { kind: 'stalled' };
|
||||
return { kind: 'ok' };
|
||||
}
|
||||
|
||||
/** 「错误与耗尽不得计为成功」—— Harness 裁定直接映射终态 */
|
||||
export function terminalOf(verdict: HarnessVerdict): TerminalState | undefined {
|
||||
if (verdict.kind === 'exhausted') return 'exhausted';
|
||||
if (verdict.kind === 'stalled') return 'stalled';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** ISO-8601 duration → 毫秒(与 event 的口径同一子集) */
|
||||
export function durationMs(iso: string): number {
|
||||
const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(iso);
|
||||
if (m === null) throw new Error(`非法时长:${iso}`);
|
||||
const [, d, h, min, s] = m;
|
||||
return Number(d ?? 0) * 86_400_000 + Number(h ?? 0) * 3_600_000 + Number(min ?? 0) * 60_000 + Number(s ?? 0) * 1_000;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 任务生命周期与终态五名裁定 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 4.3,AM 01/10)。
|
||||
*
|
||||
* 「**完成由框架裁定,终态由系统命名**」—— 终态是封闭枚举,活动不得增删。
|
||||
*/
|
||||
import type { ControlSemantic, TaskRuntimeState, TerminalState } from '@contracts';
|
||||
|
||||
export type TaskSnapshot = {
|
||||
readonly runtime: TaskRuntimeState;
|
||||
readonly terminal?: TerminalState;
|
||||
readonly reason?: string;
|
||||
};
|
||||
|
||||
export const RUNNING: TaskSnapshot = { runtime: 'running' };
|
||||
|
||||
/* ── 控制三语义(OS 06) ────────────────────────────────────────────── */
|
||||
|
||||
export type ControlOutcome =
|
||||
/** 暂停 = 可恢复冻结(监听与时限停摆) */
|
||||
| { readonly kind: 'suspended'; readonly resumable: true }
|
||||
/** 叫停 = 负责人紧急中断:挂起并**记最重档纠正**,恢复归负责人 */
|
||||
| { readonly kind: 'halted'; readonly resumable: true; readonly correctionSignal: 'heaviest' }
|
||||
/** 终止 = 不可恢复关闭 */
|
||||
| { readonly kind: 'terminated'; readonly resumable: false; readonly terminal: TerminalState };
|
||||
|
||||
export function applyControl(semantic: ControlSemantic): ControlOutcome {
|
||||
switch (semantic) {
|
||||
case 'pause':
|
||||
return { kind: 'suspended', resumable: true };
|
||||
case 'halt':
|
||||
return { kind: 'halted', resumable: true, correctionSignal: 'heaviest' };
|
||||
case 'terminate':
|
||||
return { kind: 'terminated', resumable: false, terminal: 'terminated' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 叫停**不是终态** —— 转挂起待纠偏,处置后恢复,或由负责人终止 */
|
||||
export function isTerminalControl(semantic: ControlSemantic): boolean {
|
||||
return semantic === 'terminate';
|
||||
}
|
||||
|
||||
/* ── 终态裁定 ───────────────────────────────────────────────────────── */
|
||||
|
||||
export type TerminalCause =
|
||||
/** 放行通过且落地收敛,输出口已发射 —— **唯一成功终态** */
|
||||
| { readonly kind: 'released' }
|
||||
/** 无法推进,封卷待治理处置 */
|
||||
| { readonly kind: 'blocked'; readonly reason: string }
|
||||
| { readonly kind: 'stalled'; readonly evidence: string }
|
||||
| { readonly kind: 'exhausted'; readonly detail: string }
|
||||
| { readonly kind: 'terminated'; readonly reason: string };
|
||||
|
||||
export function decideTerminal(cause: TerminalCause): { terminal: TerminalState; reason: string } {
|
||||
switch (cause.kind) {
|
||||
case 'released':
|
||||
return { terminal: 'released', reason: '放行通过且落地收敛,输出口已发射' };
|
||||
case 'blocked':
|
||||
return { terminal: 'blocked', reason: cause.reason };
|
||||
case 'stalled':
|
||||
return { terminal: 'stalled', reason: cause.evidence };
|
||||
case 'exhausted':
|
||||
return { terminal: 'exhausted', reason: cause.detail };
|
||||
case 'terminated':
|
||||
return { terminal: 'terminated', reason: cause.reason };
|
||||
}
|
||||
}
|
||||
|
||||
export function isSuccess(terminal: TerminalState): boolean {
|
||||
return terminal === 'released';
|
||||
}
|
||||
|
||||
/** 非完成终态触发流程层的**失败走向**(OS 03) */
|
||||
export function triggersFailureDisposition(terminal: TerminalState): boolean {
|
||||
return terminal !== 'released';
|
||||
}
|
||||
|
||||
/* ── 「不是终态」的三样(OS 4.3) ──────────────────────────────────── */
|
||||
|
||||
/** 驳回是**环内回转**,任务继续 —— 不出活动 */
|
||||
export function rejectionKeepsRunning(): TaskSnapshot {
|
||||
return { runtime: 'running' };
|
||||
}
|
||||
|
||||
/** 落地受阻是**运行态**,处置后仍收敛于终态名录 */
|
||||
export function landingBlocked(reason: string): TaskSnapshot {
|
||||
return { runtime: 'landing-blocked', reason };
|
||||
}
|
||||
|
||||
/* ── 断点续跑:三份持久状态(OS 09) ──────────────────────────────── */
|
||||
|
||||
export const RESUME_SOURCES = ['task-memory', 'draft-layer', 'runtime-state'] as const;
|
||||
export type ResumeSource = (typeof RESUME_SOURCES)[number];
|
||||
|
||||
export type ResumeReadiness = {
|
||||
readonly ready: boolean;
|
||||
readonly missing: readonly ResumeSource[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 「任务做一半的续跑由三份持久状态**共同**保证:
|
||||
* 任务记忆(按窗口重建上下文)、草稿层(截至断点的过程产物与动作意向)、
|
||||
* 运行时实例状态(loop 的模型定位与就绪集 / flow 的步序位置)。」
|
||||
*/
|
||||
export function resumeReadiness(present: Readonly<Record<ResumeSource, boolean>>): ResumeReadiness {
|
||||
const missing = RESUME_SOURCES.filter((s) => !present[s]);
|
||||
return { ready: missing.length === 0, missing };
|
||||
}
|
||||
|
||||
/** 「打开任务只是打开呈现,不是启动过程」—— 过程要么在跑,要么挂起在等 */
|
||||
export function isPassiveOpen(state: TaskRuntimeState): boolean {
|
||||
return state !== 'running';
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* task 用例 —— 任务运行时(编码规范 §5.3;语义出处 OS 04 / 4.3,AM 10)。
|
||||
*
|
||||
* 预算在**环外**强制:每一拍先扣先判,再交范式引擎 —— 活动不自查自守。
|
||||
*/
|
||||
import type { StaffId, TaskId } from '@contracts';
|
||||
import { taskIdSchema } from '@contracts';
|
||||
import type { StartTaskInput, Task, TaskService, TickOutcome } from '../../contract.ts';
|
||||
import { TaskAlreadyTerminalError, TaskNotFoundError } from '../../contract.ts';
|
||||
import type {
|
||||
ActivityLoader,
|
||||
Clock,
|
||||
IdGenerator,
|
||||
InteractionPhaseSetter,
|
||||
ListenerProbe,
|
||||
MemoryRecorder,
|
||||
ParadigmEngine,
|
||||
PersistenceProbe,
|
||||
ReassignNotifier,
|
||||
SchemaValidator,
|
||||
TaskEventPublisher,
|
||||
TaskStore,
|
||||
} from '../../port.ts';
|
||||
import { durationMs, judge, terminalOf, ZERO_CONSUMPTION } from '../domain/harness.ts';
|
||||
import { applyControl, decideTerminal, resumeReadiness } from '../domain/lifecycle.ts';
|
||||
|
||||
export type TaskServiceDeps = {
|
||||
readonly store: TaskStore;
|
||||
readonly activities: ActivityLoader;
|
||||
readonly schemas: SchemaValidator;
|
||||
/** 范式引擎按 paradigm 分派 —— 对上层不可区分 */
|
||||
readonly engines: Readonly<Record<'loop' | 'flow', ParadigmEngine>>;
|
||||
readonly memory: MemoryRecorder;
|
||||
readonly listeners: ListenerProbe;
|
||||
readonly persistence: PersistenceProbe;
|
||||
readonly events: TaskEventPublisher;
|
||||
readonly notifier: ReassignNotifier;
|
||||
readonly interaction: InteractionPhaseSetter;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
async function require(taskId: TaskId): Promise<Task> {
|
||||
const task = await deps.store.get(taskId);
|
||||
if (task === undefined) throw new TaskNotFoundError(taskId);
|
||||
return task;
|
||||
}
|
||||
|
||||
function engineOf(task: Task): ParadigmEngine {
|
||||
return deps.engines[task.paradigm];
|
||||
}
|
||||
|
||||
/** Harness 在环外先判:越限即熔断并挂起,处置归治理台 */
|
||||
async function harnessCheck(task: Task, stallAfter: string) {
|
||||
const lastProgress = (await deps.store.lastProgressAt(task.id)) ?? new Date(task.startedAt);
|
||||
const stall = {
|
||||
sinceLastProgressMs: deps.clock.now().getTime() - lastProgress.getTime(),
|
||||
stallAfterMs: durationMs(stallAfter),
|
||||
hasArmedListener: await deps.listeners.hasArmedListener(task.id),
|
||||
awaitingHumanReply: task.runtime === 'awaiting-human',
|
||||
};
|
||||
return judge(task.used, task.caps, stall);
|
||||
}
|
||||
|
||||
return {
|
||||
async start(input: StartTaskInput): Promise<Task> {
|
||||
const loaded = await deps.activities.load(input.activityId);
|
||||
// 输入 data 必过 schema 校验,**缺项不得启动**(AM 12.1)
|
||||
const check = deps.schemas.validate(loaded.inputSchema, input.data);
|
||||
if (!check.ok) throw new Error(`输入不合契约,不得启动:${check.errors.join('; ')}`);
|
||||
|
||||
const now = deps.clock.now();
|
||||
const task: Task = {
|
||||
id: taskIdSchema.parse(deps.ids.next()),
|
||||
activityId: input.activityId,
|
||||
paradigm: loaded.paradigm,
|
||||
...(input.workflowId === undefined ? {} : { workflowId: input.workflowId }),
|
||||
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
|
||||
ownerId: input.ownerId,
|
||||
title: input.title,
|
||||
runtime: 'running',
|
||||
envelope: loaded.envelope,
|
||||
caps: loaded.caps,
|
||||
used: ZERO_CONSUMPTION,
|
||||
startedAt: now.toISOString(),
|
||||
};
|
||||
await deps.store.insert(task);
|
||||
await deps.memory.append({
|
||||
taskId: task.id,
|
||||
kind: 'engine-transition',
|
||||
title: `起单:${task.title}`,
|
||||
// message 计入初始观察、全程留痕;**不得扩大授权边界**
|
||||
detail: { data: input.data, message: input.message, envelope: loaded.envelope },
|
||||
});
|
||||
await engineOf(task).load(task.id, loaded.businessModel);
|
||||
await deps.interaction.setPhase(task.id, 'active');
|
||||
await deps.store.markProgress(task.id, now);
|
||||
return task;
|
||||
},
|
||||
|
||||
async get(taskId) {
|
||||
return require(taskId);
|
||||
},
|
||||
|
||||
async listByOwner(ownerId, openOnly = false) {
|
||||
return deps.store.listByOwner(ownerId, openOnly);
|
||||
},
|
||||
|
||||
async openTasksOf(ownerId) {
|
||||
const tasks = await deps.store.listByOwner(ownerId, true);
|
||||
return tasks.map((t) => ({ taskId: t.id, title: t.title }));
|
||||
},
|
||||
|
||||
async tick(taskId): Promise<TickOutcome> {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
|
||||
|
||||
const loaded = await deps.activities.load(task.activityId);
|
||||
const verdict = await harnessCheck(task, loaded.stallAfter);
|
||||
const terminal = terminalOf(verdict);
|
||||
if (terminal !== undefined) {
|
||||
const finished = await this.finish(
|
||||
taskId,
|
||||
terminal === 'exhausted'
|
||||
? { kind: 'exhausted', detail: JSON.stringify(verdict) }
|
||||
: { kind: 'stalled', evidence: '该动未动、超限仍无推进' },
|
||||
);
|
||||
return { task: finished, verdict, progressed: false };
|
||||
}
|
||||
|
||||
const outcome = await engineOf(task).advance(taskId);
|
||||
// 步计量:loop 的一次决策循环 / flow 的一个声明步
|
||||
const used = await deps.store.addConsumption(taskId, { steps: 1 });
|
||||
await deps.store.markProgress(taskId, deps.clock.now());
|
||||
|
||||
const runtime =
|
||||
outcome.status === 'awaiting'
|
||||
? 'awaiting-event'
|
||||
: outcome.status === 'blocked'
|
||||
? 'suspended'
|
||||
: 'running';
|
||||
const updated = await deps.store.update(taskId, { runtime, used });
|
||||
await deps.interaction.setPhase(taskId, runtime === 'running' ? 'active' : 'awaiting-event');
|
||||
|
||||
if (outcome.status === 'blocked') {
|
||||
const finished = await this.finish(taskId, { kind: 'blocked', reason: outcome.reason ?? '无法推进' });
|
||||
return { task: finished, verdict, progressed: true };
|
||||
}
|
||||
return { task: updated, verdict, progressed: true };
|
||||
},
|
||||
|
||||
async deliver(taskId, event): Promise<TickOutcome> {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
|
||||
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `事件到达:${event.kind}/${event.ref}`,
|
||||
detail: event.payload,
|
||||
});
|
||||
await engineOf(task).onEvent(taskId, event);
|
||||
await deps.store.markProgress(taskId, deps.clock.now());
|
||||
return this.tick(taskId);
|
||||
},
|
||||
|
||||
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
|
||||
async control(taskId, semantic, by, reason) {
|
||||
const task = await require(taskId);
|
||||
const outcome = applyControl(semantic);
|
||||
await deps.events.publishControl(taskId, semantic, by, reason);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `控制事件:${semantic}`,
|
||||
detail: { by, reason, correctionSignal: outcome.kind === 'halted' ? 'heaviest' : undefined },
|
||||
});
|
||||
|
||||
if (outcome.kind === 'terminated') {
|
||||
await this.finish(taskId, { kind: 'terminated', reason: reason ?? '治理台终止' });
|
||||
return outcome;
|
||||
}
|
||||
await deps.store.update(taskId, { runtime: 'suspended', ...(reason === undefined ? {} : { reason }) });
|
||||
await deps.interaction.setPhase(taskId, 'awaiting-event');
|
||||
void task;
|
||||
return outcome;
|
||||
},
|
||||
|
||||
async resume(taskId, by) {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
|
||||
await deps.memory.append({ taskId, kind: 'engine-transition', title: '恢复', detail: { by } });
|
||||
const updated = await deps.store.update(taskId, { runtime: 'running' });
|
||||
await deps.interaction.setPhase(taskId, 'active');
|
||||
return updated;
|
||||
},
|
||||
|
||||
/** 改派:留痕并通知双方(OS 02) */
|
||||
async reassign(taskId, to, by) {
|
||||
const task = await require(taskId);
|
||||
const updated = await deps.store.update(taskId, { ownerId: to });
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
title: `改派:${task.ownerId} → ${to}`,
|
||||
detail: { by },
|
||||
});
|
||||
await deps.notifier.notify({ from: task.ownerId, to, taskId, title: task.title, by });
|
||||
return updated;
|
||||
},
|
||||
|
||||
/** 终态由系统命名;封卷触发蒸馏钩子 */
|
||||
async finish(taskId, cause) {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) return task;
|
||||
|
||||
const { terminal, reason } = decideTerminal(cause);
|
||||
const now = deps.clock.now();
|
||||
const updated = await deps.store.update(taskId, {
|
||||
runtime: 'terminal',
|
||||
terminal,
|
||||
reason,
|
||||
finishedAt: now.toISOString(),
|
||||
});
|
||||
|
||||
await deps.memory.append({ taskId, kind: 'terminal', title: `终态:${terminal}`, detail: { reason } });
|
||||
await deps.events.publishTerminal(taskId, terminal, reason);
|
||||
await deps.interaction.setPhase(taskId, 'terminal');
|
||||
|
||||
// 终态封卷 → 归档 → 蒸馏钩子产出活动记忆候选条目
|
||||
await deps.memory.seal(taskId);
|
||||
await deps.memory.distill({
|
||||
taskId,
|
||||
activityId: task.activityId,
|
||||
trigger: 'terminal',
|
||||
prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`,
|
||||
});
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async resumeReadiness(taskId) {
|
||||
const task = await require(taskId);
|
||||
return resumeReadiness({
|
||||
'task-memory': await deps.memory.hasHistory(task.id),
|
||||
'draft-layer': await deps.persistence.hasDrafts(task.id),
|
||||
'runtime-state': await deps.persistence.hasRuntimeState(task.id),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { StaffId };
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/task",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* module: task —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { BudgetCaps, Paradigm, StaffId, TaskId, TerminalState } from '@contracts';
|
||||
import type { Consumption } from './internal/domain/harness.ts';
|
||||
import type { Task } from './contract.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
export type TaskStore = {
|
||||
insert(task: Task): Promise<void>;
|
||||
get(taskId: TaskId): Promise<Task | undefined>;
|
||||
listByOwner(ownerId: StaffId, openOnly: boolean): Promise<Task[]>;
|
||||
update(taskId: TaskId, patch: Partial<Task>): Promise<Task>;
|
||||
addConsumption(taskId: TaskId, delta: Partial<Consumption>): Promise<Consumption>;
|
||||
lastProgressAt(taskId: TaskId): Promise<Date | undefined>;
|
||||
markProgress(taskId: TaskId, at: Date): Promise<void>;
|
||||
};
|
||||
|
||||
/** 活动装载 —— app 注入 `activity-registry`(L4) */
|
||||
export type ActivityLoader = {
|
||||
load(activityId: string): Promise<{
|
||||
paradigm: Paradigm;
|
||||
caps: BudgetCaps;
|
||||
envelope: Record<string, unknown>;
|
||||
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
|
||||
inputSchema: Record<string, unknown>;
|
||||
businessModel: unknown;
|
||||
stallAfter: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SchemaValidator = {
|
||||
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
|
||||
};
|
||||
|
||||
/** 范式引擎 —— loop / flow 在此汇流;对上层不可区分(§9 双层边界) */
|
||||
export type ParadigmEngine = {
|
||||
load(taskId: TaskId, model: unknown): Promise<void>;
|
||||
advance(taskId: TaskId): Promise<{ status: 'running' | 'awaiting' | 'submitted' | 'finished' | 'blocked'; reason?: string }>;
|
||||
onEvent(
|
||||
taskId: TaskId,
|
||||
event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> },
|
||||
): Promise<{ status: 'running' | 'awaiting' | 'submitted' | 'finished' | 'blocked'; reason?: string }>;
|
||||
};
|
||||
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'engine-transition' | 'terminal' | 'observation';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
seal(taskId: TaskId): Promise<void>;
|
||||
/** 终态封卷触发蒸馏钩子 —— 产出活动记忆候选条目 */
|
||||
distill(input: { taskId: TaskId; activityId: string; trigger: 'terminal'; prompt: string }): Promise<unknown>;
|
||||
hasHistory(taskId: TaskId): Promise<boolean>;
|
||||
};
|
||||
|
||||
/** 监听在等?—— app 注入 `event`(L1):监听中是健康态,与停滞分别对待 */
|
||||
export type ListenerProbe = {
|
||||
hasArmedListener(taskId: TaskId): Promise<boolean>;
|
||||
};
|
||||
|
||||
/** 草稿层与运行时状态是否在 —— 断点续跑的三份持久状态 */
|
||||
export type PersistenceProbe = {
|
||||
hasDrafts(taskId: TaskId): Promise<boolean>;
|
||||
hasRuntimeState(taskId: TaskId): Promise<boolean>;
|
||||
};
|
||||
|
||||
/** 控制事件与终态事件 —— app 注入 `event`(L1) */
|
||||
export type TaskEventPublisher = {
|
||||
publishControl(taskId: TaskId, semantic: string, by: StaffId, reason?: string): Promise<void>;
|
||||
publishTerminal(taskId: TaskId, terminal: TerminalState, reason: string): Promise<void>;
|
||||
};
|
||||
|
||||
/** 改派通知 —— app 注入 `message`(L2):改派留痕并**通知双方** */
|
||||
export type ReassignNotifier = {
|
||||
notify(input: { from: StaffId; to: StaffId; taskId: TaskId; title: string; by: StaffId }): Promise<void>;
|
||||
};
|
||||
|
||||
/** 交互态 —— app 注入 `interaction`(L3):控制权按态转交 */
|
||||
export type InteractionPhaseSetter = {
|
||||
setPhase(taskId: TaskId, phase: 'activating' | 'active' | 'awaiting-human' | 'awaiting-event' | 'transitioning' | 'terminal'): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE "sys"."flow_state" (
|
||||
"task_id" text PRIMARY KEY NOT NULL,
|
||||
"model" jsonb NOT NULL,
|
||||
"position" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."loop_state" (
|
||||
"task_id" text PRIMARY KEY NOT NULL,
|
||||
"model" jsonb NOT NULL,
|
||||
"parsed" jsonb NOT NULL,
|
||||
"runtime" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."task" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"activity_id" text NOT NULL,
|
||||
"paradigm" text NOT NULL,
|
||||
"workflow_id" text,
|
||||
"node_id" text,
|
||||
"owner_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"runtime" text DEFAULT 'running' NOT NULL,
|
||||
"terminal" text,
|
||||
"reason" text,
|
||||
"envelope" jsonb NOT NULL,
|
||||
"caps" jsonb NOT NULL,
|
||||
"used_steps" integer DEFAULT 0 NOT NULL,
|
||||
"used_tokens" integer DEFAULT 0 NOT NULL,
|
||||
"used_active_ms" integer DEFAULT 0 NOT NULL,
|
||||
"used_human_calls" integer DEFAULT 0 NOT NULL,
|
||||
"last_progress_at" timestamp with time zone,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"finished_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "task_owner_idx" ON "sys"."task" USING btree ("owner_id","runtime");--> statement-breakpoint
|
||||
CREATE INDEX "task_workflow_idx" ON "sys"."task" USING btree ("workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "task_terminal_idx" ON "sys"."task" USING btree ("terminal");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1785087362238,
|
||||
"tag": "0004_nice_ravenous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1785088279529,
|
||||
"tag": "0005_broad_sprite",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+10
-1
@@ -11,7 +11,7 @@
|
||||
| M1 | `@contracts` 首版、`event`、`ledger`、`org`、`llm` | ✅ 完成 |
|
||||
| M2 | `memory`、`toolbox`、`channel`、`message` | ✅ 完成 |
|
||||
| M3 | `interaction`、`human-tool`、`release`、`activity-registry`、`@sfeel` | ✅ 完成 |
|
||||
| M4 | `skill-engine`、`loop-engine`、`flow-engine`、`task` | ⬜ 未开始 |
|
||||
| M4 | `skill-engine`、`loop-engine`、`flow-engine`、`task` | ✅ 完成(模块与 domain;app 装配随 M5) |
|
||||
| M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | ⬜ 未开始 |
|
||||
| M6 | 前端:工作台五要素 → 治理台九功能;`minute`、`report` | ⬜ 未开始 |
|
||||
|
||||
@@ -52,12 +52,21 @@ app 侧接上派发器(advisory lock 串行 + 消费幂等)与 `/api/ingest` 模
|
||||
`interaction` 控制权按态转交 + 视图净化门 + SSE 推送;
|
||||
`activity-registry` 安装两审 + 悬空监听拒绝 + 零环境能力扫描 + 资产与包分离版本化 + 进化管线状态机。
|
||||
|
||||
**M4** — `skill-engine`(载方法不载权力 / 结晶片段经 ToolInvoker 留痕 / 装配入信封);
|
||||
`flow-engine`(四种步型 / 条件跳步 / 声明给定的失败路径 / 异步步即监听 / 步序位置持久);
|
||||
`loop-engine`(**自研 CMMN 子集解析器**,表外构件直接报错;两副标准生命周期与迁移拓扑;
|
||||
Enabled ≠ Active;前提求值与语义性前提交裁量;DMN 确定性求值与命中行留痕;DRD 缺口演绎;
|
||||
双向驱动与交集导航,断点即人环;三型行动);
|
||||
`task`(终态五名裁定 / Harness 预算与停滞熔断 / 控制三语义 / 改派 / 断点续跑三份持久状态 /
|
||||
封卷触发蒸馏)。
|
||||
|
||||
## 待接的占位(详见 docs/debt.md)
|
||||
|
||||
- `container.ts` 两个占位端口:`InFlightWorkProbe`(M4)、`TriggerMatcher`(M5)
|
||||
- 条件求值 `passthroughEvaluator`(M3 接 `@sfeel`)
|
||||
- 派发器的消费者表为空(M4 任务 / M5 触发器)
|
||||
- pg-boss 已装未用(M5 定时作业)
|
||||
- M4 四模块尚未接入 `container.ts`(需要 Mastra Agent adapter 与流程层起单,随 M5)
|
||||
|
||||
## 已建 ADR
|
||||
|
||||
|
||||
Reference in New Issue
Block a user