M3(2/5): human-tool 与 release —— 卡协议与放行门
human-tool(L3): - 三规矩机检:调用必附上下文(裸调用拒发)/ 回传即留痕 / 完成只出自放行通过 - 工单两模式 + 对话卡族的形态校验与回传校验;驳回必附意见;补全卡「没法填」旁路; 决策卡自定义收尾项必附理由;已答不得再答(只增不改) - 到达面两路(负责人 → 任务对话流;他人 → 系统消息);驳回重答 = 新卡实例、原卡闭合 - 调人预算由基座在环外强制;落地失败卡 = 执行工单的 preset release(L4): - 落地预检五项(入参 schema / 工具可用与授权 / 连接可达 / 目标预校验 / 数据冲突) - 预检全过才发验收工单,卡面即已验证的动作清单;不过即打回环内,不发卡 - 放行写合并 + 幂等落地(任务 × 意向);落地收敛才发射输出口; 受阻走落地失败卡且 emittedPorts 恒空;已成动作留存不回滚 215 个测试通过,门禁五道全绿 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
146e9bbfad
commit
561a2474ae
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* module: human-tool —— 对外类型(编码规范 §10.1)。
|
||||||
|
*
|
||||||
|
* **双层边界**(§9):范式只在引擎层;本模块是前端呈现协议的后端面 ——
|
||||||
|
* 工单两模式(执行 / 验收)+ 对话卡族(决策 / 补全 / 评价)。
|
||||||
|
* 落地失败卡是**执行工单的系统预置形态**,不构成第五种卡(OS 7.3)。
|
||||||
|
*/
|
||||||
|
import type { AcceptanceWorkOrder, ChatCard, ChatCardReply, ExecutionWorkOrder, StaffId, TaskId, WorkOrder, WorkOrderReply } from '@contracts';
|
||||||
|
import type { ArrivalSurface, CardViolation } from './internal/domain/card-rules.ts';
|
||||||
|
export type { AcceptanceWorkOrder, ArrivalSurface, CardViolation, ChatCard, ChatCardReply, ExecutionWorkOrder, WorkOrder, WorkOrderReply, };
|
||||||
|
export declare class BareCallError extends Error {
|
||||||
|
readonly violations: readonly CardViolation[];
|
||||||
|
constructor(violations: readonly CardViolation[]);
|
||||||
|
}
|
||||||
|
export declare class ReplyRejectedError extends Error {
|
||||||
|
readonly violations: readonly CardViolation[];
|
||||||
|
constructor(violations: readonly CardViolation[]);
|
||||||
|
}
|
||||||
|
export declare class HumanCallBudgetExceededError extends Error {
|
||||||
|
readonly taskId: TaskId;
|
||||||
|
readonly used: number;
|
||||||
|
readonly cap: number;
|
||||||
|
constructor(taskId: TaskId, used: number, cap: number);
|
||||||
|
}
|
||||||
|
/** 工单草稿:id / at 由基座补齐 —— ID 生成不外泄给调用方(domain 零随机) */
|
||||||
|
export type ExecutionWorkOrderDraft = Omit<ExecutionWorkOrder, 'mode' | 'done' | 'id' | 'at'> & {
|
||||||
|
readonly id?: ExecutionWorkOrder['id'] | undefined;
|
||||||
|
readonly at?: string | undefined;
|
||||||
|
};
|
||||||
|
export type AcceptanceWorkOrderDraft = Omit<AcceptanceWorkOrder, 'mode' | 'done' | 'id' | 'at'> & {
|
||||||
|
readonly id?: AcceptanceWorkOrder['id'] | undefined;
|
||||||
|
readonly at?: string | undefined;
|
||||||
|
};
|
||||||
|
/** 发卡的公共入参 */
|
||||||
|
export type IssueContext = {
|
||||||
|
readonly taskId: TaskId;
|
||||||
|
/** 任务负责人 —— 决定到达面两路 */
|
||||||
|
readonly ownerId: StaffId;
|
||||||
|
/** 收件人;缺省即负责人 */
|
||||||
|
readonly recipientId?: StaffId;
|
||||||
|
/** 契约的调人预算(打扰上限) */
|
||||||
|
readonly humanCallCap: number;
|
||||||
|
};
|
||||||
|
export type IssuedWorkOrder = {
|
||||||
|
readonly order: WorkOrder;
|
||||||
|
readonly arrival: ArrivalSurface;
|
||||||
|
/** 到达面 = system-message 时的消息 ID(跨人卡) */
|
||||||
|
readonly messageId?: string;
|
||||||
|
};
|
||||||
|
export type IssuedChatCard = {
|
||||||
|
readonly card: ChatCard;
|
||||||
|
readonly arrival: ArrivalSurface;
|
||||||
|
readonly messageId?: string;
|
||||||
|
};
|
||||||
|
export type ReplyOutcome = {
|
||||||
|
readonly order: WorkOrder;
|
||||||
|
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
|
||||||
|
readonly producesCompletion: boolean;
|
||||||
|
/** 驳回:环内回转,任务继续 */
|
||||||
|
readonly rejection?: {
|
||||||
|
readonly kind: 'redo' | 'revise';
|
||||||
|
readonly comment: string;
|
||||||
|
};
|
||||||
|
/** 选择组的出口选择 —— 随放行一并确认(AM 1.2) */
|
||||||
|
readonly selectedPorts: Readonly<Record<string, string>>;
|
||||||
|
};
|
||||||
|
export type HumanToolService = {
|
||||||
|
/** 发执行工单;裸调用拒发,预算触顶熔断。id / at 留空即由基座生成 */
|
||||||
|
issueExecution(context: IssueContext, order: ExecutionWorkOrderDraft): Promise<IssuedWorkOrder>;
|
||||||
|
/** 发验收工单 —— 发卡**前**必须已过落地预检(由 release 保证,见其 CONTRACT) */
|
||||||
|
issueAcceptance(context: IssueContext, order: AcceptanceWorkOrderDraft): Promise<IssuedWorkOrder>;
|
||||||
|
/** 落地失败卡:执行工单的系统预置形态,不是第五种卡 */
|
||||||
|
issueLandingFailure(context: IssueContext, input: {
|
||||||
|
readonly failures: ReadonlyArray<{
|
||||||
|
label: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
}): Promise<IssuedWorkOrder>;
|
||||||
|
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
|
||||||
|
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
|
||||||
|
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
|
||||||
|
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
|
||||||
|
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
||||||
|
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||||
|
chatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
|
/** org 停用前置检查要问的「待答卡」 */
|
||||||
|
openCardsOf(staffId: StaffId): Promise<Array<{
|
||||||
|
cardId: string;
|
||||||
|
title: string;
|
||||||
|
}>>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* module: human-tool —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||||
|
*/
|
||||||
|
export type { AcceptanceWorkOrder, AcceptanceWorkOrderDraft, ArrivalSurface, CardViolation, ChatCard, ChatCardReply, ExecutionWorkOrder, ExecutionWorkOrderDraft, HumanToolService, IssueContext, IssuedChatCard, IssuedWorkOrder, ReplyOutcome, WorkOrder, WorkOrderReply, } from './contract.ts';
|
||||||
|
export { BareCallError, HumanCallBudgetExceededError, ReplyRejectedError } from './contract.ts';
|
||||||
|
export type { CardStore, Clock, HumanEventPublisher, IdGenerator, MemoryRecorder, MessageOutbox } from './port.ts';
|
||||||
|
export type { HumanToolServiceDeps } from './internal/usecase/human-tool-service.ts';
|
||||||
|
export { createHumanToolService } from './internal/usecase/human-tool-service.ts';
|
||||||
|
export { allCriteriaGreen, ARRIVAL_SURFACES, arrivalOf, checkChatCard, checkChatCardReply, checkWorkOrder, checkWorkOrderReply, producesCompletion, } from './internal/domain/card-rules.ts';
|
||||||
|
export { createCardStore } from './internal/adapter/card-store.ts';
|
||||||
|
export { chatCardTable, workOrderTable } from './internal/adapter/human-tool-schema.ts';
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* module: human-tool —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||||
|
*/
|
||||||
|
import type { ChatCard, StaffId, TaskId, WorkOrder } from '@contracts';
|
||||||
|
export type Clock = {
|
||||||
|
now(): Date;
|
||||||
|
};
|
||||||
|
export type IdGenerator = {
|
||||||
|
next(): string;
|
||||||
|
};
|
||||||
|
export type CardStore = {
|
||||||
|
saveWorkOrder(taskId: TaskId, order: WorkOrder, recipientId: StaffId): Promise<void>;
|
||||||
|
getWorkOrder(id: string): Promise<{
|
||||||
|
taskId: TaskId;
|
||||||
|
order: WorkOrder;
|
||||||
|
recipientId: StaffId;
|
||||||
|
} | undefined>;
|
||||||
|
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
|
||||||
|
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
|
||||||
|
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
||||||
|
getChatCard(id: string): Promise<{
|
||||||
|
taskId: TaskId;
|
||||||
|
card: ChatCard;
|
||||||
|
recipientId: StaffId;
|
||||||
|
} | undefined>;
|
||||||
|
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
|
updateChatCard(card: ChatCard): Promise<void>;
|
||||||
|
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
|
||||||
|
/** 调人次数 —— 打扰率与预算的计数面 */
|
||||||
|
humanCallCount(taskId: TaskId): Promise<number>;
|
||||||
|
openCardsOf(staffId: StaffId): Promise<Array<{
|
||||||
|
cardId: string;
|
||||||
|
title: string;
|
||||||
|
}>>;
|
||||||
|
};
|
||||||
|
/** 跨人卡的到达面 —— app 注入 `message`(L2) */
|
||||||
|
export type MessageOutbox = {
|
||||||
|
sendElicitation(input: {
|
||||||
|
staffId: StaffId;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
cardId: string;
|
||||||
|
sourceTaskId: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}): Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
/** 回传即留痕 —— app 注入 `memory`(L2) */
|
||||||
|
export type MemoryRecorder = {
|
||||||
|
append(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
kind: 'card-sent' | 'card-reply' | 'release-verdict';
|
||||||
|
title: string;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
|
};
|
||||||
|
/** 人事件 —— app 注入 `event`(L1):回传经统一入口写回任务 */
|
||||||
|
export type HumanEventPublisher = {
|
||||||
|
publishHumanEvent(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
cardId: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* module: release —— 对外类型(编码规范 §10.1)。
|
||||||
|
*
|
||||||
|
* 放行门:草稿态 → 事实态的**唯一通道**。门归系统,启门归人(OS 10)。
|
||||||
|
*/
|
||||||
|
import type { ActionIntent, StaffId, TaskId } from '@contracts';
|
||||||
|
import type { CriterionCheck, DeliverableCheck, LandingOutcome, LandingVerdict, PrecheckFinding, PrecheckItem, PrecheckReport } from './internal/domain/precheck.ts';
|
||||||
|
export type { ActionIntent, CriterionCheck, DeliverableCheck, LandingOutcome, LandingVerdict, PrecheckFinding, PrecheckItem, PrecheckReport, };
|
||||||
|
export declare class PrecheckFailedError extends Error {
|
||||||
|
readonly report: PrecheckReport;
|
||||||
|
constructor(report: PrecheckReport);
|
||||||
|
}
|
||||||
|
export declare class DeliverablesMissingError extends Error {
|
||||||
|
readonly missing: readonly DeliverableCheck[];
|
||||||
|
constructor(missing: readonly DeliverableCheck[]);
|
||||||
|
}
|
||||||
|
export type SubmitForReleaseInput = {
|
||||||
|
readonly taskId: TaskId;
|
||||||
|
readonly ownerId: StaffId;
|
||||||
|
readonly humanCallCap: number;
|
||||||
|
readonly deliverableId: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly basis: ReadonlyArray<{
|
||||||
|
k: string;
|
||||||
|
v: string;
|
||||||
|
}>;
|
||||||
|
readonly deliverables: readonly DeliverableCheck[];
|
||||||
|
readonly criteria: readonly CriterionCheck[];
|
||||||
|
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||||
|
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
export type SubmitOutcome = {
|
||||||
|
readonly report: PrecheckReport;
|
||||||
|
/** 预检全过才发卡;这里是发出的验收工单 ID */
|
||||||
|
readonly workOrderId: string;
|
||||||
|
};
|
||||||
|
export type LandInput = {
|
||||||
|
readonly taskId: TaskId;
|
||||||
|
readonly ownerId: StaffId;
|
||||||
|
readonly humanCallCap: number;
|
||||||
|
readonly selectedPorts: Readonly<Record<string, string>>;
|
||||||
|
};
|
||||||
|
export type LandOutcome = {
|
||||||
|
readonly verdict: LandingVerdict;
|
||||||
|
/** 落地收敛后才发射的输出口 */
|
||||||
|
readonly emittedPorts: readonly string[];
|
||||||
|
readonly ledgerRowsMerged: number;
|
||||||
|
/** 落地受阻时发出的落地失败卡 */
|
||||||
|
readonly failureWorkOrderId?: string;
|
||||||
|
};
|
||||||
|
export type ReleaseService = {
|
||||||
|
/** 登记写类动作意向(入参冻结,存入草稿态) */
|
||||||
|
registerIntent(taskId: TaskId, intent: Omit<ActionIntent, 'taskId' | 'state'>): Promise<ActionIntent>;
|
||||||
|
listIntents(taskId: TaskId): Promise<ActionIntent[]>;
|
||||||
|
/** 预检 → 全过才发验收工单;不过即抛 `PrecheckFailedError`,打回环内修正 */
|
||||||
|
submitForRelease(input: SubmitForReleaseInput): Promise<SubmitOutcome>;
|
||||||
|
/** 只跑预检不发卡 —— 环内随时可看「还差什么」 */
|
||||||
|
precheck(taskId: TaskId): Promise<PrecheckReport>;
|
||||||
|
/** 放行通过即刻落地:草稿合并 + 动作执行(附幂等键)→ 收敛才发射输出口 */
|
||||||
|
land(input: LandInput): Promise<LandOutcome>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* module: release —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||||
|
*/
|
||||||
|
export type { ActionIntent, CriterionCheck, DeliverableCheck, LandInput, LandOutcome, LandingOutcome, LandingVerdict, PrecheckFinding, PrecheckItem, PrecheckReport, ReleaseService, SubmitForReleaseInput, SubmitOutcome, } from './contract.ts';
|
||||||
|
export { DeliverablesMissingError, PrecheckFailedError } from './contract.ts';
|
||||||
|
export type { CardGateway, Clock, IdGenerator, IntentStore, LedgerGateway, MemoryRecorder, PortEmitter, TaskContext, ToolGateway, } from './port.ts';
|
||||||
|
export type { ReleaseServiceDeps } from './internal/usecase/release-service.ts';
|
||||||
|
export { createReleaseService } from './internal/usecase/release-service.ts';
|
||||||
|
export { idempotencyKey, landingVerdict, missingDeliverables, PRECHECK_ITEM_LABEL, PRECHECK_ITEMS, submittable, summarize, } from './internal/domain/precheck.ts';
|
||||||
|
export { createIntentStore } from './internal/adapter/intent-store.ts';
|
||||||
|
export { actionIntentTable } from './internal/adapter/release-schema.ts';
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* module: release —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||||
|
*/
|
||||||
|
import type { ActionIntent, StaffId, TaskId } from '@contracts';
|
||||||
|
import type { LandingOutcome } from './internal/domain/precheck.ts';
|
||||||
|
export type Clock = {
|
||||||
|
now(): Date;
|
||||||
|
};
|
||||||
|
export type IdGenerator = {
|
||||||
|
next(): string;
|
||||||
|
};
|
||||||
|
export type IntentStore = {
|
||||||
|
save(intent: ActionIntent): Promise<void>;
|
||||||
|
list(taskId: TaskId): Promise<ActionIntent[]>;
|
||||||
|
setState(id: string, state: ActionIntent['state'], error?: string): Promise<void>;
|
||||||
|
};
|
||||||
|
/** 台账 —— app 注入 `ledger`(L1);放行写与冲突检查都在它那 */
|
||||||
|
export type LedgerGateway = {
|
||||||
|
checkConflicts(taskId: TaskId): Promise<Array<{
|
||||||
|
field: string;
|
||||||
|
key: Record<string, string | number>;
|
||||||
|
}>>;
|
||||||
|
releaseWrite(taskId: TaskId): Promise<{
|
||||||
|
mergedRows: number;
|
||||||
|
emittedEvents: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
/** 工具 —— app 注入 `toolbox`(L2);预检的②③④项与落地执行都经它 */
|
||||||
|
export type ToolGateway = {
|
||||||
|
/** 工具可用(未停用)与授权有效 */
|
||||||
|
isInvocable(tool: string, activityId: string): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}>;
|
||||||
|
/** 入参 schema 校验 */
|
||||||
|
validateInput(tool: string, input: Record<string, unknown>): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}>;
|
||||||
|
/** 目标预校验:工具契约含 validate 能力时调之(ADR-005) */
|
||||||
|
validateTarget(input: {
|
||||||
|
tool: string;
|
||||||
|
activityId: string;
|
||||||
|
taskId: TaskId;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
} | undefined>;
|
||||||
|
/** 落地执行 —— 附幂等键 */
|
||||||
|
execute(input: {
|
||||||
|
tool: string;
|
||||||
|
activityId: string;
|
||||||
|
taskId: TaskId;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}): Promise<LandingOutcome>;
|
||||||
|
};
|
||||||
|
/** 人类工具 —— app 注入 `human-tool`(L3):发验收工单与落地失败卡 */
|
||||||
|
export type CardGateway = {
|
||||||
|
issueAcceptance(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
ownerId: StaffId;
|
||||||
|
humanCallCap: number;
|
||||||
|
title: string;
|
||||||
|
basis: ReadonlyArray<{
|
||||||
|
k: string;
|
||||||
|
v: string;
|
||||||
|
}>;
|
||||||
|
deliverable: string;
|
||||||
|
criteria: ReadonlyArray<{
|
||||||
|
label: string;
|
||||||
|
level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
|
||||||
|
pass: boolean;
|
||||||
|
}>;
|
||||||
|
actions: ReadonlyArray<{
|
||||||
|
label: string;
|
||||||
|
effect: 'read' | 'write';
|
||||||
|
detail: string;
|
||||||
|
}>;
|
||||||
|
}): Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
issueLandingFailure(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
ownerId: StaffId;
|
||||||
|
humanCallCap: number;
|
||||||
|
failures: ReadonlyArray<{
|
||||||
|
label: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
}): Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
/** 任务上下文 —— app 注入(活动 ID 与契约的输出口声明) */
|
||||||
|
export type TaskContext = {
|
||||||
|
activityIdOf(taskId: TaskId): Promise<string>;
|
||||||
|
outputPortsOf(taskId: TaskId): Promise<{
|
||||||
|
mandatory: string[];
|
||||||
|
selective: Array<{
|
||||||
|
group: string;
|
||||||
|
ports: string[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
/** 输出口发射 —— app 注入 `event`(L1):落地收敛**之后**才发 */
|
||||||
|
export type PortEmitter = {
|
||||||
|
emit(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
port: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
|
};
|
||||||
|
export type MemoryRecorder = {
|
||||||
|
append(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
kind: 'action-intent' | 'release-verdict' | 'ledger-write';
|
||||||
|
title: string;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "@modules/activity-registry",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": { ".": "./index.ts" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# module: human-tool
|
||||||
|
|
||||||
|
职责: 人类工具的卡协议与回传校验:工单两模式(执行 / 验收)+ 对话卡族(决策 / 补全 / 评价)、
|
||||||
|
到达面两路、跨人卡、驳回重答、调人预算的环外强制、落地失败卡的预置形态
|
||||||
|
不负责: 卡内容由谁决定(loop 临场判定 / flow 步序声明给定,见范式引擎)、
|
||||||
|
落地预检与落地执行(见 release)、消息的存放与呈现(见 message,经 `MessageOutbox` 端口)
|
||||||
|
|
||||||
|
公开出口: index.ts
|
||||||
|
- 类型:`HumanToolService` `IssueContext` `IssuedWorkOrder` `IssuedChatCard` `ReplyOutcome`
|
||||||
|
`ArrivalSurface` `CardViolation`
|
||||||
|
- 错误:`BareCallError` `ReplyRejectedError` `HumanCallBudgetExceededError`
|
||||||
|
- 端口类型:`CardStore` `MessageOutbox` `MemoryRecorder` `HumanEventPublisher` `Clock` `IdGenerator`
|
||||||
|
- 用例工厂:`createHumanToolService(deps): HumanToolService`
|
||||||
|
- 纯函数:`checkWorkOrder` `checkChatCard` `checkWorkOrderReply` `checkChatCardReply`
|
||||||
|
`arrivalOf` `producesCompletion` `allCriteriaGreen`
|
||||||
|
- adapter:`createCardStore`;Drizzle 表:`workOrderTable` `chatCardTable`
|
||||||
|
|
||||||
|
层级: L3(编码规范 §4)
|
||||||
|
依赖: @contracts;@shared/db;port:`MessageOutbox`(app 注入 message)、
|
||||||
|
`MemoryRecorder`(app 注入 memory)、`HumanEventPublisher`(app 注入 event)
|
||||||
|
被依赖: release(落地失败卡)、loop-engine、flow-engine、task、org(停用前置检查的待答卡)、app
|
||||||
|
|
||||||
|
不变量(人类工具三规矩,OS 05):
|
||||||
|
- **调用必附上下文**:`basis` 为空即 `BareCallError` —— 无上下文的裸调用基座拒发
|
||||||
|
- **回传即留痕**:每次回传都写任务记忆并以人事件抛回任务,无静默通道
|
||||||
|
- **完成只出自放行通过**:`producesCompletion` 仅在验收工单 `verdict === 'pass'` 时为真;
|
||||||
|
判据全绿只是可提交,认账永远是人的动作
|
||||||
|
- **驳回必附意见**:驳回而无意见即 `ReplyRejectedError`
|
||||||
|
- **只增不改**:已回传的工单 / 已作答的卡不得再答;驳回重答是新卡实例,原卡闭合
|
||||||
|
- **到达面两路**:收件人 = 负责人 → 任务对话流;否则 → 系统消息(跨人卡不是责任转移)
|
||||||
|
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
|
||||||
|
活动代码无从自查自守
|
||||||
|
- **落地失败卡是执行工单的 preset**,不构成第五种卡
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/** 人类工具三规矩的机检 —— domain 测试零 mock(OS 05 / AM 06) */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
AcceptanceWorkOrder,
|
||||||
|
ChatCard,
|
||||||
|
DeliverableId,
|
||||||
|
ExecutionWorkOrder,
|
||||||
|
WorkOrderId,
|
||||||
|
WorkOrderReply,
|
||||||
|
} from '@contracts';
|
||||||
|
import {
|
||||||
|
allCriteriaGreen,
|
||||||
|
arrivalOf,
|
||||||
|
checkChatCard,
|
||||||
|
checkChatCardReply,
|
||||||
|
checkWorkOrder,
|
||||||
|
checkWorkOrderReply,
|
||||||
|
producesCompletion,
|
||||||
|
} from '../internal/domain/card-rules.ts';
|
||||||
|
|
||||||
|
const OID = 'wo-1' as WorkOrderId;
|
||||||
|
|
||||||
|
const execution = (over: Partial<ExecutionWorkOrder> = {}): ExecutionWorkOrder => ({
|
||||||
|
id: OID,
|
||||||
|
at: '2026-07-27T10:00:00.000Z',
|
||||||
|
mode: 'execution',
|
||||||
|
title: '补齐 RFQ 关键字段',
|
||||||
|
basis: [{ k: '缺口', v: 'PN / EAU / 交期' }],
|
||||||
|
ask: '请向客户追问缺失字段',
|
||||||
|
fields: [{ key: 'pn', label: 'PN', kind: 'text', required: true }],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptance = (over: Partial<AcceptanceWorkOrder> = {}): AcceptanceWorkOrder => ({
|
||||||
|
id: OID,
|
||||||
|
at: '2026-07-27T10:00:00.000Z',
|
||||||
|
mode: 'acceptance',
|
||||||
|
title: '验收统一受理记录',
|
||||||
|
basis: [{ k: '条目数', v: '12' }],
|
||||||
|
deliverable: 'd-1' as DeliverableId,
|
||||||
|
criteria: [{ label: '八字段齐', level: 'L1', pass: true }],
|
||||||
|
fields: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('调用必附上下文 —— 裸调用基座拒发', () => {
|
||||||
|
it('basis 为空即拒', () => {
|
||||||
|
expect(checkWorkOrder(execution({ basis: [] })).map((v) => v.code)).toContain('bare-call');
|
||||||
|
});
|
||||||
|
it('决策卡 basis 为空即拒', () => {
|
||||||
|
const card: ChatCard = {
|
||||||
|
id: 'c1' as never,
|
||||||
|
at: '2026-07-27T10:00:00.000Z',
|
||||||
|
k: 'decision',
|
||||||
|
ask: '选一个',
|
||||||
|
basis: [],
|
||||||
|
options: [
|
||||||
|
{ value: 'a', label: 'A' },
|
||||||
|
{ value: 'b', label: 'B' },
|
||||||
|
],
|
||||||
|
allowCustom: false,
|
||||||
|
};
|
||||||
|
expect(checkChatCard(card).map((v) => v.code)).toContain('bare-call');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('工单形态机检', () => {
|
||||||
|
it('规范的执行工单通过', () => {
|
||||||
|
expect(checkWorkOrder(execution())).toEqual([]);
|
||||||
|
});
|
||||||
|
it('执行工单 = 指导书 + 空表单,表单不得为空', () => {
|
||||||
|
expect(checkWorkOrder(execution({ fields: [] })).map((v) => v.code)).toContain('fields-missing');
|
||||||
|
});
|
||||||
|
it('radio 字段缺选项即拒', () => {
|
||||||
|
const v = checkWorkOrder(execution({ fields: [{ key: 'x', label: 'X', kind: 'radio' }] }));
|
||||||
|
expect(v.map((i) => i.code)).toContain('options-missing');
|
||||||
|
});
|
||||||
|
it('字段 key 重复即拒', () => {
|
||||||
|
const v = checkWorkOrder(
|
||||||
|
execution({
|
||||||
|
fields: [
|
||||||
|
{ key: 'x', label: 'X', kind: 'text' },
|
||||||
|
{ key: 'x', label: 'Y', kind: 'text' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(v.map((i) => i.code)).toContain('field-key-duplicate');
|
||||||
|
});
|
||||||
|
it('验收工单必须列判据', () => {
|
||||||
|
expect(checkWorkOrder(acceptance({ criteria: [] })).map((v) => v.code)).toContain('criteria-missing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('到达面两路', () => {
|
||||||
|
it('负责人的卡进任务对话流', () => {
|
||||||
|
expect(arrivalOf('s1', 's1')).toBe('task-chat');
|
||||||
|
});
|
||||||
|
it('别人的卡走系统消息 —— 跨人卡不是责任转移', () => {
|
||||||
|
expect(arrivalOf('s2', 's1')).toBe('system-message');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('回传校验', () => {
|
||||||
|
it('必填未回填即拒', () => {
|
||||||
|
const reply: WorkOrderReply = { mode: 'execution', workOrderId: OID, values: {} };
|
||||||
|
expect(checkWorkOrderReply(execution(), reply).map((v) => v.code)).toContain('required-missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('必填已填即过', () => {
|
||||||
|
const reply: WorkOrderReply = { mode: 'execution', workOrderId: OID, values: { pn: 'LM358' } };
|
||||||
|
expect(checkWorkOrderReply(execution(), reply)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('驳回必附意见', () => {
|
||||||
|
const reply: WorkOrderReply = {
|
||||||
|
mode: 'acceptance',
|
||||||
|
workOrderId: OID,
|
||||||
|
verdict: 'revise',
|
||||||
|
values: {},
|
||||||
|
selectedPorts: {},
|
||||||
|
};
|
||||||
|
expect(checkWorkOrderReply(acceptance(), reply).map((v) => v.code)).toContain('comment-required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('放行通过无需意见', () => {
|
||||||
|
const reply: WorkOrderReply = {
|
||||||
|
mode: 'acceptance',
|
||||||
|
workOrderId: OID,
|
||||||
|
verdict: 'pass',
|
||||||
|
values: {},
|
||||||
|
selectedPorts: {},
|
||||||
|
};
|
||||||
|
expect(checkWorkOrderReply(acceptance(), reply)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已回传的工单不得再答 —— 只增不改', () => {
|
||||||
|
const done = acceptance({ done: { at: 'x', by: 's1' as never, result: 'pass' } });
|
||||||
|
const reply: WorkOrderReply = {
|
||||||
|
mode: 'acceptance',
|
||||||
|
workOrderId: OID,
|
||||||
|
verdict: 'pass',
|
||||||
|
values: {},
|
||||||
|
selectedPorts: {},
|
||||||
|
};
|
||||||
|
expect(checkWorkOrderReply(done, reply).map((v) => v.code)).toEqual(['already-answered']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('模式不符即拒', () => {
|
||||||
|
const reply: WorkOrderReply = { mode: 'execution', workOrderId: OID, values: { pn: 'x' } };
|
||||||
|
expect(checkWorkOrderReply(acceptance(), reply).map((v) => v.code)).toEqual(['mode-mismatch']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('对话卡回传', () => {
|
||||||
|
const decision: ChatCard = {
|
||||||
|
id: 'c1' as never,
|
||||||
|
at: '2026-07-27T10:00:00.000Z',
|
||||||
|
k: 'decision',
|
||||||
|
ask: '降不降价',
|
||||||
|
basis: [{ k: '底价', v: '1.80' }],
|
||||||
|
options: [
|
||||||
|
{ value: 'hold', label: '守价' },
|
||||||
|
{ value: 'cut', label: '让价' },
|
||||||
|
],
|
||||||
|
allowCustom: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('选项内的分支通过', () => {
|
||||||
|
expect(checkChatCardReply(decision, { k: 'decision', cardId: 'c1' as never, value: 'cut' })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('选项外的分支在不允许自定义时即拒', () => {
|
||||||
|
const v = checkChatCardReply(decision, { k: 'decision', cardId: 'c1' as never, value: 'other' });
|
||||||
|
expect(v.map((i) => i.code)).toEqual(['unknown-option']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自定义收尾项必附理由', () => {
|
||||||
|
const open = { ...decision, allowCustom: true };
|
||||||
|
expect(
|
||||||
|
checkChatCardReply(open, { k: 'decision', cardId: 'c1' as never, value: 'other' }).map((i) => i.code),
|
||||||
|
).toEqual(['note-required']);
|
||||||
|
expect(
|
||||||
|
checkChatCardReply(open, { k: 'decision', cardId: 'c1' as never, value: 'other', note: '客户口头承诺追单' }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('补全卡两条路必走其一', () => {
|
||||||
|
const card: ChatCard = {
|
||||||
|
id: 'c2' as never,
|
||||||
|
at: '2026-07-27T10:00:00.000Z',
|
||||||
|
k: 'elicitation',
|
||||||
|
ask: '补交期',
|
||||||
|
fields: [{ key: 'lead', label: '交期' }],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
checkChatCardReply(card, { k: 'elicitation', cardId: 'c2' as never, values: {} }).map((i) => i.code),
|
||||||
|
).toEqual(['answer-required']);
|
||||||
|
expect(checkChatCardReply(card, { k: 'elicitation', cardId: 'c2' as never, values: { lead: '8周' } })).toEqual([]);
|
||||||
|
expect(
|
||||||
|
checkChatCardReply(card, { k: 'elicitation', cardId: 'c2' as never, values: {}, reason: '客户未答复' }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('完成只出自放行通过', () => {
|
||||||
|
it('放行通过才产生完成', () => {
|
||||||
|
expect(
|
||||||
|
producesCompletion({ mode: 'acceptance', workOrderId: OID, verdict: 'pass', values: {}, selectedPorts: {} }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it('驳回不产生完成 —— 环内回转,任务继续', () => {
|
||||||
|
expect(
|
||||||
|
producesCompletion({
|
||||||
|
mode: 'acceptance',
|
||||||
|
workOrderId: OID,
|
||||||
|
verdict: 'redo',
|
||||||
|
values: {},
|
||||||
|
selectedPorts: {},
|
||||||
|
comment: '重做',
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it('执行工单回传不产生完成', () => {
|
||||||
|
expect(producesCompletion({ mode: 'execution', workOrderId: OID, values: {} })).toBe(false);
|
||||||
|
});
|
||||||
|
it('判据全绿只是可提交,不等于完成', () => {
|
||||||
|
expect(allCriteriaGreen(acceptance())).toBe(true);
|
||||||
|
expect(allCriteriaGreen(acceptance({ criteria: [{ label: 'x', level: 'L2', pass: false }] }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* module: human-tool —— 对外类型(编码规范 §10.1)。
|
||||||
|
*
|
||||||
|
* **双层边界**(§9):范式只在引擎层;本模块是前端呈现协议的后端面 ——
|
||||||
|
* 工单两模式(执行 / 验收)+ 对话卡族(决策 / 补全 / 评价)。
|
||||||
|
* 落地失败卡是**执行工单的系统预置形态**,不构成第五种卡(OS 7.3)。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AcceptanceWorkOrder,
|
||||||
|
ChatCard,
|
||||||
|
ChatCardReply,
|
||||||
|
ExecutionWorkOrder,
|
||||||
|
StaffId,
|
||||||
|
TaskId,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderReply,
|
||||||
|
} from '@contracts';
|
||||||
|
import type { ArrivalSurface, CardViolation } from './internal/domain/card-rules.ts';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AcceptanceWorkOrder,
|
||||||
|
ArrivalSurface,
|
||||||
|
CardViolation,
|
||||||
|
ChatCard,
|
||||||
|
ChatCardReply,
|
||||||
|
ExecutionWorkOrder,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderReply,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BareCallError extends Error {
|
||||||
|
constructor(readonly violations: readonly CardViolation[]) {
|
||||||
|
super(`基座拒发:${violations.map((v) => v.message).join('; ')}`);
|
||||||
|
this.name = 'BareCallError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReplyRejectedError extends Error {
|
||||||
|
constructor(readonly violations: readonly CardViolation[]) {
|
||||||
|
super(`回传校验未过:${violations.map((v) => v.message).join('; ')}`);
|
||||||
|
this.name = 'ReplyRejectedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HumanCallBudgetExceededError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly taskId: TaskId,
|
||||||
|
readonly used: number,
|
||||||
|
readonly cap: number,
|
||||||
|
) {
|
||||||
|
super(`调人预算触顶:${used}/${cap}(打扰上限在契约授权边界,由 Harness 强制)`);
|
||||||
|
this.name = 'HumanCallBudgetExceededError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工单草稿:id / at 由基座补齐 —— ID 生成不外泄给调用方(domain 零随机) */
|
||||||
|
export type ExecutionWorkOrderDraft = Omit<ExecutionWorkOrder, 'mode' | 'done' | 'id' | 'at'> & {
|
||||||
|
readonly id?: ExecutionWorkOrder['id'] | undefined;
|
||||||
|
readonly at?: string | undefined;
|
||||||
|
};
|
||||||
|
export type AcceptanceWorkOrderDraft = Omit<AcceptanceWorkOrder, 'mode' | 'done' | 'id' | 'at'> & {
|
||||||
|
readonly id?: AcceptanceWorkOrder['id'] | undefined;
|
||||||
|
readonly at?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 发卡的公共入参 */
|
||||||
|
export type IssueContext = {
|
||||||
|
readonly taskId: TaskId;
|
||||||
|
/** 任务负责人 —— 决定到达面两路 */
|
||||||
|
readonly ownerId: StaffId;
|
||||||
|
/** 收件人;缺省即负责人 */
|
||||||
|
readonly recipientId?: StaffId;
|
||||||
|
/** 契约的调人预算(打扰上限) */
|
||||||
|
readonly humanCallCap: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IssuedWorkOrder = {
|
||||||
|
readonly order: WorkOrder;
|
||||||
|
readonly arrival: ArrivalSurface;
|
||||||
|
/** 到达面 = system-message 时的消息 ID(跨人卡) */
|
||||||
|
readonly messageId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IssuedChatCard = {
|
||||||
|
readonly card: ChatCard;
|
||||||
|
readonly arrival: ArrivalSurface;
|
||||||
|
readonly messageId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplyOutcome = {
|
||||||
|
readonly order: WorkOrder;
|
||||||
|
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
|
||||||
|
readonly producesCompletion: boolean;
|
||||||
|
/** 驳回:环内回转,任务继续 */
|
||||||
|
readonly rejection?: { readonly kind: 'redo' | 'revise'; readonly comment: string };
|
||||||
|
/** 选择组的出口选择 —— 随放行一并确认(AM 1.2) */
|
||||||
|
readonly selectedPorts: Readonly<Record<string, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HumanToolService = {
|
||||||
|
/** 发执行工单;裸调用拒发,预算触顶熔断。id / at 留空即由基座生成 */
|
||||||
|
issueExecution(context: IssueContext, order: ExecutionWorkOrderDraft): Promise<IssuedWorkOrder>;
|
||||||
|
/** 发验收工单 —— 发卡**前**必须已过落地预检(由 release 保证,见其 CONTRACT) */
|
||||||
|
issueAcceptance(context: IssueContext, order: AcceptanceWorkOrderDraft): Promise<IssuedWorkOrder>;
|
||||||
|
/** 落地失败卡:执行工单的系统预置形态,不是第五种卡 */
|
||||||
|
issueLandingFailure(
|
||||||
|
context: IssueContext,
|
||||||
|
input: { readonly failures: ReadonlyArray<{ label: string; reason: string }> },
|
||||||
|
): Promise<IssuedWorkOrder>;
|
||||||
|
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
|
||||||
|
|
||||||
|
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
|
||||||
|
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
|
||||||
|
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
|
||||||
|
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
||||||
|
|
||||||
|
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||||
|
chatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
|
/** org 停用前置检查要问的「待答卡」 */
|
||||||
|
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* module: human-tool —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||||
|
*/
|
||||||
|
export type {
|
||||||
|
AcceptanceWorkOrder,
|
||||||
|
AcceptanceWorkOrderDraft,
|
||||||
|
ArrivalSurface,
|
||||||
|
CardViolation,
|
||||||
|
ChatCard,
|
||||||
|
ChatCardReply,
|
||||||
|
ExecutionWorkOrder,
|
||||||
|
ExecutionWorkOrderDraft,
|
||||||
|
HumanToolService,
|
||||||
|
IssueContext,
|
||||||
|
IssuedChatCard,
|
||||||
|
IssuedWorkOrder,
|
||||||
|
ReplyOutcome,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderReply,
|
||||||
|
} from './contract.ts';
|
||||||
|
export { BareCallError, HumanCallBudgetExceededError, ReplyRejectedError } from './contract.ts';
|
||||||
|
|
||||||
|
export type { CardStore, Clock, HumanEventPublisher, IdGenerator, MemoryRecorder, MessageOutbox } from './port.ts';
|
||||||
|
|
||||||
|
export type { HumanToolServiceDeps } from './internal/usecase/human-tool-service.ts';
|
||||||
|
export { createHumanToolService } from './internal/usecase/human-tool-service.ts';
|
||||||
|
|
||||||
|
export {
|
||||||
|
allCriteriaGreen,
|
||||||
|
ARRIVAL_SURFACES,
|
||||||
|
arrivalOf,
|
||||||
|
checkChatCard,
|
||||||
|
checkChatCardReply,
|
||||||
|
checkWorkOrder,
|
||||||
|
checkWorkOrderReply,
|
||||||
|
producesCompletion,
|
||||||
|
} from './internal/domain/card-rules.ts';
|
||||||
|
|
||||||
|
export { createCardStore } from './internal/adapter/card-store.ts';
|
||||||
|
export { chatCardTable, workOrderTable } from './internal/adapter/human-tool-schema.ts';
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* human-tool 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||||
|
*/
|
||||||
|
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
import type { ChatCard, StaffId, TaskId, WorkOrder } from '@contracts';
|
||||||
|
import { chatCardSchema, workOrderSchema } from '@contracts';
|
||||||
|
import type { Db } from '@shared/db.ts';
|
||||||
|
import type { CardStore } from '../../port.ts';
|
||||||
|
import { chatCardTable, workOrderTable } from './human-tool-schema.ts';
|
||||||
|
|
||||||
|
export function createCardStore(db: Db): CardStore {
|
||||||
|
return {
|
||||||
|
async saveWorkOrder(taskId, order, recipientId) {
|
||||||
|
await db.insert(workOrderTable).values({
|
||||||
|
id: order.id,
|
||||||
|
taskId,
|
||||||
|
recipientId,
|
||||||
|
mode: order.mode,
|
||||||
|
title: order.title,
|
||||||
|
payload: order,
|
||||||
|
at: new Date(order.at),
|
||||||
|
done: order.done ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWorkOrder(id) {
|
||||||
|
const rows = await db.select().from(workOrderTable).where(eq(workOrderTable.id, id)).limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
if (row === undefined) return undefined;
|
||||||
|
const order = workOrderSchema.parse({
|
||||||
|
...(row.payload as Record<string, unknown>),
|
||||||
|
...(row.done === null ? {} : { done: row.done }),
|
||||||
|
});
|
||||||
|
return { taskId: row.taskId as TaskId, order, recipientId: row.recipientId as StaffId };
|
||||||
|
},
|
||||||
|
|
||||||
|
async listWorkOrders(taskId, openOnly) {
|
||||||
|
const conditions = [eq(workOrderTable.taskId, taskId)];
|
||||||
|
if (openOnly) conditions.push(isNull(workOrderTable.done));
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(workOrderTable)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(workOrderTable.at));
|
||||||
|
return rows.map((row) =>
|
||||||
|
workOrderSchema.parse({
|
||||||
|
...(row.payload as Record<string, unknown>),
|
||||||
|
...(row.done === null ? {} : { done: row.done }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async markWorkOrderDone(id, done) {
|
||||||
|
await db.update(workOrderTable).set({ done }).where(eq(workOrderTable.id, id));
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveChatCard(taskId, card, recipientId) {
|
||||||
|
await db.insert(chatCardTable).values({
|
||||||
|
id: card.id,
|
||||||
|
taskId,
|
||||||
|
recipientId,
|
||||||
|
kind: card.k,
|
||||||
|
payload: card,
|
||||||
|
at: new Date(card.at),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getChatCard(id) {
|
||||||
|
const rows = await db.select().from(chatCardTable).where(eq(chatCardTable.id, id)).limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
if (row === undefined) return undefined;
|
||||||
|
return {
|
||||||
|
taskId: row.taskId as TaskId,
|
||||||
|
card: chatCardSchema.parse(row.payload),
|
||||||
|
recipientId: row.recipientId as StaffId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async listChatCards(taskId) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(chatCardTable)
|
||||||
|
.where(eq(chatCardTable.taskId, taskId))
|
||||||
|
.orderBy(asc(chatCardTable.at));
|
||||||
|
return rows.map((row) => chatCardSchema.parse(row.payload));
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateChatCard(card: ChatCard) {
|
||||||
|
await db.update(chatCardTable).set({ payload: card }).where(eq(chatCardTable.id, card.id));
|
||||||
|
},
|
||||||
|
|
||||||
|
async closeChatCard(id, supersededBy, reason) {
|
||||||
|
await db.update(chatCardTable).set({ supersededBy, rejectedReason: reason }).where(eq(chatCardTable.id, id));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 调人次数 = 发出的工单 + 对话卡 —— 打扰率与预算的计数面(OS 8.3) */
|
||||||
|
async humanCallCount(taskId) {
|
||||||
|
const orders = await db
|
||||||
|
.select({ n: sql<number>`count(*)::int` })
|
||||||
|
.from(workOrderTable)
|
||||||
|
.where(eq(workOrderTable.taskId, taskId));
|
||||||
|
const cards = await db
|
||||||
|
.select({ n: sql<number>`count(*)::int` })
|
||||||
|
.from(chatCardTable)
|
||||||
|
.where(eq(chatCardTable.taskId, taskId));
|
||||||
|
return (orders[0]?.n ?? 0) + (cards[0]?.n ?? 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
async openCardsOf(staffId) {
|
||||||
|
const orders = await db
|
||||||
|
.select({ id: workOrderTable.id, title: workOrderTable.title })
|
||||||
|
.from(workOrderTable)
|
||||||
|
.where(and(eq(workOrderTable.recipientId, staffId), isNull(workOrderTable.done)));
|
||||||
|
const cards = await db
|
||||||
|
.select({ id: chatCardTable.id, kind: chatCardTable.kind })
|
||||||
|
.from(chatCardTable)
|
||||||
|
.where(and(eq(chatCardTable.recipientId, staffId), isNull(chatCardTable.supersededBy)));
|
||||||
|
return [
|
||||||
|
...orders.map((o) => ({ cardId: o.id, title: o.title })),
|
||||||
|
...cards.map((c) => ({ cardId: c.id, title: `${c.kind} 卡` })),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { WorkOrder };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* human-tool 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||||
|
*/
|
||||||
|
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
import { sys } from '@shared/db.ts';
|
||||||
|
|
||||||
|
/** 工单两模式 —— 执行 / 验收;落地失败卡是执行工单的 preset,不另立表 */
|
||||||
|
export const workOrderTable = sys.table(
|
||||||
|
'work_order',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
taskId: text('task_id').notNull(),
|
||||||
|
recipientId: text('recipient_id').notNull(),
|
||||||
|
mode: text('mode').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
payload: jsonb('payload').notNull(),
|
||||||
|
at: timestamp('at', { withTimezone: true }).notNull(),
|
||||||
|
done: jsonb('done'),
|
||||||
|
},
|
||||||
|
(t) => [index('work_order_task_idx').on(t.taskId, t.at), index('work_order_recipient_idx').on(t.recipientId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 对话卡族 —— 决策 / 补全 / 评价 */
|
||||||
|
export const chatCardTable = sys.table(
|
||||||
|
'chat_card',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
taskId: text('task_id').notNull(),
|
||||||
|
recipientId: text('recipient_id').notNull(),
|
||||||
|
kind: text('kind').notNull(),
|
||||||
|
payload: jsonb('payload').notNull(),
|
||||||
|
at: timestamp('at', { withTimezone: true }).notNull(),
|
||||||
|
/** 驳回重答:原卡以 supersededBy 闭合 —— 只增不改 */
|
||||||
|
supersededBy: text('superseded_by'),
|
||||||
|
rejectedReason: text('rejected_reason'),
|
||||||
|
},
|
||||||
|
(t) => [index('chat_card_task_idx').on(t.taskId, t.at), index('chat_card_recipient_idx').on(t.recipientId)],
|
||||||
|
);
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* 人类工具三规矩的机检 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 05,AM 06)。
|
||||||
|
*
|
||||||
|
* 调用必附上下文(必须)—— 每张卡自带依据与快照;**无上下文的裸调用,基座必须拒发**
|
||||||
|
* 回传即留痕(必须)—— 谁、何时、答了什么、依据什么,随卡入任务记忆
|
||||||
|
* 完成只出自放行(必须)—— 完成只能由验收工单的「放行通过」产生
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AcceptanceWorkOrder,
|
||||||
|
ChatCard,
|
||||||
|
ChatCardReply,
|
||||||
|
ExecutionWorkOrder,
|
||||||
|
FormField,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderReply,
|
||||||
|
} from '@contracts';
|
||||||
|
import { elicitationReplyIsEmpty, rejectionNeedsComment } from '@contracts';
|
||||||
|
|
||||||
|
export type CardViolation = { readonly code: string; readonly message: string };
|
||||||
|
|
||||||
|
/** 到达面两路(OS 2.1):负责人的卡进任务对话流;其他人的卡以系统消息送达 */
|
||||||
|
export const ARRIVAL_SURFACES = ['task-chat', 'system-message'] as const;
|
||||||
|
export type ArrivalSurface = (typeof ARRIVAL_SURFACES)[number];
|
||||||
|
|
||||||
|
export function arrivalOf(recipientId: string, ownerId: string): ArrivalSurface {
|
||||||
|
return recipientId === ownerId ? 'task-chat' : 'system-message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 发卡前的机检:裸调用必须被拒发 ─────────────────────────────────── */
|
||||||
|
|
||||||
|
export function checkWorkOrder(order: WorkOrder): CardViolation[] {
|
||||||
|
const out: CardViolation[] = [];
|
||||||
|
if (order.basis.length === 0) {
|
||||||
|
out.push({ code: 'bare-call', message: '调用必附上下文:卡面无依据快照,基座拒发(OS 05)' });
|
||||||
|
}
|
||||||
|
if (order.title.trim() === '') out.push({ code: 'title-missing', message: '工单标题不得为空' });
|
||||||
|
|
||||||
|
if (order.mode === 'execution') out.push(...checkExecution(order));
|
||||||
|
else out.push(...checkAcceptance(order));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkExecution(order: ExecutionWorkOrder): CardViolation[] {
|
||||||
|
const out: CardViolation[] = [];
|
||||||
|
if (order.ask.trim() === '') out.push({ code: 'ask-missing', message: '执行工单必须给出工作要求' });
|
||||||
|
if (order.fields.length === 0) {
|
||||||
|
out.push({ code: 'fields-missing', message: '执行工单 = 指导书 + 空表单,表单不得为空' });
|
||||||
|
}
|
||||||
|
out.push(...checkFieldKeys(order.fields));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAcceptance(order: AcceptanceWorkOrder): CardViolation[] {
|
||||||
|
const out: CardViolation[] = [];
|
||||||
|
if (order.criteria.length === 0) {
|
||||||
|
out.push({ code: 'criteria-missing', message: '验收工单必须逐条列出判据(成果清单的来源)' });
|
||||||
|
}
|
||||||
|
// 验收等级不得谎报:L4 生成者不得自评 —— 这里只机检「等级必须标注」
|
||||||
|
for (const c of order.criteria) {
|
||||||
|
if (c.label.trim() === '') out.push({ code: 'criterion-unlabeled', message: '判据必须有可读的表述' });
|
||||||
|
}
|
||||||
|
out.push(...checkFieldKeys(order.fields));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFieldKeys(fields: readonly FormField[]): CardViolation[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: CardViolation[] = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.key.trim() === '') out.push({ code: 'field-key-empty', message: '表单字段 key 不得为空' });
|
||||||
|
if (seen.has(field.key)) out.push({ code: 'field-key-duplicate', message: `表单字段 key 重复:${field.key}` });
|
||||||
|
seen.add(field.key);
|
||||||
|
if (field.kind === 'radio' || field.kind === 'select') {
|
||||||
|
if (field.options === undefined || field.options.length === 0) {
|
||||||
|
out.push({ code: 'options-missing', message: `${field.kind} 字段 ${field.key} 缺选项` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkChatCard(card: ChatCard): CardViolation[] {
|
||||||
|
const out: CardViolation[] = [];
|
||||||
|
switch (card.k) {
|
||||||
|
case 'decision':
|
||||||
|
if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' });
|
||||||
|
if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' });
|
||||||
|
break;
|
||||||
|
case 'elicitation':
|
||||||
|
if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' });
|
||||||
|
break;
|
||||||
|
case 'rating':
|
||||||
|
if (card.ask.trim() === '') out.push({ code: 'ask-missing', message: '评价卡必须给出评价对象' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 回传校验:与前端提交校验同 schema(§9.2) ─────────────────────── */
|
||||||
|
|
||||||
|
export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): CardViolation[] {
|
||||||
|
const out: CardViolation[] = [];
|
||||||
|
if (order.id !== reply.workOrderId) {
|
||||||
|
out.push({ code: 'mismatch', message: '回传的工单 ID 与卡不符' });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (order.done !== undefined) {
|
||||||
|
out.push({ code: 'already-answered', message: '该工单已回传,只增不改' });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (order.mode !== reply.mode) {
|
||||||
|
out.push({ code: 'mode-mismatch', message: '回传模式与工单模式不符' });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply.mode === 'acceptance') {
|
||||||
|
// 驳回必附意见 —— 三规矩之一
|
||||||
|
if (rejectionNeedsComment(reply)) {
|
||||||
|
out.push({ code: 'comment-required', message: '驳回必附意见(OS 05)' });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const execution = order as ExecutionWorkOrder;
|
||||||
|
for (const field of execution.fields) {
|
||||||
|
if (field.required !== true) continue;
|
||||||
|
const value = reply.values[field.key];
|
||||||
|
if (value === undefined || value.trim() === '') {
|
||||||
|
out.push({ code: 'required-missing', message: `必填字段未回填:${field.label || field.key}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardViolation[] {
|
||||||
|
const out: CardViolation[] = [];
|
||||||
|
if (card.id !== reply.cardId) return [{ code: 'mismatch', message: '回传的卡 ID 与卡不符' }];
|
||||||
|
if (card.k !== reply.k) return [{ code: 'mode-mismatch', message: '回传类型与卡类型不符' }];
|
||||||
|
|
||||||
|
if (card.k === 'decision' && reply.k === 'decision') {
|
||||||
|
if (card.chosen !== undefined) return [{ code: 'already-answered', message: '该决策已选,只增不改' }];
|
||||||
|
const known = card.options.some((o) => o.value === reply.value);
|
||||||
|
// 自定义收尾项:allowCustom 打开时允许选项之外的处置(必附理由)
|
||||||
|
if (!known && !card.allowCustom) out.push({ code: 'unknown-option', message: `分支不在选项内:${reply.value}` });
|
||||||
|
if (!known && card.allowCustom && (reply.note ?? '').trim() === '') {
|
||||||
|
out.push({ code: 'note-required', message: '自定义收尾项必附理由' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.k === 'elicitation' && reply.k === 'elicitation') {
|
||||||
|
if (card.done !== undefined) return [{ code: 'already-answered', message: '该补全已答,只增不改' }];
|
||||||
|
// 「没法填」旁路:要么填值,要么给理由 —— 两条路必走其一
|
||||||
|
if (elicitationReplyIsEmpty(reply)) {
|
||||||
|
out.push({ code: 'answer-required', message: '要么回填字段,要么说明「没法填」的理由' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.k === 'rating' && reply.k === 'rating') {
|
||||||
|
if (card.done !== undefined) return [{ code: 'already-answered', message: '该评价已提交,只增不改' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成只出自放行通过 —— 唯一判据。
|
||||||
|
* 判据全绿只是**可提交**,认账永远是人的动作(AM 06)。
|
||||||
|
*/
|
||||||
|
export function producesCompletion(reply: WorkOrderReply): boolean {
|
||||||
|
return reply.mode === 'acceptance' && reply.verdict === 'pass';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判据全绿?—— L1/L2 由引擎预检,L3–L5 按分级执行;这里只看核对结果 */
|
||||||
|
export function allCriteriaGreen(order: AcceptanceWorkOrder): boolean {
|
||||||
|
return order.criteria.every((c) => c.pass);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* human-tool 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AcceptanceWorkOrder,
|
||||||
|
ChatCard,
|
||||||
|
ChatCardReply,
|
||||||
|
ExecutionWorkOrder,
|
||||||
|
StaffId,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderReply,
|
||||||
|
} from '@contracts';
|
||||||
|
import { cardIdSchema, workOrderIdSchema } from '@contracts';
|
||||||
|
import type {
|
||||||
|
HumanToolService,
|
||||||
|
IssueContext,
|
||||||
|
IssuedChatCard,
|
||||||
|
IssuedWorkOrder,
|
||||||
|
ReplyOutcome,
|
||||||
|
} from '../../contract.ts';
|
||||||
|
import { BareCallError, HumanCallBudgetExceededError, ReplyRejectedError } from '../../contract.ts';
|
||||||
|
import type { CardStore, Clock, HumanEventPublisher, IdGenerator, MemoryRecorder, MessageOutbox } from '../../port.ts';
|
||||||
|
import {
|
||||||
|
arrivalOf,
|
||||||
|
checkChatCard,
|
||||||
|
checkChatCardReply,
|
||||||
|
checkWorkOrder,
|
||||||
|
checkWorkOrderReply,
|
||||||
|
producesCompletion,
|
||||||
|
} from '../domain/card-rules.ts';
|
||||||
|
|
||||||
|
export type HumanToolServiceDeps = {
|
||||||
|
readonly cards: CardStore;
|
||||||
|
readonly outbox: MessageOutbox;
|
||||||
|
readonly memory: MemoryRecorder;
|
||||||
|
readonly events: HumanEventPublisher;
|
||||||
|
readonly clock: Clock;
|
||||||
|
readonly ids: IdGenerator;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolService {
|
||||||
|
/** 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1) */
|
||||||
|
async function assertBudget(context: IssueContext): Promise<void> {
|
||||||
|
const used = await deps.cards.humanCallCount(context.taskId);
|
||||||
|
if (used >= context.humanCallCap) {
|
||||||
|
throw new HumanCallBudgetExceededError(context.taskId, used, context.humanCallCap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliver(
|
||||||
|
context: IssueContext,
|
||||||
|
kind: 'work-order' | 'chat-card',
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<{ arrival: ReturnType<typeof arrivalOf>; messageId?: string }> {
|
||||||
|
const recipient = context.recipientId ?? context.ownerId;
|
||||||
|
const arrival = arrivalOf(recipient, context.ownerId);
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: context.taskId,
|
||||||
|
kind: 'card-sent',
|
||||||
|
title,
|
||||||
|
detail: { kind, id, arrival, recipient },
|
||||||
|
});
|
||||||
|
if (arrival === 'task-chat') return { arrival };
|
||||||
|
// 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」
|
||||||
|
const message = await deps.outbox.sendElicitation({
|
||||||
|
staffId: recipient,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
cardId: id,
|
||||||
|
sourceTaskId: context.taskId,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
return { arrival, messageId: message.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueOrder(context: IssueContext, order: WorkOrder): Promise<IssuedWorkOrder> {
|
||||||
|
const violations = checkWorkOrder(order);
|
||||||
|
if (violations.length > 0) throw new BareCallError(violations);
|
||||||
|
await assertBudget(context);
|
||||||
|
|
||||||
|
const recipient = context.recipientId ?? context.ownerId;
|
||||||
|
await deps.cards.saveWorkOrder(context.taskId, order, recipient);
|
||||||
|
const delivered = await deliver(
|
||||||
|
context,
|
||||||
|
'work-order',
|
||||||
|
order.id,
|
||||||
|
order.title,
|
||||||
|
order.mode === 'execution' ? order.ask : `请验收:${order.title}`,
|
||||||
|
{ mode: order.mode },
|
||||||
|
);
|
||||||
|
return { order, arrival: delivered.arrival, ...(delivered.messageId === undefined ? {} : { messageId: delivered.messageId }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async issueExecution(context, draft) {
|
||||||
|
const order: ExecutionWorkOrder = {
|
||||||
|
...draft,
|
||||||
|
id: workOrderIdSchema.parse(draft.id ?? deps.ids.next()),
|
||||||
|
at: draft.at ?? deps.clock.now().toISOString(),
|
||||||
|
mode: 'execution',
|
||||||
|
};
|
||||||
|
return issueOrder(context, order);
|
||||||
|
},
|
||||||
|
|
||||||
|
async issueAcceptance(context, draft) {
|
||||||
|
const order: AcceptanceWorkOrder = {
|
||||||
|
...draft,
|
||||||
|
id: workOrderIdSchema.parse(draft.id ?? deps.ids.next()),
|
||||||
|
at: draft.at ?? deps.clock.now().toISOString(),
|
||||||
|
mode: 'acceptance',
|
||||||
|
};
|
||||||
|
return issueOrder(context, order);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 落地失败卡 = 执行工单的系统预置形态(作业指导 = 失败动作与原因,回填 = 处置选择) */
|
||||||
|
async issueLandingFailure(context, input) {
|
||||||
|
const order: ExecutionWorkOrder = {
|
||||||
|
id: workOrderIdSchema.parse(deps.ids.next()),
|
||||||
|
at: deps.clock.now().toISOString(),
|
||||||
|
mode: 'execution',
|
||||||
|
preset: 'landing-failure',
|
||||||
|
title: '落地受阻:请处置失败动作',
|
||||||
|
basis: input.failures.map((f) => ({ k: f.label, v: f.reason })),
|
||||||
|
ask: '以下动作在放行后执行失败。已成动作留存不回滚,请逐项处置。',
|
||||||
|
standard: '每项给出处置选择;全部处置完毕后任务方可收敛终态。',
|
||||||
|
steps: input.failures.map((f) => `${f.label}:${f.reason}`),
|
||||||
|
fields: input.failures.map((f, index) => ({
|
||||||
|
key: `disposition_${index}`,
|
||||||
|
label: f.label,
|
||||||
|
kind: 'radio' as const,
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'retry', label: '重试' },
|
||||||
|
{ value: 'offline', label: '线下完成后回填' },
|
||||||
|
{ value: 'abandon', label: '放弃非必交付项' },
|
||||||
|
{ value: 'halt', label: '叫停', negative: true },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
submitLabel: '提交处置',
|
||||||
|
};
|
||||||
|
return issueOrder(context, order);
|
||||||
|
},
|
||||||
|
|
||||||
|
async issueChatCard(context, card) {
|
||||||
|
const violations = checkChatCard(card);
|
||||||
|
if (violations.length > 0) throw new BareCallError(violations);
|
||||||
|
await assertBudget(context);
|
||||||
|
|
||||||
|
const recipient = context.recipientId ?? context.ownerId;
|
||||||
|
await deps.cards.saveChatCard(context.taskId, card, recipient);
|
||||||
|
const delivered = await deliver(context, 'chat-card', card.id, card.ask, card.ask, { k: card.k });
|
||||||
|
return { card, arrival: delivered.arrival, ...(delivered.messageId === undefined ? {} : { messageId: delivered.messageId }) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome> {
|
||||||
|
const found = await deps.cards.getWorkOrder(reply.workOrderId);
|
||||||
|
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '工单不存在' }]);
|
||||||
|
|
||||||
|
const violations = checkWorkOrderReply(found.order, reply);
|
||||||
|
if (violations.length > 0) throw new ReplyRejectedError(violations);
|
||||||
|
|
||||||
|
const at = deps.clock.now().toISOString();
|
||||||
|
const result = reply.mode === 'acceptance' ? reply.verdict : 'submitted';
|
||||||
|
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
|
||||||
|
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
|
||||||
|
|
||||||
|
// 回传即留痕:谁、何时、答了什么、依据什么,随卡入任务记忆
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: found.taskId,
|
||||||
|
kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply',
|
||||||
|
title: reply.mode === 'acceptance' ? `放行裁决:${reply.verdict}` : `执行工单回传:${found.order.title}`,
|
||||||
|
detail: { by, values, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
|
||||||
|
});
|
||||||
|
// 回传经统一入口以人事件抛回任务
|
||||||
|
await deps.events.publishHumanEvent({
|
||||||
|
taskId: found.taskId,
|
||||||
|
cardId: reply.workOrderId,
|
||||||
|
payload: { ...reply },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await deps.cards.getWorkOrder(reply.workOrderId);
|
||||||
|
const order = updated?.order ?? found.order;
|
||||||
|
|
||||||
|
return {
|
||||||
|
order,
|
||||||
|
producesCompletion: producesCompletion(reply),
|
||||||
|
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
|
||||||
|
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
|
||||||
|
: {}),
|
||||||
|
selectedPorts: reply.mode === 'acceptance' ? reply.selectedPorts : {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard> {
|
||||||
|
const found = await deps.cards.getChatCard(reply.cardId);
|
||||||
|
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]);
|
||||||
|
|
||||||
|
const violations = checkChatCardReply(found.card, reply);
|
||||||
|
if (violations.length > 0) throw new ReplyRejectedError(violations);
|
||||||
|
|
||||||
|
const at = deps.clock.now().toISOString();
|
||||||
|
const updated = applyChatReply(found.card, reply, by, at);
|
||||||
|
await deps.cards.updateChatCard(updated);
|
||||||
|
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: found.taskId,
|
||||||
|
kind: 'card-reply',
|
||||||
|
title: `${found.card.k} 卡回传`,
|
||||||
|
detail: { by, ...reply },
|
||||||
|
});
|
||||||
|
await deps.events.publishHumanEvent({ taskId: found.taskId, cardId: reply.cardId, payload: { ...reply } });
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 驳回重答:新卡实例(附驳回理由),原卡闭合 —— 每次请求与回答都是独立消息,只增不改 */
|
||||||
|
async rejectAndReask(cardId, reason) {
|
||||||
|
const found = await deps.cards.getChatCard(cardId);
|
||||||
|
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]);
|
||||||
|
|
||||||
|
const fresh: ChatCard = {
|
||||||
|
...found.card,
|
||||||
|
id: cardIdSchema.parse(deps.ids.next()),
|
||||||
|
at: deps.clock.now().toISOString(),
|
||||||
|
ask: `${found.card.ask}\n【驳回重答】${reason}`,
|
||||||
|
...(found.card.k === 'decision' ? { chosen: undefined } : { done: undefined }),
|
||||||
|
} as ChatCard;
|
||||||
|
|
||||||
|
await deps.cards.saveChatCard(found.taskId, fresh, found.recipientId);
|
||||||
|
await deps.cards.closeChatCard(cardId, fresh.id, reason);
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: found.taskId,
|
||||||
|
kind: 'card-sent',
|
||||||
|
title: `驳回重答:${reason}`,
|
||||||
|
detail: { original: cardId, reask: fresh.id },
|
||||||
|
});
|
||||||
|
return { card: fresh, arrival: arrivalOf(found.recipientId, found.recipientId) } satisfies IssuedChatCard;
|
||||||
|
},
|
||||||
|
|
||||||
|
async openWorkOrders(taskId) {
|
||||||
|
return deps.cards.listWorkOrders(taskId, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
async chatCards(taskId) {
|
||||||
|
return deps.cards.listChatCards(taskId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async openCardsOf(staffId) {
|
||||||
|
return deps.cards.openCardsOf(staffId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: string): ChatCard {
|
||||||
|
if (card.k === 'decision' && reply.k === 'decision') {
|
||||||
|
return {
|
||||||
|
...card,
|
||||||
|
chosen: { value: reply.value, at, by, ...(reply.note === undefined ? {} : { note: reply.note }) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (card.k === 'elicitation' && reply.k === 'elicitation') {
|
||||||
|
return {
|
||||||
|
...card,
|
||||||
|
done: { at, by, values: reply.values, ...(reply.reason === undefined ? {} : { reason: reply.reason }) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (card.k === 'rating' && reply.k === 'rating') {
|
||||||
|
return { ...card, done: { at, by, score: reply.score, ...(reply.note === undefined ? {} : { note: reply.note }) } };
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "@modules/human-tool",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": { ".": "./index.ts" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* module: human-tool —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||||
|
*/
|
||||||
|
import type { ChatCard, StaffId, TaskId, WorkOrder } from '@contracts';
|
||||||
|
|
||||||
|
export type Clock = { now(): Date };
|
||||||
|
export type IdGenerator = { next(): string };
|
||||||
|
|
||||||
|
export type CardStore = {
|
||||||
|
saveWorkOrder(taskId: TaskId, order: WorkOrder, recipientId: StaffId): Promise<void>;
|
||||||
|
getWorkOrder(id: string): Promise<{ taskId: TaskId; order: WorkOrder; recipientId: StaffId } | undefined>;
|
||||||
|
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
|
||||||
|
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
|
||||||
|
|
||||||
|
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
||||||
|
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
|
||||||
|
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
|
updateChatCard(card: ChatCard): Promise<void>;
|
||||||
|
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
|
||||||
|
|
||||||
|
/** 调人次数 —— 打扰率与预算的计数面 */
|
||||||
|
humanCallCount(taskId: TaskId): Promise<number>;
|
||||||
|
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 跨人卡的到达面 —— app 注入 `message`(L2) */
|
||||||
|
export type MessageOutbox = {
|
||||||
|
sendElicitation(input: {
|
||||||
|
staffId: StaffId;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
cardId: string;
|
||||||
|
sourceTaskId: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}): Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 回传即留痕 —— app 注入 `memory`(L2) */
|
||||||
|
export type MemoryRecorder = {
|
||||||
|
append(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
kind: 'card-sent' | 'card-reply' | 'release-verdict';
|
||||||
|
title: string;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 人事件 —— app 注入 `event`(L1):回传经统一入口写回任务 */
|
||||||
|
export type HumanEventPublisher = {
|
||||||
|
publishHumanEvent(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
cardId: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "@modules/interaction",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": { ".": "./index.ts" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# module: release
|
||||||
|
|
||||||
|
职责: 放行与落地 —— 落地预检五项、动作意向登记、放行写合并、幂等落地、
|
||||||
|
落地失败卡、输出口发射
|
||||||
|
不负责: 放行卡的卡协议与回传校验(见 human-tool)、草稿层与冲突检测的实现(见 ledger)、
|
||||||
|
工具执行的实现(见 toolbox)、终态命名(见 task)
|
||||||
|
|
||||||
|
公开出口: index.ts
|
||||||
|
- 类型:`ReleaseService` `PrecheckReport` `PrecheckFinding` `LandingVerdict` `LandOutcome`
|
||||||
|
`SubmitForReleaseInput` `SubmitOutcome` `DeliverableCheck` `CriterionCheck`
|
||||||
|
- 错误:`PrecheckFailedError` `DeliverablesMissingError`
|
||||||
|
- 端口类型:`IntentStore` `LedgerGateway` `ToolGateway` `CardGateway` `TaskContext`
|
||||||
|
`PortEmitter` `MemoryRecorder`
|
||||||
|
- 用例工厂:`createReleaseService(deps): ReleaseService`
|
||||||
|
- 纯函数:`landingVerdict` `missingDeliverables` `submittable` `summarize` `idempotencyKey`
|
||||||
|
- adapter:`createIntentStore`;Drizzle 表:`actionIntentTable`
|
||||||
|
|
||||||
|
层级: L4(编码规范 §4)
|
||||||
|
依赖: @contracts;@shared/db;port 注入:ledger(L1)、toolbox(L2)、human-tool(L3)、
|
||||||
|
event(L1,输出口发射)、memory(L2)
|
||||||
|
被依赖: loop-engine, flow-engine, task, app
|
||||||
|
|
||||||
|
不变量(OS 7.3 / AM 12.1):
|
||||||
|
- **预检先于放行**:`submitForRelease` 预检不过即抛 `PrecheckFailedError`,**不发卡**
|
||||||
|
- **卡面即已验证的动作清单**:验收工单的 actions 只列预检通过的意向
|
||||||
|
- **必交付物缺一不得提交放行**
|
||||||
|
- **放行通过即刻落地**,每个动作附幂等键(任务 × 意向)—— 重放不重复触达世界
|
||||||
|
- **落地收敛,输出口才发射** —— 下游只见落地完成的任务;受阻时 `emittedPorts` 恒为空
|
||||||
|
- **已成动作留存不回滚**;残余失败走落地失败卡(执行工单的预置形态),不静默成功
|
||||||
|
- **落地受阻是运行态不是终态**:处置后仍收敛于终态名录
|
||||||
|
- 放行写经 `ledger` 出口的 `releaseWrite`,不自行碰台账(§12.5#9)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/** 落地预检与落地收敛 —— domain 测试零 mock(OS 7.3) */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
idempotencyKey,
|
||||||
|
landingVerdict,
|
||||||
|
missingDeliverables,
|
||||||
|
submittable,
|
||||||
|
summarize,
|
||||||
|
} from '../internal/domain/precheck.ts';
|
||||||
|
import type { CriterionCheck, LandingOutcome } from '../internal/domain/precheck.ts';
|
||||||
|
|
||||||
|
describe('summarize —— 预检全过才可发卡', () => {
|
||||||
|
it('无 finding 即通过', () => {
|
||||||
|
const report = summarize([], [{ id: 'i1', label: '发报价邮件', effect: 'write', detail: '' }]);
|
||||||
|
expect(report.passed).toBe(true);
|
||||||
|
expect(report.verified).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('任一 finding 即不通过', () => {
|
||||||
|
const report = summarize([{ item: 'data-conflict', message: '字段已被他单改动' }], []);
|
||||||
|
expect(report.passed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('必交付物缺一不得提交放行', () => {
|
||||||
|
it('列出缺失项', () => {
|
||||||
|
const missing = missingDeliverables([
|
||||||
|
{ key: 'a', label: '统一受理记录', present: true },
|
||||||
|
{ key: 'b', label: '料号分类表', present: false },
|
||||||
|
]);
|
||||||
|
expect(missing.map((m) => m.key)).toEqual(['b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submittable —— 判据全绿只是可提交', () => {
|
||||||
|
const base: CriterionCheck[] = [
|
||||||
|
{ key: 'a', label: '八字段齐', level: 'L1', pass: true },
|
||||||
|
{ key: 'b', label: '偏离度合规', level: 'L2', pass: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('L1/L2 全绿即可提交', () => {
|
||||||
|
expect(submittable(base)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('L2 未过即不可提交', () => {
|
||||||
|
expect(submittable([...base.slice(0, 1), { key: 'b', label: 'x', level: 'L2', pass: false }])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('L5 是责任关,不参与可提交判定 —— 认账永远是人的动作', () => {
|
||||||
|
expect(submittable([...base, { key: 'c', label: '人放行', level: 'L5', pass: false }])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('landingVerdict —— 落地收敛', () => {
|
||||||
|
const ok: LandingOutcome = { id: 'i1', ok: true, retryable: false };
|
||||||
|
const bad: LandingOutcome = { id: 'i2', ok: false, error: '连接超时', retryable: true };
|
||||||
|
|
||||||
|
it('全成即收敛', () => {
|
||||||
|
expect(landingVerdict([ok])).toEqual({ kind: 'converged' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('零动作也算收敛', () => {
|
||||||
|
expect(landingVerdict([])).toEqual({ kind: 'converged' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有失败即落地受阻,并带出失败清单', () => {
|
||||||
|
const verdict = landingVerdict([ok, bad]);
|
||||||
|
expect(verdict.kind).toBe('blocked');
|
||||||
|
if (verdict.kind === 'blocked') {
|
||||||
|
expect(verdict.failures.map((f) => f.id)).toEqual(['i2']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('idempotencyKey —— 重放不重复触达世界', () => {
|
||||||
|
it('任务 × 意向', () => {
|
||||||
|
expect(idempotencyKey('t1', 'i1')).toBe('t1:i1');
|
||||||
|
expect(idempotencyKey('t1', 'i1')).toBe(idempotencyKey('t1', 'i1'));
|
||||||
|
expect(idempotencyKey('t1', 'i1')).not.toBe(idempotencyKey('t2', 'i1'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* module: release —— 对外类型(编码规范 §10.1)。
|
||||||
|
*
|
||||||
|
* 放行门:草稿态 → 事实态的**唯一通道**。门归系统,启门归人(OS 10)。
|
||||||
|
*/
|
||||||
|
import type { ActionIntent, StaffId, TaskId } from '@contracts';
|
||||||
|
import type {
|
||||||
|
CriterionCheck,
|
||||||
|
DeliverableCheck,
|
||||||
|
LandingOutcome,
|
||||||
|
LandingVerdict,
|
||||||
|
PrecheckFinding,
|
||||||
|
PrecheckItem,
|
||||||
|
PrecheckReport,
|
||||||
|
} from './internal/domain/precheck.ts';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ActionIntent,
|
||||||
|
CriterionCheck,
|
||||||
|
DeliverableCheck,
|
||||||
|
LandingOutcome,
|
||||||
|
LandingVerdict,
|
||||||
|
PrecheckFinding,
|
||||||
|
PrecheckItem,
|
||||||
|
PrecheckReport,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PrecheckFailedError extends Error {
|
||||||
|
constructor(readonly report: PrecheckReport) {
|
||||||
|
super(`落地预检未过,不发放行卡:${report.findings.map((f) => f.message).join('; ')}`);
|
||||||
|
this.name = 'PrecheckFailedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeliverablesMissingError extends Error {
|
||||||
|
constructor(readonly missing: readonly DeliverableCheck[]) {
|
||||||
|
super(`必交付物缺一不得提交放行:${missing.map((d) => d.label).join('、')}`);
|
||||||
|
this.name = 'DeliverablesMissingError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubmitForReleaseInput = {
|
||||||
|
readonly taskId: TaskId;
|
||||||
|
readonly ownerId: StaffId;
|
||||||
|
readonly humanCallCap: number;
|
||||||
|
readonly deliverableId: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly basis: ReadonlyArray<{ k: string; v: string }>;
|
||||||
|
readonly deliverables: readonly DeliverableCheck[];
|
||||||
|
readonly criteria: readonly CriterionCheck[];
|
||||||
|
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||||
|
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmitOutcome = {
|
||||||
|
readonly report: PrecheckReport;
|
||||||
|
/** 预检全过才发卡;这里是发出的验收工单 ID */
|
||||||
|
readonly workOrderId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LandInput = {
|
||||||
|
readonly taskId: TaskId;
|
||||||
|
readonly ownerId: StaffId;
|
||||||
|
readonly humanCallCap: number;
|
||||||
|
readonly selectedPorts: Readonly<Record<string, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LandOutcome = {
|
||||||
|
readonly verdict: LandingVerdict;
|
||||||
|
/** 落地收敛后才发射的输出口 */
|
||||||
|
readonly emittedPorts: readonly string[];
|
||||||
|
readonly ledgerRowsMerged: number;
|
||||||
|
/** 落地受阻时发出的落地失败卡 */
|
||||||
|
readonly failureWorkOrderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReleaseService = {
|
||||||
|
/** 登记写类动作意向(入参冻结,存入草稿态) */
|
||||||
|
registerIntent(taskId: TaskId, intent: Omit<ActionIntent, 'taskId' | 'state'>): Promise<ActionIntent>;
|
||||||
|
listIntents(taskId: TaskId): Promise<ActionIntent[]>;
|
||||||
|
|
||||||
|
/** 预检 → 全过才发验收工单;不过即抛 `PrecheckFailedError`,打回环内修正 */
|
||||||
|
submitForRelease(input: SubmitForReleaseInput): Promise<SubmitOutcome>;
|
||||||
|
/** 只跑预检不发卡 —— 环内随时可看「还差什么」 */
|
||||||
|
precheck(taskId: TaskId): Promise<PrecheckReport>;
|
||||||
|
|
||||||
|
/** 放行通过即刻落地:草稿合并 + 动作执行(附幂等键)→ 收敛才发射输出口 */
|
||||||
|
land(input: LandInput): Promise<LandOutcome>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* module: release —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||||
|
*/
|
||||||
|
export type {
|
||||||
|
ActionIntent,
|
||||||
|
CriterionCheck,
|
||||||
|
DeliverableCheck,
|
||||||
|
LandInput,
|
||||||
|
LandOutcome,
|
||||||
|
LandingOutcome,
|
||||||
|
LandingVerdict,
|
||||||
|
PrecheckFinding,
|
||||||
|
PrecheckItem,
|
||||||
|
PrecheckReport,
|
||||||
|
ReleaseService,
|
||||||
|
SubmitForReleaseInput,
|
||||||
|
SubmitOutcome,
|
||||||
|
} from './contract.ts';
|
||||||
|
export { DeliverablesMissingError, PrecheckFailedError } from './contract.ts';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CardGateway,
|
||||||
|
Clock,
|
||||||
|
IdGenerator,
|
||||||
|
IntentStore,
|
||||||
|
LedgerGateway,
|
||||||
|
MemoryRecorder,
|
||||||
|
PortEmitter,
|
||||||
|
TaskContext,
|
||||||
|
ToolGateway,
|
||||||
|
} from './port.ts';
|
||||||
|
|
||||||
|
export type { ReleaseServiceDeps } from './internal/usecase/release-service.ts';
|
||||||
|
export { createReleaseService } from './internal/usecase/release-service.ts';
|
||||||
|
|
||||||
|
export {
|
||||||
|
idempotencyKey,
|
||||||
|
landingVerdict,
|
||||||
|
missingDeliverables,
|
||||||
|
PRECHECK_ITEM_LABEL,
|
||||||
|
PRECHECK_ITEMS,
|
||||||
|
submittable,
|
||||||
|
summarize,
|
||||||
|
} from './internal/domain/precheck.ts';
|
||||||
|
|
||||||
|
export { createIntentStore } from './internal/adapter/intent-store.ts';
|
||||||
|
export { actionIntentTable } from './internal/adapter/release-schema.ts';
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* release 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||||
|
*/
|
||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
import type { ActionIntent, TaskId } from '@contracts';
|
||||||
|
import { actionIntentSchema } from '@contracts';
|
||||||
|
import type { Db } from '@shared/db.ts';
|
||||||
|
import type { IntentStore } from '../../port.ts';
|
||||||
|
import { actionIntentTable } from './release-schema.ts';
|
||||||
|
|
||||||
|
type Row = typeof actionIntentTable.$inferSelect;
|
||||||
|
|
||||||
|
function toIntent(row: Row): ActionIntent {
|
||||||
|
return actionIntentSchema.parse({
|
||||||
|
id: row.id,
|
||||||
|
taskId: row.taskId,
|
||||||
|
tool: row.tool,
|
||||||
|
input: row.input,
|
||||||
|
idempotencyKey: row.idempotencyKey,
|
||||||
|
state: row.state,
|
||||||
|
label: row.label,
|
||||||
|
...(row.detail === null ? {} : { detail: row.detail }),
|
||||||
|
...(row.error === null ? {} : { error: row.error }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIntentStore(db: Db): IntentStore {
|
||||||
|
return {
|
||||||
|
async save(intent) {
|
||||||
|
await db
|
||||||
|
.insert(actionIntentTable)
|
||||||
|
.values({
|
||||||
|
id: intent.id,
|
||||||
|
taskId: intent.taskId,
|
||||||
|
tool: intent.tool,
|
||||||
|
input: intent.input,
|
||||||
|
idempotencyKey: intent.idempotencyKey,
|
||||||
|
state: intent.state,
|
||||||
|
label: intent.label,
|
||||||
|
detail: intent.detail ?? null,
|
||||||
|
error: intent.error ?? null,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
},
|
||||||
|
async list(taskId: TaskId) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(actionIntentTable)
|
||||||
|
.where(eq(actionIntentTable.taskId, taskId))
|
||||||
|
.orderBy(asc(actionIntentTable.createdAt));
|
||||||
|
return rows.map(toIntent);
|
||||||
|
},
|
||||||
|
async setState(id, state, error) {
|
||||||
|
await db
|
||||||
|
.update(actionIntentTable)
|
||||||
|
.set({ state, error: error ?? null })
|
||||||
|
.where(eq(actionIntentTable.id, id));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* release 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||||
|
* 动作意向持久于系统自持存储 —— 写即持久,断点可续(OS 09)。
|
||||||
|
*/
|
||||||
|
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
import { sys } from '@shared/db.ts';
|
||||||
|
|
||||||
|
export const actionIntentTable = sys.table(
|
||||||
|
'action_intent',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
taskId: text('task_id').notNull(),
|
||||||
|
tool: text('tool').notNull(),
|
||||||
|
/** 入参冻结 —— 登记时即定,放行时按幂等键执行 */
|
||||||
|
input: jsonb('input').notNull(),
|
||||||
|
idempotencyKey: text('idempotency_key').notNull(),
|
||||||
|
state: text('state').notNull().default('drafted'),
|
||||||
|
label: text('label').notNull(),
|
||||||
|
detail: text('detail'),
|
||||||
|
error: text('error'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index('action_intent_task_idx').on(t.taskId, t.state)],
|
||||||
|
);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 落地预检 —— 纯判定,零 IO(编码规范 §5.3;语义出处 OS 7.3,AM 06)。
|
||||||
|
*
|
||||||
|
* 「**检查可落地,才允许放行**:发放行卡前,系统对全部动作意向执行预检 ——
|
||||||
|
* ①入参 schema 校验 ②工具可用(未停用)与授权有效 ③连接可达
|
||||||
|
* ④目标预校验(工具契约含 validate 能力时)⑤数据冲突检查。
|
||||||
|
* 预检全过才发放行卡,卡面呈现的即已验证的动作清单;预检不过的意向打回环内修正,**不发卡**。」
|
||||||
|
*/
|
||||||
|
import type { AcceptanceLevel } from '@contracts';
|
||||||
|
|
||||||
|
export const PRECHECK_ITEMS = [
|
||||||
|
'input-schema',
|
||||||
|
'tool-available',
|
||||||
|
'connection-reachable',
|
||||||
|
'target-validate',
|
||||||
|
'data-conflict',
|
||||||
|
] as const;
|
||||||
|
export type PrecheckItem = (typeof PRECHECK_ITEMS)[number];
|
||||||
|
|
||||||
|
export const PRECHECK_ITEM_LABEL: Readonly<Record<PrecheckItem, string>> = {
|
||||||
|
'input-schema': '入参 schema 校验',
|
||||||
|
'tool-available': '工具可用与授权有效',
|
||||||
|
'connection-reachable': '连接可达',
|
||||||
|
'target-validate': '目标预校验',
|
||||||
|
'data-conflict': '数据冲突检查',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrecheckFinding = {
|
||||||
|
readonly item: PrecheckItem;
|
||||||
|
/** 关联的动作意向;数据冲突项无意向,留空 */
|
||||||
|
readonly intentId?: string;
|
||||||
|
readonly message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrecheckReport = {
|
||||||
|
/** 全过才可发卡 */
|
||||||
|
readonly passed: boolean;
|
||||||
|
readonly findings: readonly PrecheckFinding[];
|
||||||
|
/** 已验证的动作清单 —— 通过时即验收工单卡面呈现的那份 */
|
||||||
|
readonly verified: ReadonlyArray<{ id: string; label: string; effect: 'read' | 'write'; detail: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function summarize(
|
||||||
|
findings: readonly PrecheckFinding[],
|
||||||
|
verified: PrecheckReport['verified'],
|
||||||
|
): PrecheckReport {
|
||||||
|
return { passed: findings.length === 0, findings, verified };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 必交付物与判据 ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type DeliverableCheck = { readonly key: string; readonly label: string; readonly present: boolean };
|
||||||
|
export type CriterionCheck = { readonly key: string; readonly label: string; readonly level: AcceptanceLevel; readonly pass: boolean };
|
||||||
|
|
||||||
|
/** 必交付物**缺一不得提交放行**(AM 01) */
|
||||||
|
export function missingDeliverables(checks: readonly DeliverableCheck[]): DeliverableCheck[] {
|
||||||
|
return checks.filter((c) => !c.present);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判据全绿只是**可提交**,完成只出自放行通过(AM 10.2)。
|
||||||
|
* L1/L2 由引擎预检;L3–L5 按分级执行 —— L5 是责任关,不参与「可提交」判定。
|
||||||
|
*/
|
||||||
|
export function submittable(checks: readonly CriterionCheck[]): boolean {
|
||||||
|
return checks.filter((c) => c.level !== 'L5').every((c) => c.pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 落地收敛 ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type LandingOutcome = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly ok: boolean;
|
||||||
|
readonly error?: string;
|
||||||
|
readonly retryable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LandingVerdict =
|
||||||
|
/** 落地收敛 —— 输出口可发射,终态方成「放行完成」 */
|
||||||
|
| { readonly kind: 'converged' }
|
||||||
|
/** 落地受阻 —— **运行态**不是终态;走落地失败卡,处置后仍收敛于终态名录 */
|
||||||
|
| { readonly kind: 'blocked'; readonly failures: readonly LandingOutcome[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「放行通过即刻执行(附幂等键);落地收敛,输出口才发射 —— 下游只见落地完成的任务。
|
||||||
|
* 已成动作留存**不回滚**。」
|
||||||
|
*/
|
||||||
|
export function landingVerdict(outcomes: readonly LandingOutcome[]): LandingVerdict {
|
||||||
|
const failures = outcomes.filter((o) => !o.ok);
|
||||||
|
return failures.length === 0 ? { kind: 'converged' } : { kind: 'blocked', failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 幂等键:任务 × 意向 —— 重放不重复触达世界(OS 7.3) */
|
||||||
|
export function idempotencyKey(taskId: string, intentId: string): string {
|
||||||
|
return `${taskId}:${intentId}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* release 用例 —— 放行门(编码规范 §5.3;语义出处 OS 7.3 / 09,AM 06)。
|
||||||
|
*
|
||||||
|
* 「预检放行,落地收敛」:检查可落地才允许放行 → 放行通过即刻落地(附幂等键)
|
||||||
|
* → 落地收敛输出口才发射 → 终态方成「放行完成」。
|
||||||
|
*/
|
||||||
|
import type { ActionIntent, TaskId } from '@contracts';
|
||||||
|
import { emissionPortNames } from '@contracts';
|
||||||
|
import type {
|
||||||
|
LandInput,
|
||||||
|
LandOutcome,
|
||||||
|
ReleaseService,
|
||||||
|
SubmitForReleaseInput,
|
||||||
|
SubmitOutcome,
|
||||||
|
} from '../../contract.ts';
|
||||||
|
import { DeliverablesMissingError, PrecheckFailedError } from '../../contract.ts';
|
||||||
|
import type {
|
||||||
|
CardGateway,
|
||||||
|
Clock,
|
||||||
|
IdGenerator,
|
||||||
|
IntentStore,
|
||||||
|
LedgerGateway,
|
||||||
|
MemoryRecorder,
|
||||||
|
PortEmitter,
|
||||||
|
TaskContext,
|
||||||
|
ToolGateway,
|
||||||
|
} from '../../port.ts';
|
||||||
|
import type { LandingOutcome, PrecheckFinding } from '../domain/precheck.ts';
|
||||||
|
import { idempotencyKey, landingVerdict, missingDeliverables, summarize } from '../domain/precheck.ts';
|
||||||
|
|
||||||
|
export type ReleaseServiceDeps = {
|
||||||
|
readonly intents: IntentStore;
|
||||||
|
readonly ledger: LedgerGateway;
|
||||||
|
readonly tools: ToolGateway;
|
||||||
|
readonly cards: CardGateway;
|
||||||
|
readonly tasks: TaskContext;
|
||||||
|
readonly ports: PortEmitter;
|
||||||
|
readonly memory: MemoryRecorder;
|
||||||
|
readonly clock: Clock;
|
||||||
|
readonly ids: IdGenerator;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
||||||
|
async function runPrecheck(taskId: TaskId) {
|
||||||
|
const activityId = await deps.tasks.activityIdOf(taskId);
|
||||||
|
const intents = (await deps.intents.list(taskId)).filter((i) => i.state === 'drafted' || i.state === 'prechecked');
|
||||||
|
const findings: PrecheckFinding[] = [];
|
||||||
|
const verified: Array<{ id: string; label: string; effect: 'read' | 'write'; detail: string }> = [];
|
||||||
|
|
||||||
|
for (const intent of intents) {
|
||||||
|
// ① 入参 schema 校验
|
||||||
|
const schema = await deps.tools.validateInput(intent.tool, intent.input);
|
||||||
|
if (!schema.ok) {
|
||||||
|
findings.push({ item: 'input-schema', intentId: intent.id, message: `${intent.label}:${schema.errors.join('; ')}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ② 工具可用与授权有效 / ③ 连接可达
|
||||||
|
const invocable = await deps.tools.isInvocable(intent.tool, activityId);
|
||||||
|
if (!invocable.ok) {
|
||||||
|
findings.push({
|
||||||
|
item: 'tool-available',
|
||||||
|
intentId: intent.id,
|
||||||
|
message: `${intent.label}:${invocable.reason ?? '工具不可用'}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ④ 目标预校验 —— 工具契约含 validate 能力时调之
|
||||||
|
const target = await deps.tools.validateTarget({
|
||||||
|
tool: intent.tool,
|
||||||
|
activityId,
|
||||||
|
taskId,
|
||||||
|
payload: intent.input,
|
||||||
|
});
|
||||||
|
if (target !== undefined && !target.ok) {
|
||||||
|
findings.push({
|
||||||
|
item: 'target-validate',
|
||||||
|
intentId: intent.id,
|
||||||
|
message: `${intent.label}:${target.error ?? '目标预校验未过'}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
verified.push({ id: intent.id, label: intent.label, effect: 'write', detail: intent.detail ?? '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ 数据冲突检查:他单已改同一字段即预检不过,打回环内基于新正式版重拟
|
||||||
|
const conflicts = await deps.ledger.checkConflicts(taskId);
|
||||||
|
for (const conflict of conflicts) {
|
||||||
|
findings.push({
|
||||||
|
item: 'data-conflict',
|
||||||
|
message: `字段 ${conflict.field} 已被他单改动,请基于新正式版重拟`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarize(findings, verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async registerIntent(taskId, draft) {
|
||||||
|
const intent: ActionIntent = { ...draft, taskId, state: 'drafted' };
|
||||||
|
await deps.intents.save(intent);
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'action-intent',
|
||||||
|
title: `登记动作意向:${intent.label}`,
|
||||||
|
detail: { tool: intent.tool, idempotencyKey: intent.idempotencyKey },
|
||||||
|
});
|
||||||
|
return intent;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listIntents(taskId) {
|
||||||
|
return deps.intents.list(taskId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async precheck(taskId) {
|
||||||
|
return runPrecheck(taskId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 预检全过才发放行卡 —— 不过即抛,**不发卡**(OS 7.3) */
|
||||||
|
async submitForRelease(input: SubmitForReleaseInput): Promise<SubmitOutcome> {
|
||||||
|
const missing = missingDeliverables(input.deliverables);
|
||||||
|
if (missing.length > 0) throw new DeliverablesMissingError(missing);
|
||||||
|
|
||||||
|
const report = await runPrecheck(input.taskId);
|
||||||
|
if (!report.passed) throw new PrecheckFailedError(report);
|
||||||
|
|
||||||
|
for (const item of report.verified) await deps.intents.setState(item.id, 'prechecked');
|
||||||
|
|
||||||
|
const card = await deps.cards.issueAcceptance({
|
||||||
|
taskId: input.taskId,
|
||||||
|
ownerId: input.ownerId,
|
||||||
|
humanCallCap: input.humanCallCap,
|
||||||
|
title: input.title,
|
||||||
|
basis: input.basis,
|
||||||
|
deliverable: input.deliverableId,
|
||||||
|
criteria: input.criteria.map((c) => ({ label: c.label, level: c.level, pass: c.pass })),
|
||||||
|
// 卡面呈现的即**已验证**的动作清单
|
||||||
|
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { report, workOrderId: card.id };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 放行通过即刻落地 —— 草稿合并 + 动作执行;收敛才发射输出口 */
|
||||||
|
async land(input: LandInput): Promise<LandOutcome> {
|
||||||
|
const activityId = await deps.tasks.activityIdOf(input.taskId);
|
||||||
|
|
||||||
|
// 放行写:草稿层合并入正式版(原子,过冲突检查),此刻才产生数据事件
|
||||||
|
const merged = await deps.ledger.releaseWrite(input.taskId);
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: input.taskId,
|
||||||
|
kind: 'ledger-write',
|
||||||
|
title: `放行写合并 ${merged.mergedRows} 行`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 外部动作意向执行,附幂等键;已成动作留存不回滚
|
||||||
|
const intents = (await deps.intents.list(input.taskId)).filter((i) => i.state === 'prechecked');
|
||||||
|
const outcomes: LandingOutcome[] = [];
|
||||||
|
for (const intent of intents) {
|
||||||
|
const outcome = await deps.tools.execute({
|
||||||
|
tool: intent.tool,
|
||||||
|
activityId,
|
||||||
|
taskId: input.taskId,
|
||||||
|
payload: intent.input,
|
||||||
|
idempotencyKey: idempotencyKey(input.taskId, intent.id),
|
||||||
|
});
|
||||||
|
outcomes.push(outcome);
|
||||||
|
await deps.intents.setState(intent.id, outcome.ok ? 'landed' : 'failed', outcome.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = landingVerdict(outcomes);
|
||||||
|
|
||||||
|
if (verdict.kind === 'blocked') {
|
||||||
|
// 落地受阻是**运行态**不是终态:发落地失败卡,处置后仍收敛于终态名录
|
||||||
|
const card = await deps.cards.issueLandingFailure({
|
||||||
|
taskId: input.taskId,
|
||||||
|
ownerId: input.ownerId,
|
||||||
|
humanCallCap: input.humanCallCap,
|
||||||
|
failures: verdict.failures.map((f) => ({
|
||||||
|
label: intents.find((i) => i.id === f.id)?.label ?? f.id,
|
||||||
|
reason: f.error ?? '未知错误',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: input.taskId,
|
||||||
|
kind: 'release-verdict',
|
||||||
|
title: `落地受阻:${verdict.failures.length} 个动作失败`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
verdict,
|
||||||
|
emittedPorts: [],
|
||||||
|
ledgerRowsMerged: merged.mergedRows,
|
||||||
|
failureWorkOrderId: card.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 落地收敛,输出口才发射 —— 下游只见落地完成的任务
|
||||||
|
const ports = await deps.tasks.outputPortsOf(input.taskId);
|
||||||
|
const emitted = emissionPortNames(
|
||||||
|
{
|
||||||
|
mandatory: ports.mandatory.map((name) => ({ name, payload: {} })),
|
||||||
|
selective: ports.selective.map((g) => ({
|
||||||
|
group: g.group,
|
||||||
|
ports: g.ports.map((name) => ({ name, payload: {} })),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
input.selectedPorts,
|
||||||
|
);
|
||||||
|
for (const port of emitted) {
|
||||||
|
await deps.ports.emit({ taskId: input.taskId, port, payload: {} });
|
||||||
|
}
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: input.taskId,
|
||||||
|
kind: 'release-verdict',
|
||||||
|
title: `落地收敛,发射输出口:${emitted.join('、') || '(无)'}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { verdict, emittedPorts: emitted, ledgerRowsMerged: merged.mergedRows };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "@modules/release",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": { ".": "./index.ts" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* module: release —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||||
|
*/
|
||||||
|
import type { ActionIntent, StaffId, TaskId } from '@contracts';
|
||||||
|
import type { LandingOutcome } from './internal/domain/precheck.ts';
|
||||||
|
|
||||||
|
export type Clock = { now(): Date };
|
||||||
|
export type IdGenerator = { next(): string };
|
||||||
|
|
||||||
|
export type IntentStore = {
|
||||||
|
save(intent: ActionIntent): Promise<void>;
|
||||||
|
list(taskId: TaskId): Promise<ActionIntent[]>;
|
||||||
|
setState(id: string, state: ActionIntent['state'], error?: string): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 台账 —— app 注入 `ledger`(L1);放行写与冲突检查都在它那 */
|
||||||
|
export type LedgerGateway = {
|
||||||
|
checkConflicts(taskId: TaskId): Promise<Array<{ field: string; key: Record<string, string | number> }>>;
|
||||||
|
releaseWrite(taskId: TaskId): Promise<{ mergedRows: number; emittedEvents: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 工具 —— app 注入 `toolbox`(L2);预检的②③④项与落地执行都经它 */
|
||||||
|
export type ToolGateway = {
|
||||||
|
/** 工具可用(未停用)与授权有效 */
|
||||||
|
isInvocable(tool: string, activityId: string): Promise<{ ok: boolean; reason?: string }>;
|
||||||
|
/** 入参 schema 校验 */
|
||||||
|
validateInput(tool: string, input: Record<string, unknown>): Promise<{ ok: boolean; errors: string[] }>;
|
||||||
|
/** 目标预校验:工具契约含 validate 能力时调之(ADR-005) */
|
||||||
|
validateTarget(input: {
|
||||||
|
tool: string;
|
||||||
|
activityId: string;
|
||||||
|
taskId: TaskId;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}): Promise<{ ok: boolean; error?: string } | undefined>;
|
||||||
|
/** 落地执行 —— 附幂等键 */
|
||||||
|
execute(input: {
|
||||||
|
tool: string;
|
||||||
|
activityId: string;
|
||||||
|
taskId: TaskId;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}): Promise<LandingOutcome>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 人类工具 —— app 注入 `human-tool`(L3):发验收工单与落地失败卡 */
|
||||||
|
export type CardGateway = {
|
||||||
|
issueAcceptance(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
ownerId: StaffId;
|
||||||
|
humanCallCap: number;
|
||||||
|
title: string;
|
||||||
|
basis: ReadonlyArray<{ k: string; v: string }>;
|
||||||
|
deliverable: string;
|
||||||
|
criteria: ReadonlyArray<{ label: string; level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5'; pass: boolean }>;
|
||||||
|
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
|
||||||
|
}): Promise<{ id: string }>;
|
||||||
|
issueLandingFailure(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
ownerId: StaffId;
|
||||||
|
humanCallCap: number;
|
||||||
|
failures: ReadonlyArray<{ label: string; reason: string }>;
|
||||||
|
}): Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 任务上下文 —— app 注入(活动 ID 与契约的输出口声明) */
|
||||||
|
export type TaskContext = {
|
||||||
|
activityIdOf(taskId: TaskId): Promise<string>;
|
||||||
|
outputPortsOf(taskId: TaskId): Promise<{ mandatory: string[]; selective: Array<{ group: string; ports: string[] }> }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 输出口发射 —— app 注入 `event`(L1):落地收敛**之后**才发 */
|
||||||
|
export type PortEmitter = {
|
||||||
|
emit(input: { taskId: TaskId; port: string; payload: Record<string, unknown> }): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemoryRecorder = {
|
||||||
|
append(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
kind: 'action-intent' | 'release-verdict' | 'ledger-write';
|
||||||
|
title: string;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE "sys"."chat_card" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"task_id" text NOT NULL,
|
||||||
|
"recipient_id" text NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"payload" jsonb NOT NULL,
|
||||||
|
"at" timestamp with time zone NOT NULL,
|
||||||
|
"superseded_by" text,
|
||||||
|
"rejected_reason" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sys"."work_order" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"task_id" text NOT NULL,
|
||||||
|
"recipient_id" text NOT NULL,
|
||||||
|
"mode" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"payload" jsonb NOT NULL,
|
||||||
|
"at" timestamp with time zone NOT NULL,
|
||||||
|
"done" jsonb
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sys"."action_intent" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"task_id" text NOT NULL,
|
||||||
|
"tool" text NOT NULL,
|
||||||
|
"input" jsonb NOT NULL,
|
||||||
|
"idempotency_key" text NOT NULL,
|
||||||
|
"state" text DEFAULT 'drafted' NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"detail" text,
|
||||||
|
"error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "chat_card_task_idx" ON "sys"."chat_card" USING btree ("task_id","at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "chat_card_recipient_idx" ON "sys"."chat_card" USING btree ("recipient_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "work_order_task_idx" ON "sys"."work_order" USING btree ("task_id","at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "work_order_recipient_idx" ON "sys"."work_order" USING btree ("recipient_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "action_intent_task_idx" ON "sys"."action_intent" USING btree ("task_id","state");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
|||||||
"when": 1785086166573,
|
"when": 1785086166573,
|
||||||
"tag": "0002_superb_talisman",
|
"tag": "0002_superb_talisman",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785086925397,
|
||||||
|
"tag": "0003_youthful_swarm",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user