From f0444f1417866e94a50dd9c87865ee74f4a5d608 Mon Sep 17 00:00:00 2001 From: JCAI Main Agent Date: Mon, 27 Jul 2026 01:16:52 +0800 Subject: [PATCH] =?UTF-8?q?M2:=20=E5=9F=BA=E5=BA=A7=E8=83=BD=E5=8A=9B=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20memory=20/=20toolbox=20/=20channel=20/=20m?= =?UTF-8?q?essage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memory 任务记忆四性质(只追加·信封盖戳·治理先行·归侧)、活动记忆四层与条目三件套、 任务日志两级投影(结构化零 LLM / 叙述性小结不入事实库)、度量八指标只读投影 - toolbox 注册两审(静态审 + 试运行审)、效应等级决定运行时待遇、契约演进只增不破坏、 白名单与纯工具私有作用域、ToolInvoker(两缝之二,live/record/replay) - channel 通道实例配置、入站归一(解析 → 系统写 → 数据事件)、出站发送与关联记录登记 - message 通知 / 跨任务征答(就地作答 · 驳回重答只增不改)/ 死信 / 无主兜底 app:message 接上 event 的 MessageFallback;派发器按目标 ID 取 advisory lock 串行消费 + 消费幂等表;/api/ingest 模拟邮件通道(mock 只在外界) 已验证:无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落 OP 岗位的消息要素 门禁五道全绿;150 个单元测试 + 1 个 PG 集成测试 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .data/attachments/inbound/m-1001.eml | 12 + .../api/src/modules/channel/contract.d.ts | 48 + .../apps/api/src/modules/channel/index.d.ts | 11 + .../apps/api/src/modules/channel/port.d.ts | 94 + .../apps/api/src/modules/memory/contract.d.ts | 51 + .../apps/api/src/modules/memory/index.d.ts | 13 + .../apps/api/src/modules/memory/port.d.ts | 42 + .../api/src/modules/message/contract.d.ts | 41 + .../apps/api/src/modules/message/index.d.ts | 11 + .../apps/api/src/modules/message/port.d.ts | 29 + .../api/src/modules/toolbox/contract.d.ts | 109 ++ .../apps/api/src/modules/toolbox/index.d.ts | 15 + .../apps/api/src/modules/toolbox/port.d.ts | 68 + apps/api/package.json | 3 + apps/api/src/app/config.ts | 8 + apps/api/src/app/container.ts | 151 +- apps/api/src/app/main.ts | 6 + apps/api/src/app/routes/ingest.ts | 42 + apps/api/src/app/server.ts | 2 + apps/api/src/app/workers/dispatcher.ts | 87 + apps/api/src/modules/channel/CONTRACT.md | 35 + .../channel/__tests__/payload-mapping.test.ts | 81 + apps/api/src/modules/channel/contract.ts | 57 + apps/api/src/modules/channel/index.ts | 36 + .../internal/adapter/channel-schema.ts | 35 + .../channel/internal/adapter/channel-store.ts | 96 + .../internal/domain/payload-mapping.ts | 100 + .../internal/usecase/channel-service.ts | 135 ++ apps/api/src/modules/channel/package.json | 7 + apps/api/src/modules/channel/port.ts | 71 + .../event/internal/domain/ownership.ts | 2 +- apps/api/src/modules/memory/CONTRACT.md | 35 + .../memory/__tests__/task-memory.test.ts | 189 ++ apps/api/src/modules/memory/contract.ts | 76 + apps/api/src/modules/memory/index.ts | 47 + .../memory/internal/adapter/memory-schema.ts | 50 + .../memory/internal/adapter/memory-store.ts | 148 ++ .../memory/internal/domain/activity-memory.ts | 94 + .../memory/internal/domain/task-log.ts | 123 ++ .../memory/internal/domain/task-memory.ts | 114 ++ .../memory/internal/usecase/memory-service.ts | 192 ++ apps/api/src/modules/memory/package.json | 7 + apps/api/src/modules/memory/port.ts | 39 + apps/api/src/modules/message/CONTRACT.md | 28 + .../message/__tests__/message-kind.test.ts | 62 + apps/api/src/modules/message/contract.ts | 47 + apps/api/src/modules/message/index.ts | 21 + .../internal/adapter/message-schema.ts | 24 + .../message/internal/adapter/message-store.ts | 92 + .../message/internal/domain/message-kind.ts | 65 + .../internal/usecase/message-service.ts | 125 ++ apps/api/src/modules/message/package.json | 7 + apps/api/src/modules/message/port.ts | 24 + apps/api/src/modules/toolbox/CONTRACT.md | 36 + .../toolbox/__tests__/tool-contract.test.ts | 139 ++ apps/api/src/modules/toolbox/contract.ts | 129 ++ apps/api/src/modules/toolbox/index.ts | 56 + .../internal/adapter/toolbox-schema.ts | 48 + .../toolbox/internal/adapter/toolbox-store.ts | 213 +++ .../internal/domain/registration-audit.ts | 83 + .../toolbox/internal/domain/tool-contract.ts | 107 ++ .../toolbox/internal/usecase/tool-invoker.ts | 137 ++ .../internal/usecase/toolbox-service.ts | 115 ++ apps/api/src/modules/toolbox/package.json | 7 + apps/api/src/modules/toolbox/port.ts | 49 + db/migrations/0001_nostalgic_cobalt_man.sql | 70 + db/migrations/0002_superb_talisman.sql | 41 + db/migrations/meta/0001_snapshot.json | 1372 ++++++++++++++ db/migrations/meta/0002_snapshot.json | 1643 +++++++++++++++++ db/migrations/meta/_journal.json | 14 + docs/PROGRESS.md | 62 + docs/debt.md | 6 +- pnpm-lock.yaml | 85 + 73 files changed, 7488 insertions(+), 31 deletions(-) create mode 100644 .data/attachments/inbound/m-1001.eml create mode 100644 api-report/apps/api/src/modules/channel/contract.d.ts create mode 100644 api-report/apps/api/src/modules/channel/index.d.ts create mode 100644 api-report/apps/api/src/modules/channel/port.d.ts create mode 100644 api-report/apps/api/src/modules/memory/contract.d.ts create mode 100644 api-report/apps/api/src/modules/memory/index.d.ts create mode 100644 api-report/apps/api/src/modules/memory/port.d.ts create mode 100644 api-report/apps/api/src/modules/message/contract.d.ts create mode 100644 api-report/apps/api/src/modules/message/index.d.ts create mode 100644 api-report/apps/api/src/modules/message/port.d.ts create mode 100644 api-report/apps/api/src/modules/toolbox/contract.d.ts create mode 100644 api-report/apps/api/src/modules/toolbox/index.d.ts create mode 100644 api-report/apps/api/src/modules/toolbox/port.d.ts create mode 100644 apps/api/src/app/routes/ingest.ts create mode 100644 apps/api/src/app/workers/dispatcher.ts create mode 100644 apps/api/src/modules/channel/CONTRACT.md create mode 100644 apps/api/src/modules/channel/__tests__/payload-mapping.test.ts create mode 100644 apps/api/src/modules/channel/contract.ts create mode 100644 apps/api/src/modules/channel/index.ts create mode 100644 apps/api/src/modules/channel/internal/adapter/channel-schema.ts create mode 100644 apps/api/src/modules/channel/internal/adapter/channel-store.ts create mode 100644 apps/api/src/modules/channel/internal/domain/payload-mapping.ts create mode 100644 apps/api/src/modules/channel/internal/usecase/channel-service.ts create mode 100644 apps/api/src/modules/channel/package.json create mode 100644 apps/api/src/modules/channel/port.ts create mode 100644 apps/api/src/modules/memory/CONTRACT.md create mode 100644 apps/api/src/modules/memory/__tests__/task-memory.test.ts create mode 100644 apps/api/src/modules/memory/contract.ts create mode 100644 apps/api/src/modules/memory/index.ts create mode 100644 apps/api/src/modules/memory/internal/adapter/memory-schema.ts create mode 100644 apps/api/src/modules/memory/internal/adapter/memory-store.ts create mode 100644 apps/api/src/modules/memory/internal/domain/activity-memory.ts create mode 100644 apps/api/src/modules/memory/internal/domain/task-log.ts create mode 100644 apps/api/src/modules/memory/internal/domain/task-memory.ts create mode 100644 apps/api/src/modules/memory/internal/usecase/memory-service.ts create mode 100644 apps/api/src/modules/memory/package.json create mode 100644 apps/api/src/modules/memory/port.ts create mode 100644 apps/api/src/modules/message/CONTRACT.md create mode 100644 apps/api/src/modules/message/__tests__/message-kind.test.ts create mode 100644 apps/api/src/modules/message/contract.ts create mode 100644 apps/api/src/modules/message/index.ts create mode 100644 apps/api/src/modules/message/internal/adapter/message-schema.ts create mode 100644 apps/api/src/modules/message/internal/adapter/message-store.ts create mode 100644 apps/api/src/modules/message/internal/domain/message-kind.ts create mode 100644 apps/api/src/modules/message/internal/usecase/message-service.ts create mode 100644 apps/api/src/modules/message/package.json create mode 100644 apps/api/src/modules/message/port.ts create mode 100644 apps/api/src/modules/toolbox/CONTRACT.md create mode 100644 apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts create mode 100644 apps/api/src/modules/toolbox/contract.ts create mode 100644 apps/api/src/modules/toolbox/index.ts create mode 100644 apps/api/src/modules/toolbox/internal/adapter/toolbox-schema.ts create mode 100644 apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts create mode 100644 apps/api/src/modules/toolbox/internal/domain/registration-audit.ts create mode 100644 apps/api/src/modules/toolbox/internal/domain/tool-contract.ts create mode 100644 apps/api/src/modules/toolbox/internal/usecase/tool-invoker.ts create mode 100644 apps/api/src/modules/toolbox/internal/usecase/toolbox-service.ts create mode 100644 apps/api/src/modules/toolbox/package.json create mode 100644 apps/api/src/modules/toolbox/port.ts create mode 100644 db/migrations/0001_nostalgic_cobalt_man.sql create mode 100644 db/migrations/0002_superb_talisman.sql create mode 100644 db/migrations/meta/0001_snapshot.json create mode 100644 db/migrations/meta/0002_snapshot.json create mode 100644 docs/PROGRESS.md diff --git a/.data/attachments/inbound/m-1001.eml b/.data/attachments/inbound/m-1001.eml new file mode 100644 index 0000000..644bc68 --- /dev/null +++ b/.data/attachments/inbound/m-1001.eml @@ -0,0 +1,12 @@ +{ + "messageId": "m-1001", + "from": "buyer@acme.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "询价 RFQ - 车规 Analog", + "body": "请报价:LM358DR 5000pcs, 交期 8 周", + "receivedAt": "2026-07-27T02:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/api-report/apps/api/src/modules/channel/contract.d.ts b/api-report/apps/api/src/modules/channel/contract.d.ts new file mode 100644 index 0000000..c1bda3b --- /dev/null +++ b/api-report/apps/api/src/modules/channel/contract.d.ts @@ -0,0 +1,48 @@ +/** + * module: channel —— 对外类型(编码规范 §10.1)。 + */ +import type { InboundMail, OwnershipResolution, TaskId } from '@contracts'; +import type { ChannelInstanceConfig, ExtractRule, LedgerMapping, NormalizedWrite } from './internal/domain/payload-mapping.ts'; +export type { ChannelInstanceConfig, ExtractRule, InboundMail, LedgerMapping, NormalizedWrite, OwnershipResolution }; +export declare class ChannelNotFoundError extends Error { + readonly channelId: string; + constructor(channelId: string); +} +export declare class ChannelDisabledError extends Error { + readonly channelId: string; + constructor(channelId: string); +} +export type InboundOutcome = { + readonly eventId: string; + readonly resolution: OwnershipResolution; + readonly writes: readonly NormalizedWrite[]; + readonly listenersHit: number; + readonly triggersHit: number; + readonly fellBackToMessage: boolean; +}; +export type OutboundMail = { + readonly taskId: TaskId; + readonly to: readonly string[]; + readonly cc?: readonly string[]; + readonly subject: string; + readonly body: string; + readonly threadId?: string; + readonly attachments?: ReadonlyArray<{ + name: string; + ref: string; + }>; +}; +export type ChannelService = { + list(): Promise; + upsert(config: ChannelInstanceConfig): Promise; + /** + * 入站:归属解析 → 归一为**系统写** → 数据事件。 + * 「环外回音经归属解析归一为数据写入后,以数据事件抛出 —— 活动认数据,不认通道。」 + */ + ingestMail(channelId: string, mail: InboundMail): Promise; + /** 出站:触达世界的动作;发送即登记出站关联记录(归属解析①③级的对表来源) */ + sendMail(channelId: string, mail: OutboundMail): Promise<{ + messageId: string; + threadId: string; + }>; +}; diff --git a/api-report/apps/api/src/modules/channel/index.d.ts b/api-report/apps/api/src/modules/channel/index.d.ts new file mode 100644 index 0000000..4da1597 --- /dev/null +++ b/api-report/apps/api/src/modules/channel/index.d.ts @@ -0,0 +1,11 @@ +/** + * module: channel —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { ChannelInstanceConfig, ChannelService, ExtractRule, InboundMail, InboundOutcome, LedgerMapping, NormalizedWrite, OutboundMail, } from './contract.ts'; +export { ChannelDisabledError, ChannelNotFoundError } from './contract.ts'; +export type { AttachmentArchive, ChannelRepository, Clock, EventGateway, IdGenerator, LedgerSystemWriter, MailTransport, } from './port.ts'; +export type { ChannelServiceDeps } from './internal/usecase/channel-service.ts'; +export { createChannelService } from './internal/usecase/channel-service.ts'; +export { extractFields, normalize } from './internal/domain/payload-mapping.ts'; +export { createChannelRepository, createFileAttachmentArchive, createSimulatedMailTransport, } from './internal/adapter/channel-store.ts'; +export { channelTable, outboundMailTable } from './internal/adapter/channel-schema.ts'; diff --git a/api-report/apps/api/src/modules/channel/port.d.ts b/api-report/apps/api/src/modules/channel/port.d.ts new file mode 100644 index 0000000..e7f68b0 --- /dev/null +++ b/api-report/apps/api/src/modules/channel/port.d.ts @@ -0,0 +1,94 @@ +/** + * module: channel —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + */ +import type { EnvelopeDraft, OwnershipResolution, PositionId, TaskId } from '@contracts'; +import type { ChannelInstanceConfig } from './internal/domain/payload-mapping.ts'; +export type Clock = { + now(): Date; +}; +export type IdGenerator = { + next(): string; +}; +export type ChannelRepository = { + list(): Promise; + get(channelId: string): Promise; + upsert(config: ChannelInstanceConfig): Promise; +}; +/** 事件入口 —— app 注入 `event`(L1)的出口 */ +export type EventGateway = { + ingestInbound(input: { + facts: { + threadId?: string; + from: string; + to: readonly string[]; + subject: string; + body: string; + receivedAt: Date; + }; + rules: { + patterns: ReadonlyArray<{ + key: string; + regex: string; + source: 'subject' | 'body' | 'both'; + }>; + addressWindowMs: number; + fallbackPosition?: PositionId | undefined; + }; + draft: EnvelopeDraft; + }): Promise<{ + eventId: string; + resolution: OwnershipResolution; + plan: { + taskTargets: readonly string[]; + triggerTargets: readonly string[]; + messageFallback: boolean; + }; + }>; + recordOutbound(ref: { + taskId: TaskId; + threadId?: string; + address: string; + sentAt: Date; + }): Promise; +}; +/** + * 台账系统写 —— app 注入 `ledger`(L1)的 `systemWrite`。 + * 入站归一是「登记世界已发生的事实」,走**系统写**:不过放行门,写正式版并即时抛数据事件(OS 09)。 + */ +export type LedgerSystemWriter = { + systemWrite(input: { + address: { + domain: string; + object: string; + }; + key: Record; + values: Record; + cause: { + kind: 'inbound'; + ref: string; + }; + taskId?: TaskId; + }): Promise; +}; +/** 邮件收发 —— 外界。MVP 允许模拟通道 / 录制回放替身(README MVP 完成判据) */ +export type MailTransport = { + send(input: { + readonly from: string; + readonly to: readonly string[]; + readonly cc: readonly string[]; + readonly subject: string; + readonly body: string; + readonly threadId?: string; + readonly attachments: ReadonlyArray<{ + name: string; + ref: string; + }>; + }): Promise<{ + messageId: string; + threadId: string; + }>; +}; +/** 原件存档 —— 附件对象的存放;返回存档地址(信封的原件指针) */ +export type AttachmentArchive = { + put(name: string, content: string): Promise; +}; diff --git a/api-report/apps/api/src/modules/memory/contract.d.ts b/api-report/apps/api/src/modules/memory/contract.d.ts new file mode 100644 index 0000000..fdd22a9 --- /dev/null +++ b/api-report/apps/api/src/modules/memory/contract.d.ts @@ -0,0 +1,51 @@ +/** + * module: memory —— 对外类型(编码规范 §10.1)。 + */ +import type { AttributionSide, TaskId } from '@contracts'; +import type { ActivityMemoryEntry, CandidateIncrement, Evidence, MemoryLayer } from './internal/domain/activity-memory.ts'; +import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from './internal/domain/task-memory.ts'; +import type { LogLine, Metrics, MetricsInput } from './internal/domain/task-log.ts'; +export type { ActivityMemoryEntry, AttributionSide, CandidateIncrement, EntryKind, Evidence, GovernanceMark, LogLine, MemoryEntry, MemoryLayer, Metrics, MetricsInput, VersionStamp, }; +export { NoEvidenceError } from './internal/domain/activity-memory.ts'; +/** 追加一条任务记忆 —— 归侧由 kind 自动推导,调用方不得指定(零埋点) */ +export type AppendEntry = { + readonly taskId: TaskId; + readonly kind: EntryKind; + readonly title: string; + readonly detail?: Record; + readonly stamp: VersionStamp; + readonly governance?: Partial; + readonly linksTo?: string; + readonly eventRef?: string; +}; +export type DistillInput = { + readonly taskId: TaskId; + readonly activityId: string; + readonly trigger: 'terminal' | 'milestone'; + readonly milestone?: string; + readonly prompt: string; +}; +export type MemoryService = { + append(entry: AppendEntry): Promise; + history(taskId: TaskId): Promise; + /** 活动侧唯一可配置项:窗口大小 —— 每次 LLM 调用加载多少轮 */ + window(taskId: TaskId, size: number): Promise; + seal(taskId: TaskId): Promise; + taskLog(taskId: TaskId, options?: { + canSeeMasked?: boolean; + }): Promise; + /** 叙述性小结(LLM,可选)—— 叙述是视图不是事实,不入事实库 */ + narrate(taskId: TaskId): Promise; + loadActivityMemory(activityId: string, options?: { + layers?: readonly MemoryLayer[]; + limit?: number; + }): Promise; + /** **不设直写旁路**:唯一写入口是蒸馏钩子 */ + distill(input: DistillInput): Promise; + /** 人可修剪 —— 治理台 · 记忆管理 */ + prune(entryId: string): Promise; + setIncrementState(entryId: string, state: CandidateIncrement['state']): Promise; + metrics(input: Omit & { + taskIds: readonly TaskId[]; + }): Promise; +}; diff --git a/api-report/apps/api/src/modules/memory/index.d.ts b/api-report/apps/api/src/modules/memory/index.d.ts new file mode 100644 index 0000000..8c6c56a --- /dev/null +++ b/api-report/apps/api/src/modules/memory/index.d.ts @@ -0,0 +1,13 @@ +/** + * module: memory —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { ActivityMemoryEntry, AppendEntry, AttributionSide, CandidateIncrement, DistillInput, EntryKind, Evidence, GovernanceMark, LogLine, MemoryEntry, MemoryLayer, MemoryService, Metrics, MetricsInput, VersionStamp, } from './contract.ts'; +export { NoEvidenceError } from './contract.ts'; +export type { ActivityMemoryStore, Clock, IdGenerator, SystemModel, TaskMemoryStore } from './port.ts'; +export type { MemoryServiceDeps } from './internal/usecase/memory-service.ts'; +export { createMemoryService } from './internal/usecase/memory-service.ts'; +export { assertThreePart, isBinding, MEMORY_LAYER_LABEL, MEMORY_LAYER_TARGET, MEMORY_LAYERS, selectForLoading, } from './internal/domain/activity-memory.ts'; +export { assertAppendOnly, deriveSide, distillable, ENTRY_KINDS, redactForReader, windowOf, } from './internal/domain/task-memory.ts'; +export { computeMetrics, splitBySide, structuredLog } from './internal/domain/task-log.ts'; +export { createActivityMemoryStore, createTaskMemoryStore } from './internal/adapter/memory-store.ts'; +export { activityMemoryTable, taskMemorySealTable, taskMemoryTable } from './internal/adapter/memory-schema.ts'; diff --git a/api-report/apps/api/src/modules/memory/port.d.ts b/api-report/apps/api/src/modules/memory/port.d.ts new file mode 100644 index 0000000..5f8086c --- /dev/null +++ b/api-report/apps/api/src/modules/memory/port.d.ts @@ -0,0 +1,42 @@ +/** + * module: memory —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + */ +import type { TaskId } from '@contracts'; +import type { ActivityMemoryEntry, MemoryLayer } from './internal/domain/activity-memory.ts'; +import type { MemoryEntry } from './internal/domain/task-memory.ts'; +export type Clock = { + now(): Date; +}; +export type IdGenerator = { + next(): string; +}; +export type TaskMemoryStore = { + nextSeq(taskId: TaskId): Promise; + append(entry: MemoryEntry): Promise; + list(taskId: TaskId): Promise; + tail(taskId: TaskId, size: number): Promise; + seal(taskId: TaskId, at: Date): Promise; + isSealed(taskId: TaskId): Promise; +}; +export type ActivityMemoryStore = { + list(activityId: string, layers: readonly MemoryLayer[] | undefined, limit: number): Promise; + insertMany(entries: readonly ActivityMemoryEntry[]): Promise; + prune(entryId: string, at: Date): Promise; + setIncrementState(entryId: string, state: string): Promise; +}; +/** + * 模型调用 —— app 注入 `llm`(L1)的 ModelGateway。 + * 蒸馏钩子与叙述性小结用**系统用途**绑定实例:计量入系统账、不占活动预算(OS 02)。 + */ +export type SystemModel = { + generate(input: { + readonly model: string; + readonly prompt: string; + readonly outputSchema?: Record; + }): Promise<{ + text: string; + object?: unknown; + }>; + /** 系统用途模型名;未配置即 undefined —— 此时叙述与蒸馏静默跳过,不报错 */ + systemModelName(): Promise; +}; diff --git a/api-report/apps/api/src/modules/message/contract.d.ts b/api-report/apps/api/src/modules/message/contract.d.ts new file mode 100644 index 0000000..abab193 --- /dev/null +++ b/api-report/apps/api/src/modules/message/contract.d.ts @@ -0,0 +1,41 @@ +/** + * module: message —— 对外类型(编码规范 §10.1)。 + */ +import type { Envelope, StaffId } from '@contracts'; +import type { MessageKind, SystemMessage } from './internal/domain/message-kind.ts'; +export type { Envelope, MessageKind, SystemMessage }; +export declare class MessageNotAnswerableError extends Error { + readonly messageId: string; + constructor(messageId: string); +} +export type SendNotice = { + readonly staffId: StaffId; + readonly title: string; + readonly body: string; + readonly eventRef?: string; +}; +export type SendElicitation = { + readonly staffId: StaffId; + readonly title: string; + readonly body: string; + readonly cardId: string; + readonly sourceTaskId: string; + readonly payload: Record; +}; +export type MessageService = { + inbox(staffId: StaffId, options?: { + unreadOnly?: boolean; + limit?: number; + }): Promise; + markRead(messageId: string): Promise; + notify(input: SendNotice): Promise; + /** 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」(OS 2.1) */ + sendElicitation(input: SendElicitation): Promise; + answer(messageId: string, staffId: StaffId, answer: Record): Promise; + /** 驳回重答 = 新发一条系统消息(新卡实例),原卡闭合 —— 只增不改 */ + rejectAndReask(messageId: string, reason: string): Promise; + /** 死信:重试超限 → 系统通知送达相关人的消息要素与治理台 */ + deadLetter(envelope: Envelope, error: string): Promise; + /** 无主兜底:相关人 = 通道实例配置的默认认领岗位 */ + unowned(envelope: Envelope, fallbackPosition: string | undefined): Promise; +}; diff --git a/api-report/apps/api/src/modules/message/index.d.ts b/api-report/apps/api/src/modules/message/index.d.ts new file mode 100644 index 0000000..e381d3d --- /dev/null +++ b/api-report/apps/api/src/modules/message/index.d.ts @@ -0,0 +1,11 @@ +/** + * module: message —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { MessageKind, MessageService, SendElicitation, SendNotice, SystemMessage } from './contract.ts'; +export { MessageNotAnswerableError } from './contract.ts'; +export type { Clock, IdGenerator, MessageRepository, PositionResolver } from './port.ts'; +export type { MessageServiceDeps } from './internal/usecase/message-service.ts'; +export { createMessageService } from './internal/usecase/message-service.ts'; +export { canAnswer, MESSAGE_KIND_LABEL, MESSAGE_KINDS, pendingAnswerCount, unreadCount, } from './internal/domain/message-kind.ts'; +export { createMessageRepository } from './internal/adapter/message-store.ts'; +export { messageTable } from './internal/adapter/message-schema.ts'; diff --git a/api-report/apps/api/src/modules/message/port.d.ts b/api-report/apps/api/src/modules/message/port.d.ts new file mode 100644 index 0000000..1b86dcb --- /dev/null +++ b/api-report/apps/api/src/modules/message/port.d.ts @@ -0,0 +1,29 @@ +/** + * module: message —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + */ +import type { StaffId } from '@contracts'; +import type { SystemMessage } from './internal/domain/message-kind.ts'; +export type Clock = { + now(): Date; +}; +export type IdGenerator = { + next(): string; +}; +export type MessageRepository = { + list(staffId: StaffId, unreadOnly: boolean, limit: number): Promise; + get(messageId: string): Promise; + insert(message: SystemMessage): Promise; + markRead(messageId: string, at: Date): Promise; + markAnswered(messageId: string, at: Date, answer: Record): Promise; + supersede(messageId: string, byMessageId: string, reason: string): Promise; +}; +/** 岗位解析 —— app 注入 `org`(L1):无主兜底要把消息落到默认认领岗位上的人 */ +export type PositionResolver = { + staffOfPosition(positionId: string): Promise>; + /** 治理台收件人(任务管理岗)—— 死信同时送治理台 */ + governanceStaff(): Promise>; +}; diff --git a/api-report/apps/api/src/modules/toolbox/contract.d.ts b/api-report/apps/api/src/modules/toolbox/contract.d.ts new file mode 100644 index 0000000..9851378 --- /dev/null +++ b/api-report/apps/api/src/modules/toolbox/contract.d.ts @@ -0,0 +1,109 @@ +/** + * module: toolbox —— 对外类型(编码规范 §10.1)。 + * + * 两条缝之二(§7.3):`ToolInvoker` 支持 record | replay | live —— + * **任何工具执行不经 ToolInvoker,回放审就对它失明**(§12.5#10)。 + */ +import type { ActionIntent, TaskId } from '@contracts'; +import type { AuditFinding } from './internal/domain/registration-audit.ts'; +import type { CallDisposition, EffectLevel, EvolutionViolation, Preauthorization, ToolContract } from './internal/domain/tool-contract.ts'; +export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, Preauthorization, ToolContract }; +export declare const INVOKER_MODES: readonly ["live", "record", "replay"]; +export type InvokerMode = (typeof INVOKER_MODES)[number]; +export declare class ToolNotFoundError extends Error { + readonly tool: string; + constructor(tool: string); +} +export declare class ToolNotAllowedError extends Error { + readonly tool: string; + readonly activityId: string; + constructor(tool: string, activityId: string); +} +export declare class ToolDisabledError extends Error { + readonly tool: string; + constructor(tool: string); +} +export declare class ToolReplayMissError extends Error { + readonly key: string; + constructor(key: string); +} +export declare class RegistrationRejectedError extends Error { + readonly findings: readonly AuditFinding[]; + constructor(findings: readonly AuditFinding[]); +} +export type ToolCall = { + readonly tool: string; + readonly operation?: string; + readonly input: Record; + readonly activityId: string; + readonly taskId?: TaskId; + readonly stepNo?: number; +}; +export type ToolResult = { + readonly ok: boolean; + readonly output?: Record; + readonly error?: { + readonly code: string; + readonly retryable: boolean; + readonly message: string; + }; + readonly latencyMs: number; + readonly mode: InvokerMode; +}; +/** 调用留痕:入参快照、出参、时延与错误自动留痕入任务记忆(OS 7.3) */ +export type InvocationTrace = { + readonly tool: string; + readonly input: Record; + readonly result: ToolResult; + readonly at: string; +}; +/** + * 工具调用器 —— 活动基座六件里的「工具(调用契约)」。 + * **读类**过程中自由调用;**写类**登记为动作意向,放行后落地(除契约显式预授权者)。 + */ +export type ToolInvoker = { + readonly mode: InvokerMode; + /** 判定运行时待遇 —— 调用方据此决定是即调还是登记意向 */ + disposition(call: ToolCall, preauthorized: readonly Preauthorization[]): Promise; + invoke(call: ToolCall): Promise; + /** 落地预检第四项:工具契约含 validate 能力时调之(ADR-005) */ + validate(call: ToolCall): Promise; + withMode(mode: InvokerMode): ToolInvoker; +}; +export type RegisterToolInput = { + readonly contract: ToolContract; + /** 契约测试集:样例入参 → 期望出参形状;试运行审的依据 */ + readonly samples: ReadonlyArray<{ + input: Record; + expectShape: Record; + }>; + /** 连接配置的值 —— 只存治理台,加密落库(ADR-009) */ + readonly connectionValues?: Readonly>; +}; +export type RegistrationVerdict = { + readonly accepted: boolean; + readonly staticFindings: readonly AuditFinding[]; + readonly evolution: readonly EvolutionViolation[]; + readonly trialRun: { + readonly ran: number; + readonly passed: number; + readonly failures: readonly string[]; + }; +}; +export type ToolboxService = { + /** 注册两审:静态审读清单,试运行审验代码 */ + register(input: RegisterToolInput): Promise; + list(): Promise; + get(name: string): Promise; + /** 有活动声明引用的工具不得删除;可停用 —— 停用后调用返回结构化错误 */ + setEnabled(name: string, enabled: boolean): Promise; + bindConnection(name: string, values: Readonly>): Promise; + /** 白名单:活动只经调用契约用工具,授权在白名单与契约,不在活动代码 */ + setAllowlist(activityId: string, tools: readonly string[]): Promise; + allowlistOf(activityId: string): Promise; + /** 安装静态审:活动声明的工具契约齐备?装不齐不上架 */ + assertContractsAvailable(names: readonly string[]): Promise<{ + ok: boolean; + missing: string[]; + }>; +}; diff --git a/api-report/apps/api/src/modules/toolbox/index.d.ts b/api-report/apps/api/src/modules/toolbox/index.d.ts new file mode 100644 index 0000000..50375b4 --- /dev/null +++ b/api-report/apps/api/src/modules/toolbox/index.d.ts @@ -0,0 +1,15 @@ +/** + * module: toolbox —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, InvocationTrace, InvokerMode, Preauthorization, RegisterToolInput, RegistrationVerdict, ToolCall, ToolContract, ToolInvoker, ToolResult, ToolboxService, } from './contract.ts'; +export { INVOKER_MODES, RegistrationRejectedError, ToolDisabledError, ToolNotAllowedError, ToolNotFoundError, ToolReplayMissError, } from './contract.ts'; +export type { Clock, IdGenerator, SchemaValidator, SecretBox, ToolRegistry, ToolReplayStore, ToolRuntime, } from './port.ts'; +export type { ToolInvokerDeps } from './internal/usecase/tool-invoker.ts'; +export { createToolInvoker, toolReplayKey } from './internal/usecase/tool-invoker.ts'; +export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts'; +export { createToolboxService } from './internal/usecase/toolbox-service.ts'; +export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts'; +export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts'; +export type { ToolHandler } from './internal/adapter/toolbox-store.ts'; +export { createSchemaValidator, createToolRegistry, createToolReplayStore, createToolRuntime, isAllowed, } from './internal/adapter/toolbox-store.ts'; +export { replayToolTable, toolAllowlistTable, toolTable } from './internal/adapter/toolbox-schema.ts'; diff --git a/api-report/apps/api/src/modules/toolbox/port.d.ts b/api-report/apps/api/src/modules/toolbox/port.d.ts new file mode 100644 index 0000000..37de32c --- /dev/null +++ b/api-report/apps/api/src/modules/toolbox/port.d.ts @@ -0,0 +1,68 @@ +/** + * module: toolbox —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + */ +import type { ToolContract } from './internal/domain/tool-contract.ts'; +import type { ToolCall, ToolResult } from './contract.ts'; +export type Clock = { + now(): Date; +}; +export type IdGenerator = { + next(): string; +}; +/** 凭证密封 —— 与 llm 同一实现,由 app 注入(ADR-009) */ +export type SecretBox = { + seal(plaintext: string): string; + open(cipher: string): string; +}; +export type ToolRegistry = { + list(): Promise; + get(name: string): Promise; + isEnabled(name: string): Promise; + upsert(contract: ToolContract, connectionCipher: Record | null): Promise; + setEnabled(name: string, enabled: boolean): Promise; + connectionCipher(name: string): Promise | undefined>; + setAllowlist(activityId: string, tools: readonly string[]): Promise; + allowlistOf(activityId: string): Promise; + /** 有活动声明引用即不得删除 */ + referencedBy(name: string): Promise; +}; +/** 工具录制回放存储 —— 与 llm 的 ReplayStore 同构,另存一表(§7.3) */ +export type ToolReplayStore = { + put(record: { + key: string; + call: ToolCall; + result: ToolResult; + recordedAt: Date; + }): Promise; + get(key: string): Promise<{ + call: ToolCall; + result: ToolResult; + } | undefined>; +}; +/** + * 工具执行 —— 实现对系统:server 侧执行代码,运行时受限, + * **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。 + */ +export type ToolRuntime = { + execute(input: { + readonly contract: ToolContract; + readonly connection: Readonly>; + readonly call: ToolCall; + readonly idempotencyKey?: string; + }): Promise<{ + ok: boolean; + output?: Record; + error?: { + code: string; + retryable: boolean; + message: string; + }; + }>; +}; +/** JSON Schema 校验 —— ajv 在 adapter,usecase 只见本端口(ADR-003) */ +export type SchemaValidator = { + validate(schema: Record, value: unknown): { + ok: boolean; + errors: string[]; + }; +}; diff --git a/apps/api/package.json b/apps/api/package.json index 4cabf6e..eee4c8d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,9 +17,12 @@ "@ai-sdk/openai-compatible": "^3.0.14", "@hono/node-server": "^2.0.12", "ai": "^7.0.37", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "pg": "^8.22.0", + "pg-boss": "^12.26.3", "zod": "^4.4.3" }, "devDependencies": { diff --git a/apps/api/src/app/config.ts b/apps/api/src/app/config.ts index 89f7613..21044e1 100644 --- a/apps/api/src/app/config.ts +++ b/apps/api/src/app/config.ts @@ -1,10 +1,16 @@ /** * 环境配置 —— 只在 app/ 层读环境变量;模块内一律经 port 注入(§5.3)。 */ +import { join } from 'node:path'; + export type AppConfig = { readonly nodeEnv: 'development' | 'test' | 'production'; readonly port: number; readonly databaseUrl: string; + /** 原件存档根目录 —— 信封的原件指针指向这里 */ + readonly attachmentRoot: string; + /** 派发器轮询间隔(毫秒) */ + readonly dispatchIntervalMs: number; }; const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai'; @@ -15,5 +21,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development', port: Number(env['PORT'] ?? 8787), databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL, + attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'), + dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000), }; } diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 7d61d9c..a0ab6b9 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -9,7 +9,7 @@ import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import type { Db } from '@shared/db.ts'; -import type { EventService, MessageFallback, TriggerMatcher } from '@modules/event'; +import type { EventService, TriggerMatcher } from '@modules/event'; import { createEventService, createEventStore } from '@modules/event'; import type { LedgerService } from '@modules/ledger'; import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger'; @@ -28,6 +28,26 @@ import { createProviderInvoker, createReplayStore, } from '@modules/llm'; +import type { MemoryService } from '@modules/memory'; +import { createActivityMemoryStore, createMemoryService, createTaskMemoryStore } from '@modules/memory'; +import type { ToolboxService, ToolHandler, ToolInvoker } from '@modules/toolbox'; +import { + createSchemaValidator, + createToolInvoker, + createToolRegistry, + createToolReplayStore, + createToolRuntime, + createToolboxService, +} from '@modules/toolbox'; +import type { MessageService } from '@modules/message'; +import { createMessageRepository, createMessageService } from '@modules/message'; +import type { ChannelService } from '@modules/channel'; +import { + createChannelRepository, + createChannelService, + createFileAttachmentArchive, + createSimulatedMailTransport, +} from '@modules/channel'; import type { AppConfig } from './config.ts'; import { createIdGenerator, systemClock } from './runtime.ts'; @@ -42,12 +62,17 @@ export type Container = { readonly event: EventService; readonly llm: LlmService; readonly modelGateway: ModelGateway; + readonly memory: MemoryService; + readonly toolbox: ToolboxService; + readonly toolInvoker: ToolInvoker; + readonly message: MessageService; + readonly channel: ChannelService; close(): Promise; }; /** - * M1 的占位实现 —— 在途工作探针要问的是 task(L6)与 human-tool(L3),二者随 M3/M4 落地。 - * 占位返回空集,停用检查因此在 M1 一律放行;M4 起接真实用例。 + * 在途工作探针 —— 停用前置检查要问的是 task(L6)与 human-tool(L3),二者随 M3/M4 落地。 + * 占位返回空集;M4 起接真实用例(docs/debt.md · M1-1)。 */ const emptyInFlightProbe: InFlightWorkProbe = { openTasksOf: async () => [], @@ -55,26 +80,15 @@ const emptyInFlightProbe: InFlightWorkProbe = { openApprovalsOf: async () => [], }; -/** M1 的占位实现 —— 触发器(L7)随 M5 落地,当下恒不命中 */ +/** 触发器判定 —— trigger(L7)随 M5 落地,当下恒不命中(docs/debt.md · M1-1) */ const noTriggerMatcher: TriggerMatcher = { match: async () => [] }; -/** M1 的占位实现 —— 消息(L2)随 M2 落地;在此之前无主事件只落日志,不静默丢弃 */ -function createConsoleMessageFallback(): MessageFallback { - return { - async deadLetter(envelope, error) { - process.stderr.write(`[死信] event=${envelope.id} type=${envelope.type} error=${error}\n`); - }, - async unowned(envelope, fallbackPosition) { - process.stderr.write( - `[无主兜底] event=${envelope.id} type=${envelope.type} 认领岗位=${fallbackPosition ?? '未配置'}\n`, - ); - }, - }; -} - -/** S-FEEL 求值 —— @sfeel 随 M3 落地;M1 的条件监听一律按「无条件」处理 */ +/** S-FEEL 求值 —— @sfeel 随 M3 落地;在此之前监听只用类型 + 关联键过滤 */ const passthroughEvaluator = (): boolean => true; +/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */ +export const firstPartyToolHandlers = new Map(); + export async function createContainer(config: AppConfig): Promise { const pool = new Pool({ connectionString: config.databaseUrl, max: 10 }); const db = drizzle(pool); @@ -84,13 +98,44 @@ export async function createContainer(config: AppConfig): Promise { // 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出 await pool.query(DRAFT_DDL); + const org = createOrgService({ + positions: createPositionRepository(db), + staff: createStaffRepository(db), + grants: createGrantRepository(db), + inFlight: emptyInFlightProbe, + ids, + }); + + // message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入 + const message = createMessageService({ + repo: createMessageRepository(db), + org: { + staffOfPosition: async (positionId) => { + const { staff } = await org.listStaff(); + return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id })); + }, + governanceStaff: async () => { + const { staff } = await org.listStaff(); + const governing = await Promise.all( + staff.map(async (s) => ({ + s, + ok: await org.authorize(s.id, { kind: 'governance', function: 'task' }, 'govern'), + })), + ); + return governing.filter((g) => g.ok).map((g) => ({ id: g.s.id })); + }, + }, + clock: systemClock, + ids, + }); + const event = createEventService({ store: createEventStore(db, ids), clock: systemClock, ids, evaluate: passthroughEvaluator, triggers: noTriggerMatcher, - messages: createConsoleMessageFallback(), + messages: message, }); const ledger = createLedgerService({ @@ -101,14 +146,6 @@ export async function createContainer(config: AppConfig): Promise { ids, }); - const org = createOrgService({ - positions: createPositionRepository(db), - staff: createStaffRepository(db), - grants: createGrantRepository(db), - inFlight: emptyInFlightProbe, - ids, - }); - const instances = createLlmInstanceRepository(db); const provider = createProviderInvoker(); const llm = createLlmService({ instances, provider, secrets, clock: systemClock, ids }); @@ -121,6 +158,59 @@ export async function createContainer(config: AppConfig): Promise { mode: 'live', }); + const memory = createMemoryService({ + tasks: createTaskMemoryStore(db), + activities: createActivityMemoryStore(db), + model: { + // 系统用途模型:叙述性小结 / 汇报生成 / 蒸馏钩子 —— 计量入系统账,不占活动预算 + generate: async (input) => { + const result = await modelGateway.generate({ + model: input.model, + prompt: input.prompt, + ...(input.outputSchema === undefined ? {} : { outputSchema: input.outputSchema }), + label: 'system-use', + }); + return { text: result.text, ...(result.object === undefined ? {} : { object: result.object }) }; + }, + systemModelName: async () => (await llm.systemUseBinding())?.name, + }, + clock: systemClock, + ids, + }); + + const toolRegistry = createToolRegistry(db); + const toolRuntime = createToolRuntime(firstPartyToolHandlers); + const schemas = createSchemaValidator(); + const toolbox = createToolboxService({ + registry: toolRegistry, + runtime: toolRuntime, + schemas, + secrets, + clock: systemClock, + }); + const toolInvoker = createToolInvoker({ + registry: toolRegistry, + runtime: toolRuntime, + replay: createToolReplayStore(db), + schemas, + secrets, + clock: systemClock, + mode: 'live', + }); + + const channel = createChannelService({ + channels: createChannelRepository(db), + events: { + ingestInbound: (input) => event.ingestInbound(input), + recordOutbound: (ref) => event.recordOutbound(ref), + }, + ledger: { systemWrite: (input) => ledger.systemWrite(input) }, + mail: createSimulatedMailTransport(db, ids), + archive: createFileAttachmentArchive(config.attachmentRoot), + clock: systemClock, + ids, + }); + return { config, pool, @@ -130,6 +220,11 @@ export async function createContainer(config: AppConfig): Promise { event, llm, modelGateway, + memory, + toolbox, + toolInvoker, + message, + channel, async close() { await pool.end(); }, diff --git a/apps/api/src/app/main.ts b/apps/api/src/app/main.ts index f0e3a11..b8f2a0a 100644 --- a/apps/api/src/app/main.ts +++ b/apps/api/src/app/main.ts @@ -6,18 +6,24 @@ import { serve } from '@hono/node-server'; import { loadConfig } from './config.ts'; import { createContainer } from './container.ts'; import { createServer } from './server.ts'; +import { startDispatcher } from './workers/dispatcher.ts'; export async function boot(): Promise { const config = loadConfig(); const container = await createContainer(config); await container.org.ensureSeeded(); + // 派发器:投递记录即队列,按目标 ID 取 advisory lock 串行消费(ADR-008) + // 消费者随 M4(任务)/ M5(触发器)注册;此刻空表即「只标记送达」 + const dispatcher = startDispatcher({ container, consumers: new Map() }, config.dispatchIntervalMs); + const app = createServer(container); serve({ fetch: app.fetch, port: config.port }, (info) => { process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`); }); const shutdown = async (): Promise => { + dispatcher.stop(); await container.close(); process.exit(0); }; diff --git a/apps/api/src/app/routes/ingest.ts b/apps/api/src/app/routes/ingest.ts new file mode 100644 index 0000000..e8af7cb --- /dev/null +++ b/apps/api/src/app/routes/ingest.ts @@ -0,0 +1,42 @@ +/** + * 入站路由 —— 模拟邮件通道的注入口(编码规范 §3;README MVP 完成判据)。 + * + * mock 只允许出现在**外界**:这条路由代替真实 IMAP 拉件, + * 之后的归属解析、事件流、三级消费、系统写全部是真机制。 + */ +import type { Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { ingestMailRoute } from '@contracts'; +import { ChannelDisabledError, ChannelNotFoundError } from '@modules/channel'; +import type { Container } from '../container.ts'; +import type { AppEnv } from '../http-env.ts'; + +export function registerIngestRoutes(app: Hono, container: Container): void { + app.post(ingestMailRoute.path, async (c) => { + const parsed = ingestMailRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入站邮件不合 schema', detail: parsed.error.issues }; + return c.json(body, 400); + } + try { + const outcome = await container.channel.ingestMail(c.req.param('channelId'), parsed.data); + return c.json({ + eventId: outcome.eventId, + resolution: outcome.resolution, + listenersHit: outcome.listenersHit, + triggersHit: outcome.triggersHit, + fellBackToMessage: outcome.fellBackToMessage, + }); + } catch (error) { + if (error instanceof ChannelNotFoundError) { + const body: ApiError = { code: 'not-found', message: error.message }; + return c.json(body, 404); + } + if (error instanceof ChannelDisabledError) { + const body: ApiError = { code: 'precondition-failed', message: error.message }; + return c.json(body, 412); + } + throw error; + } + }); +} diff --git a/apps/api/src/app/server.ts b/apps/api/src/app/server.ts index f60c2e0..3e93dc2 100644 --- a/apps/api/src/app/server.ts +++ b/apps/api/src/app/server.ts @@ -8,6 +8,7 @@ import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org'; import type { Container } from './container.ts'; import type { AppEnv } from './http-env.ts'; import { registerGovernanceRoutes } from './routes/governance.ts'; +import { registerIngestRoutes } from './routes/ingest.ts'; import { registerWorkbenchRoutes } from './routes/workbench.ts'; export type { AppEnv }; @@ -39,6 +40,7 @@ export function createServer(container: Container): Hono { registerWorkbenchRoutes(app, container); registerGovernanceRoutes(app, container); + registerIngestRoutes(app, container); return app; } diff --git a/apps/api/src/app/workers/dispatcher.ts b/apps/api/src/app/workers/dispatcher.ts new file mode 100644 index 0000000..33cab69 --- /dev/null +++ b/apps/api/src/app/workers/dispatcher.ts @@ -0,0 +1,87 @@ +/** + * 投递派发器 —— ADR-008 #2。 + * + * 按 `state='pending' and next_attempt_at <= now()` 取投递记录, + * 对**目标 ID 取 advisory lock** 串行执行 —— 这正是「同任务按序」的实现(§7.1)。 + * 消费幂等由 `sys.event_consumption(event_id, consumer)` 唯一约束承担。 + * + * 消费者(任务 / 触发器)随 M4 / M5 落地;在此之前派发器把投递标记为已送达并留痕, + * 保证投递链路本身可测、不积压(docs/debt.md · M1-3)。 + */ +import { sql } from 'drizzle-orm'; +import type { Delivery } from '@modules/event'; +import { claimPendingDeliveries } from '@modules/event'; +import type { Container } from '../container.ts'; + +/** 消费者:按目标类型分派 */ +export type Consumer = (delivery: Delivery, container: Container) => Promise; + +export type DispatcherDeps = { + readonly container: Container; + readonly consumers: ReadonlyMap; + readonly batchSize?: number; +}; + +/** 目标 ID → advisory lock 键(稳定散列,同一目标恒同一把锁) */ +function lockKey(target: Delivery['target']): number { + const text = `${target.kind}:${target.id}`; + let hash = 5381; + for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; + return hash; +} + +export async function dispatchOnce(deps: DispatcherDeps): Promise { + const { container } = deps; + const batch = await claimPendingDeliveries(container.db, deps.batchSize ?? 20); + let handled = 0; + + for (const delivery of batch) { + const key = lockKey(delivery.target); + // 同任务按序:取到锁才处理,取不到说明同目标的另一条正在处理,留待下一轮 + const locked = await container.db.execute<{ ok: boolean }>( + sql`select pg_try_advisory_lock(${key}) as ok`, + ); + if (locked.rows[0]?.ok !== true) continue; + + try { + const consumer = deps.consumers.get(delivery.target.kind); + if (consumer !== undefined) { + const claimed = await container.event.claimConsumption( + delivery.eventId, + `${delivery.target.kind}:${delivery.target.id}`, + ); + // 至少一次 + 幂等:已消费过的重复投递直接标记完成,不再执行副作用 + if (claimed) await consumer(delivery, container); + } + await container.db.execute( + sql`update sys.event_delivery set state = 'delivered' where id = ${delivery.id}`, + ); + handled += 1; + } catch (error) { + await container.event.reportFailure(delivery.id, error instanceof Error ? error.message : String(error)); + } finally { + await container.db.execute(sql`select pg_advisory_unlock(${key})`); + } + } + return handled; +} + +export function startDispatcher(deps: DispatcherDeps, intervalMs: number): { stop(): void } { + let stopped = false; + const tick = async (): Promise => { + if (stopped) return; + try { + await dispatchOnce(deps); + } catch (error) { + process.stderr.write(`[dispatcher] ${error instanceof Error ? error.message : String(error)}\n`); + } + }; + const timer = setInterval(() => void tick(), intervalMs); + timer.unref(); + return { + stop() { + stopped = true; + clearInterval(timer); + }, + }; +} diff --git a/apps/api/src/modules/channel/CONTRACT.md b/apps/api/src/modules/channel/CONTRACT.md new file mode 100644 index 0000000..3e30f48 --- /dev/null +++ b/apps/api/src/modules/channel/CONTRACT.md @@ -0,0 +1,35 @@ +# module: channel + +职责: 通道实例:配置(归属键提取规则 / 载荷映射 / 默认认领岗位)、入站归一 + (解析 → 系统写 → 数据事件)、出站发送与出站关联记录登记 +不负责: 归属解析与三级消费判定(见 event,经 `EventGateway` 端口)、 + 台账写入的实现(见 ledger,经 `LedgerSystemWriter` 端口)、消息兜底的呈现(见 message) + +公开出口: index.ts + - 类型:`ChannelService` `ChannelInstanceConfig` `ExtractRule` `LedgerMapping` + `NormalizedWrite` `InboundOutcome` `OutboundMail` + - 错误:`ChannelNotFoundError` `ChannelDisabledError` + - 端口类型:`ChannelRepository` `EventGateway` `LedgerSystemWriter` `MailTransport` + `AttachmentArchive` `Clock` `IdGenerator` + - 用例工厂:`createChannelService(deps: ChannelServiceDeps): ChannelService` + - 纯函数:`extractFields` `normalize` + - adapter:`createChannelRepository` `createSimulatedMailTransport` `createFileAttachmentArchive` + - Drizzle 表:`channelTable` `outboundMailTable` + +层级: L2(编码规范 §4) +依赖: @contracts;@shared/db;port:`EventGateway`(app 注入 event)、 + `LedgerSystemWriter`(app 注入 ledger)、`MailTransport`(模拟通道) +被依赖: release(出站发送)、app(治理台 · 系统管理 / ingest 路由) + +不变量: + - **活动认数据,不认通道**:入站一律归一为域.对象.字段的写入,通道对活动的前提不可见 + - 入站归一走 **系统写**(登记世界已发生的事实,免门、即时抛数据事件), + 绝不走草稿写、也不绕过 `ledger` 出口(§12.5#9) + - 归属解析**不在本模块**:通道只交事实,判定归 event —— 匹配次序与降级口径一处实现 + - **无主不写台账**:仅有主入站才归一为系统写;无主入站只入事件流并走消息兜底 + - 无主键的映射结果丢弃(无从定位),由调用方按提取失败处置 + - 出站即登记关联记录:线程 ID 与往来地址是归属解析①③级的对表来源 + - 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入); + 系统内部机制一律真跑,不得 mock + +影响半径: index/contract/port 未变 → 对外零影响 diff --git a/apps/api/src/modules/channel/__tests__/payload-mapping.test.ts b/apps/api/src/modules/channel/__tests__/payload-mapping.test.ts new file mode 100644 index 0000000..aaa8881 --- /dev/null +++ b/apps/api/src/modules/channel/__tests__/payload-mapping.test.ts @@ -0,0 +1,81 @@ +/** 入站归一的载荷映射 —— domain 测试零 mock(OS 6.2) */ +import { describe, expect, it } from 'vitest'; +import { extractFields, normalize } from '../internal/domain/payload-mapping.ts'; +import type { ExtractRule, LedgerMapping } from '../internal/domain/payload-mapping.ts'; + +const fields = { + subject: '回复:报价 Q202601', + body: 'PN: LM358DR,我方接受单价 1.85 元', + from: 'buyer@acme.com', + to: ['bd@we.com'], +}; + +const rules: ExtractRule[] = [ + { key: 'quoteNo', regex: 'Q\\d{6}', source: 'subject' }, + { key: 'pn', regex: 'PN:\\s*([A-Z0-9]+)', source: 'body' }, + { key: 'sender', regex: '.+', source: 'from' }, +]; + +describe('extractFields', () => { + it('按来源分别提取,捕获组优先', () => { + expect(extractFields(fields, rules)).toEqual({ + quoteNo: 'Q202601', + pn: 'LM358DR', + sender: 'buyer@acme.com', + }); + }); + + it('不命中即不产出该键', () => { + expect(extractFields({ ...fields, subject: '你好' }, [rules[0]!])).toEqual({}); + }); + + it('both 同时看主题与正文', () => { + expect( + extractFields({ ...fields, subject: '你好' }, [{ key: 'pn', regex: 'LM\\d+DR', source: 'both' }]), + ).toEqual({ pn: 'LM358DR' }); + }); +}); + +describe('normalize —— 提取字段 → 域.对象.字段', () => { + const mapping: LedgerMapping[] = [ + { from: 'quoteNo', domain: 'quote', object: 'reply', field: 'quote_no', primaryKey: true }, + { from: 'pn', domain: 'quote', object: 'reply', field: 'pn', primaryKey: true }, + { from: 'sender', domain: 'quote', object: 'reply', field: 'sender' }, + { from: '', domain: 'quote', object: 'reply', field: 'channel', constant: 'mail' }, + ]; + + it('同一对象的字段合并成一次写入,主键进 key', () => { + const writes = normalize(extractFields(fields, rules), mapping); + expect(writes).toEqual([ + { + domain: 'quote', + object: 'reply', + key: { quote_no: 'Q202601', pn: 'LM358DR' }, + values: { sender: 'buyer@acme.com', channel: 'mail' }, + }, + ]); + }); + + it('常量映射不依赖提取结果', () => { + const writes = normalize({}, [{ from: 'x', domain: 'd', object: 'o', field: 'k', primaryKey: true, constant: 'C' }]); + expect(writes[0]?.key).toEqual({ k: 'C' }); + }); + + it('无主键的写入丢弃 —— 无从定位', () => { + expect(normalize({ sender: 'a@b.com' }, [{ from: 'sender', domain: 'd', object: 'o', field: 'f' }])).toEqual([]); + }); + + it('缺提取值的映射跳过,不写 undefined', () => { + const writes = normalize({ quoteNo: 'Q1' }, mapping); + expect(writes[0]?.values).toEqual({ channel: 'mail' }); + expect(writes[0]?.key).toEqual({ quote_no: 'Q1' }); + }); + + it('多对象各成一次写入', () => { + const writes = normalize({ a: '1', b: '2' }, [ + { from: 'a', domain: 'd1', object: 'o1', field: 'k', primaryKey: true }, + { from: 'b', domain: 'd2', object: 'o2', field: 'k', primaryKey: true }, + ]); + expect(writes).toHaveLength(2); + }); +}); diff --git a/apps/api/src/modules/channel/contract.ts b/apps/api/src/modules/channel/contract.ts new file mode 100644 index 0000000..29b3d4a --- /dev/null +++ b/apps/api/src/modules/channel/contract.ts @@ -0,0 +1,57 @@ +/** + * module: channel —— 对外类型(编码规范 §10.1)。 + */ +import type { InboundMail, OwnershipResolution, TaskId } from '@contracts'; +import type { + ChannelInstanceConfig, + ExtractRule, + LedgerMapping, + NormalizedWrite, +} from './internal/domain/payload-mapping.ts'; + +export type { ChannelInstanceConfig, ExtractRule, InboundMail, LedgerMapping, NormalizedWrite, OwnershipResolution }; + +export class ChannelNotFoundError extends Error { + constructor(readonly channelId: string) { + super(`通道实例不存在:${channelId}`); + this.name = 'ChannelNotFoundError'; + } +} + +export class ChannelDisabledError extends Error { + constructor(readonly channelId: string) { + super(`通道实例已停用:${channelId}`); + this.name = 'ChannelDisabledError'; + } +} + +export type InboundOutcome = { + readonly eventId: string; + readonly resolution: OwnershipResolution; + readonly writes: readonly NormalizedWrite[]; + readonly listenersHit: number; + readonly triggersHit: number; + readonly fellBackToMessage: boolean; +}; + +export type OutboundMail = { + readonly taskId: TaskId; + readonly to: readonly string[]; + readonly cc?: readonly string[]; + readonly subject: string; + readonly body: string; + readonly threadId?: string; + readonly attachments?: ReadonlyArray<{ name: string; ref: string }>; +}; + +export type ChannelService = { + list(): Promise; + upsert(config: ChannelInstanceConfig): Promise; + /** + * 入站:归属解析 → 归一为**系统写** → 数据事件。 + * 「环外回音经归属解析归一为数据写入后,以数据事件抛出 —— 活动认数据,不认通道。」 + */ + ingestMail(channelId: string, mail: InboundMail): Promise; + /** 出站:触达世界的动作;发送即登记出站关联记录(归属解析①③级的对表来源) */ + sendMail(channelId: string, mail: OutboundMail): Promise<{ messageId: string; threadId: string }>; +}; diff --git a/apps/api/src/modules/channel/index.ts b/apps/api/src/modules/channel/index.ts new file mode 100644 index 0000000..78ebb05 --- /dev/null +++ b/apps/api/src/modules/channel/index.ts @@ -0,0 +1,36 @@ +/** + * module: channel —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + ChannelInstanceConfig, + ChannelService, + ExtractRule, + InboundMail, + InboundOutcome, + LedgerMapping, + NormalizedWrite, + OutboundMail, +} from './contract.ts'; +export { ChannelDisabledError, ChannelNotFoundError } from './contract.ts'; + +export type { + AttachmentArchive, + ChannelRepository, + Clock, + EventGateway, + IdGenerator, + LedgerSystemWriter, + MailTransport, +} from './port.ts'; + +export type { ChannelServiceDeps } from './internal/usecase/channel-service.ts'; +export { createChannelService } from './internal/usecase/channel-service.ts'; + +export { extractFields, normalize } from './internal/domain/payload-mapping.ts'; + +export { + createChannelRepository, + createFileAttachmentArchive, + createSimulatedMailTransport, +} from './internal/adapter/channel-store.ts'; +export { channelTable, outboundMailTable } from './internal/adapter/channel-schema.ts'; diff --git a/apps/api/src/modules/channel/internal/adapter/channel-schema.ts b/apps/api/src/modules/channel/internal/adapter/channel-schema.ts new file mode 100644 index 0000000..c073418 --- /dev/null +++ b/apps/api/src/modules/channel/internal/adapter/channel-schema.ts @@ -0,0 +1,35 @@ +/** + * channel 的 sys schema 表(编码规范 §7 / ADR-002)。 + * 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集。 + */ +import { boolean, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core'; +import { sys } from '@shared/db.ts'; + +export const channelTable = sys.table('channel_instance', { + id: text('id').primaryKey(), + name: text('name').notNull(), + kind: text('kind').notNull(), + address: text('address').notNull(), + enabled: boolean('enabled').notNull().default(true), + /** 归属键提取规则(声明式,OS 6.2) */ + extract: jsonb('extract').notNull().default([]), + /** 载荷映射:提取字段 → 域.对象.字段,与边映射同一形式 */ + mapping: jsonb('mapping').notNull().default([]), + /** 无主入站的默认认领岗位 */ + fallbackPosition: text('fallback_position'), + addressWindowMs: integer('address_window_ms').notNull().default(604_800_000), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +/** 出站存档:模拟通道把「已发出的信」落库,供演示与回放查看 */ +export const outboundMailTable = sys.table('outbound_mail', { + id: text('id').primaryKey(), + threadId: text('thread_id').notNull(), + fromAddr: text('from_addr').notNull(), + toAddrs: jsonb('to_addrs').notNull(), + ccAddrs: jsonb('cc_addrs').notNull().default([]), + subject: text('subject').notNull(), + body: text('body').notNull(), + attachments: jsonb('attachments').notNull().default([]), + sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/apps/api/src/modules/channel/internal/adapter/channel-store.ts b/apps/api/src/modules/channel/internal/adapter/channel-store.ts new file mode 100644 index 0000000..d0d9cd5 --- /dev/null +++ b/apps/api/src/modules/channel/internal/adapter/channel-store.ts @@ -0,0 +1,96 @@ +/** + * channel 存储与传输 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。 + */ +import { eq } from 'drizzle-orm'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import type { Db } from '@shared/db.ts'; +import { positionIdSchema } from '@contracts'; +import type { AttachmentArchive, ChannelRepository, MailTransport } from '../../port.ts'; +import type { ChannelInstanceConfig, ExtractRule, LedgerMapping } from '../domain/payload-mapping.ts'; +import { channelTable, outboundMailTable } from './channel-schema.ts'; + +type Row = typeof channelTable.$inferSelect; + +function toConfig(row: Row): ChannelInstanceConfig { + return { + id: row.id, + name: row.name, + kind: row.kind as ChannelInstanceConfig['kind'], + address: row.address, + enabled: row.enabled, + extract: row.extract as ExtractRule[], + mapping: row.mapping as LedgerMapping[], + fallbackPosition: row.fallbackPosition === null ? undefined : positionIdSchema.parse(row.fallbackPosition), + addressWindowMs: row.addressWindowMs, + }; +} + +export function createChannelRepository(db: Db): ChannelRepository { + return { + async list() { + const rows = await db.select().from(channelTable).orderBy(channelTable.name); + return rows.map(toConfig); + }, + async get(channelId) { + const rows = await db.select().from(channelTable).where(eq(channelTable.id, channelId)).limit(1); + return rows[0] === undefined ? undefined : toConfig(rows[0]); + }, + async upsert(config) { + const values = { + id: config.id, + name: config.name, + kind: config.kind, + address: config.address, + enabled: config.enabled, + extract: config.extract, + mapping: config.mapping, + fallbackPosition: config.fallbackPosition ?? null, + addressWindowMs: config.addressWindowMs, + }; + const [row] = await db + .insert(channelTable) + .values(values) + .onConflictDoUpdate({ target: channelTable.id, set: values }) + .returning(); + if (row === undefined) throw new Error('通道实例写入未返回行'); + return toConfig(row); + }, + }; +} + +/** + * 模拟邮件通道 —— **mock 只允许出现在外界**(README MVP 完成判据): + * 出站信落库不真发,入站经 /api/ingest 注入。系统内部机制一律真跑。 + */ +export function createSimulatedMailTransport(db: Db, ids: { next(): string }): MailTransport { + return { + async send(input) { + const messageId = ids.next(); + const threadId = input.threadId ?? messageId; + await db.insert(outboundMailTable).values({ + id: messageId, + threadId, + fromAddr: input.from, + toAddrs: [...input.to], + ccAddrs: [...input.cc], + subject: input.subject, + body: input.body, + attachments: [...input.attachments], + }); + return { messageId, threadId }; + }, + }; +} + +/** 原件存档 —— 落本地目录,返回存档地址作为信封的原件指针 */ +export function createFileAttachmentArchive(root: string): AttachmentArchive { + return { + async put(name, content) { + const path = join(root, name); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, 'utf8'); + return `file://${path}`; + }, + }; +} diff --git a/apps/api/src/modules/channel/internal/domain/payload-mapping.ts b/apps/api/src/modules/channel/internal/domain/payload-mapping.ts new file mode 100644 index 0000000..65e9c0d --- /dev/null +++ b/apps/api/src/modules/channel/internal/domain/payload-mapping.ts @@ -0,0 +1,100 @@ +/** + * 通道入站的载荷映射 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 6.2)。 + * + * 「有主入站归一为**数据写入**:写入目标由通道实例配置的载荷映射给定 + * (提取字段 → 域.对象.字段,**与边映射同一形式**),原件存档为附件对象,以数据事件抛出。」 + * + * 活动认数据,不认通道 —— 通道对活动的前提不可见。 + */ +import type { PositionId } from '@contracts'; + +export type ExtractRule = { + readonly key: string; + readonly regex: string; + readonly source: 'subject' | 'body' | 'both' | 'from' | 'to'; +}; + +/** 提取字段 → 域.对象.字段 */ +export type LedgerMapping = { + readonly from: string; + readonly domain: string; + readonly object: string; + readonly field: string; + /** 该字段是否参与主键 */ + readonly primaryKey?: boolean; + /** 常量:from 留空时用它 */ + readonly constant?: string; +}; + +export type ChannelInstanceConfig = { + readonly id: string; + readonly name: string; + readonly kind: 'mail' | 'im'; + readonly address: string; + readonly enabled: boolean; + /** 归属键提取规则(声明式) */ + readonly extract: readonly ExtractRule[]; + /** 载荷映射 */ + readonly mapping: readonly LedgerMapping[]; + /** 无主入站的默认认领岗位 */ + readonly fallbackPosition?: PositionId | undefined; + readonly addressWindowMs: number; +}; + +export type InboundFields = { + readonly subject: string; + readonly body: string; + readonly from: string; + readonly to: readonly string[]; +}; + +export function extractFields(fields: InboundFields, rules: readonly ExtractRule[]): Record { + const out: Record = {}; + for (const rule of rules) { + const haystack = + rule.source === 'subject' + ? fields.subject + : rule.source === 'body' + ? fields.body + : rule.source === 'from' + ? fields.from + : rule.source === 'to' + ? fields.to.join(',') + : `${fields.subject}\n${fields.body}`; + const match = new RegExp(rule.regex).exec(haystack); + const captured = match?.[1] ?? match?.[0]; + if (captured !== undefined) out[rule.key] = captured; + } + return out; +} + +export type NormalizedWrite = { + readonly domain: string; + readonly object: string; + readonly key: Record; + readonly values: Record; +}; + +/** + * 归一:提取结果 + 映射 → 一组「域.对象」的写入。 + * 同一对象的多个字段合并成一次写入;主键字段进 key,其余进 values。 + */ +export function normalize( + extracted: Readonly>, + mapping: readonly LedgerMapping[], +): NormalizedWrite[] { + const byObject = new Map; values: Record }>(); + + for (const rule of mapping) { + const value = rule.constant ?? extracted[rule.from]; + if (value === undefined) continue; + const objectKey = `${rule.domain}.${rule.object}`; + const bucket = byObject.get(objectKey) ?? { domain: rule.domain, object: rule.object, key: {}, values: {} }; + if (rule.primaryKey === true) bucket.key[rule.field] = value; + else bucket.values[rule.field] = value; + byObject.set(objectKey, bucket); + } + + // 无主键的写入无从定位,丢弃并由调用方按「提取失败」处置 + return [...byObject.values()].filter((w) => Object.keys(w.key).length > 0); +} diff --git a/apps/api/src/modules/channel/internal/usecase/channel-service.ts b/apps/api/src/modules/channel/internal/usecase/channel-service.ts new file mode 100644 index 0000000..ea87e3b --- /dev/null +++ b/apps/api/src/modules/channel/internal/usecase/channel-service.ts @@ -0,0 +1,135 @@ +/** + * channel 用例 —— 入站归一与出站发送(编码规范 §5.3;语义出处 OS 6.2 / 07)。 + */ +import type { InboundMail, TaskId } from '@contracts'; +import type { ChannelService, InboundOutcome, OutboundMail } from '../../contract.ts'; +import { ChannelDisabledError, ChannelNotFoundError } from '../../contract.ts'; +import type { + AttachmentArchive, + ChannelRepository, + Clock, + EventGateway, + IdGenerator, + LedgerSystemWriter, + MailTransport, +} from '../../port.ts'; +import { extractFields, normalize } from '../domain/payload-mapping.ts'; + +export type ChannelServiceDeps = { + readonly channels: ChannelRepository; + readonly events: EventGateway; + readonly ledger: LedgerSystemWriter; + readonly mail: MailTransport; + readonly archive: AttachmentArchive; + readonly clock: Clock; + readonly ids: IdGenerator; +}; + +export function createChannelService(deps: ChannelServiceDeps): ChannelService { + async function require(channelId: string) { + const config = await deps.channels.get(channelId); + if (config === undefined) throw new ChannelNotFoundError(channelId); + if (!config.enabled) throw new ChannelDisabledError(channelId); + return config; + } + + return { + async list() { + return deps.channels.list(); + }, + + async upsert(config) { + return deps.channels.upsert(config); + }, + + async ingestMail(channelId, mail: InboundMail): Promise { + const config = await require(channelId); + const receivedAt = new Date(mail.receivedAt); + + // 原件存档为附件对象 —— 信封带原件指针,不带内容本身 + const originRef = await deps.archive.put( + `inbound/${mail.messageId}.eml`, + JSON.stringify({ ...mail }, null, 2), + ); + + const extracted = extractFields( + { subject: mail.subject, body: mail.body, from: mail.from, to: mail.to }, + config.extract, + ); + + // 归属解析 + 三级消费判定归 event(L1);channel 只把通道事实交上去 + const result = await deps.events.ingestInbound({ + facts: { + ...(mail.threadId === undefined ? {} : { threadId: mail.threadId }), + from: mail.from, + to: mail.to, + subject: mail.subject, + body: mail.body, + receivedAt, + }, + rules: { + patterns: config.extract + .filter((r) => r.source === 'subject' || r.source === 'body' || r.source === 'both') + .map((r) => ({ key: r.key, regex: r.regex, source: r.source as 'subject' | 'body' | 'both' })), + addressWindowMs: config.addressWindowMs, + fallbackPosition: config.fallbackPosition, + }, + draft: { + type: 'data', + correlation: { cause: { kind: 'inbound', ref: mail.messageId }, keys: extracted }, + payload: { data: { ...extracted, subject: mail.subject, from: mail.from }, originRef }, + occurredAt: receivedAt.toISOString(), + source: { kind: 'channel', detail: config.name }, + }, + }); + + // 有主入站归一为**系统写**:登记世界已发生的事实,免门、即时抛数据事件 + const writes = normalize(extracted, config.mapping); + if (result.resolution.outcome === 'owned') { + for (const write of writes) { + await deps.ledger.systemWrite({ + address: { domain: write.domain, object: write.object }, + key: write.key, + values: write.values, + cause: { kind: 'inbound', ref: mail.messageId }, + taskId: result.resolution.taskId, + }); + } + } + + return { + eventId: result.eventId, + resolution: result.resolution, + writes, + listenersHit: result.plan.taskTargets.length, + triggersHit: result.plan.triggerTargets.length, + fellBackToMessage: result.plan.messageFallback, + }; + }, + + async sendMail(channelId, mail: OutboundMail) { + const config = await require(channelId); + const sent = await deps.mail.send({ + from: config.address, + to: mail.to, + cc: mail.cc ?? [], + subject: mail.subject, + body: mail.body, + ...(mail.threadId === undefined ? {} : { threadId: mail.threadId }), + attachments: mail.attachments ?? [], + }); + + // 出站即登记关联记录 —— 归属解析①线程与③地址+时间窗的对表来源 + const sentAt = deps.clock.now(); + for (const address of mail.to) { + await deps.events.recordOutbound({ + taskId: mail.taskId as TaskId, + threadId: sent.threadId, + address, + sentAt, + }); + } + return sent; + }, + }; +} diff --git a/apps/api/src/modules/channel/package.json b/apps/api/src/modules/channel/package.json new file mode 100644 index 0000000..7715314 --- /dev/null +++ b/apps/api/src/modules/channel/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/channel", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/channel/port.ts b/apps/api/src/modules/channel/port.ts new file mode 100644 index 0000000..6d2f9c4 --- /dev/null +++ b/apps/api/src/modules/channel/port.ts @@ -0,0 +1,71 @@ +/** + * module: channel —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + */ +import type { EnvelopeDraft, OwnershipResolution, PositionId, TaskId } from '@contracts'; +import type { ChannelInstanceConfig } from './internal/domain/payload-mapping.ts'; + +export type Clock = { now(): Date }; +export type IdGenerator = { next(): string }; + +export type ChannelRepository = { + list(): Promise; + get(channelId: string): Promise; + upsert(config: ChannelInstanceConfig): Promise; +}; + +/** 事件入口 —— app 注入 `event`(L1)的出口 */ +export type EventGateway = { + ingestInbound(input: { + facts: { + threadId?: string; + from: string; + to: readonly string[]; + subject: string; + body: string; + receivedAt: Date; + }; + rules: { + patterns: ReadonlyArray<{ key: string; regex: string; source: 'subject' | 'body' | 'both' }>; + addressWindowMs: number; + fallbackPosition?: PositionId | undefined; + }; + draft: EnvelopeDraft; + }): Promise<{ + eventId: string; + resolution: OwnershipResolution; + plan: { taskTargets: readonly string[]; triggerTargets: readonly string[]; messageFallback: boolean }; + }>; + recordOutbound(ref: { taskId: TaskId; threadId?: string; address: string; sentAt: Date }): Promise; +}; + +/** + * 台账系统写 —— app 注入 `ledger`(L1)的 `systemWrite`。 + * 入站归一是「登记世界已发生的事实」,走**系统写**:不过放行门,写正式版并即时抛数据事件(OS 09)。 + */ +export type LedgerSystemWriter = { + systemWrite(input: { + address: { domain: string; object: string }; + key: Record; + values: Record; + cause: { kind: 'inbound'; ref: string }; + taskId?: TaskId; + }): Promise; +}; + +/** 邮件收发 —— 外界。MVP 允许模拟通道 / 录制回放替身(README MVP 完成判据) */ +export type MailTransport = { + send(input: { + readonly from: string; + readonly to: readonly string[]; + readonly cc: readonly string[]; + readonly subject: string; + readonly body: string; + readonly threadId?: string; + readonly attachments: ReadonlyArray<{ name: string; ref: string }>; + }): Promise<{ messageId: string; threadId: string }>; +}; + +/** 原件存档 —— 附件对象的存放;返回存档地址(信封的原件指针) */ +export type AttachmentArchive = { + put(name: string, content: string): Promise; +}; diff --git a/apps/api/src/modules/event/internal/domain/ownership.ts b/apps/api/src/modules/event/internal/domain/ownership.ts index 489956e..6545dba 100644 --- a/apps/api/src/modules/event/internal/domain/ownership.ts +++ b/apps/api/src/modules/event/internal/domain/ownership.ts @@ -16,7 +16,7 @@ export type OwnershipKeyRules = { /** 地址 + 时间窗的窗口长度(毫秒) */ readonly addressWindowMs: number; /** 无主入站的默认认领岗位 */ - readonly fallbackPosition?: PositionId; + readonly fallbackPosition?: PositionId | undefined; }; export type InboundFacts = { diff --git a/apps/api/src/modules/memory/CONTRACT.md b/apps/api/src/modules/memory/CONTRACT.md new file mode 100644 index 0000000..b76c684 --- /dev/null +++ b/apps/api/src/modules/memory/CONTRACT.md @@ -0,0 +1,35 @@ +# module: memory + +职责: 任务记忆(会话历史 + 窗口配置)、活动记忆四层存取、任务日志两级投影、 + 蒸馏钩子执行、度量八指标的只读投影 +不负责: 事件投递(见 event)、模型实例与凭证(见 llm,经 `SystemModel` 端口)、 + 候选增量的回放与放行(见 activity-registry 的进化管线) + +公开出口: index.ts + - 类型:`MemoryService` `MemoryEntry` `AppendEntry` `VersionStamp` `GovernanceMark` + `ActivityMemoryEntry` `MemoryLayer` `Evidence` `CandidateIncrement` `LogLine` `Metrics` + - 错误:`NoEvidenceError` + - 端口类型:`TaskMemoryStore` `ActivityMemoryStore` `SystemModel` `Clock` `IdGenerator` + - 用例工厂:`createMemoryService(deps: MemoryServiceDeps): MemoryService` + - 纯函数:`deriveSide` `windowOf` `distillable` `redactForReader` `assertAppendOnly` + `structuredLog` `splitBySide` `computeMetrics` `assertThreePart` `selectForLoading` `isBinding` + - adapter:`createTaskMemoryStore` `createActivityMemoryStore` + - Drizzle 表:`taskMemoryTable` `taskMemorySealTable` `activityMemoryTable` + +层级: L2(编码规范 §4) +依赖: @contracts;@shared/db;port:`SystemModel`(app 注入 llm 的 ModelGateway + 系统用途实例) +被依赖: interaction, human-tool, release, activity-registry, loop-engine, flow-engine, + skill-engine, task, report, app(治理台 · 记忆管理) + +不变量: + - **只追加**:序号严格递增;封卷后不得追加 + - **归侧自动推导**:`deriveSide(kind)` 是纯函数,调用方不得指定 —— 零埋点,统计即查询 + - **信封盖戳**:每条带 `VersionStamp`(契约 / 业务模型 / 技能 / 模型版本 + 定位) + - **治理先行**:脱敏 / 可学习标记在留痕时刻打上;`distillable` 只放行 learnable 且未脱敏的条目 + - **投影不得比源暴露更多**:`taskLog` 按读者权限走 `redactForReader` + - **写入唯蒸馏**:活动记忆没有直写方法 —— `distill` 是唯一写入口,人只能 `prune` + - **无证据链不得入库**:`assertThreePart` 对蒸馏产物同样生效 + - **记忆不载权力**:`isBinding` 仅在候选增量 `released` 时为真;语义引擎不读活动记忆 + - 叙述性小结是**视图不是事实**:`narrate` 不落库,系统用途模型未配置时返回 undefined + +影响半径: index/contract/port 未变 → 对外零影响 diff --git a/apps/api/src/modules/memory/__tests__/task-memory.test.ts b/apps/api/src/modules/memory/__tests__/task-memory.test.ts new file mode 100644 index 0000000..fc83189 --- /dev/null +++ b/apps/api/src/modules/memory/__tests__/task-memory.test.ts @@ -0,0 +1,189 @@ +/** 任务记忆四条性质与日志投影 —— domain 测试零 mock(OS 8.1 / AM 9.1) */ +import { describe, expect, it } from 'vitest'; +import { + assertAppendOnly, + deriveSide, + distillable, + ENTRY_KINDS, + redactForReader, + windowOf, +} from '../internal/domain/task-memory.ts'; +import type { EntryKind, MemoryEntry } from '../internal/domain/task-memory.ts'; +import { computeMetrics, splitBySide, structuredLog } from '../internal/domain/task-log.ts'; +import { assertThreePart, isBinding, NoEvidenceError, selectForLoading } from '../internal/domain/activity-memory.ts'; +import type { ActivityMemoryEntry } from '../internal/domain/activity-memory.ts'; + +const entry = (kind: EntryKind, over: Partial = {}): MemoryEntry => ({ + id: `e-${kind}-${over.seq ?? 1}`, + taskId: 't1', + seq: over.seq ?? 1, + at: '2026-07-27T10:00:00.000Z', + kind, + side: deriveSide(kind), + title: `${kind} 标题`, + stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, + governance: { masked: false, learnable: true }, + ...over, +}); + +describe('deriveSide —— 归侧自动推导,零埋点', () => { + it('每一种条目都有确定的归侧', () => { + for (const kind of ENTRY_KINDS) expect(deriveSide(kind)).toBeTruthy(); + }); + it('机器 / 人 / 人裁 / 世界等待各归其位', () => { + expect(deriveSide('action')).toBe('machine'); + expect(deriveSide('card-reply')).toBe('human'); + expect(deriveSide('human-input')).toBe('human'); + expect(deriveSide('release-verdict')).toBe('human-verdict'); + expect(deriveSide('terminal')).toBe('human-verdict'); + expect(deriveSide('card-sent')).toBe('world-wait'); + }); +}); + +describe('assertAppendOnly —— 只追加', () => { + it('序号递增放行', () => { + expect(() => assertAppendOnly(3, 4)).not.toThrow(); + }); + it('回退或重复即拒', () => { + expect(() => assertAppendOnly(4, 4)).toThrow(/只追加/); + expect(() => assertAppendOnly(4, 3)).toThrow(/只追加/); + }); +}); + +describe('windowOf —— 活动侧唯一可配置项', () => { + const entries = [1, 2, 3, 4, 5].map((n) => entry('observation', { seq: n })); + it('取最后 N 条', () => { + expect(windowOf(entries, 2).map((e) => e.seq)).toEqual([4, 5]); + }); + it('窗口大于总数即全量', () => { + expect(windowOf(entries, 99)).toHaveLength(5); + }); + it('窗口为零即空 —— 但历史仍完整在库', () => { + expect(windowOf(entries, 0)).toEqual([]); + }); +}); + +describe('治理先行', () => { + it('不可学习或已脱敏的条目不进蒸馏队列', () => { + const rows = [ + entry('action', { seq: 1 }), + entry('action', { seq: 2, governance: { masked: false, learnable: false } }), + entry('action', { seq: 3, governance: { masked: true, learnable: true } }), + ]; + expect(distillable(rows).map((e) => e.seq)).toEqual([1]); + }); + + it('投影不得比源暴露更多', () => { + const masked = entry('result', { governance: { masked: true, learnable: true }, detail: { secret: 'x' } }); + expect(redactForReader(masked, false).title).toBe('【已脱敏】'); + expect(redactForReader(masked, false).detail).toEqual({}); + expect(redactForReader(masked, true).title).toBe(masked.title); + }); +}); + +describe('structuredLog —— 零 LLM,可重算', () => { + const entries = [ + entry('thought', { seq: 1 }), + entry('action', { seq: 2 }), + entry('card-sent', { seq: 3 }), + entry('card-reply', { seq: 4 }), + entry('release-verdict', { seq: 5 }), + ]; + + it('只取骨架条目,思考不入日志', () => { + const lines = structuredLog(entries); + expect(lines).toHaveLength(4); + expect(lines.some((l) => l.title.startsWith('思考'))).toBe(false); + }); + + it('两次生成完全一致 —— 口径与源恒一致', () => { + expect(structuredLog(entries)).toEqual(structuredLog(entries)); + }); + + it('归侧使日志天然分栏', () => { + const split = splitBySide(structuredLog(entries)); + expect(split.machine).toHaveLength(1); + expect(split['world-wait']).toHaveLength(1); + expect(split.human).toHaveLength(1); + expect(split['human-verdict']).toHaveLength(1); + }); +}); + +describe('computeMetrics —— 度量八指标', () => { + it('自主率:裁决与等待不进分母', () => { + const entries = [ + entry('action', { seq: 1 }), + entry('result', { seq: 2 }), + entry('card-reply', { seq: 3 }), + entry('release-verdict', { seq: 4 }), + entry('card-sent', { seq: 5 }), + ]; + const m = computeMetrics({ + entries, + submissions: 2, + corrections: 1, + taskCount: 1, + deterministicJudgements: 3, + totalJudgements: 4, + }); + expect(m.autonomy).toBeCloseTo(2 / 3); + expect(m.correction).toBe(0.5); + expect(m.effectiveAutonomy).toBeCloseTo((2 / 3) * 0.5); + expect(m.interruption).toBe(1); + expect(m.dmnCoverage).toBe(0.75); + }); + + it('零样本不除零', () => { + const m = computeMetrics({ + entries: [], + submissions: 0, + corrections: 0, + taskCount: 0, + deterministicJudgements: 0, + totalJudgements: 0, + }); + expect(m).toEqual({ autonomy: 0, correction: 0, effectiveAutonomy: 0, interruption: 0, dmnCoverage: 0 }); + }); +}); + +describe('活动记忆 —— 三件套与不载权力', () => { + const base: ActivityMemoryEntry = { + id: 'a1', + activityId: 'act-1', + layer: 'verdict', + statement: '低于底价 20% 一律先问 PM', + evidence: [{ taskId: 't1', entryIds: ['e-1'] }], + createdAt: '2026-07-27T10:00:00.000Z', + }; + + it('无证据链不得入库', () => { + expect(() => assertThreePart({ statement: base.statement, evidence: [] })).toThrow(NoEvidenceError); + expect(() => assertThreePart({ statement: base.statement, evidence: [{ taskId: 't1', entryIds: [] }] })).toThrow( + NoEvidenceError, + ); + }); + + it('空陈述不得入库', () => { + expect(() => assertThreePart({ statement: ' ', evidence: base.evidence })).toThrow(NoEvidenceError); + }); + + it('三件套齐备放行', () => { + expect(() => assertThreePart(base)).not.toThrow(); + }); + + it('记忆不载权力:只有已放行的候选增量才有约束力', () => { + expect(isBinding(base)).toBe(false); + expect(isBinding({ ...base, increment: { target: 'dmn', diff: '+row', state: 'candidate' } })).toBe(false); + expect(isBinding({ ...base, increment: { target: 'dmn', diff: '+row', state: 'released' } })).toBe(true); + }); + + it('装载按层选摘,已修剪的不入上下文', () => { + const rows: ActivityMemoryEntry[] = [ + base, + { ...base, id: 'a2', layer: 'fact' }, + { ...base, id: 'a3', layer: 'fact', prunedAt: '2026-07-27T11:00:00.000Z' }, + ]; + expect(selectForLoading(rows, { layers: ['fact'], limit: 10 }).map((e) => e.id)).toEqual(['a2']); + expect(selectForLoading(rows, { limit: 10 })).toHaveLength(2); + }); +}); diff --git a/apps/api/src/modules/memory/contract.ts b/apps/api/src/modules/memory/contract.ts new file mode 100644 index 0000000..885049a --- /dev/null +++ b/apps/api/src/modules/memory/contract.ts @@ -0,0 +1,76 @@ +/** + * module: memory —— 对外类型(编码规范 §10.1)。 + */ +import type { AttributionSide, TaskId } from '@contracts'; +import type { + ActivityMemoryEntry, + CandidateIncrement, + Evidence, + MemoryLayer, +} from './internal/domain/activity-memory.ts'; +import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from './internal/domain/task-memory.ts'; +import type { LogLine, Metrics, MetricsInput } from './internal/domain/task-log.ts'; + +export type { + ActivityMemoryEntry, + AttributionSide, + CandidateIncrement, + EntryKind, + Evidence, + GovernanceMark, + LogLine, + MemoryEntry, + MemoryLayer, + Metrics, + MetricsInput, + VersionStamp, +}; + +export { NoEvidenceError } from './internal/domain/activity-memory.ts'; + +/** 追加一条任务记忆 —— 归侧由 kind 自动推导,调用方不得指定(零埋点) */ +export type AppendEntry = { + readonly taskId: TaskId; + readonly kind: EntryKind; + readonly title: string; + readonly detail?: Record; + readonly stamp: VersionStamp; + readonly governance?: Partial; + readonly linksTo?: string; + readonly eventRef?: string; +}; + +export type DistillInput = { + readonly taskId: TaskId; + readonly activityId: string; + readonly trigger: 'terminal' | 'milestone'; + readonly milestone?: string; + readonly prompt: string; +}; + +export type MemoryService = { + /* 任务记忆:框架自动记录、只追加 */ + append(entry: AppendEntry): Promise; + history(taskId: TaskId): Promise; + /** 活动侧唯一可配置项:窗口大小 —— 每次 LLM 调用加载多少轮 */ + window(taskId: TaskId, size: number): Promise; + seal(taskId: TaskId): Promise; + + /* 任务日志:任务记忆的两级投影 */ + taskLog(taskId: TaskId, options?: { canSeeMasked?: boolean }): Promise; + /** 叙述性小结(LLM,可选)—— 叙述是视图不是事实,不入事实库 */ + narrate(taskId: TaskId): Promise; + + /* 活动记忆:四层存取;写入唯蒸馏 */ + loadActivityMemory( + activityId: string, + options?: { layers?: readonly MemoryLayer[]; limit?: number }, + ): Promise; + /** **不设直写旁路**:唯一写入口是蒸馏钩子 */ + distill(input: DistillInput): Promise; + /** 人可修剪 —— 治理台 · 记忆管理 */ + prune(entryId: string): Promise; + setIncrementState(entryId: string, state: CandidateIncrement['state']): Promise; + + metrics(input: Omit & { taskIds: readonly TaskId[] }): Promise; +}; diff --git a/apps/api/src/modules/memory/index.ts b/apps/api/src/modules/memory/index.ts new file mode 100644 index 0000000..b40fe03 --- /dev/null +++ b/apps/api/src/modules/memory/index.ts @@ -0,0 +1,47 @@ +/** + * module: memory —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + ActivityMemoryEntry, + AppendEntry, + AttributionSide, + CandidateIncrement, + DistillInput, + EntryKind, + Evidence, + GovernanceMark, + LogLine, + MemoryEntry, + MemoryLayer, + MemoryService, + Metrics, + MetricsInput, + VersionStamp, +} from './contract.ts'; +export { NoEvidenceError } from './contract.ts'; + +export type { ActivityMemoryStore, Clock, IdGenerator, SystemModel, TaskMemoryStore } from './port.ts'; + +export type { MemoryServiceDeps } from './internal/usecase/memory-service.ts'; +export { createMemoryService } from './internal/usecase/memory-service.ts'; + +export { + assertThreePart, + isBinding, + MEMORY_LAYER_LABEL, + MEMORY_LAYER_TARGET, + MEMORY_LAYERS, + selectForLoading, +} from './internal/domain/activity-memory.ts'; +export { + assertAppendOnly, + deriveSide, + distillable, + ENTRY_KINDS, + redactForReader, + windowOf, +} from './internal/domain/task-memory.ts'; +export { computeMetrics, splitBySide, structuredLog } from './internal/domain/task-log.ts'; + +export { createActivityMemoryStore, createTaskMemoryStore } from './internal/adapter/memory-store.ts'; +export { activityMemoryTable, taskMemorySealTable, taskMemoryTable } from './internal/adapter/memory-schema.ts'; diff --git a/apps/api/src/modules/memory/internal/adapter/memory-schema.ts b/apps/api/src/modules/memory/internal/adapter/memory-schema.ts new file mode 100644 index 0000000..de2a097 --- /dev/null +++ b/apps/api/src/modules/memory/internal/adapter/memory-schema.ts @@ -0,0 +1,50 @@ +/** + * memory 的 sys schema 表(编码规范 §7 / ADR-002)。 + * + * 注:@mastra/memory 的线程/消息(mastra schema)由 Mastra 单写,投影一律经本模块出口(§6.2 规则 3); + * 这里存的是**任务记忆的权威条目**(只追加、盖戳、归侧)与活动记忆四层。 + */ +import { boolean, index, integer, jsonb, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sys } from '@shared/db.ts'; + +export const taskMemoryTable = sys.table( + 'task_memory', + { + id: text('id').primaryKey(), + taskId: text('task_id').notNull(), + seq: integer('seq').notNull(), + at: timestamp('at', { withTimezone: true }).notNull(), + kind: text('kind').notNull(), + side: text('side').notNull(), + title: text('title').notNull(), + detail: jsonb('detail'), + stamp: jsonb('stamp').notNull(), + governance: jsonb('governance').notNull(), + linksTo: text('links_to'), + eventRef: text('event_ref'), + }, + (t) => [uniqueIndex('task_memory_seq_key').on(t.taskId, t.seq), index('task_memory_task_idx').on(t.taskId, t.at)], +); + +/** 封卷:终态后归档,不再追加 */ +export const taskMemorySealTable = sys.table('task_memory_seal', { + taskId: text('task_id').primaryKey(), + sealedAt: timestamp('sealed_at', { withTimezone: true }).notNull(), +}); + +export const activityMemoryTable = sys.table( + 'activity_memory', + { + id: text('id').primaryKey(), + activityId: text('activity_id').notNull(), + layer: text('layer').notNull(), + statement: text('statement').notNull(), + /** 证据链 —— 空即不得入库(AM 9.2) */ + evidence: jsonb('evidence').notNull(), + increment: jsonb('increment'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + prunedAt: timestamp('pruned_at', { withTimezone: true }), + active: boolean('active').notNull().default(true), + }, + (t) => [index('activity_memory_layer_idx').on(t.activityId, t.layer, t.createdAt)], +); diff --git a/apps/api/src/modules/memory/internal/adapter/memory-store.ts b/apps/api/src/modules/memory/internal/adapter/memory-store.ts new file mode 100644 index 0000000..dc66398 --- /dev/null +++ b/apps/api/src/modules/memory/internal/adapter/memory-store.ts @@ -0,0 +1,148 @@ +/** + * memory 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。 + */ +import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; +import type { TaskId } from '@contracts'; +import type { Db } from '@shared/db.ts'; +import type { ActivityMemoryStore, TaskMemoryStore } from '../../port.ts'; +import type { ActivityMemoryEntry, MemoryLayer } from '../domain/activity-memory.ts'; +import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '../domain/task-memory.ts'; +import { activityMemoryTable, taskMemorySealTable, taskMemoryTable } from './memory-schema.ts'; + +type TaskRow = typeof taskMemoryTable.$inferSelect; +type ActivityRow = typeof activityMemoryTable.$inferSelect; + +function toEntry(row: TaskRow): MemoryEntry { + return { + id: row.id, + taskId: row.taskId, + seq: row.seq, + at: row.at.toISOString(), + kind: row.kind as EntryKind, + side: row.side as MemoryEntry['side'], + title: row.title, + ...(row.detail === null ? {} : { detail: row.detail as Record }), + stamp: row.stamp as VersionStamp, + governance: row.governance as GovernanceMark, + ...(row.linksTo === null ? {} : { linksTo: row.linksTo }), + ...(row.eventRef === null ? {} : { eventRef: row.eventRef }), + }; +} + +function toActivityEntry(row: ActivityRow): ActivityMemoryEntry { + return { + id: row.id, + activityId: row.activityId, + layer: row.layer as MemoryLayer, + statement: row.statement, + evidence: row.evidence as ActivityMemoryEntry['evidence'], + ...(row.increment === null + ? {} + : { increment: row.increment as NonNullable }), + createdAt: row.createdAt.toISOString(), + ...(row.prunedAt === null ? {} : { prunedAt: row.prunedAt.toISOString() }), + }; +} + +export function createTaskMemoryStore(db: Db): TaskMemoryStore { + return { + async nextSeq(taskId: TaskId) { + const rows = await db + .select({ max: sql`max(${taskMemoryTable.seq})` }) + .from(taskMemoryTable) + .where(eq(taskMemoryTable.taskId, taskId)); + return (rows[0]?.max ?? 0) + 1; + }, + async append(entry) { + await db.insert(taskMemoryTable).values({ + id: entry.id, + taskId: entry.taskId, + seq: entry.seq, + at: new Date(entry.at), + kind: entry.kind, + side: entry.side, + title: entry.title, + detail: entry.detail ?? null, + stamp: entry.stamp, + governance: entry.governance, + linksTo: entry.linksTo ?? null, + eventRef: entry.eventRef ?? null, + }); + }, + async list(taskId: TaskId) { + const rows = await db + .select() + .from(taskMemoryTable) + .where(eq(taskMemoryTable.taskId, taskId)) + .orderBy(asc(taskMemoryTable.seq)); + return rows.map(toEntry); + }, + async tail(taskId: TaskId, size: number) { + const rows = await db + .select() + .from(taskMemoryTable) + .where(eq(taskMemoryTable.taskId, taskId)) + .orderBy(desc(taskMemoryTable.seq)) + .limit(Math.max(size, 0)); + return rows.map(toEntry).reverse(); + }, + async seal(taskId: TaskId, at: Date) { + await db + .insert(taskMemorySealTable) + .values({ taskId, sealedAt: at }) + .onConflictDoNothing(); + }, + async isSealed(taskId: TaskId) { + const rows = await db + .select() + .from(taskMemorySealTable) + .where(eq(taskMemorySealTable.taskId, taskId)) + .limit(1); + return rows.length > 0; + }, + }; +} + +export function createActivityMemoryStore(db: Db): ActivityMemoryStore { + return { + async list(activityId, layers, limit) { + const conditions = [eq(activityMemoryTable.activityId, activityId), isNull(activityMemoryTable.prunedAt)]; + if (layers !== undefined && layers.length > 0) { + conditions.push(inArray(activityMemoryTable.layer, [...layers])); + } + const rows = await db + .select() + .from(activityMemoryTable) + .where(and(...conditions)) + .orderBy(desc(activityMemoryTable.createdAt)) + .limit(limit); + return rows.map(toActivityEntry); + }, + async insertMany(entries) { + if (entries.length === 0) return; + await db.insert(activityMemoryTable).values( + entries.map((e) => ({ + id: e.id, + activityId: e.activityId, + layer: e.layer, + statement: e.statement, + evidence: e.evidence, + increment: e.increment ?? null, + createdAt: new Date(e.createdAt), + })), + ); + }, + async prune(entryId, at) { + await db + .update(activityMemoryTable) + .set({ prunedAt: at, active: false }) + .where(eq(activityMemoryTable.id, entryId)); + }, + async setIncrementState(entryId, state) { + await db + .update(activityMemoryTable) + .set({ increment: sql`jsonb_set(coalesce(${activityMemoryTable.increment}, '{}'::jsonb), '{state}', to_jsonb(${state}::text))` }) + .where(eq(activityMemoryTable.id, entryId)); + }, + }; +} diff --git a/apps/api/src/modules/memory/internal/domain/activity-memory.ts b/apps/api/src/modules/memory/internal/domain/activity-memory.ts new file mode 100644 index 0000000..ae93d78 --- /dev/null +++ b/apps/api/src/modules/memory/internal/domain/activity-memory.ts @@ -0,0 +1,94 @@ +/** + * 活动记忆四层 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 8.2,AM 9.2)。 + * + * 一类一本,跨全部任务实例累积;结构不是自由笔记 —— 每类条目对准业务模型的一个作用面。 + * 三条铁律: + * 条目三件套(必须)—— 归纳陈述 + 证据链 + 候选增量(可选);**无证据链的条目不得入库** + * 写入 = 蒸馏(必须)—— 只经蒸馏钩子从任务记忆归纳;人可修剪;**不设直写旁路** + * 记忆不载权力(必须)—— 只有参考力,强制力只经候选增量 + 回放 + 人放行进入业务模型 + */ + +export const MEMORY_LAYERS = ['fact', 'pattern', 'verdict', 'exception'] as const; +export type MemoryLayer = (typeof MEMORY_LAYERS)[number]; + +export const MEMORY_LAYER_LABEL: Readonly> = { + fact: '事实层', + pattern: '模式层', + verdict: '裁决层', + exception: '例外层', +}; + +/** 每层优化谁 —— 记忆天然是模型优化的原料堆(AM 9.2) */ +export const MEMORY_LAYER_TARGET: Readonly> = { + fact: '校准时限 · ifPart 阈值 · DMN 输入项 · 重试参数', + pattern: '候选结构增量:裁量项转常项 · 新前提 · 阶段重组 · 步序修订', + verdict: '候选新前提条件 · 收紧完成判据 · DMN 新规则行', + exception: '规则适用边界 · 前提负向条件 · 技能适用描述修正', +}; + +/** 证据链:指回任务记忆的具体片段,**可回放** */ +export type Evidence = { + readonly taskId: string; + readonly entryIds: readonly string[]; + readonly note?: string; +}; + +export const INCREMENT_STATES = ['candidate', 'replaying', 'released', 'rejected'] as const; +export type IncrementState = (typeof INCREMENT_STATES)[number]; + +/** 候选增量:落到业务模型 / 技能的具体 diff */ +export type CandidateIncrement = { + readonly target: 'cmmn' | 'dmn' | 'flow-steps' | 'skill'; + readonly diff: string; + readonly state: IncrementState; +}; + +export type ActivityMemoryEntry = { + readonly id: string; + readonly activityId: string; + readonly layer: MemoryLayer; + /** 归纳陈述 */ + readonly statement: string; + /** 证据链 —— 空即不得入库 */ + readonly evidence: readonly Evidence[]; + readonly increment?: CandidateIncrement; + readonly createdAt: string; + readonly prunedAt?: string; +}; + +export class NoEvidenceError extends Error { + constructor(readonly statement: string) { + super(`活动记忆条目无证据链,不得入库:${statement}`); + this.name = 'NoEvidenceError'; + } +} + +/** 条目三件套的机检 —— 写入前必过 */ +export function assertThreePart(entry: Pick): void { + if (entry.statement.trim() === '') throw new NoEvidenceError('(空陈述)'); + const hasEvidence = entry.evidence.some((e) => e.entryIds.length > 0); + if (!hasEvidence) throw new NoEvidenceError(entry.statement); +} + +/** + * 装载:按定位选摘相关条目入 Agent 上下文(渐进披露)。 + * **语义引擎不读活动记忆** —— 引擎零业务,记忆是 Agent 侧的输入(AM 9.2)。 + */ +export function selectForLoading( + entries: readonly ActivityMemoryEntry[], + options: { readonly layers?: readonly MemoryLayer[]; readonly limit: number }, +): ActivityMemoryEntry[] { + const layers = options.layers; + return entries + .filter((e) => e.prunedAt === undefined) + .filter((e) => layers === undefined || layers.includes(e.layer)) + .slice(0, options.limit); +} + +/** + * 记忆不载权力:条目对行为只有**参考力**。 + * 强制力的唯一路径是候选增量经回放与人放行进入业务模型本身 —— 本函数是那条路径的判据。 + */ +export function isBinding(entry: ActivityMemoryEntry): boolean { + return entry.increment?.state === 'released'; +} diff --git a/apps/api/src/modules/memory/internal/domain/task-log.ts b/apps/api/src/modules/memory/internal/domain/task-log.ts new file mode 100644 index 0000000..45f1f97 --- /dev/null +++ b/apps/api/src/modules/memory/internal/domain/task-log.ts @@ -0,0 +1,123 @@ +/** + * 任务日志:任务记忆的两级投影 —— 纯函数,零 IO(编码规范 §5.3;语义出处 OS 8.1)。 + * + * 「做事与留痕是同一个动作,不存在补写日志」—— + * 结构化日志(零 LLM)以引擎事件流为骨架,按事件类型筛选、模板措辞; + * **任何时刻可重算,口径与源恒一致**;归侧标签使其天然分栏。 + * 叙述性小结(LLM,可选)是视图不是事实,不入事实库 —— 由 usecase 经系统用途模型生成。 + */ +import type { AttributionSide } from '@contracts'; +import { ATTRIBUTION_SIDE_LABEL } from '@contracts'; +import type { EntryKind, MemoryEntry } from './task-memory.ts'; + +export type LogLine = { + readonly id: string; + readonly at: string; + readonly side: AttributionSide; + readonly sideLabel: string; + readonly title: string; + readonly summary: string | undefined; + readonly eventRef: string | undefined; + readonly position: string | undefined; +}; + +/** 骨架:只有这些种类进结构化日志(OS 8.1 列举) */ +const SKELETON: ReadonlySet = new Set([ + 'engine-transition', + 'action', + 'result', + 'card-sent', + 'card-reply', + 'ledger-write', + 'action-intent', + 'release-verdict', + 'terminal', + 'human-input', +]); + +const TEMPLATE: Readonly string>> = { + observation: (e) => `观察:${e.title}`, + thought: (e) => `思考:${e.title}`, + action: (e) => `调用:${e.title}`, + result: (e) => `返回:${e.title}`, + 'engine-transition': (e) => `迁移:${e.title}`, + evaluation: (e) => `求值:${e.title}`, + 'card-sent': (e) => `发出:${e.title}`, + 'card-reply': (e) => `回传:${e.title}`, + 'ledger-write': (e) => `写入:${e.title}`, + 'action-intent': (e) => `动作意向:${e.title}`, + 'release-verdict': (e) => `放行裁决:${e.title}`, + terminal: (e) => `终态:${e.title}`, + 'human-input': (e) => `留言:${e.title}`, +}; + +/** 结构化日志:零 LLM,可重算,每条自带谁 · 何时 · 依据 · 留痕指针 */ +export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] { + return entries + .filter((e) => SKELETON.has(e.kind)) + .map((e) => ({ + id: e.id, + at: e.at, + side: e.side, + sideLabel: ATTRIBUTION_SIDE_LABEL[e.side], + title: TEMPLATE[e.kind](e), + summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined, + eventRef: e.eventRef, + position: e.stamp.position, + })); +} + +/** 归侧标签使日志天然分栏:机器做的 / 人做的 / 人裁的 / 在等谁 */ +export function splitBySide(lines: readonly LogLine[]): Record { + const out: Record = { + machine: [], + human: [], + 'human-verdict': [], + 'world-wait': [], + }; + for (const line of lines) out[line.side].push(line); + return out; +} + +/* ── 度量八指标(OS 8.3):只读投影,零埋点,统计即查询 ─────────────── */ + +export type Metrics = { + /** 自主率 = 机器产出 ÷(机器产出 + 人产出);裁决与等待不进分母 */ + readonly autonomy: number; + /** 纠正率 = 被纠正批次 ÷ 提交批次 */ + readonly correction: number; + /** 有效自主率 = 自主率 ×(1 − 纠正率) */ + readonly effectiveAutonomy: number; + /** 打扰率 = 每单任务调用人类工具的次数 */ + readonly interruption: number; + /** DMN 覆盖率 = 确定性求值占判断次数的比例 */ + readonly dmnCoverage: number; +}; + +export type MetricsInput = { + readonly entries: readonly MemoryEntry[]; + /** 提交批次数:每次放行提交 = 一个产出批次 */ + readonly submissions: number; + /** 被纠正批次:驳回重做 / 驳回迭代 / 叫停 / 裁决推翻 */ + readonly corrections: number; + readonly taskCount: number; + /** 判断次数中由 DMN / 机检确定性求值的次数 */ + readonly deterministicJudgements: number; + readonly totalJudgements: number; +}; + +export function computeMetrics(input: MetricsInput): Metrics { + const machine = input.entries.filter((e) => e.side === 'machine').length; + const human = input.entries.filter((e) => e.side === 'human').length; + const denominator = machine + human; + const autonomy = denominator === 0 ? 0 : machine / denominator; + const correction = input.submissions === 0 ? 0 : input.corrections / input.submissions; + const cards = input.entries.filter((e) => e.kind === 'card-sent').length; + return { + autonomy, + correction, + effectiveAutonomy: autonomy * (1 - correction), + interruption: input.taskCount === 0 ? 0 : cards / input.taskCount, + dmnCoverage: input.totalJudgements === 0 ? 0 : input.deterministicJudgements / input.totalJudgements, + }; +} diff --git a/apps/api/src/modules/memory/internal/domain/task-memory.ts b/apps/api/src/modules/memory/internal/domain/task-memory.ts new file mode 100644 index 0000000..ffbb267 --- /dev/null +++ b/apps/api/src/modules/memory/internal/domain/task-memory.ts @@ -0,0 +1,114 @@ +/** + * 任务记忆的纯逻辑 —— 零 IO(编码规范 §5.3;语义出处 OS 8.1,AM 9.1)。 + * + * 任务记忆 = 会话历史:消息历史 + 引擎事件流,同轴合流、按时间全序。四条性质(必须): + * 只追加 · 信封盖戳 · 治理先行 · 归侧 + */ +import type { AttributionSide } from '@contracts'; + +/** 条目种类 —— 消息历史与引擎事件流两股同轴 */ +export const ENTRY_KINDS = [ + 'observation', + 'thought', + 'action', + 'result', + 'engine-transition', + 'evaluation', + 'card-sent', + 'card-reply', + 'ledger-write', + 'action-intent', + 'release-verdict', + 'terminal', + 'human-input', +] as const; +export type EntryKind = (typeof ENTRY_KINDS)[number]; + +/** 版本信封:行为可归因、历史可回放的前提(AM 9.1) */ +export type VersionStamp = { + readonly contractVersion: string; + readonly businessModelVersion: string; + readonly skillVersions: readonly string[]; + readonly modelName?: string; + /** 定位:loop = 阶段 · 目标项;flow = 步序位置 */ + readonly position?: string; +}; + +/** 治理标记:留痕时刻打上,不合规的记录不得进入蒸馏队列 */ +export type GovernanceMark = { + readonly masked: boolean; + readonly learnable: boolean; + readonly retainUntil?: string; +}; + +export type MemoryEntry = { + readonly id: string; + readonly taskId: string; + readonly seq: number; + readonly at: string; + readonly kind: EntryKind; + readonly side: AttributionSide; + readonly title: string; + readonly detail?: Record; + readonly stamp: VersionStamp; + readonly governance: GovernanceMark; + /** 迟到信号回链原步 —— 不改写因果,只以后续条目累加 */ + readonly linksTo?: string; + /** 留痕指针:事件存储位置 */ + readonly eventRef?: string; +}; + +/** + * 归侧:每条按**动作类型自动**打四侧标签 —— 度量的口径来源,零埋点(OS 8.1)。 + * 这是纯推导,不是可配置项:同一种动作恒定同一侧。 + */ +export function deriveSide(kind: EntryKind): AttributionSide { + switch (kind) { + case 'observation': + case 'thought': + case 'action': + case 'result': + case 'engine-transition': + case 'evaluation': + case 'ledger-write': + case 'action-intent': + return 'machine'; + case 'card-reply': + case 'human-input': + return 'human'; + case 'release-verdict': + case 'terminal': + return 'human-verdict'; + case 'card-sent': + return 'world-wait'; + } +} + +/** 只追加:序号严格递增,同一任务内不得回退或重复 */ +export function assertAppendOnly(previousSeq: number, nextSeq: number): void { + if (nextSeq <= previousSeq) { + throw new Error(`任务记忆只追加:序号必须递增(${previousSeq} → ${nextSeq})`); + } +} + +/** + * 窗口:活动侧**唯一**可配置项 —— 每次 LLM 调用加载多少轮会话历史。 + * 窗口之外的历史不进上下文,但完整在库(续跑、审计、回放永远对全量记录进行)。 + */ +export function windowOf(entries: readonly MemoryEntry[], window: number): MemoryEntry[] { + if (window <= 0) return []; + return entries.slice(-window); +} + +/** 治理约束:不合规的记录不得进入蒸馏队列(OS 8.1) */ +export function distillable(entries: readonly MemoryEntry[]): MemoryEntry[] { + return entries.filter((e) => e.governance.learnable && !e.governance.masked); +} + +/** + * 投影不得比源暴露更多 —— 日志继承任务记忆的治理标记,按读者权限裁剪生成(OS 8.1)。 + */ +export function redactForReader(entry: MemoryEntry, canSeeMasked: boolean): MemoryEntry { + if (!entry.governance.masked || canSeeMasked) return entry; + return { ...entry, title: '【已脱敏】', ...(entry.detail === undefined ? {} : { detail: {} }) }; +} diff --git a/apps/api/src/modules/memory/internal/usecase/memory-service.ts b/apps/api/src/modules/memory/internal/usecase/memory-service.ts new file mode 100644 index 0000000..3966317 --- /dev/null +++ b/apps/api/src/modules/memory/internal/usecase/memory-service.ts @@ -0,0 +1,192 @@ +/** + * memory 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。 + */ +import type { TaskId } from '@contracts'; +import type { AppendEntry, DistillInput, MemoryService } from '../../contract.ts'; +import type { ActivityMemoryStore, Clock, IdGenerator, SystemModel, TaskMemoryStore } from '../../port.ts'; +import type { ActivityMemoryEntry, MemoryLayer } from '../domain/activity-memory.ts'; +import { assertThreePart, MEMORY_LAYERS, selectForLoading } from '../domain/activity-memory.ts'; +import { computeMetrics, structuredLog } from '../domain/task-log.ts'; +import type { MemoryEntry } from '../domain/task-memory.ts'; +import { assertAppendOnly, deriveSide, distillable, redactForReader, windowOf } from '../domain/task-memory.ts'; + +export type MemoryServiceDeps = { + readonly tasks: TaskMemoryStore; + readonly activities: ActivityMemoryStore; + readonly model: SystemModel; + readonly clock: Clock; + readonly ids: IdGenerator; +}; + +/** 蒸馏产物的形状 —— 系统用途模型按此 schema 出参 */ +const DISTILL_SCHEMA = { + type: 'object', + required: ['entries'], + properties: { + entries: { + type: 'array', + items: { + type: 'object', + required: ['layer', 'statement', 'entryIds'], + properties: { + layer: { enum: [...MEMORY_LAYERS] }, + statement: { type: 'string' }, + entryIds: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, +} as const; + +type DistillDraft = { layer: string; statement: string; entryIds: string[] }; + +export function createMemoryService(deps: MemoryServiceDeps): MemoryService { + return { + async append(input: AppendEntry): Promise { + if (await deps.tasks.isSealed(input.taskId)) { + throw new Error(`任务记忆已封卷,不得追加:${input.taskId}`); + } + const seq = await deps.tasks.nextSeq(input.taskId); + assertAppendOnly(seq - 1, seq); + const entry: MemoryEntry = { + id: deps.ids.next(), + taskId: input.taskId, + seq, + at: deps.clock.now().toISOString(), + kind: input.kind, + // 归侧自动推导 —— 调用方不得指定(零埋点,OS 8.1) + side: deriveSide(input.kind), + title: input.title, + ...(input.detail === undefined ? {} : { detail: input.detail }), + stamp: input.stamp, + governance: { + masked: input.governance?.masked ?? false, + learnable: input.governance?.learnable ?? true, + ...(input.governance?.retainUntil === undefined ? {} : { retainUntil: input.governance.retainUntil }), + }, + ...(input.linksTo === undefined ? {} : { linksTo: input.linksTo }), + ...(input.eventRef === undefined ? {} : { eventRef: input.eventRef }), + }; + await deps.tasks.append(entry); + return entry; + }, + + async history(taskId) { + return deps.tasks.list(taskId); + }, + + async window(taskId, size) { + // 窗口之外的历史不进上下文,但完整在库 + return windowOf(await deps.tasks.tail(taskId, size), size); + }, + + async seal(taskId) { + await deps.tasks.seal(taskId, deps.clock.now()); + }, + + async taskLog(taskId, options) { + const entries = await deps.tasks.list(taskId); + const visible = entries.map((e) => redactForReader(e, options?.canSeeMasked ?? false)); + return structuredLog(visible); + }, + + async narrate(taskId) { + const model = await deps.model.systemModelName(); + if (model === undefined) return undefined; + const lines = await this.taskLog(taskId); + if (lines.length === 0) return undefined; + const { text } = await deps.model.generate({ + model, + prompt: + '以下是一单任务的结构化日志。用不超过 200 字做阶段小结,只陈述日志里有的事实,不要推测。\n\n' + + lines.map((l) => `${l.at} [${l.sideLabel}] ${l.title}`).join('\n'), + }); + return text; + }, + + async loadActivityMemory(activityId, options) { + const entries = await deps.activities.list(activityId, options?.layers, options?.limit ?? 50); + return selectForLoading(entries, { + ...(options?.layers === undefined ? {} : { layers: options.layers }), + limit: options?.limit ?? 50, + }); + }, + + /** 写入唯蒸馏:从任务记忆归纳,条目必挂证据链 */ + async distill(input: DistillInput): Promise { + const model = await deps.model.systemModelName(); + if (model === undefined) return []; + + const source = distillable(await deps.tasks.list(input.taskId)); + if (source.length === 0) return []; + + const { object } = await deps.model.generate({ + model, + prompt: + `${input.prompt}\n\n` + + '从以下任务记忆里归纳活动记忆条目。每条必须给出 layer(fact/pattern/verdict/exception)、' + + 'statement(一般化陈述)与 entryIds(引用的条目 ID,不得为空)。只输出 JSON。\n\n' + + source.map((e) => `${e.id} [${e.kind}] ${e.title}`).join('\n'), + outputSchema: DISTILL_SCHEMA, + }); + + const drafts = extractDrafts(object); + const known = new Set(source.map((e) => e.id)); + const now = deps.clock.now().toISOString(); + + const entries: ActivityMemoryEntry[] = []; + for (const draft of drafts) { + const entryIds = draft.entryIds.filter((id) => known.has(id)); + const candidate = { + id: deps.ids.next(), + activityId: input.activityId, + layer: normalizeLayer(draft.layer), + statement: draft.statement, + evidence: [{ taskId: input.taskId, entryIds }], + createdAt: now, + } satisfies ActivityMemoryEntry; + // 无证据链的条目不得入库 —— 蒸馏产物一样过这道门 + try { + assertThreePart(candidate); + } catch { + continue; + } + entries.push(candidate); + } + + if (entries.length > 0) await deps.activities.insertMany(entries); + return entries; + }, + + async prune(entryId) { + await deps.activities.prune(entryId, deps.clock.now()); + }, + + async setIncrementState(entryId, state) { + await deps.activities.setIncrementState(entryId, state); + }, + + async metrics(input) { + const all: MemoryEntry[] = []; + for (const taskId of input.taskIds) all.push(...(await deps.tasks.list(taskId as TaskId))); + return computeMetrics({ ...input, entries: all }); + }, + }; +} + +function extractDrafts(object: unknown): DistillDraft[] { + if (object === null || typeof object !== 'object') return []; + const entries = (object as { entries?: unknown }).entries; + if (!Array.isArray(entries)) return []; + return entries.flatMap((raw): DistillDraft[] => { + if (raw === null || typeof raw !== 'object') return []; + const { layer, statement, entryIds } = raw as Record; + if (typeof layer !== 'string' || typeof statement !== 'string') return []; + const ids = Array.isArray(entryIds) ? entryIds.filter((x): x is string => typeof x === 'string') : []; + return [{ layer, statement, entryIds: ids }]; + }); +} + +function normalizeLayer(value: string): MemoryLayer { + return (MEMORY_LAYERS as readonly string[]).includes(value) ? (value as MemoryLayer) : 'fact'; +} diff --git a/apps/api/src/modules/memory/package.json b/apps/api/src/modules/memory/package.json new file mode 100644 index 0000000..943da35 --- /dev/null +++ b/apps/api/src/modules/memory/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/memory", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/memory/port.ts b/apps/api/src/modules/memory/port.ts new file mode 100644 index 0000000..852d157 --- /dev/null +++ b/apps/api/src/modules/memory/port.ts @@ -0,0 +1,39 @@ +/** + * module: memory —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + */ +import type { TaskId } from '@contracts'; +import type { ActivityMemoryEntry, MemoryLayer } from './internal/domain/activity-memory.ts'; +import type { MemoryEntry } from './internal/domain/task-memory.ts'; + +export type Clock = { now(): Date }; +export type IdGenerator = { next(): string }; + +export type TaskMemoryStore = { + nextSeq(taskId: TaskId): Promise; + append(entry: MemoryEntry): Promise; + list(taskId: TaskId): Promise; + tail(taskId: TaskId, size: number): Promise; + seal(taskId: TaskId, at: Date): Promise; + isSealed(taskId: TaskId): Promise; +}; + +export type ActivityMemoryStore = { + list(activityId: string, layers: readonly MemoryLayer[] | undefined, limit: number): Promise; + insertMany(entries: readonly ActivityMemoryEntry[]): Promise; + prune(entryId: string, at: Date): Promise; + setIncrementState(entryId: string, state: string): Promise; +}; + +/** + * 模型调用 —— app 注入 `llm`(L1)的 ModelGateway。 + * 蒸馏钩子与叙述性小结用**系统用途**绑定实例:计量入系统账、不占活动预算(OS 02)。 + */ +export type SystemModel = { + generate(input: { + readonly model: string; + readonly prompt: string; + readonly outputSchema?: Record; + }): Promise<{ text: string; object?: unknown }>; + /** 系统用途模型名;未配置即 undefined —— 此时叙述与蒸馏静默跳过,不报错 */ + systemModelName(): Promise; +}; diff --git a/apps/api/src/modules/message/CONTRACT.md b/apps/api/src/modules/message/CONTRACT.md new file mode 100644 index 0000000..0c0e976 --- /dev/null +++ b/apps/api/src/modules/message/CONTRACT.md @@ -0,0 +1,28 @@ +# module: message + +职责: 系统消息:通知、跨任务征答收件箱(就地作答 / 驳回重答)、死信与无主兜底的人侧呈现 +不负责: 卡的内容与回传校验(见 human-tool)、事件投递与三级消费判定(见 event)、 + 任务与审批(它们是**责任**要素,消息只是信息) + +公开出口: index.ts + - 类型:`MessageService` `SystemMessage` `MessageKind` `SendNotice` `SendElicitation` + - 错误:`MessageNotAnswerableError` + - 端口类型:`MessageRepository` `PositionResolver` `Clock` `IdGenerator` + - 用例工厂:`createMessageService(deps: MessageServiceDeps): MessageService` + - 纯函数:`canAnswer` `unreadCount` `pendingAnswerCount` + - adapter:`createMessageRepository`;Drizzle 表:`messageTable` + +层级: L2(编码规范 §4) +依赖: @contracts;@shared/db;port:`PositionResolver`(app 注入 org) +被依赖: event(经 `MessageFallback` 端口,由 app 注入 —— event 是 L1,不反向依赖本模块)、 + human-tool(跨人卡的到达面)、app(工作台 · 消息) + +不变量: + - **消息不产生责任**:它是信息要素,与任务 / 审批(责任要素)分明 + - **跨人卡是一次性解答请求,不是责任转移**:作答权限只及卡面与决策包,不开放任务对话流 + - **只增不改**:驳回重答 = 新发一条系统消息(新卡实例,附驳回理由),原卡闭合; + 已答或已闭合的卡不得再答(`canAnswer` 是唯一判据) + - 死信同时送**相关人的消息要素与治理台 · 任务管理** + - 无主兜底的相关人 = 通道实例配置的**默认认领岗位**;未配置则落治理台,绝不静默丢弃 + +影响半径: index/contract/port 未变 → 对外零影响 diff --git a/apps/api/src/modules/message/__tests__/message-kind.test.ts b/apps/api/src/modules/message/__tests__/message-kind.test.ts new file mode 100644 index 0000000..c4db867 --- /dev/null +++ b/apps/api/src/modules/message/__tests__/message-kind.test.ts @@ -0,0 +1,62 @@ +/** 跨任务征答的作答判据 —— domain 测试零 mock(OS 2.1) */ +import { describe, expect, it } from 'vitest'; +import { canAnswer, pendingAnswerCount, unreadCount } from '../internal/domain/message-kind.ts'; +import type { SystemMessage } from '../internal/domain/message-kind.ts'; + +const msg = (over: Partial = {}): SystemMessage => ({ + id: 'm1', + kind: 'elicitation', + staffId: 's1', + title: '请补全交期', + body: '缺客户可接受交期', + at: '2026-07-27T10:00:00.000Z', + card: { cardId: 'c1', sourceTaskId: 't1', payload: {} }, + ...over, +}); + +describe('canAnswer —— 跨人卡是一次性解答请求', () => { + it('本人未答的征答可答', () => { + expect(canAnswer(msg(), 's1')).toBe(true); + }); + + it('别人的卡不可答', () => { + expect(canAnswer(msg(), 's2')).toBe(false); + }); + + it('通知不可答 —— 消息不产生责任', () => { + expect(canAnswer(msg({ kind: 'notice' }), 's1')).toBe(false); + }); + + it('已答的卡不可再答 —— 只增不改', () => { + expect(canAnswer(msg({ card: { cardId: 'c1', sourceTaskId: 't1', payload: {}, answeredAt: 'x' } }), 's1')).toBe( + false, + ); + }); + + it('被驳回重答闭合的原卡不可再答', () => { + expect(canAnswer(msg({ card: { cardId: 'c1', sourceTaskId: 't1', payload: {}, supersededBy: 'm2' } }), 's1')).toBe( + false, + ); + }); + + it('无卡体的征答不可答', () => { + const { card: _card, ...rest } = msg(); + expect(canAnswer(rest as SystemMessage, 's1')).toBe(false); + }); +}); + +describe('计数', () => { + it('未读计数', () => { + expect(unreadCount([msg(), msg({ id: 'm2', readAt: 'x' })])).toBe(1); + }); + + it('待答计数只算未答未闭合的征答', () => { + expect( + pendingAnswerCount([ + msg(), + msg({ id: 'm2', card: { cardId: 'c2', sourceTaskId: 't1', payload: {}, answeredAt: 'x' } }), + msg({ id: 'm3', kind: 'notice' }), + ]), + ).toBe(1); + }); +}); diff --git a/apps/api/src/modules/message/contract.ts b/apps/api/src/modules/message/contract.ts new file mode 100644 index 0000000..129a251 --- /dev/null +++ b/apps/api/src/modules/message/contract.ts @@ -0,0 +1,47 @@ +/** + * module: message —— 对外类型(编码规范 §10.1)。 + */ +import type { Envelope, StaffId } from '@contracts'; +import type { MessageKind, SystemMessage } from './internal/domain/message-kind.ts'; + +export type { Envelope, MessageKind, SystemMessage }; + +export class MessageNotAnswerableError extends Error { + constructor(readonly messageId: string) { + super(`该消息不可作答:${messageId}(已答、已闭合,或不是跨任务征答)`); + this.name = 'MessageNotAnswerableError'; + } +} + +export type SendNotice = { + readonly staffId: StaffId; + readonly title: string; + readonly body: string; + readonly eventRef?: string; +}; + +export type SendElicitation = { + readonly staffId: StaffId; + readonly title: string; + readonly body: string; + readonly cardId: string; + readonly sourceTaskId: string; + readonly payload: Record; +}; + +export type MessageService = { + inbox(staffId: StaffId, options?: { unreadOnly?: boolean; limit?: number }): Promise; + markRead(messageId: string): Promise; + notify(input: SendNotice): Promise; + /** 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」(OS 2.1) */ + sendElicitation(input: SendElicitation): Promise; + answer(messageId: string, staffId: StaffId, answer: Record): Promise; + /** 驳回重答 = 新发一条系统消息(新卡实例),原卡闭合 —— 只增不改 */ + rejectAndReask(messageId: string, reason: string): Promise; + + /* ── event 的 `MessageFallback` 端口的实现面(OS 06) ─────────────── */ + /** 死信:重试超限 → 系统通知送达相关人的消息要素与治理台 */ + deadLetter(envelope: Envelope, error: string): Promise; + /** 无主兜底:相关人 = 通道实例配置的默认认领岗位 */ + unowned(envelope: Envelope, fallbackPosition: string | undefined): Promise; +}; diff --git a/apps/api/src/modules/message/index.ts b/apps/api/src/modules/message/index.ts new file mode 100644 index 0000000..f1c4760 --- /dev/null +++ b/apps/api/src/modules/message/index.ts @@ -0,0 +1,21 @@ +/** + * module: message —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { MessageKind, MessageService, SendElicitation, SendNotice, SystemMessage } from './contract.ts'; +export { MessageNotAnswerableError } from './contract.ts'; + +export type { Clock, IdGenerator, MessageRepository, PositionResolver } from './port.ts'; + +export type { MessageServiceDeps } from './internal/usecase/message-service.ts'; +export { createMessageService } from './internal/usecase/message-service.ts'; + +export { + canAnswer, + MESSAGE_KIND_LABEL, + MESSAGE_KINDS, + pendingAnswerCount, + unreadCount, +} from './internal/domain/message-kind.ts'; + +export { createMessageRepository } from './internal/adapter/message-store.ts'; +export { messageTable } from './internal/adapter/message-schema.ts'; diff --git a/apps/api/src/modules/message/internal/adapter/message-schema.ts b/apps/api/src/modules/message/internal/adapter/message-schema.ts new file mode 100644 index 0000000..7a07206 --- /dev/null +++ b/apps/api/src/modules/message/internal/adapter/message-schema.ts @@ -0,0 +1,24 @@ +/** + * message 的 sys schema 表(编码规范 §7 / ADR-002)。 + */ +import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core'; +import { sys } from '@shared/db.ts'; + +export const messageTable = sys.table( + 'system_message', + { + id: text('id').primaryKey(), + kind: text('kind').notNull(), + staffId: text('staff_id').notNull(), + positionId: text('position_id'), + title: text('title').notNull(), + body: text('body').notNull(), + at: timestamp('at', { withTimezone: true }).notNull().defaultNow(), + readAt: timestamp('read_at', { withTimezone: true }), + /** 跨任务征答的卡体;驳回重答以 supersededBy 闭合原卡 —— 只增不改 */ + card: jsonb('card'), + answer: jsonb('answer'), + eventRef: text('event_ref'), + }, + (t) => [index('message_staff_idx').on(t.staffId, t.at), index('message_unread_idx').on(t.staffId, t.readAt)], +); diff --git a/apps/api/src/modules/message/internal/adapter/message-store.ts b/apps/api/src/modules/message/internal/adapter/message-store.ts new file mode 100644 index 0000000..0256c5e --- /dev/null +++ b/apps/api/src/modules/message/internal/adapter/message-store.ts @@ -0,0 +1,92 @@ +/** + * message 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。 + */ +import { and, desc, eq, isNull } from 'drizzle-orm'; +import type { StaffId } from '@contracts'; +import type { Db } from '@shared/db.ts'; +import type { MessageRepository } from '../../port.ts'; +import type { MessageKind, SystemMessage } from '../domain/message-kind.ts'; +import { messageTable } from './message-schema.ts'; + +type Row = typeof messageTable.$inferSelect; + +function toMessage(row: Row): SystemMessage { + return { + id: row.id, + kind: row.kind as MessageKind, + staffId: row.staffId, + ...(row.positionId === null ? {} : { positionId: row.positionId }), + title: row.title, + body: row.body, + at: row.at.toISOString(), + ...(row.readAt === null ? {} : { readAt: row.readAt.toISOString() }), + ...(row.card === null ? {} : { card: row.card as NonNullable }), + ...(row.eventRef === null ? {} : { eventRef: row.eventRef }), + }; +} + +export function createMessageRepository(db: Db): MessageRepository { + return { + async list(staffId: StaffId, unreadOnly, limit) { + const conditions = [eq(messageTable.staffId, staffId)]; + if (unreadOnly) conditions.push(isNull(messageTable.readAt)); + const rows = await db + .select() + .from(messageTable) + .where(and(...conditions)) + .orderBy(desc(messageTable.at)) + .limit(limit); + return rows.map(toMessage); + }, + + async get(messageId) { + const rows = await db.select().from(messageTable).where(eq(messageTable.id, messageId)).limit(1); + return rows[0] === undefined ? undefined : toMessage(rows[0]); + }, + + async insert(message) { + const [row] = await db + .insert(messageTable) + .values({ + id: message.id, + kind: message.kind, + staffId: message.staffId, + positionId: message.positionId ?? null, + title: message.title, + body: message.body, + at: new Date(message.at), + card: message.card ?? null, + eventRef: message.eventRef ?? null, + }) + .returning(); + if (row === undefined) throw new Error('消息写入未返回行'); + return toMessage(row); + }, + + async markRead(messageId, at) { + await db.update(messageTable).set({ readAt: at }).where(eq(messageTable.id, messageId)); + }, + + async markAnswered(messageId, at, answer) { + const rows = await db.select().from(messageTable).where(eq(messageTable.id, messageId)).limit(1); + const row = rows[0]; + if (row === undefined) return; + const card = (row.card ?? {}) as Record; + await db + .update(messageTable) + .set({ card: { ...card, answeredAt: at.toISOString() }, answer, readAt: row.readAt ?? at }) + .where(eq(messageTable.id, messageId)); + }, + + async supersede(messageId, byMessageId, reason) { + const rows = await db.select().from(messageTable).where(eq(messageTable.id, messageId)).limit(1); + const row = rows[0]; + if (row === undefined) return; + const card = (row.card ?? {}) as Record; + await db + .update(messageTable) + .set({ card: { ...card, supersededBy: byMessageId, rejectedReason: reason } }) + .where(eq(messageTable.id, messageId)); + }, + }; +} diff --git a/apps/api/src/modules/message/internal/domain/message-kind.ts b/apps/api/src/modules/message/internal/domain/message-kind.ts new file mode 100644 index 0000000..62b6e89 --- /dev/null +++ b/apps/api/src/modules/message/internal/domain/message-kind.ts @@ -0,0 +1,65 @@ +/** + * 系统消息的种类与投影 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 2.1 / 06)。 + * + * 「消息是**信息**,不产生责任」—— 与任务、审批(责任要素)分明。 + * 三类内容:通知(承载触发器的邮件和消息、公告、提醒与告警)+ 跨任务征答(他人任务发来的卡) + * + 死信与回执的人侧呈现。 + */ + +export const MESSAGE_KINDS = ['notice', 'elicitation', 'dead-letter', 'unowned', 'receipt'] as const; +export type MessageKind = (typeof MESSAGE_KINDS)[number]; + +export const MESSAGE_KIND_LABEL: Readonly> = { + notice: '通知', + elicitation: '跨任务征答', + 'dead-letter': '死信', + unowned: '无主入站', + receipt: '回执', +}; + +export type SystemMessage = { + readonly id: string; + readonly kind: MessageKind; + /** 收件人:员工;跨任务卡按岗位解析出人后落到具体员工 */ + readonly staffId: string; + /** 岗位收件(无主兜底按默认认领岗位投递) */ + readonly positionId?: string; + readonly title: string; + readonly body: string; + readonly at: string; + readonly readAt?: string; + /** 跨任务征答:卡本体与来源任务 —— 就地作答,不开放任务对话流 */ + readonly card?: { + readonly cardId: string; + readonly sourceTaskId: string; + readonly payload: Record; + readonly answeredAt?: string; + /** 驳回重答:新卡实例,附驳回理由与原答引用;原卡闭合 —— 只增不改 */ + readonly supersededBy?: string; + readonly rejectedReason?: string; + }; + readonly eventRef?: string; +}; + +/** + * 跨人卡是**一次性的解答请求**,不是责任转移 —— + * 作答权限只及卡面与决策包,不开放任务对话流(OS 2.1)。 + */ +export function canAnswer(message: SystemMessage, staffId: string): boolean { + if (message.kind !== 'elicitation') return false; + if (message.staffId !== staffId) return false; + if (message.card === undefined) return false; + // 已作答或已被驳回重答闭合的卡不得再答 —— 每次请求与回答都是独立消息 + return message.card.answeredAt === undefined && message.card.supersededBy === undefined; +} + +/** 未读计数:消息不产生责任,只做信息提示 */ +export function unreadCount(messages: readonly SystemMessage[]): number { + return messages.filter((m) => m.readAt === undefined).length; +} + +/** 待答计数:跨任务征答里还没作答的 */ +export function pendingAnswerCount(messages: readonly SystemMessage[]): number { + return messages.filter((m) => m.kind === 'elicitation' && m.card?.answeredAt === undefined && m.card?.supersededBy === undefined) + .length; +} diff --git a/apps/api/src/modules/message/internal/usecase/message-service.ts b/apps/api/src/modules/message/internal/usecase/message-service.ts new file mode 100644 index 0000000..f8af6c3 --- /dev/null +++ b/apps/api/src/modules/message/internal/usecase/message-service.ts @@ -0,0 +1,125 @@ +/** + * message 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。 + */ +import type { StaffId } from '@contracts'; +import type { MessageService, SendElicitation, SendNotice } from '../../contract.ts'; +import { MessageNotAnswerableError } from '../../contract.ts'; +import type { Clock, IdGenerator, MessageRepository, PositionResolver } from '../../port.ts'; +import type { MessageKind, SystemMessage } from '../domain/message-kind.ts'; +import { canAnswer } from '../domain/message-kind.ts'; + +export type MessageServiceDeps = { + readonly repo: MessageRepository; + readonly org: PositionResolver; + readonly clock: Clock; + readonly ids: IdGenerator; +}; + +export function createMessageService(deps: MessageServiceDeps): MessageService { + function build( + kind: MessageKind, + staffId: StaffId, + title: string, + body: string, + extra: Partial = {}, + ): SystemMessage { + return { + id: deps.ids.next(), + kind, + staffId, + title, + body, + at: deps.clock.now().toISOString(), + ...extra, + }; + } + + async function fanOut(kind: MessageKind, staff: ReadonlyArray<{ id: StaffId }>, title: string, body: string, eventRef?: string) { + for (const person of staff) { + await deps.repo.insert(build(kind, person.id, title, body, eventRef === undefined ? {} : { eventRef })); + } + } + + return { + async inbox(staffId, options) { + return deps.repo.list(staffId, options?.unreadOnly ?? false, options?.limit ?? 100); + }, + + async markRead(messageId) { + await deps.repo.markRead(messageId, deps.clock.now()); + }, + + async notify(input: SendNotice) { + return deps.repo.insert( + build('notice', input.staffId, input.title, input.body, input.eventRef === undefined ? {} : { eventRef: input.eventRef }), + ); + }, + + async sendElicitation(input: SendElicitation) { + return deps.repo.insert( + build('elicitation', input.staffId, input.title, input.body, { + card: { cardId: input.cardId, sourceTaskId: input.sourceTaskId, payload: input.payload }, + }), + ); + }, + + async answer(messageId, staffId, answer) { + const message = await deps.repo.get(messageId); + if (message === undefined || !canAnswer(message, staffId)) { + throw new MessageNotAnswerableError(messageId); + } + await deps.repo.markAnswered(messageId, deps.clock.now(), answer); + const updated = await deps.repo.get(messageId); + if (updated === undefined) throw new MessageNotAnswerableError(messageId); + return updated; + }, + + /** 驳回重答:新卡实例(附驳回理由与原答引用),原卡闭合 —— 每次请求与回答都是独立消息 */ + async rejectAndReask(messageId, reason) { + const original = await deps.repo.get(messageId); + if (original === undefined || original.card === undefined) { + throw new MessageNotAnswerableError(messageId); + } + const reask = build('elicitation', original.staffId as StaffId, original.title, original.body, { + card: { + cardId: deps.ids.next(), + sourceTaskId: original.card.sourceTaskId, + payload: original.card.payload, + rejectedReason: reason, + }, + }); + const created = await deps.repo.insert(reask); + await deps.repo.supersede(messageId, created.id, reason); + return created; + }, + + /** 死信:送相关人的消息要素**与**治理台 · 任务管理(OS 6.3) */ + async deadLetter(envelope, error) { + const governance = await deps.org.governanceStaff(); + await fanOut( + 'dead-letter', + governance, + `事件投递失败入死信:${envelope.type}`, + `事件 ${envelope.id} 重试超限。原因:${error}`, + envelope.trace.storeRef, + ); + }, + + /** 无主兜底:相关人 = 通道实例配置的默认认领岗位;未配置则落治理台 */ + async unowned(envelope, fallbackPosition) { + const recipients = + fallbackPosition === undefined + ? await deps.org.governanceStaff() + : await deps.org.staffOfPosition(fallbackPosition); + const targets = recipients.length > 0 ? recipients : await deps.org.governanceStaff(); + const subject = typeof envelope.payload.data['subject'] === 'string' ? envelope.payload.data['subject'] : '(无主题)'; + await fanOut( + 'unowned', + targets, + `无主入站待认领:${subject}`, + `事件 ${envelope.id} 未命中任何任务监听与触发器。原件:${envelope.payload.originRef ?? '无'}`, + envelope.trace.storeRef, + ); + }, + }; +} diff --git a/apps/api/src/modules/message/package.json b/apps/api/src/modules/message/package.json new file mode 100644 index 0000000..701eb05 --- /dev/null +++ b/apps/api/src/modules/message/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/message", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/message/port.ts b/apps/api/src/modules/message/port.ts new file mode 100644 index 0000000..0fccf2e --- /dev/null +++ b/apps/api/src/modules/message/port.ts @@ -0,0 +1,24 @@ +/** + * module: message —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + */ +import type { StaffId } from '@contracts'; +import type { SystemMessage } from './internal/domain/message-kind.ts'; + +export type Clock = { now(): Date }; +export type IdGenerator = { next(): string }; + +export type MessageRepository = { + list(staffId: StaffId, unreadOnly: boolean, limit: number): Promise; + get(messageId: string): Promise; + insert(message: SystemMessage): Promise; + markRead(messageId: string, at: Date): Promise; + markAnswered(messageId: string, at: Date, answer: Record): Promise; + supersede(messageId: string, byMessageId: string, reason: string): Promise; +}; + +/** 岗位解析 —— app 注入 `org`(L1):无主兜底要把消息落到默认认领岗位上的人 */ +export type PositionResolver = { + staffOfPosition(positionId: string): Promise>; + /** 治理台收件人(任务管理岗)—— 死信同时送治理台 */ + governanceStaff(): Promise>; +}; diff --git a/apps/api/src/modules/toolbox/CONTRACT.md b/apps/api/src/modules/toolbox/CONTRACT.md new file mode 100644 index 0000000..dfacb74 --- /dev/null +++ b/apps/api/src/modules/toolbox/CONTRACT.md @@ -0,0 +1,36 @@ +# module: toolbox + +职责: 功能工具注册与两审、调用契约、效应等级与运行时待遇、连接配置声明与凭证绑定、 + 白名单授权、调用留痕、纯工具的活动私有作用域、`ToolInvoker`(**两条缝之二**) +不负责: 动作意向的存放与放行落地(见 ledger 的 `ActionIntent` 与 release)、 + 人类工具与 LLM 工具(前者见 human-tool,后者是本模块的一类工具契约) + +公开出口: index.ts + - 类型:`ToolboxService` `ToolInvoker` `ToolContract` `ToolCall` `ToolResult` `CallDisposition` + `EffectLevel` `RegisterToolInput` `RegistrationVerdict` `AuditFinding` `Preauthorization` + - 错误:`ToolNotFoundError` `ToolNotAllowedError` `ToolDisabledError` `ToolReplayMissError` + `RegistrationRejectedError` + - 端口类型:`ToolRegistry` `ToolRuntime` `ToolReplayStore` `SchemaValidator` `SecretBox` `Clock` + - 用例工厂:`createToolboxService` `createToolInvoker` + - 纯函数:`dispositionOf` `checkEvolution` `staticAudit` `auditPassed` `toolReplayKey` + - adapter:`createToolRegistry` `createToolReplayStore` `createSchemaValidator` `createToolRuntime` + - Drizzle 表:`toolTable` `toolAllowlistTable` `replayToolTable` + +层级: L2(编码规范 §4) +依赖: @contracts;@shared/db;port:`SecretBox`(app 注入,与 llm 同一实现)、`ToolRuntime` +被依赖: channel, release, activity-registry, skill-engine, loop-engine, flow-engine, task, + app(治理台 · 工具管理) + +不变量: + - **一切工具执行经 `ToolInvoker`** —— 绕过它,回放审就对它失明(§12.5#10) + - `replay` 模式**绝不回退实调**:缺录即 `ToolReplayMissError` + - **白名单在结构上拦截**:功能工具按活动白名单,纯工具按 `ownerActivityId` 私有作用域; + 不在名单即 `ToolNotAllowedError`,活动代码无从绕开 + - **效应等级决定待遇**:读类即调;写类登记为动作意向;唯一例外是契约**显式逐项列举**的预授权 + - **契约演进只增不破坏**:出参只增字段、入参新增必为可选、效应等级不得变更; + 破坏性变更 = 新工具名 + - **注册两审**:静态审有 error 即不上架;试运行审要求样例集非空且全过 + - **凭证隔离**:连接值加密落库,运行时只把**申报过的连接键**交给处理器,不透传其它环境值 + - 停用后调用返回结构化错误(`ToolDisabledError`),不静默成功 + +影响半径: index/contract/port 未变 → 对外零影响 diff --git a/apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts b/apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts new file mode 100644 index 0000000..a6cf50c --- /dev/null +++ b/apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts @@ -0,0 +1,139 @@ +/** 调用契约、效应等级与注册静态审 —— domain 测试零 mock(OS 7.3) */ +import { describe, expect, it } from 'vitest'; +import { checkEvolution, dispositionOf } from '../internal/domain/tool-contract.ts'; +import type { ToolContract } from '../internal/domain/tool-contract.ts'; +import { auditPassed, staticAudit } from '../internal/domain/registration-audit.ts'; + +const contract = (over: Partial = {}): ToolContract => ({ + name: 'crm.customer-read', + version: '1.0.0', + description: '读取客户资料库中的客户档案,用于取客户等级与付款条款', + input: { type: 'object', properties: { customerCode: { type: 'string' } }, required: ['customerCode'] }, + output: { type: 'object', properties: { grade: { type: 'string' }, terms: { type: 'string' } } }, + effect: 'read', + errors: [{ code: 'not-found', retryable: false, message: '客户不存在' }], + idempotent: true, + timeoutMs: 15_000, + retries: 2, + connectionKeys: ['endpoint', 'apiKey'], + ...over, +}); + +describe('dispositionOf —— 效应等级决定运行时待遇', () => { + it('读类:过程中自由调用', () => { + expect(dispositionOf(contract(), 'read', [])).toEqual({ kind: 'invoke-now', reason: 'read' }); + }); + + it('写类:登记为动作意向,集中在放行时落地', () => { + expect(dispositionOf(contract({ effect: 'write' }), 'send', [])).toEqual({ kind: 'register-intent' }); + }); + + it('写类 + 显式预授权:过程中即时执行', () => { + const disposition = dispositionOf(contract({ effect: 'write', name: 'mail.send' }), 'nudge', [ + { tool: 'mail.send', operation: 'nudge' }, + ]); + expect(disposition).toEqual({ kind: 'invoke-now', reason: 'preauthorized' }); + }); + + it('预授权必须逐项列举:工具对了动作不对,仍是意向', () => { + expect( + dispositionOf(contract({ effect: 'write', name: 'mail.send' }), 'quote', [ + { tool: 'mail.send', operation: 'nudge' }, + ]), + ).toEqual({ kind: 'register-intent' }); + }); +}); + +describe('checkEvolution —— 契约演进只增不破坏', () => { + it('出参只增字段:合法', () => { + const next = contract({ + output: { type: 'object', properties: { grade: {}, terms: {}, risk: {} } }, + }); + expect(checkEvolution(contract(), next)).toEqual([]); + }); + + it('出参删字段:拦下', () => { + const next = contract({ output: { type: 'object', properties: { grade: {} } } }); + expect(checkEvolution(contract(), next).map((v) => v.field)).toEqual(['output.terms']); + }); + + it('入参新增必填:拦下', () => { + const next = contract({ + input: { + type: 'object', + properties: { customerCode: {}, region: {} }, + required: ['customerCode', 'region'], + }, + }); + expect(checkEvolution(contract(), next).map((v) => v.field)).toEqual(['input.region']); + }); + + it('入参新增可选:合法', () => { + const next = contract({ + input: { type: 'object', properties: { customerCode: {}, region: {} }, required: ['customerCode'] }, + }); + expect(checkEvolution(contract(), next)).toEqual([]); + }); + + it('入参删字段:拦下', () => { + const next = contract({ input: { type: 'object', properties: {}, required: [] } }); + expect(checkEvolution(contract(), next).map((v) => v.field)).toEqual(['input.customerCode']); + }); + + it('效应等级变更:拦下', () => { + expect(checkEvolution(contract(), contract({ effect: 'write', idempotent: true })).map((v) => v.field)).toContain( + 'effect', + ); + }); + + it('改名 = 破坏性变更,必须换新工具名', () => { + const v = checkEvolution(contract(), contract({ name: 'crm.customer-read-v2' })); + expect(v).toHaveLength(1); + expect(v[0]?.field).toBe('name'); + }); +}); + +describe('staticAudit —— 注册两审之静态审', () => { + it('规范契约通过', () => { + expect(auditPassed(staticAudit(contract()))).toBe(true); + }); + + it('工具名须是小写点分标识', () => { + expect(staticAudit(contract({ name: 'CRM_Read' })).map((f) => f.code)).toContain('name-invalid'); + }); + + it('描述过短即拦', () => { + expect(staticAudit(contract({ description: '读' })).map((f) => f.code)).toContain('description-missing'); + }); + + it('入参不是 object schema 即拦', () => { + expect(staticAudit(contract({ input: { type: 'string' } })).map((f) => f.code)).toContain('schema-invalid'); + }); + + it('写类工具必须声明幂等性 —— 落地要附幂等键', () => { + const findings = staticAudit(contract({ effect: 'write', idempotent: false })); + expect(findings.map((f) => f.code)).toContain('idempotency-missing'); + expect(auditPassed(findings)).toBe(false); + }); + + it('超时须在合理区间', () => { + expect(staticAudit(contract({ timeoutMs: 0 })).map((f) => f.code)).toContain('timeout-invalid'); + expect(staticAudit(contract({ timeoutMs: 999_999 })).map((f) => f.code)).toContain('timeout-invalid'); + }); + + it('纯工具:无连接配置、无外效应、绑活动作用域', () => { + const pure = contract({ pure: true, connectionKeys: [], effect: 'read', ownerActivityId: 'act-1' }); + expect(auditPassed(staticAudit(pure))).toBe(true); + + expect(staticAudit({ ...pure, connectionKeys: ['x'] }).map((f) => f.code)).toContain('pure-tool-has-connection'); + expect(staticAudit({ ...pure, effect: 'write', idempotent: true }).map((f) => f.code)).toContain('effect-invalid'); + const unscoped = staticAudit({ ...pure, ownerActivityId: undefined }); + expect(unscoped.map((f) => f.code)).toContain('pure-tool-not-scoped'); + }); + + it('功能工具没申报连接目标只给告警,不阻塞', () => { + const findings = staticAudit(contract({ connectionKeys: [] })); + expect(findings.map((f) => f.code)).toContain('connection-keys-missing'); + expect(auditPassed(findings)).toBe(true); + }); +}); diff --git a/apps/api/src/modules/toolbox/contract.ts b/apps/api/src/modules/toolbox/contract.ts new file mode 100644 index 0000000..1abdd80 --- /dev/null +++ b/apps/api/src/modules/toolbox/contract.ts @@ -0,0 +1,129 @@ +/** + * module: toolbox —— 对外类型(编码规范 §10.1)。 + * + * 两条缝之二(§7.3):`ToolInvoker` 支持 record | replay | live —— + * **任何工具执行不经 ToolInvoker,回放审就对它失明**(§12.5#10)。 + */ +import type { ActionIntent, TaskId } from '@contracts'; +import type { + AuditFinding, + } from './internal/domain/registration-audit.ts'; +import type { + CallDisposition, + EffectLevel, + EvolutionViolation, + Preauthorization, + ToolContract, +} from './internal/domain/tool-contract.ts'; + +export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, Preauthorization, ToolContract }; + +export const INVOKER_MODES = ['live', 'record', 'replay'] as const; +export type InvokerMode = (typeof INVOKER_MODES)[number]; + +export class ToolNotFoundError extends Error { + constructor(readonly tool: string) { + super(`工具未注册:${tool}`); + this.name = 'ToolNotFoundError'; + } +} + +export class ToolNotAllowedError extends Error { + constructor( + readonly tool: string, + readonly activityId: string, + ) { + super(`工具不在本活动白名单:${tool}(活动 ${activityId})—— 越权在结构上不可能`); + this.name = 'ToolNotAllowedError'; + } +} + +export class ToolDisabledError extends Error { + constructor(readonly tool: string) { + super(`工具已停用:${tool} —— 活动按失败路径处理`); + this.name = 'ToolDisabledError'; + } +} + +export class ToolReplayMissError extends Error { + constructor(readonly key: string) { + super(`工具回放缺录:${key} —— 回放模式下不得回退为实调`); + this.name = 'ToolReplayMissError'; + } +} + +export class RegistrationRejectedError extends Error { + constructor(readonly findings: readonly AuditFinding[]) { + super(`工具注册未过审:${findings.map((f) => f.message).join('; ')}`); + this.name = 'RegistrationRejectedError'; + } +} + +export type ToolCall = { + readonly tool: string; + readonly operation?: string; + readonly input: Record; + readonly activityId: string; + readonly taskId?: TaskId; + readonly stepNo?: number; +}; + +export type ToolResult = { + readonly ok: boolean; + readonly output?: Record; + readonly error?: { readonly code: string; readonly retryable: boolean; readonly message: string }; + readonly latencyMs: number; + readonly mode: InvokerMode; +}; + +/** 调用留痕:入参快照、出参、时延与错误自动留痕入任务记忆(OS 7.3) */ +export type InvocationTrace = { + readonly tool: string; + readonly input: Record; + readonly result: ToolResult; + readonly at: string; +}; + +/** + * 工具调用器 —— 活动基座六件里的「工具(调用契约)」。 + * **读类**过程中自由调用;**写类**登记为动作意向,放行后落地(除契约显式预授权者)。 + */ +export type ToolInvoker = { + readonly mode: InvokerMode; + /** 判定运行时待遇 —— 调用方据此决定是即调还是登记意向 */ + disposition(call: ToolCall, preauthorized: readonly Preauthorization[]): Promise; + invoke(call: ToolCall): Promise; + /** 落地预检第四项:工具契约含 validate 能力时调之(ADR-005) */ + validate(call: ToolCall): Promise; + withMode(mode: InvokerMode): ToolInvoker; +}; + +export type RegisterToolInput = { + readonly contract: ToolContract; + /** 契约测试集:样例入参 → 期望出参形状;试运行审的依据 */ + readonly samples: ReadonlyArray<{ input: Record; expectShape: Record }>; + /** 连接配置的值 —— 只存治理台,加密落库(ADR-009) */ + readonly connectionValues?: Readonly>; +}; + +export type RegistrationVerdict = { + readonly accepted: boolean; + readonly staticFindings: readonly AuditFinding[]; + readonly evolution: readonly EvolutionViolation[]; + readonly trialRun: { readonly ran: number; readonly passed: number; readonly failures: readonly string[] }; +}; + +export type ToolboxService = { + /** 注册两审:静态审读清单,试运行审验代码 */ + register(input: RegisterToolInput): Promise; + list(): Promise; + get(name: string): Promise; + /** 有活动声明引用的工具不得删除;可停用 —— 停用后调用返回结构化错误 */ + setEnabled(name: string, enabled: boolean): Promise; + bindConnection(name: string, values: Readonly>): Promise; + /** 白名单:活动只经调用契约用工具,授权在白名单与契约,不在活动代码 */ + setAllowlist(activityId: string, tools: readonly string[]): Promise; + allowlistOf(activityId: string): Promise; + /** 安装静态审:活动声明的工具契约齐备?装不齐不上架 */ + assertContractsAvailable(names: readonly string[]): Promise<{ ok: boolean; missing: string[] }>; +}; diff --git a/apps/api/src/modules/toolbox/index.ts b/apps/api/src/modules/toolbox/index.ts new file mode 100644 index 0000000..dd60184 --- /dev/null +++ b/apps/api/src/modules/toolbox/index.ts @@ -0,0 +1,56 @@ +/** + * module: toolbox —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + ActionIntent, + AuditFinding, + CallDisposition, + EffectLevel, + EvolutionViolation, + InvocationTrace, + InvokerMode, + Preauthorization, + RegisterToolInput, + RegistrationVerdict, + ToolCall, + ToolContract, + ToolInvoker, + ToolResult, + ToolboxService, +} from './contract.ts'; +export { + INVOKER_MODES, + RegistrationRejectedError, + ToolDisabledError, + ToolNotAllowedError, + ToolNotFoundError, + ToolReplayMissError, +} from './contract.ts'; + +export type { + Clock, + IdGenerator, + SchemaValidator, + SecretBox, + ToolRegistry, + ToolReplayStore, + ToolRuntime, +} from './port.ts'; + +export type { ToolInvokerDeps } from './internal/usecase/tool-invoker.ts'; +export { createToolInvoker, toolReplayKey } from './internal/usecase/tool-invoker.ts'; +export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts'; +export { createToolboxService } from './internal/usecase/toolbox-service.ts'; + +export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts'; +export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts'; + +export type { ToolHandler } from './internal/adapter/toolbox-store.ts'; +export { + createSchemaValidator, + createToolRegistry, + createToolReplayStore, + createToolRuntime, + isAllowed, +} from './internal/adapter/toolbox-store.ts'; +export { replayToolTable, toolAllowlistTable, toolTable } from './internal/adapter/toolbox-schema.ts'; diff --git a/apps/api/src/modules/toolbox/internal/adapter/toolbox-schema.ts b/apps/api/src/modules/toolbox/internal/adapter/toolbox-schema.ts new file mode 100644 index 0000000..f766217 --- /dev/null +++ b/apps/api/src/modules/toolbox/internal/adapter/toolbox-schema.ts @@ -0,0 +1,48 @@ +/** + * toolbox 的 sys schema 表(编码规范 §7 / ADR-002 / ADR-009)。 + */ +import { boolean, index, integer, jsonb, primaryKey, text, timestamp } from 'drizzle-orm/pg-core'; +import { sys } from '@shared/db.ts'; + +export const toolTable = sys.table( + 'tool', + { + name: text('name').primaryKey(), + version: text('version').notNull(), + description: text('description').notNull(), + input: jsonb('input').notNull(), + output: jsonb('output').notNull(), + effect: text('effect').notNull(), + errors: jsonb('errors').notNull().default([]), + idempotent: boolean('idempotent').notNull().default(false), + timeoutMs: integer('timeout_ms').notNull().default(30_000), + retries: integer('retries').notNull().default(1), + connectionKeys: jsonb('connection_keys').notNull().default([]), + capabilities: jsonb('capabilities'), + pure: boolean('pure').notNull().default(false), + ownerActivityId: text('owner_activity_id'), + /** 凭证隔离:值加密落库,不入包、不入活动、不入留痕明文(ADR-009) */ + connectionCipher: jsonb('connection_cipher'), + enabled: boolean('enabled').notNull().default(true), + registeredAt: timestamp('registered_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index('tool_effect_idx').on(t.effect), index('tool_owner_idx').on(t.ownerActivityId)], +); + +/** 白名单:授权在白名单与契约,不在活动代码(OS 04) */ +export const toolAllowlistTable = sys.table( + 'tool_allowlist', + { + activityId: text('activity_id').notNull(), + tool: text('tool').notNull(), + }, + (t) => [primaryKey({ columns: [t.activityId, t.tool] })], +); + +/** 工具录制回放 —— 与 llm 的 replay_model 同构(§7.3 两缝) */ +export const replayToolTable = sys.table('replay_tool', { + key: text('key').primaryKey(), + call: jsonb('call').notNull(), + result: jsonb('result').notNull(), + recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts b/apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts new file mode 100644 index 0000000..1fe76ce --- /dev/null +++ b/apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts @@ -0,0 +1,213 @@ +/** + * toolbox 存储与运行时 adapter —— Drizzle / ajv 只准出现在 adapter(编码规范 §5.3 / §11.4)。 + */ +import { Ajv } from 'ajv'; +import addFormats from 'ajv-formats'; +import { and, eq } from 'drizzle-orm'; +import type { Db } from '@shared/db.ts'; +import type { SchemaValidator, ToolRegistry, ToolReplayStore, ToolRuntime } from '../../port.ts'; +import type { ToolCall, ToolResult } from '../../contract.ts'; +import type { EffectLevel, ToolContract } from '../domain/tool-contract.ts'; +import { replayToolTable, toolAllowlistTable, toolTable } from './toolbox-schema.ts'; + +type Row = typeof toolTable.$inferSelect; + +function toContract(row: Row): ToolContract { + return { + name: row.name, + version: row.version, + description: row.description, + input: row.input as Record, + output: row.output as Record, + effect: row.effect as EffectLevel, + errors: row.errors as ToolContract['errors'], + idempotent: row.idempotent, + timeoutMs: row.timeoutMs, + retries: row.retries, + connectionKeys: row.connectionKeys as string[], + ...(row.capabilities === null ? {} : { capabilities: row.capabilities as ToolContract['capabilities'] }), + pure: row.pure, + ...(row.ownerActivityId === null ? {} : { ownerActivityId: row.ownerActivityId }), + }; +} + +export function createToolRegistry(db: Db): ToolRegistry { + return { + async list() { + const rows = await db.select().from(toolTable).orderBy(toolTable.name); + return rows.map(toContract); + }, + async get(name) { + const rows = await db.select().from(toolTable).where(eq(toolTable.name, name)).limit(1); + return rows[0] === undefined ? undefined : toContract(rows[0]); + }, + async isEnabled(name) { + const rows = await db.select({ enabled: toolTable.enabled }).from(toolTable).where(eq(toolTable.name, name)).limit(1); + return rows[0]?.enabled ?? false; + }, + async upsert(contract, connectionCipher) { + const values = { + name: contract.name, + version: contract.version, + description: contract.description, + input: contract.input, + output: contract.output, + effect: contract.effect, + errors: contract.errors, + idempotent: contract.idempotent, + timeoutMs: contract.timeoutMs, + retries: contract.retries, + connectionKeys: contract.connectionKeys, + capabilities: contract.capabilities ?? null, + pure: contract.pure ?? false, + ownerActivityId: contract.ownerActivityId ?? null, + connectionCipher, + }; + await db + .insert(toolTable) + .values(values) + .onConflictDoUpdate({ + target: toolTable.name, + set: { + version: values.version, + description: values.description, + input: values.input, + output: values.output, + effect: values.effect, + errors: values.errors, + idempotent: values.idempotent, + timeoutMs: values.timeoutMs, + retries: values.retries, + connectionKeys: values.connectionKeys, + capabilities: values.capabilities, + pure: values.pure, + ownerActivityId: values.ownerActivityId, + // 未传凭证即保持原值(只写不读) + ...(connectionCipher === null ? {} : { connectionCipher }), + }, + }); + }, + async setEnabled(name, enabled) { + await db.update(toolTable).set({ enabled }).where(eq(toolTable.name, name)); + }, + async connectionCipher(name) { + const rows = await db.select({ c: toolTable.connectionCipher }).from(toolTable).where(eq(toolTable.name, name)).limit(1); + const value = rows[0]?.c; + return value === null || value === undefined ? undefined : (value as Record); + }, + async setAllowlist(activityId, tools) { + await db.transaction(async (tx) => { + await tx.delete(toolAllowlistTable).where(eq(toolAllowlistTable.activityId, activityId)); + if (tools.length === 0) return; + await tx + .insert(toolAllowlistTable) + .values(tools.map((tool) => ({ activityId, tool }))) + .onConflictDoNothing(); + }); + }, + async allowlistOf(activityId) { + const rows = await db + .select({ tool: toolAllowlistTable.tool }) + .from(toolAllowlistTable) + .where(eq(toolAllowlistTable.activityId, activityId)); + return rows.map((r) => r.tool); + }, + async referencedBy(name) { + const rows = await db + .select({ activityId: toolAllowlistTable.activityId }) + .from(toolAllowlistTable) + .where(eq(toolAllowlistTable.tool, name)); + return rows.map((r) => r.activityId); + }, + }; +} + +export function createToolReplayStore(db: Db): ToolReplayStore { + return { + async put(record) { + await db + .insert(replayToolTable) + .values({ key: record.key, call: record.call, result: record.result, recordedAt: record.recordedAt }) + .onConflictDoUpdate({ + target: replayToolTable.key, + set: { call: record.call, result: record.result, recordedAt: record.recordedAt }, + }); + }, + async get(key) { + const rows = await db.select().from(replayToolTable).where(eq(replayToolTable.key, key)).limit(1); + const row = rows[0]; + if (row === undefined) return undefined; + return { call: row.call as ToolCall, result: row.result as ToolResult }; + }, + }; +} + +/** ADR-003:声明层 = JSON Schema 2020-12 子集,运行时校验用 ajv */ +export function createSchemaValidator(): SchemaValidator { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + return { + validate(schema, value) { + const validate = ajv.compile(schema); + const ok = validate(value) === true; + return { + ok, + errors: ok ? [] : (validate.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message ?? ''}`.trim()), + }; + }, + }; +} + +/** + * 工具执行运行时 —— 实现对系统。 + * 处理器由 app 注册(第一方功能工具与纯工具);运行时**只把申报的连接目标交给处理器**, + * 不提供任何其它环境能力(OS 7.3)。 + */ +export type ToolHandler = (input: { + readonly input: Record; + readonly connection: Readonly>; + readonly operation: string | undefined; + readonly idempotencyKey: string | undefined; +}) => Promise<{ ok: boolean; output?: Record; error?: { code: string; retryable: boolean; message: string } }>; + +export function createToolRuntime(handlers: ReadonlyMap): ToolRuntime { + return { + async execute({ contract, connection, call, idempotencyKey }) { + const handler = handlers.get(contract.name); + if (handler === undefined) { + return { ok: false, error: { code: 'tool-unimplemented', retryable: false, message: `工具无实现:${contract.name}` } }; + } + // 只把申报的连接键交出去,多余的环境值一律不透传 + const scoped: Record = {}; + for (const key of contract.connectionKeys) { + const value = connection[key]; + if (value !== undefined) scoped[key] = value; + } + const timeout = new Promise<{ ok: false; error: { code: string; retryable: boolean; message: string } }>((resolve) => + setTimeout( + () => resolve({ ok: false, error: { code: 'timeout', retryable: true, message: `超时 ${contract.timeoutMs}ms` } }), + contract.timeoutMs, + ), + ); + return Promise.race([ + handler({ + input: call.input, + connection: scoped, + operation: call.operation, + idempotencyKey, + }), + timeout, + ]); + }, + }; +} + +/** 供 app 使用的辅助:把 `and`/`eq` 的组合暴露给白名单查询(保持 adapter 内聚) */ +export async function isAllowed(db: Db, activityId: string, tool: string): Promise { + const rows = await db + .select({ tool: toolAllowlistTable.tool }) + .from(toolAllowlistTable) + .where(and(eq(toolAllowlistTable.activityId, activityId), eq(toolAllowlistTable.tool, tool))) + .limit(1); + return rows.length > 0; +} diff --git a/apps/api/src/modules/toolbox/internal/domain/registration-audit.ts b/apps/api/src/modules/toolbox/internal/domain/registration-audit.ts new file mode 100644 index 0000000..359aa2b --- /dev/null +++ b/apps/api/src/modules/toolbox/internal/domain/registration-audit.ts @@ -0,0 +1,83 @@ +/** + * 注册两审的静态审 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 7.3)。 + * + * 静态审:签名、无动态执行、调用契约 schema 校验、效应等级核验、连接配置声明齐备。 + * 试运行审(以 mock 绑定跑契约测试集)在 usecase —— 它要跑代码,不是纯判定。 + */ +import type { ToolContract } from './tool-contract.ts'; + +export type AuditFinding = { + readonly code: + | 'name-invalid' + | 'description-missing' + | 'schema-invalid' + | 'effect-invalid' + | 'connection-keys-missing' + | 'timeout-invalid' + | 'idempotency-missing' + | 'pure-tool-has-connection' + | 'pure-tool-not-scoped'; + readonly severity: 'error' | 'warn'; + readonly message: string; +}; + +const NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/; + +export function staticAudit(contract: ToolContract): AuditFinding[] { + const out: AuditFinding[] = []; + + if (!NAME.test(contract.name)) { + out.push({ code: 'name-invalid', severity: 'error', message: '工具名须是小写点分标识,如 crm.customer-read' }); + } + if (contract.description.trim().length < 8) { + out.push({ + code: 'description-missing', + severity: 'error', + message: '描述是面向 AI 的适用说明(何时用、语义、边界),不得为空或过短', + }); + } + if (!isObjectSchema(contract.input) || !isObjectSchema(contract.output)) { + out.push({ code: 'schema-invalid', severity: 'error', message: '入参 / 出参必须是 object 型 JSON Schema' }); + } + if (contract.timeoutMs <= 0 || contract.timeoutMs > 300_000) { + out.push({ code: 'timeout-invalid', severity: 'error', message: '超时须在 (0, 300000] 毫秒之间' }); + } + if (contract.effect === 'write' && !contract.idempotent) { + // 写类动作执行时附幂等键 —— 契约必须声明幂等性(OS 7.3 调用运行时) + out.push({ + code: 'idempotency-missing', + severity: 'error', + message: '写类工具必须声明幂等性:放行落地时要附幂等键', + }); + } + + if (contract.pure === true) { + // 纯工具的简化规格:无连接配置、无外效应、活动私有作用域 + if (contract.connectionKeys.length > 0) { + out.push({ code: 'pure-tool-has-connection', severity: 'error', message: '纯工具不得有连接配置' }); + } + if (contract.effect !== 'read') { + out.push({ code: 'effect-invalid', severity: 'error', message: '纯工具无外效应,效应等级只能是读' }); + } + if (contract.ownerActivityId === undefined) { + out.push({ code: 'pure-tool-not-scoped', severity: 'error', message: '纯工具须绑定所属活动(私有作用域)' }); + } + } else if (contract.connectionKeys.length === 0) { + // 功能工具触达外界,必须申报连接目标 —— 运行时只可访问注册申报的连接目标 + out.push({ + code: 'connection-keys-missing', + severity: 'warn', + message: '功能工具未申报连接配置键:运行时将无任何可访问目标', + }); + } + + return out; +} + +export function auditPassed(findings: readonly AuditFinding[]): boolean { + return !findings.some((f) => f.severity === 'error'); +} + +function isObjectSchema(schema: Record): boolean { + return schema['type'] === 'object' && typeof schema['properties'] === 'object' && schema['properties'] !== null; +} diff --git a/apps/api/src/modules/toolbox/internal/domain/tool-contract.ts b/apps/api/src/modules/toolbox/internal/domain/tool-contract.ts new file mode 100644 index 0000000..3e448b7 --- /dev/null +++ b/apps/api/src/modules/toolbox/internal/domain/tool-contract.ts @@ -0,0 +1,107 @@ +/** + * 调用契约与效应等级 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 7.3)。 + * + * 工具承载**权力**,约束严于活动包,三面分离:契约对活动,实现对系统,凭证对治理台。 + */ + +/** 效应等级决定运行时待遇 —— 读类自由调,写类登记为动作意向 */ +export const EFFECT_LEVELS = ['read', 'write'] as const; +export type EffectLevel = (typeof EFFECT_LEVELS)[number]; + +export const EFFECT_LEVEL_LABEL: Readonly> = { read: '读', write: '写' }; + +export type ToolContract = { + /** 全局唯一逻辑名 */ + readonly name: string; + readonly version: string; + /** 面向 AI 的适用说明:何时用、语义、边界 */ + readonly description: string; + readonly input: Record; + readonly output: Record; + readonly effect: EffectLevel; + /** 结构化错误码 + 可重试标志 */ + readonly errors: ReadonlyArray<{ code: string; retryable: boolean; message: string }>; + readonly idempotent: boolean; + readonly timeoutMs: number; + readonly retries: number; + /** 连接配置声明:只声明键,值不入包(OS 7.3 凭证隔离) */ + readonly connectionKeys: readonly string[]; + /** ADR-005:`capabilities.validate` 存在即在落地预检时调 */ + readonly capabilities?: { readonly validate?: boolean | undefined } | undefined; + /** 纯工具:无外效应,随活动包自带,作用域是活动私有 */ + readonly pure?: boolean | undefined; + readonly ownerActivityId?: string | undefined; +}; + +/* ── 运行时待遇(OS 7.3) ────────────────────────────────────────────── */ + +export type CallDisposition = + /** 读类:过程中可自由调用(白名单内),读取即快照留痕 */ + | { readonly kind: 'invoke-now'; readonly reason: 'read' } + /** 契约显式预授权的写类动作可在过程中即时执行 —— 预授权必须逐项列举 */ + | { readonly kind: 'invoke-now'; readonly reason: 'preauthorized' } + /** 写类:登记为动作意向(入参冻结,存入草稿态),放行后由系统执行落地 */ + | { readonly kind: 'register-intent' }; + +export type Preauthorization = { readonly tool: string; readonly operation: string }; + +export function dispositionOf( + contract: ToolContract, + operation: string, + preauthorized: readonly Preauthorization[], +): CallDisposition { + if (contract.effect === 'read') return { kind: 'invoke-now', reason: 'read' }; + const listed = preauthorized.some((p) => p.tool === contract.name && p.operation === operation); + return listed ? { kind: 'invoke-now', reason: 'preauthorized' } : { kind: 'register-intent' }; +} + +/* ── 契约演进只增不破坏(OS 7.3 必须) ─────────────────────────────── */ + +export type EvolutionViolation = { readonly field: string; readonly message: string }; + +/** + * 出参**只增字段**;入参新增字段**必为可选**;破坏性变更 = 新工具名。 + * 这里只检查 JSON Schema 的 properties / required 两面 —— 声明层是 JSON Schema 子集(ADR-003)。 + */ +export function checkEvolution(previous: ToolContract, next: ToolContract): EvolutionViolation[] { + const out: EvolutionViolation[] = []; + if (previous.name !== next.name) { + out.push({ field: 'name', message: '破坏性变更必须换新工具名,不得原地改名' }); + return out; + } + if (previous.effect !== next.effect) { + out.push({ field: 'effect', message: `效应等级不得变更(${previous.effect} → ${next.effect})` }); + } + + const prevOut = propertyNames(previous.output); + for (const field of prevOut) { + if (!propertyNames(next.output).includes(field)) { + out.push({ field: `output.${field}`, message: '出参只增字段,不得删除' }); + } + } + + const prevRequired = requiredNames(previous.input); + const nextRequired = requiredNames(next.input); + for (const field of nextRequired) { + if (!prevRequired.includes(field)) { + out.push({ field: `input.${field}`, message: '入参新增字段必为可选,不得直接必填' }); + } + } + for (const field of propertyNames(previous.input)) { + if (!propertyNames(next.input).includes(field)) { + out.push({ field: `input.${field}`, message: '入参不得删除已有字段' }); + } + } + return out; +} + +function propertyNames(schema: Record): string[] { + const props = schema['properties']; + if (props === null || typeof props !== 'object') return []; + return Object.keys(props as Record); +} + +function requiredNames(schema: Record): string[] { + const req = schema['required']; + return Array.isArray(req) ? req.filter((x): x is string => typeof x === 'string') : []; +} diff --git a/apps/api/src/modules/toolbox/internal/usecase/tool-invoker.ts b/apps/api/src/modules/toolbox/internal/usecase/tool-invoker.ts new file mode 100644 index 0000000..b5397b7 --- /dev/null +++ b/apps/api/src/modules/toolbox/internal/usecase/tool-invoker.ts @@ -0,0 +1,137 @@ +/** + * ToolInvoker —— 两条缝之二(编码规范 §7.3)。 + * + * 一切调用经调用契约:入参快照、出参、时延与错误自动留痕; + * 白名单在此把关 —— **越权在结构上不可能**(OS 04 基座即笼子)。 + */ +import type { CallDisposition, InvokerMode, ToolCall, ToolInvoker, ToolResult } from '../../contract.ts'; +import { + ToolDisabledError, + ToolNotAllowedError, + ToolNotFoundError, + ToolReplayMissError, +} from '../../contract.ts'; +import type { + Clock, + SchemaValidator, + SecretBox, + ToolRegistry, + ToolReplayStore, + ToolRuntime, +} from '../../port.ts'; +import { dispositionOf } from '../domain/tool-contract.ts'; + +export type ToolInvokerDeps = { + readonly registry: ToolRegistry; + readonly runtime: ToolRuntime; + readonly replay: ToolReplayStore; + readonly schemas: SchemaValidator; + readonly secrets: SecretBox; + readonly clock: Clock; + readonly mode: InvokerMode; +}; + +/** 录制回放定位:任务 × 步序号;缺定位退到工具名 + 入参指纹 */ +export function toolReplayKey(call: ToolCall): string { + if (call.taskId !== undefined && call.stepNo !== undefined) return `task:${call.taskId}#${call.stepNo}:${call.tool}`; + return `fp:${call.tool}:${hash(JSON.stringify(sortKeys(call.input)))}`; +} + +export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker { + async function loadContract(call: ToolCall) { + const contract = await deps.registry.get(call.tool); + if (contract === undefined) throw new ToolNotFoundError(call.tool); + + // 白名单:纯工具是活动私有作用域,功能工具按活动白名单 + if (contract.pure === true) { + if (contract.ownerActivityId !== call.activityId) throw new ToolNotAllowedError(call.tool, call.activityId); + } else { + const allowlist = await deps.registry.allowlistOf(call.activityId); + if (!allowlist.includes(call.tool)) throw new ToolNotAllowedError(call.tool, call.activityId); + } + return contract; + } + + async function run(call: ToolCall, idempotencyKey?: string): Promise { + const key = toolReplayKey(call); + + if (deps.mode === 'replay') { + const record = await deps.replay.get(key); + if (record === undefined) throw new ToolReplayMissError(key); + return { ...record.result, mode: 'replay' }; + } + + const contract = await loadContract(call); + if (!(await deps.registry.isEnabled(call.tool))) throw new ToolDisabledError(call.tool); + + const inputCheck = deps.schemas.validate(contract.input, call.input); + if (!inputCheck.ok) { + return { + ok: false, + error: { code: 'input-invalid', retryable: false, message: inputCheck.errors.join('; ') }, + latencyMs: 0, + mode: deps.mode, + }; + } + + const cipher = (await deps.registry.connectionCipher(call.tool)) ?? {}; + const connection: Record = {}; + for (const [k, v] of Object.entries(cipher)) connection[k] = deps.secrets.open(v); + + const started = deps.clock.now().getTime(); + const raw = await deps.runtime.execute({ + contract, + connection, + call, + ...(idempotencyKey === undefined ? {} : { idempotencyKey }), + }); + const latencyMs = deps.clock.now().getTime() - started; + + const result: ToolResult = { + ok: raw.ok, + ...(raw.output === undefined ? {} : { output: raw.output }), + ...(raw.error === undefined ? {} : { error: raw.error }), + latencyMs, + mode: deps.mode, + }; + + if (deps.mode === 'record') { + await deps.replay.put({ key, call, result, recordedAt: deps.clock.now() }); + } + return result; + } + + const invoker: ToolInvoker = { + mode: deps.mode, + + async disposition(call, preauthorized): Promise { + const contract = await loadContract(call); + return dispositionOf(contract, call.operation ?? call.tool, preauthorized); + }, + + invoke: (call) => run(call), + + async validate(call) { + const contract = await loadContract(call); + if (contract.capabilities?.validate !== true) return undefined; + return run({ ...call, operation: 'validate' }); + }, + + withMode(mode) { + return createToolInvoker({ ...deps, mode }); + }, + }; + return invoker; +} + +function sortKeys(value: Record): Record { + const out: Record = {}; + for (const key of Object.keys(value).sort()) out[key] = value[key]; + return out; +} + +function hash(text: string): string { + let h = 5381; + for (let i = 0; i < text.length; i += 1) h = ((h << 5) + h + text.charCodeAt(i)) >>> 0; + return h.toString(36); +} diff --git a/apps/api/src/modules/toolbox/internal/usecase/toolbox-service.ts b/apps/api/src/modules/toolbox/internal/usecase/toolbox-service.ts new file mode 100644 index 0000000..236dab3 --- /dev/null +++ b/apps/api/src/modules/toolbox/internal/usecase/toolbox-service.ts @@ -0,0 +1,115 @@ +/** + * toolbox 用例 —— 注册两审、白名单、凭证绑定(编码规范 §5.3;语义出处 OS 7.1 / 7.3)。 + */ +import type { RegisterToolInput, RegistrationVerdict, ToolboxService } from '../../contract.ts'; +import { RegistrationRejectedError } from '../../contract.ts'; +import type { Clock, SchemaValidator, SecretBox, ToolRegistry, ToolRuntime } from '../../port.ts'; +import { auditPassed, staticAudit } from '../domain/registration-audit.ts'; +import { checkEvolution } from '../domain/tool-contract.ts'; + +export type ToolboxServiceDeps = { + readonly registry: ToolRegistry; + readonly runtime: ToolRuntime; + readonly schemas: SchemaValidator; + readonly secrets: SecretBox; + readonly clock: Clock; +}; + +export function createToolboxService(deps: ToolboxServiceDeps): ToolboxService { + return { + /** 注册两审:静态审(读清单)+ 试运行审(以 mock 绑定跑契约测试集),全过方可上架 */ + async register(input: RegisterToolInput): Promise { + const staticFindings = staticAudit(input.contract); + + const previous = await deps.registry.get(input.contract.name); + const evolution = previous === undefined ? [] : checkEvolution(previous, input.contract); + + const failures: string[] = []; + let passed = 0; + if (auditPassed(staticFindings) && evolution.length === 0) { + for (const [index, sample] of input.samples.entries()) { + const inputCheck = deps.schemas.validate(input.contract.input, sample.input); + if (!inputCheck.ok) { + failures.push(`样例 ${index + 1} 入参不合契约:${inputCheck.errors.join('; ')}`); + continue; + } + const run = await deps.runtime.execute({ + contract: input.contract, + connection: {}, + call: { tool: input.contract.name, input: sample.input, activityId: '__trial__' }, + }); + if (!run.ok) { + failures.push(`样例 ${index + 1} 执行失败:${run.error?.message ?? '未知'}`); + continue; + } + const shapeCheck = deps.schemas.validate(sample.expectShape, run.output ?? {}); + if (!shapeCheck.ok) { + failures.push(`样例 ${index + 1} 出参形状不符:${shapeCheck.errors.join('; ')}`); + continue; + } + passed += 1; + } + } + + const accepted = + auditPassed(staticFindings) && evolution.length === 0 && failures.length === 0 && input.samples.length > 0; + + if (accepted) { + const cipher = + input.connectionValues === undefined + ? null + : Object.fromEntries(Object.entries(input.connectionValues).map(([k, v]) => [k, deps.secrets.seal(v)])); + await deps.registry.upsert(input.contract, cipher); + } + + return { + accepted, + staticFindings, + evolution, + trialRun: { ran: input.samples.length, passed, failures }, + }; + }, + + async list() { + return deps.registry.list(); + }, + + async get(name) { + return deps.registry.get(name); + }, + + async setEnabled(name, enabled) { + if (!enabled) { + // 有活动声明引用的工具不得删除;停用是合法动作,调用返回结构化错误 + await deps.registry.referencedBy(name); + } + await deps.registry.setEnabled(name, enabled); + }, + + async bindConnection(name, values) { + const contract = await deps.registry.get(name); + if (contract === undefined) { + throw new RegistrationRejectedError([ + { code: 'name-invalid', severity: 'error', message: `工具未注册:${name}` }, + ]); + } + const cipher = Object.fromEntries(Object.entries(values).map(([k, v]) => [k, deps.secrets.seal(v)])); + await deps.registry.upsert(contract, cipher); + }, + + async setAllowlist(activityId, tools) { + await deps.registry.setAllowlist(activityId, tools); + }, + + async allowlistOf(activityId) { + return deps.registry.allowlistOf(activityId); + }, + + async assertContractsAvailable(names) { + const all = await deps.registry.list(); + const known = new Set(all.map((c) => c.name)); + const missing = [...new Set(names)].filter((n) => !known.has(n)); + return { ok: missing.length === 0, missing }; + }, + }; +} diff --git a/apps/api/src/modules/toolbox/package.json b/apps/api/src/modules/toolbox/package.json new file mode 100644 index 0000000..5c6998c --- /dev/null +++ b/apps/api/src/modules/toolbox/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/toolbox", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/toolbox/port.ts b/apps/api/src/modules/toolbox/port.ts new file mode 100644 index 0000000..606af9c --- /dev/null +++ b/apps/api/src/modules/toolbox/port.ts @@ -0,0 +1,49 @@ +/** + * module: toolbox —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + */ +import type { ToolContract } from './internal/domain/tool-contract.ts'; +import type { ToolCall, ToolResult } from './contract.ts'; + +export type Clock = { now(): Date }; +export type IdGenerator = { next(): string }; + +/** 凭证密封 —— 与 llm 同一实现,由 app 注入(ADR-009) */ +export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string }; + +export type ToolRegistry = { + list(): Promise; + get(name: string): Promise; + isEnabled(name: string): Promise; + upsert(contract: ToolContract, connectionCipher: Record | null): Promise; + setEnabled(name: string, enabled: boolean): Promise; + connectionCipher(name: string): Promise | undefined>; + + setAllowlist(activityId: string, tools: readonly string[]): Promise; + allowlistOf(activityId: string): Promise; + /** 有活动声明引用即不得删除 */ + referencedBy(name: string): Promise; +}; + +/** 工具录制回放存储 —— 与 llm 的 ReplayStore 同构,另存一表(§7.3) */ +export type ToolReplayStore = { + put(record: { key: string; call: ToolCall; result: ToolResult; recordedAt: Date }): Promise; + get(key: string): Promise<{ call: ToolCall; result: ToolResult } | undefined>; +}; + +/** + * 工具执行 —— 实现对系统:server 侧执行代码,运行时受限, + * **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。 + */ +export type ToolRuntime = { + execute(input: { + readonly contract: ToolContract; + readonly connection: Readonly>; + readonly call: ToolCall; + readonly idempotencyKey?: string; + }): Promise<{ ok: boolean; output?: Record; error?: { code: string; retryable: boolean; message: string } }>; +}; + +/** JSON Schema 校验 —— ajv 在 adapter,usecase 只见本端口(ADR-003) */ +export type SchemaValidator = { + validate(schema: Record, value: unknown): { ok: boolean; errors: string[] }; +}; diff --git a/db/migrations/0001_nostalgic_cobalt_man.sql b/db/migrations/0001_nostalgic_cobalt_man.sql new file mode 100644 index 0000000..394a889 --- /dev/null +++ b/db/migrations/0001_nostalgic_cobalt_man.sql @@ -0,0 +1,70 @@ +CREATE TABLE "sys"."activity_memory" ( + "id" text PRIMARY KEY NOT NULL, + "activity_id" text NOT NULL, + "layer" text NOT NULL, + "statement" text NOT NULL, + "evidence" jsonb NOT NULL, + "increment" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "pruned_at" timestamp with time zone, + "active" boolean DEFAULT true NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sys"."task_memory_seal" ( + "task_id" text PRIMARY KEY NOT NULL, + "sealed_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sys"."task_memory" ( + "id" text PRIMARY KEY NOT NULL, + "task_id" text NOT NULL, + "seq" integer NOT NULL, + "at" timestamp with time zone NOT NULL, + "kind" text NOT NULL, + "side" text NOT NULL, + "title" text NOT NULL, + "detail" jsonb, + "stamp" jsonb NOT NULL, + "governance" jsonb NOT NULL, + "links_to" text, + "event_ref" text +); +--> statement-breakpoint +CREATE TABLE "sys"."replay_tool" ( + "key" text PRIMARY KEY NOT NULL, + "call" jsonb NOT NULL, + "result" jsonb NOT NULL, + "recorded_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sys"."tool_allowlist" ( + "activity_id" text NOT NULL, + "tool" text NOT NULL, + CONSTRAINT "tool_allowlist_activity_id_tool_pk" PRIMARY KEY("activity_id","tool") +); +--> statement-breakpoint +CREATE TABLE "sys"."tool" ( + "name" text PRIMARY KEY NOT NULL, + "version" text NOT NULL, + "description" text NOT NULL, + "input" jsonb NOT NULL, + "output" jsonb NOT NULL, + "effect" text NOT NULL, + "errors" jsonb DEFAULT '[]'::jsonb NOT NULL, + "idempotent" boolean DEFAULT false NOT NULL, + "timeout_ms" integer DEFAULT 30000 NOT NULL, + "retries" integer DEFAULT 1 NOT NULL, + "connection_keys" jsonb DEFAULT '[]'::jsonb NOT NULL, + "capabilities" jsonb, + "pure" boolean DEFAULT false NOT NULL, + "owner_activity_id" text, + "connection_cipher" jsonb, + "enabled" boolean DEFAULT true NOT NULL, + "registered_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "activity_memory_layer_idx" ON "sys"."activity_memory" USING btree ("activity_id","layer","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "task_memory_seq_key" ON "sys"."task_memory" USING btree ("task_id","seq");--> statement-breakpoint +CREATE INDEX "task_memory_task_idx" ON "sys"."task_memory" USING btree ("task_id","at");--> statement-breakpoint +CREATE INDEX "tool_effect_idx" ON "sys"."tool" USING btree ("effect");--> statement-breakpoint +CREATE INDEX "tool_owner_idx" ON "sys"."tool" USING btree ("owner_activity_id"); \ No newline at end of file diff --git a/db/migrations/0002_superb_talisman.sql b/db/migrations/0002_superb_talisman.sql new file mode 100644 index 0000000..7ce229c --- /dev/null +++ b/db/migrations/0002_superb_talisman.sql @@ -0,0 +1,41 @@ +CREATE TABLE "sys"."channel_instance" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "kind" text NOT NULL, + "address" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "extract" jsonb DEFAULT '[]'::jsonb NOT NULL, + "mapping" jsonb DEFAULT '[]'::jsonb NOT NULL, + "fallback_position" text, + "address_window_ms" integer DEFAULT 604800000 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sys"."outbound_mail" ( + "id" text PRIMARY KEY NOT NULL, + "thread_id" text NOT NULL, + "from_addr" text NOT NULL, + "to_addrs" jsonb NOT NULL, + "cc_addrs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "subject" text NOT NULL, + "body" text NOT NULL, + "attachments" jsonb DEFAULT '[]'::jsonb NOT NULL, + "sent_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sys"."system_message" ( + "id" text PRIMARY KEY NOT NULL, + "kind" text NOT NULL, + "staff_id" text NOT NULL, + "position_id" text, + "title" text NOT NULL, + "body" text NOT NULL, + "at" timestamp with time zone DEFAULT now() NOT NULL, + "read_at" timestamp with time zone, + "card" jsonb, + "answer" jsonb, + "event_ref" text +); +--> statement-breakpoint +CREATE INDEX "message_staff_idx" ON "sys"."system_message" USING btree ("staff_id","at");--> statement-breakpoint +CREATE INDEX "message_unread_idx" ON "sys"."system_message" USING btree ("staff_id","read_at"); \ No newline at end of file diff --git a/db/migrations/meta/0001_snapshot.json b/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..0d21a3e --- /dev/null +++ b/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1372 @@ +{ + "id": "1b1733be-0e6e-4211-91ad-cec4ef3a0120", + "prevId": "76bf5784-08dd-4a68-9840-058ec13c7378", + "version": "7", + "dialect": "postgresql", + "tables": { + "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.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.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.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/0002_snapshot.json b/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..841ff76 --- /dev/null +++ b/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,1643 @@ +{ + "id": "fd181494-7012-4ec2-b0d1-f75dfe2805e0", + "prevId": "1b1733be-0e6e-4211-91ad-cec4ef3a0120", + "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.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.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 4a28cea..59795dd 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1785084862267, "tag": "0000_luxuriant_gorilla_man", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785086083016, + "tag": "0001_nostalgic_cobalt_man", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785086166573, + "tag": "0002_superb_talisman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md new file mode 100644 index 0000000..4e6d2dc --- /dev/null +++ b/docs/PROGRESS.md @@ -0,0 +1,62 @@ +# 实施进度(主 Agent 维护) + +> 里程碑序见 编码规范 §15。每个里程碑先立骨架(index / contract / port / CONTRACT.md + 空测试) +> 再填实现;门禁从 M0 起全绿,红了先修门禁再前进。 + +## 状态总览 + +| 里程碑 | 内容 | 状态 | +|---|---|---| +| M0 | 工作区骨架 + 五道门禁 + 环境自举 | ✅ 完成 | +| M1 | `@contracts` 首版、`event`、`ledger`、`org`、`llm` | ✅ 完成 | +| M2 | `memory`、`toolbox`、`channel`、`message` | ✅ 完成 | +| M3 | `interaction`、`human-tool`、`release`、`activity-registry`、`@sfeel` | ⬜ 未开始 | +| M4 | `skill-engine`、`loop-engine`、`flow-engine`、`task` | ⬜ 未开始 | +| M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | ⬜ 未开始 | +| M6 | 前端:工作台五要素 → 治理台九功能;`minute`、`report` | ⬜ 未开始 | + +## 常用命令 + +```bash +pnpm gates # gen:tiers → typecheck → lint → depcruise → api:check → test(五道门禁) +pnpm db:up # docker compose 起 PG16(四 schema 由 db/init 建) +pnpm db:generate # drizzle-kit 生成 sys schema 迁移 +pnpm db:migrate # 应用迁移 +pnpm db:seed # 播种五岗位五员工(登录者 = 冯兵) +pnpm dev:api # tsup watch + node dist/main.js(:8787) +pnpm dev:web # vite(:5173,/api 反代到 8787) +``` + +契约变更后:`pnpm api:check --update && git add api-report`(§12.4)。 + +## 已完成的落点 + +**M0** — pnpm workspaces / tsconfig 双工程 / eslint flat / dependency-cruiser +(基线八条 + JCAI 增补,tier-order 由 `tools/module-tiers.json` 生成)/ 契约快照只留契约面 / +docker-compose PG16 + 四 schema。 + +**M1** — `@contracts` 九文件首版;四个 L1 模块各带 index / contract / port / CONTRACT.md / +domain 零 mock 测试;app 层 Hono 服务、鉴权中间件、DI 装配、drizzle 迁移、AES-256-GCM 凭证密封。 +已验证:种子播种、岗位授权(BD 被拒 / PM 放行)、LLM 四元绑定密钥不外泄、 +ledger 对真实 PG 的 schema 自生长与写三分。 + +**M2** — 四个 L2 模块:`memory`(任务记忆四性质 / 活动记忆四层 / 任务日志两级投影 / 度量)、 +`toolbox`(注册两审 / 效应等级 / 白名单 / `ToolInvoker` 第二条缝)、 +`channel`(入站归一 → 系统写 → 数据事件;出站与关联记录)、`message`(通知 / 跨任务征答 / 死信 / 无主兜底)。 +app 侧接上派发器(advisory lock 串行 + 消费幂等)与 `/api/ingest` 模拟通道。 +已验证:一封无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落到 OP 岗位李元初的消息要素。 + +## 待接的占位(详见 docs/debt.md) + +- `container.ts` 两个占位端口:`InFlightWorkProbe`(M4)、`TriggerMatcher`(M5) +- 条件求值 `passthroughEvaluator`(M3 接 `@sfeel`) +- 派发器的消费者表为空(M4 任务 / M5 触发器) +- pg-boss 已装未用(M5 定时作业) + +## 已建 ADR + +编码规范 §14 已占 001–006,新 ADR 自 **007** 续(README 写「从 005 续」是笔误): + +- ADR-007 工具链与门禁的落地形态 +- ADR-008 投递队列:`sys.event_delivery` 是队列本体,pg-boss 只管定时 +- ADR-009 凭证加密:AES-256-GCM + `JCAI_SECRET_KEY` diff --git a/docs/debt.md b/docs/debt.md index 37c4d62..4d8d3e9 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -7,6 +7,8 @@ |---|---|---|---|---| | M0-1 | `pnpm db:seed` 只打印种子计划,未真正写库 | 种子数据(五岗位 / 五员工)已在 `apps/api/src/app/seed.ts` 冻结为常量并定义 `OrgSeeder` 端口;写入实现依赖 `org` 模块的 Drizzle schema,属 M1 | M1 | 已清理(M1) | | M0-2 | `packages/{sfeel,activity-sdk,ui-kit}` 仅有版本常量 | M0 只立骨架,内容按里程碑序填(§15);`@contracts` 已于 M1 落首版 | M3–M5 | 进行中 | -| M1-1 | `container.ts` 里三个占位端口:`InFlightWorkProbe`(空集)、`TriggerMatcher`(恒不命中)、`MessageFallback`(落 stderr) | 三者的真身分别在 task(L6,M4)、trigger(L7,M5)、message(L2,M2);M1 只有 L1,端口先立、实现随层级到位 | M2 / M4 / M5 | 进行中 | +| M1-1 | `container.ts` 里两个占位端口:`InFlightWorkProbe`(空集)、`TriggerMatcher`(恒不命中) | 二者的真身在 task(L6,M4)与 trigger(L7,M5);`MessageFallback` 已于 M2 接真身 | M4 / M5 | 进行中 | | M1-2 | 条件求值 `passthroughEvaluator` 恒真 | `@sfeel` 属 M3;M1 的监听只用类型 + 关联键过滤,不用内容条件 | M3 | 进行中 | -| M1-3 | 投递记录写入即止,尚无派发 worker | 队列本体已按 ADR-008 落 `sys.event_delivery`;派发 worker 与 pg-boss 定时随 M2 | M2 | 进行中 | +| M1-3 | ~~投递记录写入即止,尚无派发 worker~~ | 派发器已于 M2 落地(advisory lock 串行 + 消费幂等);消费者(任务 / 触发器)随 M4 / M5 注册 | M2 | 已清理 | +| M2-1 | 派发器的消费者表为空 —— 投递只标记已送达 | 任务消费者属 task(M4),触发器消费者属 trigger(M5);投递链路本身已可测 | M4 / M5 | 进行中 | +| M2-2 | pg-boss 已装未用 | 定时作业(监听时限扫描、周期触发器)随 M5 落地;当下派发器用 setInterval | M5 | 进行中 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e52a1f..a03d92f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,12 @@ importers: ai: specifier: ^7.0.37 version: 7.0.37(zod@4.4.3) + ajv: + specifier: ^8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@types/pg@8.20.0)(pg@8.22.0) @@ -68,6 +74,9 @@ importers: pg: specifier: ^8.22.0 version: 8.22.0 + pg-boss: + specifier: ^12.26.3 + version: 12.26.3 zod: specifier: ^4.4.3 version: 4.4.3 @@ -76,14 +85,22 @@ importers: specifier: ^8.20.0 version: 8.20.0 + apps/api/src/modules/channel: {} + apps/api/src/modules/event: {} apps/api/src/modules/ledger: {} apps/api/src/modules/llm: {} + apps/api/src/modules/memory: {} + + apps/api/src/modules/message: {} + apps/api/src/modules/org: {} + apps/api/src/modules/toolbox: {} + apps/web: dependencies: react: @@ -1311,6 +1328,14 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1550,6 +1575,10 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} + cron-parser@5.6.2: + resolution: {integrity: sha512-yJ/G1LVir6CnlkLI40CsampPLKl1SprGGlUagWtGJxewytRVYezv5xVyzzbT+Pvzx+VIRvZVF7/a0eEMTxdnTA==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2073,6 +2102,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2138,6 +2171,10 @@ packages: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} + non-error@0.1.0: + resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} + engines: {node: '>=20'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2190,6 +2227,11 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-boss@12.26.3: + resolution: {integrity: sha512-slpR0zeMXzX3c+gAI6035XzJfNfUYndCdvxudJojB2S1LZEMxT31xBBEVm7shEcJQ35Vj2ik+w2Hf7lmLuvrZQ==} + engines: {node: '>=22.12.0'} + hasBin: true + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -2405,6 +2447,10 @@ packages: engines: {node: '>=10'} hasBin: true + serialize-error@13.0.1: + resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} + engines: {node: '>=20'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2510,6 +2556,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -2624,6 +2674,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + typescript-eslint@8.65.0: resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3738,6 +3792,10 @@ snapshots: '@ai-sdk/provider-utils': 5.0.12(zod@4.4.3) zod: 4.4.3 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -3968,6 +4026,10 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 + cron-parser@5.6.2: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4472,6 +4534,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4530,6 +4594,8 @@ snapshots: node-releases@2.0.51: {} + non-error@0.1.0: {} + normalize-path@3.0.0: {} object-assign@4.1.1: {} @@ -4576,6 +4642,14 @@ snapshots: pathval@2.0.1: {} + pg-boss@12.26.3: + dependencies: + cron-parser: 5.6.2 + pg: 8.22.0 + serialize-error: 13.0.1 + transitivePeerDependencies: + - pg-native + pg-cloudflare@1.4.0: optional: true @@ -4803,6 +4877,11 @@ snapshots: semver@7.8.5: {} + serialize-error@13.0.1: + dependencies: + non-error: 0.1.0 + type-fest: 5.8.0 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4910,6 +4989,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tagged-tag@1.0.0: {} + tapable@2.3.3: {} tar-fs@2.1.5: @@ -5074,6 +5155,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + typescript-eslint@8.65.0(eslint@9.39.5)(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)