diff --git a/api-report/apps/api/src/modules/flow-engine/contract.d.ts b/api-report/apps/api/src/modules/flow-engine/contract.d.ts new file mode 100644 index 0000000..9b120f3 --- /dev/null +++ b/api-report/apps/api/src/modules/flow-engine/contract.d.ts @@ -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; + position(taskId: TaskId): Promise; + /** 推进一步(或若干确定步);异步步进入 awaiting 并登记监听 */ + advance(taskId: TaskId): Promise; + /** 事件即步的完成条件:人事件 / 数据事件 / 时间事件到达即续跑 */ + resume(taskId: TaskId, event: { + kind: 'human' | 'data' | 'time'; + ref: string; + payload: Record; + }): Promise; +}; diff --git a/api-report/apps/api/src/modules/flow-engine/index.d.ts b/api-report/apps/api/src/modules/flow-engine/index.d.ts new file mode 100644 index 0000000..140f213 --- /dev/null +++ b/api-report/apps/api/src/modules/flow-engine/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/flow-engine/port.d.ts b/api-report/apps/api/src/modules/flow-engine/port.d.ts new file mode 100644 index 0000000..e73700f --- /dev/null +++ b/api-report/apps/api/src/modules/flow-engine/port.d.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; + load(taskId: TaskId): Promise<{ + model: FlowModel; + position: FlowPosition; + } | undefined>; +}; +/** 条件谓词求值 —— app 注入 `@sfeel`;零 LLM */ +export type PredicateEvaluator = (expression: string, scope: Readonly>) => boolean; +/** 任务数据 —— app 注入 `ledger`(L1):读任务视图 = 正式版 + 本任务草稿层 */ +export type TaskDataGateway = { + view(taskId: TaskId): Promise>; + draftWrite(input: { + taskId: TaskId; + domain: string; + object: string; + key: Record; + patch: Record; + }): Promise; +}; +/** 工具步与纯工具步 —— app 注入 `toolbox`(L2) */ +export type ToolStepInvoker = { + invoke(input: { + tool: string; + input: Record; + activityId: string; + taskId: TaskId; + stepNo: number; + }): Promise<{ + ok: boolean; + output?: Record; + error?: { + message: string; + retryable: boolean; + }; + }>; + /** 写类工具在 flow 里也走「登记动作意向」,除非契约显式预授权 */ + registerIntent(input: { + taskId: TaskId; + tool: string; + input: Record; + label: string; + }): Promise; + isWriteEffect(tool: string): Promise; +}; +/** LLM 步 —— app 注入 `llm`(L1)的 ModelGateway;模板与出参 schema 随声明版本冻结 */ +export type LlmStepGateway = { + generate(input: { + model: string; + prompt: string; + outputSchema: Record; + 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; + }): Promise<{ + id: string; + }>; +}; +/** 监听登记 —— app 注入 `event`(L1):异步步的完成条件 = 监听,时限必配 */ +export type ListenerRegistrar = { + register(input: { + taskId: TaskId; + eventType: 'data' | 'time' | 'human'; + keyFilter: Record; + deadline: string; + }): Promise<{ + id: string; + }>; +}; +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'action' | 'result' | 'engine-transition'; + title: string; + detail?: Record; + }): Promise; +}; +/** 任务上下文 —— 活动 ID、负责人、预算 */ +export type TaskContext = { + of(taskId: TaskId): Promise<{ + activityId: string; + ownerId: StaffId; + humanCallCap: number; + }>; +}; diff --git a/api-report/apps/api/src/modules/loop-engine/contract.d.ts b/api-report/apps/api/src/modules/loop-engine/contract.d.ts new file mode 100644 index 0000000..891fb71 --- /dev/null +++ b/api-report/apps/api/src/modules/loop-engine/contract.d.ts @@ -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; +} +/** 调人:四卡之一 —— 调人 = 立监听听人 */ + | { + readonly kind: 'human'; + readonly itemId: string; + readonly card: Record; + readonly workOrder: 'execution' | 'acceptance'; +} +/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */ + | { + readonly kind: 'listen'; + readonly itemId: string; + readonly eventType: 'data' | 'time'; + readonly keyFilter: Record; + 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; + runtime(taskId: TaskId): Promise; + /** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */ + publish(taskId: TaskId): Promise; + /** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */ + runTurn(taskId: TaskId): Promise; + /** 事件驱动续跑:数据 / 时间 / 人 / 控制 */ + onEvent(taskId: TaskId, event: { + kind: 'data' | 'time' | 'human' | 'control'; + ref: string; + payload: Record; + }): Promise; + /** DMN 确定性求值 —— 能算的不问 LLM */ + decide(taskId: TaskId, decision: string): Promise; + /** 驳回:相关计划项以新实例重新入场(迁移不是状态) */ + reenter(taskId: TaskId, itemId: string): Promise; +}; diff --git a/api-report/apps/api/src/modules/loop-engine/index.d.ts b/api-report/apps/api/src/modules/loop-engine/index.d.ts new file mode 100644 index 0000000..e5fa97c --- /dev/null +++ b/api-report/apps/api/src/modules/loop-engine/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/loop-engine/port.d.ts b/api-report/apps/api/src/modules/loop-engine/port.d.ts new file mode 100644 index 0000000..31a1574 --- /dev/null +++ b/api-report/apps/api/src/modules/loop-engine/port.d.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; + load(taskId: TaskId): Promise<{ + model: LoopModel; + parsed: CaseModel; + runtime: CaseRuntime; + } | undefined>; +}; +/** S-FEEL —— app 注入 `@sfeel`;引擎零 LLM */ +export type Sfeel = { + condition(expression: string, scope: Readonly>): boolean; + expression(expression: string, scope: Readonly>): unknown; + unaryTest(cell: string, value: unknown, scope: Readonly>): boolean; +}; +/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */ +export type TaskDataGateway = { + view(taskId: TaskId): Promise>; + draftWrite(input: { + taskId: TaskId; + domain: string; + object: string; + key: Record; + patch: Record; + }): Promise; +}; +/** + * Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。 + * **回合由 usecase 驱动,不放开自由 maxSteps,预算在环外由 Harness 强制。** + */ +export type AgentPort = { + decide(input: { + taskId: TaskId; + /** 叙事面:说明书 + 活动记忆选摘 + 任务记忆窗口 */ + narrative: string; + enabled: EnabledSet; + scope: Record; + }): Promise; +}; +/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */ +export type ToolGateway = { + invoke(input: { + tool: string; + input: Record; + activityId: string; + taskId: TaskId; + }): Promise<{ + ok: boolean; + output?: Record; + error?: { + message: string; + }; + }>; + isWriteEffect(tool: string): Promise; + registerIntent(input: { + taskId: TaskId; + tool: string; + input: Record; + label: string; + }): Promise; +}; +export type CardGateway = { + issue(input: { + taskId: TaskId; + ownerId: StaffId; + humanCallCap: number; + workOrder: 'execution' | 'acceptance'; + card: Record; + }): Promise<{ + id: string; + }>; +}; +export type ListenerRegistrar = { + register(input: { + taskId: TaskId; + eventType: 'data' | 'time' | 'human'; + keyFilter: Record; + deadline: string; + }): Promise<{ + id: string; + }>; +}; +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation'; + title: string; + detail?: Record; + }): Promise; + /** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */ + loadActivityMemory(activityId: string, limit: number): Promise>; +}; +export type TaskContext = { + of(taskId: TaskId): Promise<{ + activityId: string; + ownerId: StaffId; + humanCallCap: number; + }>; +}; diff --git a/api-report/apps/api/src/modules/skill-engine/contract.d.ts b/api-report/apps/api/src/modules/skill-engine/contract.d.ts new file mode 100644 index 0000000..8fcfbf9 --- /dev/null +++ b/api-report/apps/api/src/modules/skill-engine/contract.d.ts @@ -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>; + readonly stepNo?: number; +}; +export type ScriptOutcome = { + readonly ok: boolean; + readonly steps: ReadonlyArray<{ + tool: string; + ok: boolean; + output?: Record; + error?: string; + }>; + readonly stamp: readonly SkillStamp[]; +}; +export type SkillEngineService = { + /** 常驻上下文只有目录 —— 渐进披露 */ + index(activityId: string): Promise; + /** 按事加载正文;装配前过扩权检查 */ + load(activityId: string, skillIds: readonly string[]): Promise; + /** 结晶片段脚本 = 确定性动作链;经 ToolInvoker 调用而留痕,不得内联绕过 */ + runScript(input: RunScriptInput): Promise; + /** 装配入信封 —— 供任务记忆盖戳 */ + stamp(activityId: string, skillIds: readonly string[]): Promise; +}; diff --git a/api-report/apps/api/src/modules/skill-engine/index.d.ts b/api-report/apps/api/src/modules/skill-engine/index.d.ts new file mode 100644 index 0000000..64f7176 --- /dev/null +++ b/api-report/apps/api/src/modules/skill-engine/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/skill-engine/port.d.ts b/api-report/apps/api/src/modules/skill-engine/port.d.ts new file mode 100644 index 0000000..c288077 --- /dev/null +++ b/api-report/apps/api/src/modules/skill-engine/port.d.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; + allowlistOf(activityId: string): Promise; +}; +/** + * 工具调用 —— app 注入 `toolbox`(L2)的 `ToolInvoker`。 + * 脚本引用的工具**仍过本活动白名单与调用契约** —— 载方法不载权力。 + */ +export type ScriptToolInvoker = { + invoke(input: { + tool: string; + input: Record; + activityId: string; + taskId: TaskId; + stepNo?: number; + }): Promise<{ + ok: boolean; + output?: Record; + error?: { + message: string; + }; + }>; +}; +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'action' | 'result'; + title: string; + detail?: Record; + }): Promise; +}; diff --git a/api-report/apps/api/src/modules/task/contract.d.ts b/api-report/apps/api/src/modules/task/contract.d.ts new file mode 100644 index 0000000..607f225 --- /dev/null +++ b/api-report/apps/api/src/modules/task/contract.d.ts @@ -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>; + 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; + readonly message?: string; +}; +export type TickOutcome = { + readonly task: Task; + readonly verdict: HarnessVerdict; + readonly progressed: boolean; +}; +export type TaskService = { + /** 起单:装载 → 校验输入 → 建任务记忆 → 首个回合 */ + start(input: StartTaskInput): Promise; + get(taskId: TaskId): Promise; + listByOwner(ownerId: StaffId, openOnly?: boolean): Promise; + /** org 停用前置检查要问的「在途任务」 */ + openTasksOf(ownerId: StaffId): Promise>; + /** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */ + tick(taskId: TaskId): Promise; + /** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */ + deliver(taskId: TaskId, event: { + kind: 'data' | 'time' | 'human' | 'control'; + ref: string; + payload: Record; + }): Promise; + /** 控制三语义:暂停 / 叫停 / 终止 */ + control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise; + resume(taskId: TaskId, by: StaffId): Promise; + /** 改派:在途任务的负责人变更,**留痕并通知双方** */ + reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise; + /** 终态裁定 —— 完成由框架裁定,终态由系统命名 */ + finish(taskId: TaskId, cause: TerminalCause): Promise; + /** 断点续跑的三份持久状态是否齐备 */ + resumeReadiness(taskId: TaskId): Promise; +}; diff --git a/api-report/apps/api/src/modules/task/index.d.ts b/api-report/apps/api/src/modules/task/index.d.ts new file mode 100644 index 0000000..de9809a --- /dev/null +++ b/api-report/apps/api/src/modules/task/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/task/port.d.ts b/api-report/apps/api/src/modules/task/port.d.ts new file mode 100644 index 0000000..9541854 --- /dev/null +++ b/api-report/apps/api/src/modules/task/port.d.ts @@ -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; + get(taskId: TaskId): Promise; + listByOwner(ownerId: StaffId, openOnly: boolean): Promise; + update(taskId: TaskId, patch: Partial): Promise; + addConsumption(taskId: TaskId, delta: Partial): Promise; + lastProgressAt(taskId: TaskId): Promise; + markProgress(taskId: TaskId, at: Date): Promise; +}; +/** 活动装载 —— app 注入 `activity-registry`(L4) */ +export type ActivityLoader = { + load(activityId: string): Promise<{ + paradigm: Paradigm; + caps: BudgetCaps; + envelope: Record; + /** 输入 data 的 JSON Schema —— 缺项不得启动 */ + inputSchema: Record; + businessModel: unknown; + stallAfter: string; + }>; +}; +export type SchemaValidator = { + validate(schema: Record, value: unknown): { + ok: boolean; + errors: string[]; + }; +}; +/** 范式引擎 —— loop / flow 在此汇流;对上层不可区分(§9 双层边界) */ +export type ParadigmEngine = { + load(taskId: TaskId, model: unknown): Promise; + 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; + }): 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; + }): Promise; + seal(taskId: TaskId): Promise; + /** 终态封卷触发蒸馏钩子 —— 产出活动记忆候选条目 */ + distill(input: { + taskId: TaskId; + activityId: string; + trigger: 'terminal'; + prompt: string; + }): Promise; + hasHistory(taskId: TaskId): Promise; +}; +/** 监听在等?—— app 注入 `event`(L1):监听中是健康态,与停滞分别对待 */ +export type ListenerProbe = { + hasArmedListener(taskId: TaskId): Promise; +}; +/** 草稿层与运行时状态是否在 —— 断点续跑的三份持久状态 */ +export type PersistenceProbe = { + hasDrafts(taskId: TaskId): Promise; + hasRuntimeState(taskId: TaskId): Promise; +}; +/** 控制事件与终态事件 —— app 注入 `event`(L1) */ +export type TaskEventPublisher = { + publishControl(taskId: TaskId, semantic: string, by: StaffId, reason?: string): Promise; + publishTerminal(taskId: TaskId, terminal: TerminalState, reason: string): Promise; +}; +/** 改派通知 —— app 注入 `message`(L2):改派留痕并**通知双方** */ +export type ReassignNotifier = { + notify(input: { + from: StaffId; + to: StaffId; + taskId: TaskId; + title: string; + by: StaffId; + }): Promise; +}; +/** 交互态 —— app 注入 `interaction`(L3):控制权按态转交 */ +export type InteractionPhaseSetter = { + setPhase(taskId: TaskId, phase: 'activating' | 'active' | 'awaiting-human' | 'awaiting-event' | 'transitioning' | 'terminal'): Promise; +}; diff --git a/apps/api/src/modules/flow-engine/CONTRACT.md b/apps/api/src/modules/flow-engine/CONTRACT.md new file mode 100644 index 0000000..3951026 --- /dev/null +++ b/apps/api/src/modules/flow-engine/CONTRACT.md @@ -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` —— 断点续跑的三份持久状态之一 diff --git a/apps/api/src/modules/flow-engine/contract.ts b/apps/api/src/modules/flow-engine/contract.ts new file mode 100644 index 0000000..b449725 --- /dev/null +++ b/apps/api/src/modules/flow-engine/contract.ts @@ -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; + position(taskId: TaskId): Promise; + /** 推进一步(或若干确定步);异步步进入 awaiting 并登记监听 */ + advance(taskId: TaskId): Promise; + /** 事件即步的完成条件:人事件 / 数据事件 / 时间事件到达即续跑 */ + resume(taskId: TaskId, event: { kind: 'human' | 'data' | 'time'; ref: string; payload: Record }): Promise; +}; diff --git a/apps/api/src/modules/flow-engine/index.ts b/apps/api/src/modules/flow-engine/index.ts new file mode 100644 index 0000000..3fded18 --- /dev/null +++ b/apps/api/src/modules/flow-engine/index.ts @@ -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'; diff --git a/apps/api/src/modules/flow-engine/internal/adapter/flow-state-schema.ts b/apps/api/src/modules/flow-engine/internal/adapter/flow-state-schema.ts new file mode 100644 index 0000000..c0d8802 --- /dev/null +++ b/apps/api/src/modules/flow-engine/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(), +}); diff --git a/apps/api/src/modules/flow-engine/internal/adapter/flow-state-store.ts b/apps/api/src/modules/flow-engine/internal/adapter/flow-state-store.ts new file mode 100644 index 0000000..92b7451 --- /dev/null +++ b/apps/api/src/modules/flow-engine/internal/adapter/flow-state-store.ts @@ -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 }; + }, + }; +} diff --git a/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts b/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts new file mode 100644 index 0000000..80d7a97 --- /dev/null +++ b/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts @@ -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; + 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>) => 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>, + 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): 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(); + 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'; + }); +} diff --git a/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts b/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts new file mode 100644 index 0000000..4cf2f1f --- /dev/null +++ b/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts @@ -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): Promise { + if (step.output === undefined) return; + const patch: Record = {}; + 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): Record { + const out: Record = {}; + for (const [key, path] of Object.entries(step.input)) { + out[key] = path.split('.').reduce((acc, seg) => { + if (acc === null || acc === undefined || typeof acc !== 'object') return undefined; + return (acc as Record)[seg]; + }, scope); + } + return out; + } + + async function runOne( + taskId: TaskId, + step: FlowStep, + index: number, + position: FlowPosition, + ): Promise { + 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; + 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 { + 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' }; + }, + }; +} diff --git a/apps/api/src/modules/flow-engine/package.json b/apps/api/src/modules/flow-engine/package.json new file mode 100644 index 0000000..6f810b4 --- /dev/null +++ b/apps/api/src/modules/flow-engine/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/flow-engine", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/flow-engine/port.ts b/apps/api/src/modules/flow-engine/port.ts new file mode 100644 index 0000000..82a9eff --- /dev/null +++ b/apps/api/src/modules/flow-engine/port.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; + load(taskId: TaskId): Promise<{ model: FlowModel; position: FlowPosition } | undefined>; +}; + +/** 条件谓词求值 —— app 注入 `@sfeel`;零 LLM */ +export type PredicateEvaluator = (expression: string, scope: Readonly>) => boolean; + +/** 任务数据 —— app 注入 `ledger`(L1):读任务视图 = 正式版 + 本任务草稿层 */ +export type TaskDataGateway = { + view(taskId: TaskId): Promise>; + draftWrite(input: { + taskId: TaskId; + domain: string; + object: string; + key: Record; + patch: Record; + }): Promise; +}; + +/** 工具步与纯工具步 —— app 注入 `toolbox`(L2) */ +export type ToolStepInvoker = { + invoke(input: { + tool: string; + input: Record; + activityId: string; + taskId: TaskId; + stepNo: number; + }): Promise<{ ok: boolean; output?: Record; error?: { message: string; retryable: boolean } }>; + /** 写类工具在 flow 里也走「登记动作意向」,除非契约显式预授权 */ + registerIntent(input: { + taskId: TaskId; + tool: string; + input: Record; + label: string; + }): Promise; + isWriteEffect(tool: string): Promise; +}; + +/** LLM 步 —— app 注入 `llm`(L1)的 ModelGateway;模板与出参 schema 随声明版本冻结 */ +export type LlmStepGateway = { + generate(input: { + model: string; + prompt: string; + outputSchema: Record; + 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; + }): Promise<{ id: string }>; +}; + +/** 监听登记 —— app 注入 `event`(L1):异步步的完成条件 = 监听,时限必配 */ +export type ListenerRegistrar = { + register(input: { + taskId: TaskId; + eventType: 'data' | 'time' | 'human'; + keyFilter: Record; + deadline: string; + }): Promise<{ id: string }>; +}; + +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'action' | 'result' | 'engine-transition'; + title: string; + detail?: Record; + }): Promise; +}; + +/** 任务上下文 —— 活动 ID、负责人、预算 */ +export type TaskContext = { + of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>; +}; diff --git a/apps/api/src/modules/loop-engine/CONTRACT.md b/apps/api/src/modules/loop-engine/CONTRACT.md new file mode 100644 index 0000000..6332fb7 --- /dev/null +++ b/apps/api/src/modules/loop-engine/CONTRACT.md @@ -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 新实例重新入场 diff --git a/apps/api/src/modules/loop-engine/__tests__/cmmn.test.ts b/apps/api/src/modules/loop-engine/__tests__/cmmn.test.ts new file mode 100644 index 0000000..9d16a83 --- /dev/null +++ b/apps/api/src/modules/loop-engine/__tests__/cmmn.test.ts @@ -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 = ` + + + + + + + + + create + + + create + semantic: 回价是否可信 + + + + + + + + + + + + + + P4D + + + +`; + +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('', ''); + expect(() => parseCmmn(bad)).toThrow(CmmnParseError); + }); + + it('标签不匹配报错', () => { + expect(() => parseCmmn('')).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>): 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(/成环/); + }); +}); diff --git a/apps/api/src/modules/loop-engine/contract.ts b/apps/api/src/modules/loop-engine/contract.ts new file mode 100644 index 0000000..42741a5 --- /dev/null +++ b/apps/api/src/modules/loop-engine/contract.ts @@ -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 } + /** 调人:四卡之一 —— 调人 = 立监听听人 */ + | { readonly kind: 'human'; readonly itemId: string; readonly card: Record; readonly workOrder: 'execution' | 'acceptance' } + /** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */ + | { readonly kind: 'listen'; readonly itemId: string; readonly eventType: 'data' | 'time'; readonly keyFilter: Record; 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; + runtime(taskId: TaskId): Promise; + /** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */ + publish(taskId: TaskId): Promise; + /** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */ + runTurn(taskId: TaskId): Promise; + /** 事件驱动续跑:数据 / 时间 / 人 / 控制 */ + onEvent(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record }): Promise; + /** DMN 确定性求值 —— 能算的不问 LLM */ + decide(taskId: TaskId, decision: string): Promise; + /** 驳回:相关计划项以新实例重新入场(迁移不是状态) */ + reenter(taskId: TaskId, itemId: string): Promise; +}; diff --git a/apps/api/src/modules/loop-engine/index.ts b/apps/api/src/modules/loop-engine/index.ts new file mode 100644 index 0000000..f565967 --- /dev/null +++ b/apps/api/src/modules/loop-engine/index.ts @@ -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'; diff --git a/apps/api/src/modules/loop-engine/internal/adapter/loop-state-schema.ts b/apps/api/src/modules/loop-engine/internal/adapter/loop-state-schema.ts new file mode 100644 index 0000000..d3f1727 --- /dev/null +++ b/apps/api/src/modules/loop-engine/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(), +}); diff --git a/apps/api/src/modules/loop-engine/internal/adapter/loop-state-store.ts b/apps/api/src/modules/loop-engine/internal/adapter/loop-state-store.ts new file mode 100644 index 0000000..e99df0f --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/adapter/loop-state-store.ts @@ -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, + }; + }, + }; +} diff --git a/apps/api/src/modules/loop-engine/internal/domain/cmmn-parse.ts b/apps/api/src/modules/loop-engine/internal/domain/cmmn-parse.ts new file mode 100644 index 0000000..1c576bc --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/domain/cmmn-parse.ts @@ -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>; + 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(//g, ''); + const stack: Array<{ tag: string; attrs: Record; 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(`多余的结束标签 `); + if (finished.tag !== tag) throw new CmmnParseError(`标签不匹配:<${finished.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 = {}; + 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(//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> = { + 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(); + 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, + 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): 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): 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; +} diff --git a/apps/api/src/modules/loop-engine/internal/domain/dmn.ts b/apps/api/src/modules/loop-engine/internal/domain/dmn.ts new file mode 100644 index 0000000..a515d72 --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/domain/dmn.ts @@ -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>) => boolean; +export type ExpressionEvaluator = (expression: string, scope: Readonly>) => unknown; + +/** 决策留痕:输入快照 + 决策表版本 + 命中规则行 —— 决策可回放、可归因 */ +export type DecisionTrace = { + readonly decision: string; + readonly tableId: string; + readonly inputs: Readonly>; + readonly hitRules: readonly string[]; + readonly outputs: Readonly>; +}; + +export type DecisionResult = + | { readonly kind: 'decided'; readonly trace: DecisionTrace } + /** 输入不齐 —— DRD 指出缺什么(缺口演绎的后拉遍历) */ + | { readonly kind: 'incomplete'; readonly missing: readonly string[] } + /** 无规则命中 */ + | { readonly kind: 'no-match'; readonly inputs: Readonly> }; + +/** 求值一张决策表 */ +export function evaluateTable( + table: DmnDecisionTable, + scope: Readonly>, + evaluateExpression: ExpressionEvaluator, + evaluateUnaryTest: UnaryTestEvaluator, +): DecisionResult { + const inputs: Record = {}; + 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 = {}; + + 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): 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(); + 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(); + + 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 { + return result.kind === 'decided'; +} diff --git a/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts b/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts new file mode 100644 index 0000000..457a7be --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts @@ -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> = { + 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> = { + 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>) => 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>, + changedRefs: ReadonlySet, + 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>, + changedRefs: ReadonlySet, + 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): 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, + ), + }; +} diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts new file mode 100644 index 0000000..729c0d5 --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts @@ -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): Set { + 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 { + 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 = { ...scope }; + let last = undefined as ReturnType | 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; + }, + }; +} diff --git a/apps/api/src/modules/loop-engine/package.json b/apps/api/src/modules/loop-engine/package.json new file mode 100644 index 0000000..cd10f06 --- /dev/null +++ b/apps/api/src/modules/loop-engine/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/loop-engine", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/loop-engine/port.ts b/apps/api/src/modules/loop-engine/port.ts new file mode 100644 index 0000000..294f323 --- /dev/null +++ b/apps/api/src/modules/loop-engine/port.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; + load(taskId: TaskId): Promise<{ model: LoopModel; parsed: CaseModel; runtime: CaseRuntime } | undefined>; +}; + +/** S-FEEL —— app 注入 `@sfeel`;引擎零 LLM */ +export type Sfeel = { + condition(expression: string, scope: Readonly>): boolean; + expression(expression: string, scope: Readonly>): unknown; + unaryTest(cell: string, value: unknown, scope: Readonly>): boolean; +}; + +/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */ +export type TaskDataGateway = { + view(taskId: TaskId): Promise>; + draftWrite(input: { + taskId: TaskId; + domain: string; + object: string; + key: Record; + patch: Record; + }): Promise; +}; + +/** + * Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。 + * **回合由 usecase 驱动,不放开自由 maxSteps,预算在环外由 Harness 强制。** + */ +export type AgentPort = { + decide(input: { + taskId: TaskId; + /** 叙事面:说明书 + 活动记忆选摘 + 任务记忆窗口 */ + narrative: string; + enabled: EnabledSet; + scope: Record; + }): Promise; +}; + +/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */ +export type ToolGateway = { + invoke(input: { tool: string; input: Record; activityId: string; taskId: TaskId }): Promise<{ + ok: boolean; + output?: Record; + error?: { message: string }; + }>; + isWriteEffect(tool: string): Promise; + registerIntent(input: { taskId: TaskId; tool: string; input: Record; label: string }): Promise; +}; + +export type CardGateway = { + issue(input: { + taskId: TaskId; + ownerId: StaffId; + humanCallCap: number; + workOrder: 'execution' | 'acceptance'; + card: Record; + }): Promise<{ id: string }>; +}; + +export type ListenerRegistrar = { + register(input: { + taskId: TaskId; + eventType: 'data' | 'time' | 'human'; + keyFilter: Record; + deadline: string; + }): Promise<{ id: string }>; +}; + +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation'; + title: string; + detail?: Record; + }): Promise; + /** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */ + loadActivityMemory(activityId: string, limit: number): Promise>; +}; + +export type TaskContext = { + of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>; +}; diff --git a/apps/api/src/modules/skill-engine/CONTRACT.md b/apps/api/src/modules/skill-engine/CONTRACT.md new file mode 100644 index 0000000..5db4992 --- /dev/null +++ b/apps/api/src/modules/skill-engine/CONTRACT.md @@ -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()` + - 技能**不是独立范式**:无自己的约束体系,一切继承本规范 diff --git a/apps/api/src/modules/skill-engine/contract.ts b/apps/api/src/modules/skill-engine/contract.ts new file mode 100644 index 0000000..f29a431 --- /dev/null +++ b/apps/api/src/modules/skill-engine/contract.ts @@ -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>; + readonly stepNo?: number; +}; + +export type ScriptOutcome = { + readonly ok: boolean; + readonly steps: ReadonlyArray<{ tool: string; ok: boolean; output?: Record; error?: string }>; + readonly stamp: readonly SkillStamp[]; +}; + +export type SkillEngineService = { + /** 常驻上下文只有目录 —— 渐进披露 */ + index(activityId: string): Promise; + /** 按事加载正文;装配前过扩权检查 */ + load(activityId: string, skillIds: readonly string[]): Promise; + /** 结晶片段脚本 = 确定性动作链;经 ToolInvoker 调用而留痕,不得内联绕过 */ + runScript(input: RunScriptInput): Promise; + /** 装配入信封 —— 供任务记忆盖戳 */ + stamp(activityId: string, skillIds: readonly string[]): Promise; +}; diff --git a/apps/api/src/modules/skill-engine/index.ts b/apps/api/src/modules/skill-engine/index.ts new file mode 100644 index 0000000..01dba0e --- /dev/null +++ b/apps/api/src/modules/skill-engine/index.ts @@ -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'; diff --git a/apps/api/src/modules/skill-engine/internal/domain/skill-assembly.ts b/apps/api/src/modules/skill-engine/internal/domain/skill-assembly.ts new file mode 100644 index 0000000..8ba6515 --- /dev/null +++ b/apps/api/src/modules/skill-engine/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> }; + +export function crystallizedChain(skill: Skill): CrystallizedStep[] { + return skill.crystallized.map((s) => ({ tool: s.tool, input: s.input })); +} + +/** 入参插值:`{{path}}` 从任务数据取值 —— 纯字符串替换,零求值 */ +export function interpolate( + input: Readonly>, + scope: Readonly>, +): Record { + const out: Record = {}; + 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>, 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)[segment]; + } + return current; +} diff --git a/apps/api/src/modules/skill-engine/internal/usecase/skill-engine-service.ts b/apps/api/src/modules/skill-engine/internal/usecase/skill-engine-service.ts new file mode 100644 index 0000000..c1a0d85 --- /dev/null +++ b/apps/api/src/modules/skill-engine/internal/usecase/skill-engine-service.ts @@ -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 { + 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 { + 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; 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]) }; + }, + }; +} diff --git a/apps/api/src/modules/skill-engine/package.json b/apps/api/src/modules/skill-engine/package.json new file mode 100644 index 0000000..05ff5b6 --- /dev/null +++ b/apps/api/src/modules/skill-engine/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/skill-engine", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/skill-engine/port.ts b/apps/api/src/modules/skill-engine/port.ts new file mode 100644 index 0000000..43e511c --- /dev/null +++ b/apps/api/src/modules/skill-engine/port.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; + allowlistOf(activityId: string): Promise; +}; + +/** + * 工具调用 —— app 注入 `toolbox`(L2)的 `ToolInvoker`。 + * 脚本引用的工具**仍过本活动白名单与调用契约** —— 载方法不载权力。 + */ +export type ScriptToolInvoker = { + invoke(input: { + tool: string; + input: Record; + activityId: string; + taskId: TaskId; + stepNo?: number; + }): Promise<{ ok: boolean; output?: Record; error?: { message: string } }>; +}; + +export type MemoryRecorder = { + append(input: { taskId: TaskId; kind: 'action' | 'result'; title: string; detail?: Record }): Promise; +}; diff --git a/apps/api/src/modules/task/CONTRACT.md b/apps/api/src/modules/task/CONTRACT.md new file mode 100644 index 0000000..dc2514e --- /dev/null +++ b/apps/api/src/modules/task/CONTRACT.md @@ -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`,产出活动记忆候选条目 + - **断点续跑靠三份持久状态**:任务记忆 / 草稿层 / 运行时实例状态,缺一即不可续 diff --git a/apps/api/src/modules/task/__tests__/harness.test.ts b/apps/api/src/modules/task/__tests__/harness.test.ts new file mode 100644 index 0000000..e6984e3 --- /dev/null +++ b/apps/api/src/modules/task/__tests__/harness.test.ts @@ -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(/非法时长/); + }); +}); diff --git a/apps/api/src/modules/task/contract.ts b/apps/api/src/modules/task/contract.ts new file mode 100644 index 0000000..3a2a462 --- /dev/null +++ b/apps/api/src/modules/task/contract.ts @@ -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>; + 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; + readonly message?: string; +}; + +export type TickOutcome = { + readonly task: Task; + readonly verdict: HarnessVerdict; + readonly progressed: boolean; +}; + +export type TaskService = { + /** 起单:装载 → 校验输入 → 建任务记忆 → 首个回合 */ + start(input: StartTaskInput): Promise; + get(taskId: TaskId): Promise; + listByOwner(ownerId: StaffId, openOnly?: boolean): Promise; + /** org 停用前置检查要问的「在途任务」 */ + openTasksOf(ownerId: StaffId): Promise>; + + /** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */ + tick(taskId: TaskId): Promise; + /** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */ + deliver(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record }): Promise; + + /** 控制三语义:暂停 / 叫停 / 终止 */ + control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise; + resume(taskId: TaskId, by: StaffId): Promise; + /** 改派:在途任务的负责人变更,**留痕并通知双方** */ + reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise; + + /** 终态裁定 —— 完成由框架裁定,终态由系统命名 */ + finish(taskId: TaskId, cause: TerminalCause): Promise; + /** 断点续跑的三份持久状态是否齐备 */ + resumeReadiness(taskId: TaskId): Promise; +}; diff --git a/apps/api/src/modules/task/index.ts b/apps/api/src/modules/task/index.ts new file mode 100644 index 0000000..f1a8f75 --- /dev/null +++ b/apps/api/src/modules/task/index.ts @@ -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'; diff --git a/apps/api/src/modules/task/internal/adapter/task-schema.ts b/apps/api/src/modules/task/internal/adapter/task-schema.ts new file mode 100644 index 0000000..b34b268 --- /dev/null +++ b/apps/api/src/modules/task/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), + ], +); diff --git a/apps/api/src/modules/task/internal/adapter/task-store.ts b/apps/api/src/modules/task/internal/adapter/task-store.ts new file mode 100644 index 0000000..a323c9e --- /dev/null +++ b/apps/api/src/modules/task/internal/adapter/task-store.ts @@ -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, + 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 = {}; + 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 { + 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)); + }, + }; +} diff --git a/apps/api/src/modules/task/internal/domain/harness.ts b/apps/api/src/modules/task/internal/domain/harness.ts new file mode 100644 index 0000000..75f1289 --- /dev/null +++ b/apps/api/src/modules/task/internal/domain/harness.ts @@ -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; +} diff --git a/apps/api/src/modules/task/internal/domain/lifecycle.ts b/apps/api/src/modules/task/internal/domain/lifecycle.ts new file mode 100644 index 0000000..cb20d6c --- /dev/null +++ b/apps/api/src/modules/task/internal/domain/lifecycle.ts @@ -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>): ResumeReadiness { + const missing = RESUME_SOURCES.filter((s) => !present[s]); + return { ready: missing.length === 0, missing }; +} + +/** 「打开任务只是打开呈现,不是启动过程」—— 过程要么在跑,要么挂起在等 */ +export function isPassiveOpen(state: TaskRuntimeState): boolean { + return state !== 'running'; +} diff --git a/apps/api/src/modules/task/internal/usecase/task-service.ts b/apps/api/src/modules/task/internal/usecase/task-service.ts new file mode 100644 index 0000000..e1366c6 --- /dev/null +++ b/apps/api/src/modules/task/internal/usecase/task-service.ts @@ -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>; + 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 { + 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 { + 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 { + 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 { + 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 }; diff --git a/apps/api/src/modules/task/package.json b/apps/api/src/modules/task/package.json new file mode 100644 index 0000000..25db345 --- /dev/null +++ b/apps/api/src/modules/task/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/task", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/task/port.ts b/apps/api/src/modules/task/port.ts new file mode 100644 index 0000000..c086f6a --- /dev/null +++ b/apps/api/src/modules/task/port.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; + get(taskId: TaskId): Promise; + listByOwner(ownerId: StaffId, openOnly: boolean): Promise; + update(taskId: TaskId, patch: Partial): Promise; + addConsumption(taskId: TaskId, delta: Partial): Promise; + lastProgressAt(taskId: TaskId): Promise; + markProgress(taskId: TaskId, at: Date): Promise; +}; + +/** 活动装载 —— app 注入 `activity-registry`(L4) */ +export type ActivityLoader = { + load(activityId: string): Promise<{ + paradigm: Paradigm; + caps: BudgetCaps; + envelope: Record; + /** 输入 data 的 JSON Schema —— 缺项不得启动 */ + inputSchema: Record; + businessModel: unknown; + stallAfter: string; + }>; +}; + +export type SchemaValidator = { + validate(schema: Record, value: unknown): { ok: boolean; errors: string[] }; +}; + +/** 范式引擎 —— loop / flow 在此汇流;对上层不可区分(§9 双层边界) */ +export type ParadigmEngine = { + load(taskId: TaskId, model: unknown): Promise; + 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 }, + ): 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; + }): Promise; + seal(taskId: TaskId): Promise; + /** 终态封卷触发蒸馏钩子 —— 产出活动记忆候选条目 */ + distill(input: { taskId: TaskId; activityId: string; trigger: 'terminal'; prompt: string }): Promise; + hasHistory(taskId: TaskId): Promise; +}; + +/** 监听在等?—— app 注入 `event`(L1):监听中是健康态,与停滞分别对待 */ +export type ListenerProbe = { + hasArmedListener(taskId: TaskId): Promise; +}; + +/** 草稿层与运行时状态是否在 —— 断点续跑的三份持久状态 */ +export type PersistenceProbe = { + hasDrafts(taskId: TaskId): Promise; + hasRuntimeState(taskId: TaskId): Promise; +}; + +/** 控制事件与终态事件 —— app 注入 `event`(L1) */ +export type TaskEventPublisher = { + publishControl(taskId: TaskId, semantic: string, by: StaffId, reason?: string): Promise; + publishTerminal(taskId: TaskId, terminal: TerminalState, reason: string): Promise; +}; + +/** 改派通知 —— app 注入 `message`(L2):改派留痕并**通知双方** */ +export type ReassignNotifier = { + notify(input: { from: StaffId; to: StaffId; taskId: TaskId; title: string; by: StaffId }): Promise; +}; + +/** 交互态 —— app 注入 `interaction`(L3):控制权按态转交 */ +export type InteractionPhaseSetter = { + setPhase(taskId: TaskId, phase: 'activating' | 'active' | 'awaiting-human' | 'awaiting-event' | 'transitioning' | 'terminal'): Promise; +}; diff --git a/db/migrations/0005_broad_sprite.sql b/db/migrations/0005_broad_sprite.sql new file mode 100644 index 0000000..674bb0f --- /dev/null +++ b/db/migrations/0005_broad_sprite.sql @@ -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"); \ No newline at end of file diff --git a/db/migrations/meta/0005_snapshot.json b/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..a950d0b --- /dev/null +++ b/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,2600 @@ +{ + "id": "d9cf0d23-00d3-4ced-9372-9f5e46edec64", + "prevId": "7d11021b-f59a-46ad-9193-4161a642408e", + "version": "7", + "dialect": "postgresql", + "tables": { + "sys.activity_asset": { + "name": "activity_asset", + "schema": "sys", + "columns": { + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective": { + "name": "effective", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_asset_effective_key": { + "name": "activity_asset_effective_key", + "columns": [ + { + "expression": "activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sys\".\"activity_asset\".\"effective\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "activity_asset_activity_id_kind_version_pk": { + "name": "activity_asset_activity_id_kind_version_pk", + "columns": [ + "activity_id", + "kind", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.activity_package": { + "name": "activity_package", + "schema": "sys", + "columns": { + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paradigm": { + "name": "paradigm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "artifact": { + "name": "artifact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_package_category_idx": { + "name": "activity_package_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "paradigm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "activity_package_activity_id_version_pk": { + "name": "activity_package_activity_id_version_pk", + "columns": [ + "activity_id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.asset_increment": { + "name": "asset_increment", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "diff": { + "name": "diff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_entry_ids": { + "name": "evidence_entry_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'candidate'" + }, + "replay_report": { + "name": "replay_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "released_by": { + "name": "released_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_increment_activity_idx": { + "name": "asset_increment_activity_idx", + "columns": [ + { + "expression": "activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.channel_instance": { + "name": "channel_instance", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "extract": { + "name": "extract", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "mapping": { + "name": "mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "fallback_position": { + "name": "fallback_position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_window_ms": { + "name": "address_window_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 604800000 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.outbound_mail": { + "name": "outbound_mail", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_addr": { + "name": "from_addr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_addrs": { + "name": "to_addrs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "cc_addrs": { + "name": "cc_addrs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.event_consumption": { + "name": "event_consumption", + "schema": "sys", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer": { + "name": "consumer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "consumption_key": { + "name": "consumption_key", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "event_consumption_event_id_consumer_pk": { + "name": "event_consumption_event_id_consumer_pk", + "columns": [ + "event_id", + "consumer" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.event_delivery": { + "name": "event_delivery", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_pending_idx": { + "name": "delivery_pending_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_target_idx": { + "name": "delivery_target_idx", + "columns": [ + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_delivery_event_id_event_id_fk": { + "name": "event_delivery_event_id_event_id_fk", + "tableFrom": "event_delivery", + "tableTo": "event", + "schemaTo": "sys", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.event": { + "name": "event", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation": { + "name": "correlation", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "trace": { + "name": "trace", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "event_target_idx": { + "name": "event_target_idx", + "columns": [ + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_occurred_idx": { + "name": "event_occurred_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.listener": { + "name": "listener", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_filter": { + "name": "key_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "semantic": { + "name": "semantic", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deadline": { + "name": "deadline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "escalation": { + "name": "escalation", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'armed'" + } + }, + "indexes": { + "listener_armed_idx": { + "name": "listener_armed_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listener_task_idx": { + "name": "listener_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.outbound_ref": { + "name": "outbound_ref", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "outbound_thread_idx": { + "name": "outbound_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_addr_idx": { + "name": "outbound_addr_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.flow_state": { + "name": "flow_state", + "schema": "sys", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "model": { + "name": "model", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.chat_card": { + "name": "chat_card", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "superseded_by": { + "name": "superseded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_reason": { + "name": "rejected_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_card_task_idx": { + "name": "chat_card_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_card_recipient_idx": { + "name": "chat_card_recipient_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.work_order": { + "name": "work_order", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "done": { + "name": "done", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "work_order_task_idx": { + "name": "work_order_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_order_recipient_idx": { + "name": "work_order_recipient_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.chat_message": { + "name": "chat_message", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "chat_message_task_idx": { + "name": "chat_message_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.task_view": { + "name": "task_view", + "schema": "sys", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "blocks": { + "name": "blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'activating'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.llm_instance": { + "name": "llm_instance", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_cipher": { + "name": "api_key_cipher", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_use": { + "name": "system_use", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "llm_instance_name_key": { + "name": "llm_instance_name_key", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.replay_model": { + "name": "replay_model", + "schema": "sys", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "call": { + "name": "call", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "replay_model_recorded_idx": { + "name": "replay_model_recorded_idx", + "columns": [ + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.loop_state": { + "name": "loop_state", + "schema": "sys", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "model": { + "name": "model", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "parsed": { + "name": "parsed", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "runtime": { + "name": "runtime", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.activity_memory": { + "name": "activity_memory", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layer": { + "name": "layer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "statement": { + "name": "statement", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "increment": { + "name": "increment", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pruned_at": { + "name": "pruned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "activity_memory_layer_idx": { + "name": "activity_memory_layer_idx", + "columns": [ + { + "expression": "activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.task_memory_seal": { + "name": "task_memory_seal", + "schema": "sys", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sealed_at": { + "name": "sealed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.task_memory": { + "name": "task_memory", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stamp": { + "name": "stamp", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "governance": { + "name": "governance", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "links_to": { + "name": "links_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "task_memory_seq_key": { + "name": "task_memory_seq_key", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_memory_task_idx": { + "name": "task_memory_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.system_message": { + "name": "system_message", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_id": { + "name": "position_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "card": { + "name": "card", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_staff_idx": { + "name": "message_staff_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_unread_idx": { + "name": "message_unread_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.position_grant": { + "name": "position_grant", + "schema": "sys", + "columns": { + "position_id": { + "name": "position_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object": { + "name": "object", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "position_grant_position_id_position_id_fk": { + "name": "position_grant_position_id_position_id_fk", + "tableFrom": "position_grant", + "tableTo": "position", + "schemaTo": "sys", + "columnsFrom": [ + "position_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "position_grant_position_id_object_key_action_pk": { + "name": "position_grant_position_id_object_key_action_pk", + "columns": [ + "position_id", + "object_key", + "action" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.position": { + "name": "position", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "position_code_key": { + "name": "position_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.staff": { + "name": "staff", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_id": { + "name": "position_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "staff_account_key": { + "name": "staff_account_key", + "columns": [ + { + "expression": "account", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "staff_position_idx": { + "name": "staff_position_idx", + "columns": [ + { + "expression": "position_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_position_id_position_id_fk": { + "name": "staff_position_id_position_id_fk", + "tableFrom": "staff", + "tableTo": "position", + "schemaTo": "sys", + "columnsFrom": [ + "position_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.action_intent": { + "name": "action_intent", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'drafted'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_intent_task_idx": { + "name": "action_intent_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.task": { + "name": "task", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paradigm": { + "name": "paradigm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime": { + "name": "runtime", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "terminal": { + "name": "terminal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "envelope": { + "name": "envelope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "caps": { + "name": "caps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "used_steps": { + "name": "used_steps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "used_tokens": { + "name": "used_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "used_active_ms": { + "name": "used_active_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "used_human_calls": { + "name": "used_human_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_progress_at": { + "name": "last_progress_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "task_owner_idx": { + "name": "task_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "runtime", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_workflow_idx": { + "name": "task_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_terminal_idx": { + "name": "task_terminal_idx", + "columns": [ + { + "expression": "terminal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.replay_tool": { + "name": "replay_tool", + "schema": "sys", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "call": { + "name": "call", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.tool_allowlist": { + "name": "tool_allowlist", + "schema": "sys", + "columns": { + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tool_allowlist_activity_id_tool_pk": { + "name": "tool_allowlist_activity_id_tool_pk", + "columns": [ + "activity_id", + "tool" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.tool": { + "name": "tool", + "schema": "sys", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "idempotent": { + "name": "idempotent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "timeout_ms": { + "name": "timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "connection_keys": { + "name": "connection_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "pure": { + "name": "pure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "owner_activity_id": { + "name": "owner_activity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_cipher": { + "name": "connection_cipher", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_effect_idx": { + "name": "tool_effect_idx", + "columns": [ + { + "expression": "effect", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_owner_idx": { + "name": "tool_owner_idx", + "columns": [ + { + "expression": "owner_activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 7f5d48b..3ac7973 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1785087362238, "tag": "0004_nice_ravenous", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1785088279529, + "tag": "0005_broad_sprite", + "breakpoints": true } ] } \ No newline at end of file diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5dbf578..11167b6 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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