diff --git a/api-report/apps/api/src/modules/human-tool/contract.d.ts b/api-report/apps/api/src/modules/human-tool/contract.d.ts new file mode 100644 index 0000000..a81f258 --- /dev/null +++ b/api-report/apps/api/src/modules/human-tool/contract.d.ts @@ -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 & { + readonly id?: ExecutionWorkOrder['id'] | undefined; + readonly at?: string | undefined; +}; +export type AcceptanceWorkOrderDraft = Omit & { + 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>; +}; +export type HumanToolService = { + /** 发执行工单;裸调用拒发,预算触顶熔断。id / at 留空即由基座生成 */ + issueExecution(context: IssueContext, order: ExecutionWorkOrderDraft): Promise; + /** 发验收工单 —— 发卡**前**必须已过落地预检(由 release 保证,见其 CONTRACT) */ + issueAcceptance(context: IssueContext, order: AcceptanceWorkOrderDraft): Promise; + /** 落地失败卡:执行工单的系统预置形态,不是第五种卡 */ + issueLandingFailure(context: IssueContext, input: { + readonly failures: ReadonlyArray<{ + label: string; + reason: string; + }>; + }): Promise; + issueChatCard(context: IssueContext, card: ChatCard): Promise; + replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise; + replyChatCard(reply: ChatCardReply, by: StaffId): Promise; + /** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */ + rejectAndReask(cardId: string, reason: string): Promise; + openWorkOrders(taskId: TaskId): Promise; + chatCards(taskId: TaskId): Promise; + /** org 停用前置检查要问的「待答卡」 */ + openCardsOf(staffId: StaffId): Promise>; +}; diff --git a/api-report/apps/api/src/modules/human-tool/index.d.ts b/api-report/apps/api/src/modules/human-tool/index.d.ts new file mode 100644 index 0000000..54e3eaa --- /dev/null +++ b/api-report/apps/api/src/modules/human-tool/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/human-tool/port.d.ts b/api-report/apps/api/src/modules/human-tool/port.d.ts new file mode 100644 index 0000000..0cc6664 --- /dev/null +++ b/api-report/apps/api/src/modules/human-tool/port.d.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; + getWorkOrder(id: string): Promise<{ + taskId: TaskId; + order: WorkOrder; + recipientId: StaffId; + } | undefined>; + listWorkOrders(taskId: TaskId, openOnly: boolean): Promise; + markWorkOrderDone(id: string, done: NonNullable): Promise; + saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise; + getChatCard(id: string): Promise<{ + taskId: TaskId; + card: ChatCard; + recipientId: StaffId; + } | undefined>; + listChatCards(taskId: TaskId): Promise; + updateChatCard(card: ChatCard): Promise; + closeChatCard(id: string, supersededBy: string, reason: string): Promise; + /** 调人次数 —— 打扰率与预算的计数面 */ + humanCallCount(taskId: TaskId): Promise; + openCardsOf(staffId: StaffId): Promise>; +}; +/** 跨人卡的到达面 —— app 注入 `message`(L2) */ +export type MessageOutbox = { + sendElicitation(input: { + staffId: StaffId; + title: string; + body: string; + cardId: string; + sourceTaskId: string; + payload: Record; + }): Promise<{ + id: string; + }>; +}; +/** 回传即留痕 —— app 注入 `memory`(L2) */ +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'card-sent' | 'card-reply' | 'release-verdict'; + title: string; + detail?: Record; + }): Promise; +}; +/** 人事件 —— app 注入 `event`(L1):回传经统一入口写回任务 */ +export type HumanEventPublisher = { + publishHumanEvent(input: { + taskId: TaskId; + cardId: string; + payload: Record; + }): Promise; +}; diff --git a/api-report/apps/api/src/modules/release/contract.d.ts b/api-report/apps/api/src/modules/release/contract.d.ts new file mode 100644 index 0000000..ab0524e --- /dev/null +++ b/api-report/apps/api/src/modules/release/contract.d.ts @@ -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>; +}; +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>; +}; +export type LandOutcome = { + readonly verdict: LandingVerdict; + /** 落地收敛后才发射的输出口 */ + readonly emittedPorts: readonly string[]; + readonly ledgerRowsMerged: number; + /** 落地受阻时发出的落地失败卡 */ + readonly failureWorkOrderId?: string; +}; +export type ReleaseService = { + /** 登记写类动作意向(入参冻结,存入草稿态) */ + registerIntent(taskId: TaskId, intent: Omit): Promise; + listIntents(taskId: TaskId): Promise; + /** 预检 → 全过才发验收工单;不过即抛 `PrecheckFailedError`,打回环内修正 */ + submitForRelease(input: SubmitForReleaseInput): Promise; + /** 只跑预检不发卡 —— 环内随时可看「还差什么」 */ + precheck(taskId: TaskId): Promise; + /** 放行通过即刻落地:草稿合并 + 动作执行(附幂等键)→ 收敛才发射输出口 */ + land(input: LandInput): Promise; +}; diff --git a/api-report/apps/api/src/modules/release/index.d.ts b/api-report/apps/api/src/modules/release/index.d.ts new file mode 100644 index 0000000..5338ad2 --- /dev/null +++ b/api-report/apps/api/src/modules/release/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/release/port.d.ts b/api-report/apps/api/src/modules/release/port.d.ts new file mode 100644 index 0000000..2c24b84 --- /dev/null +++ b/api-report/apps/api/src/modules/release/port.d.ts @@ -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; + list(taskId: TaskId): Promise; + setState(id: string, state: ActionIntent['state'], error?: string): Promise; +}; +/** 台账 —— app 注入 `ledger`(L1);放行写与冲突检查都在它那 */ +export type LedgerGateway = { + checkConflicts(taskId: TaskId): Promise; + }>>; + 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): Promise<{ + ok: boolean; + errors: string[]; + }>; + /** 目标预校验:工具契约含 validate 能力时调之(ADR-005) */ + validateTarget(input: { + tool: string; + activityId: string; + taskId: TaskId; + payload: Record; + }): Promise<{ + ok: boolean; + error?: string; + } | undefined>; + /** 落地执行 —— 附幂等键 */ + execute(input: { + tool: string; + activityId: string; + taskId: TaskId; + payload: Record; + idempotencyKey: string; + }): Promise; +}; +/** 人类工具 —— 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; + 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; + }): Promise; +}; +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'action-intent' | 'release-verdict' | 'ledger-write'; + title: string; + detail?: Record; + }): Promise; +}; diff --git a/apps/api/src/modules/activity-registry/package.json b/apps/api/src/modules/activity-registry/package.json new file mode 100644 index 0000000..b093499 --- /dev/null +++ b/apps/api/src/modules/activity-registry/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/activity-registry", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/human-tool/CONTRACT.md b/apps/api/src/modules/human-tool/CONTRACT.md new file mode 100644 index 0000000..f3b8b1d --- /dev/null +++ b/apps/api/src/modules/human-tool/CONTRACT.md @@ -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**,不构成第五种卡 diff --git a/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts b/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts new file mode 100644 index 0000000..18553ed --- /dev/null +++ b/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts @@ -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 => ({ + 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 => ({ + 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); + }); +}); diff --git a/apps/api/src/modules/human-tool/contract.ts b/apps/api/src/modules/human-tool/contract.ts new file mode 100644 index 0000000..4694424 --- /dev/null +++ b/apps/api/src/modules/human-tool/contract.ts @@ -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 & { + readonly id?: ExecutionWorkOrder['id'] | undefined; + readonly at?: string | undefined; +}; +export type AcceptanceWorkOrderDraft = Omit & { + 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>; +}; + +export type HumanToolService = { + /** 发执行工单;裸调用拒发,预算触顶熔断。id / at 留空即由基座生成 */ + issueExecution(context: IssueContext, order: ExecutionWorkOrderDraft): Promise; + /** 发验收工单 —— 发卡**前**必须已过落地预检(由 release 保证,见其 CONTRACT) */ + issueAcceptance(context: IssueContext, order: AcceptanceWorkOrderDraft): Promise; + /** 落地失败卡:执行工单的系统预置形态,不是第五种卡 */ + issueLandingFailure( + context: IssueContext, + input: { readonly failures: ReadonlyArray<{ label: string; reason: string }> }, + ): Promise; + issueChatCard(context: IssueContext, card: ChatCard): Promise; + + replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise; + replyChatCard(reply: ChatCardReply, by: StaffId): Promise; + /** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */ + rejectAndReask(cardId: string, reason: string): Promise; + + openWorkOrders(taskId: TaskId): Promise; + chatCards(taskId: TaskId): Promise; + /** org 停用前置检查要问的「待答卡」 */ + openCardsOf(staffId: StaffId): Promise>; +}; diff --git a/apps/api/src/modules/human-tool/index.ts b/apps/api/src/modules/human-tool/index.ts new file mode 100644 index 0000000..f4e4a4f --- /dev/null +++ b/apps/api/src/modules/human-tool/index.ts @@ -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'; diff --git a/apps/api/src/modules/human-tool/internal/adapter/card-store.ts b/apps/api/src/modules/human-tool/internal/adapter/card-store.ts new file mode 100644 index 0000000..4e472ff --- /dev/null +++ b/apps/api/src/modules/human-tool/internal/adapter/card-store.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), + ...(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), + ...(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`count(*)::int` }) + .from(workOrderTable) + .where(eq(workOrderTable.taskId, taskId)); + const cards = await db + .select({ n: sql`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 }; diff --git a/apps/api/src/modules/human-tool/internal/adapter/human-tool-schema.ts b/apps/api/src/modules/human-tool/internal/adapter/human-tool-schema.ts new file mode 100644 index 0000000..21f1c7b --- /dev/null +++ b/apps/api/src/modules/human-tool/internal/adapter/human-tool-schema.ts @@ -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)], +); diff --git a/apps/api/src/modules/human-tool/internal/domain/card-rules.ts b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts new file mode 100644 index 0000000..b410e8e --- /dev/null +++ b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts @@ -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(); + 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); +} diff --git a/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts b/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts new file mode 100644 index 0000000..ee017d1 --- /dev/null +++ b/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts @@ -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 { + 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, + ): Promise<{ arrival: ReturnType; 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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/modules/human-tool/package.json b/apps/api/src/modules/human-tool/package.json new file mode 100644 index 0000000..1883a74 --- /dev/null +++ b/apps/api/src/modules/human-tool/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/human-tool", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/human-tool/port.ts b/apps/api/src/modules/human-tool/port.ts new file mode 100644 index 0000000..ba0dbe7 --- /dev/null +++ b/apps/api/src/modules/human-tool/port.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; + getWorkOrder(id: string): Promise<{ taskId: TaskId; order: WorkOrder; recipientId: StaffId } | undefined>; + listWorkOrders(taskId: TaskId, openOnly: boolean): Promise; + markWorkOrderDone(id: string, done: NonNullable): Promise; + + saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise; + getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>; + listChatCards(taskId: TaskId): Promise; + updateChatCard(card: ChatCard): Promise; + closeChatCard(id: string, supersededBy: string, reason: string): Promise; + + /** 调人次数 —— 打扰率与预算的计数面 */ + humanCallCount(taskId: TaskId): Promise; + openCardsOf(staffId: StaffId): Promise>; +}; + +/** 跨人卡的到达面 —— app 注入 `message`(L2) */ +export type MessageOutbox = { + sendElicitation(input: { + staffId: StaffId; + title: string; + body: string; + cardId: string; + sourceTaskId: string; + payload: Record; + }): Promise<{ id: string }>; +}; + +/** 回传即留痕 —— app 注入 `memory`(L2) */ +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'card-sent' | 'card-reply' | 'release-verdict'; + title: string; + detail?: Record; + }): Promise; +}; + +/** 人事件 —— app 注入 `event`(L1):回传经统一入口写回任务 */ +export type HumanEventPublisher = { + publishHumanEvent(input: { + taskId: TaskId; + cardId: string; + payload: Record; + }): Promise; +}; diff --git a/apps/api/src/modules/interaction/package.json b/apps/api/src/modules/interaction/package.json new file mode 100644 index 0000000..2b48e69 --- /dev/null +++ b/apps/api/src/modules/interaction/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/interaction", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/release/CONTRACT.md b/apps/api/src/modules/release/CONTRACT.md new file mode 100644 index 0000000..50ba3f3 --- /dev/null +++ b/apps/api/src/modules/release/CONTRACT.md @@ -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) diff --git a/apps/api/src/modules/release/__tests__/precheck.test.ts b/apps/api/src/modules/release/__tests__/precheck.test.ts new file mode 100644 index 0000000..853f138 --- /dev/null +++ b/apps/api/src/modules/release/__tests__/precheck.test.ts @@ -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')); + }); +}); diff --git a/apps/api/src/modules/release/contract.ts b/apps/api/src/modules/release/contract.ts new file mode 100644 index 0000000..6ab20e4 --- /dev/null +++ b/apps/api/src/modules/release/contract.ts @@ -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>; +}; + +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>; +}; + +export type LandOutcome = { + readonly verdict: LandingVerdict; + /** 落地收敛后才发射的输出口 */ + readonly emittedPorts: readonly string[]; + readonly ledgerRowsMerged: number; + /** 落地受阻时发出的落地失败卡 */ + readonly failureWorkOrderId?: string; +}; + +export type ReleaseService = { + /** 登记写类动作意向(入参冻结,存入草稿态) */ + registerIntent(taskId: TaskId, intent: Omit): Promise; + listIntents(taskId: TaskId): Promise; + + /** 预检 → 全过才发验收工单;不过即抛 `PrecheckFailedError`,打回环内修正 */ + submitForRelease(input: SubmitForReleaseInput): Promise; + /** 只跑预检不发卡 —— 环内随时可看「还差什么」 */ + precheck(taskId: TaskId): Promise; + + /** 放行通过即刻落地:草稿合并 + 动作执行(附幂等键)→ 收敛才发射输出口 */ + land(input: LandInput): Promise; +}; diff --git a/apps/api/src/modules/release/index.ts b/apps/api/src/modules/release/index.ts new file mode 100644 index 0000000..aea8e7e --- /dev/null +++ b/apps/api/src/modules/release/index.ts @@ -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'; diff --git a/apps/api/src/modules/release/internal/adapter/intent-store.ts b/apps/api/src/modules/release/internal/adapter/intent-store.ts new file mode 100644 index 0000000..a49c31e --- /dev/null +++ b/apps/api/src/modules/release/internal/adapter/intent-store.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)); + }, + }; +} diff --git a/apps/api/src/modules/release/internal/adapter/release-schema.ts b/apps/api/src/modules/release/internal/adapter/release-schema.ts new file mode 100644 index 0000000..a589a7c --- /dev/null +++ b/apps/api/src/modules/release/internal/adapter/release-schema.ts @@ -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)], +); diff --git a/apps/api/src/modules/release/internal/domain/precheck.ts b/apps/api/src/modules/release/internal/domain/precheck.ts new file mode 100644 index 0000000..40a2900 --- /dev/null +++ b/apps/api/src/modules/release/internal/domain/precheck.ts @@ -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> = { + '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}`; +} diff --git a/apps/api/src/modules/release/internal/usecase/release-service.ts b/apps/api/src/modules/release/internal/usecase/release-service.ts new file mode 100644 index 0000000..685a9f4 --- /dev/null +++ b/apps/api/src/modules/release/internal/usecase/release-service.ts @@ -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 { + 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 { + 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 }; + }, + }; +} diff --git a/apps/api/src/modules/release/package.json b/apps/api/src/modules/release/package.json new file mode 100644 index 0000000..51c46d0 --- /dev/null +++ b/apps/api/src/modules/release/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/release", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/release/port.ts b/apps/api/src/modules/release/port.ts new file mode 100644 index 0000000..86ad816 --- /dev/null +++ b/apps/api/src/modules/release/port.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; + list(taskId: TaskId): Promise; + setState(id: string, state: ActionIntent['state'], error?: string): Promise; +}; + +/** 台账 —— app 注入 `ledger`(L1);放行写与冲突检查都在它那 */ +export type LedgerGateway = { + checkConflicts(taskId: TaskId): Promise }>>; + 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): Promise<{ ok: boolean; errors: string[] }>; + /** 目标预校验:工具契约含 validate 能力时调之(ADR-005) */ + validateTarget(input: { + tool: string; + activityId: string; + taskId: TaskId; + payload: Record; + }): Promise<{ ok: boolean; error?: string } | undefined>; + /** 落地执行 —— 附幂等键 */ + execute(input: { + tool: string; + activityId: string; + taskId: TaskId; + payload: Record; + idempotencyKey: string; + }): Promise; +}; + +/** 人类工具 —— 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; + 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 }): Promise; +}; + +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'action-intent' | 'release-verdict' | 'ledger-write'; + title: string; + detail?: Record; + }): Promise; +}; diff --git a/db/migrations/0003_youthful_swarm.sql b/db/migrations/0003_youthful_swarm.sql new file mode 100644 index 0000000..64541e1 --- /dev/null +++ b/db/migrations/0003_youthful_swarm.sql @@ -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"); \ No newline at end of file diff --git a/db/migrations/meta/0003_snapshot.json b/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..d6e4109 --- /dev/null +++ b/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1936 @@ +{ + "id": "30793d61-e9d2-4f31-a180-7f079ac8edc4", + "prevId": "fd181494-7012-4ec2-b0d1-f75dfe2805e0", + "version": "7", + "dialect": "postgresql", + "tables": { + "sys.channel_instance": { + "name": "channel_instance", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "extract": { + "name": "extract", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "mapping": { + "name": "mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "fallback_position": { + "name": "fallback_position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_window_ms": { + "name": "address_window_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 604800000 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.outbound_mail": { + "name": "outbound_mail", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_addr": { + "name": "from_addr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_addrs": { + "name": "to_addrs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "cc_addrs": { + "name": "cc_addrs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.event_consumption": { + "name": "event_consumption", + "schema": "sys", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer": { + "name": "consumer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "consumption_key": { + "name": "consumption_key", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "event_consumption_event_id_consumer_pk": { + "name": "event_consumption_event_id_consumer_pk", + "columns": [ + "event_id", + "consumer" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.event_delivery": { + "name": "event_delivery", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_pending_idx": { + "name": "delivery_pending_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_target_idx": { + "name": "delivery_target_idx", + "columns": [ + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_delivery_event_id_event_id_fk": { + "name": "event_delivery_event_id_event_id_fk", + "tableFrom": "event_delivery", + "tableTo": "event", + "schemaTo": "sys", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.event": { + "name": "event", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation": { + "name": "correlation", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "trace": { + "name": "trace", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "event_target_idx": { + "name": "event_target_idx", + "columns": [ + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_occurred_idx": { + "name": "event_occurred_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.listener": { + "name": "listener", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_filter": { + "name": "key_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "semantic": { + "name": "semantic", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deadline": { + "name": "deadline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "escalation": { + "name": "escalation", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'armed'" + } + }, + "indexes": { + "listener_armed_idx": { + "name": "listener_armed_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listener_task_idx": { + "name": "listener_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.outbound_ref": { + "name": "outbound_ref", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "outbound_thread_idx": { + "name": "outbound_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_addr_idx": { + "name": "outbound_addr_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.chat_card": { + "name": "chat_card", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "superseded_by": { + "name": "superseded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_reason": { + "name": "rejected_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_card_task_idx": { + "name": "chat_card_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_card_recipient_idx": { + "name": "chat_card_recipient_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.work_order": { + "name": "work_order", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "done": { + "name": "done", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "work_order_task_idx": { + "name": "work_order_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_order_recipient_idx": { + "name": "work_order_recipient_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.llm_instance": { + "name": "llm_instance", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_cipher": { + "name": "api_key_cipher", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_use": { + "name": "system_use", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "llm_instance_name_key": { + "name": "llm_instance_name_key", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.replay_model": { + "name": "replay_model", + "schema": "sys", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "call": { + "name": "call", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "replay_model_recorded_idx": { + "name": "replay_model_recorded_idx", + "columns": [ + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.activity_memory": { + "name": "activity_memory", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layer": { + "name": "layer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "statement": { + "name": "statement", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "increment": { + "name": "increment", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pruned_at": { + "name": "pruned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "activity_memory_layer_idx": { + "name": "activity_memory_layer_idx", + "columns": [ + { + "expression": "activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.task_memory_seal": { + "name": "task_memory_seal", + "schema": "sys", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sealed_at": { + "name": "sealed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.task_memory": { + "name": "task_memory", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stamp": { + "name": "stamp", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "governance": { + "name": "governance", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "links_to": { + "name": "links_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "task_memory_seq_key": { + "name": "task_memory_seq_key", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_memory_task_idx": { + "name": "task_memory_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.system_message": { + "name": "system_message", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_id": { + "name": "position_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "card": { + "name": "card", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_staff_idx": { + "name": "message_staff_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_unread_idx": { + "name": "message_unread_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.position_grant": { + "name": "position_grant", + "schema": "sys", + "columns": { + "position_id": { + "name": "position_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object": { + "name": "object", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "position_grant_position_id_position_id_fk": { + "name": "position_grant_position_id_position_id_fk", + "tableFrom": "position_grant", + "tableTo": "position", + "schemaTo": "sys", + "columnsFrom": [ + "position_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "position_grant_position_id_object_key_action_pk": { + "name": "position_grant_position_id_object_key_action_pk", + "columns": [ + "position_id", + "object_key", + "action" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.position": { + "name": "position", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "position_code_key": { + "name": "position_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.staff": { + "name": "staff", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_id": { + "name": "position_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "staff_account_key": { + "name": "staff_account_key", + "columns": [ + { + "expression": "account", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "staff_position_idx": { + "name": "staff_position_idx", + "columns": [ + { + "expression": "position_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_position_id_position_id_fk": { + "name": "staff_position_id_position_id_fk", + "tableFrom": "staff", + "tableTo": "position", + "schemaTo": "sys", + "columnsFrom": [ + "position_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.action_intent": { + "name": "action_intent", + "schema": "sys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'drafted'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_intent_task_idx": { + "name": "action_intent_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.replay_tool": { + "name": "replay_tool", + "schema": "sys", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "call": { + "name": "call", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.tool_allowlist": { + "name": "tool_allowlist", + "schema": "sys", + "columns": { + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tool_allowlist_activity_id_tool_pk": { + "name": "tool_allowlist_activity_id_tool_pk", + "columns": [ + "activity_id", + "tool" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sys.tool": { + "name": "tool", + "schema": "sys", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "idempotent": { + "name": "idempotent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "timeout_ms": { + "name": "timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "connection_keys": { + "name": "connection_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "pure": { + "name": "pure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "owner_activity_id": { + "name": "owner_activity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_cipher": { + "name": "connection_cipher", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_effect_idx": { + "name": "tool_effect_idx", + "columns": [ + { + "expression": "effect", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_owner_idx": { + "name": "tool_owner_idx", + "columns": [ + { + "expression": "owner_activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 59795dd..74caacd 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1785086166573, "tag": "0002_superb_talisman", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785086925397, + "tag": "0003_youthful_swarm", + "breakpoints": true } ] } \ No newline at end of file