/** * DI 装配 —— `app/` 是**唯一组装层**(编码规范 §5.2)。 * 只有这里能同时认识多个模块:new 出 adapter、注入 port、把用例接起来。 * * 跨层协作一律经端口注入:`ledger` 要发数据事件 → 注入 `event` 的出口; * `event` 的三级消费下游(trigger L7 / message L2)在其**上**,同样经端口注入。 */ import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import { evaluateCondition } from '@sfeel'; import type { Db } from '@shared/db.ts'; import type { TaskId } from '@contracts'; import type { EventService, TriggerMatcher } from '@modules/event'; import { createEventService, createEventStore } from '@modules/event'; import type { LedgerService } from '@modules/ledger'; import type { LoopEngineService } from '@modules/loop-engine'; import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger'; import type { InFlightWorkProbe, KeycloakDirectory, OrgService, RosterSource, TokenVerifier } from '@modules/org'; import { createKeycloakDirectory, createKeycloakRoster, createKeycloakTokenVerifier, createOrgService, createPositionRepository, createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, TokenRejectedError, } from '@modules/org'; import { createRemoteJWKSet } from 'jose'; import { createTicketIssuer } from './ticket.ts'; import type { TicketIssuer } from './ticket.ts'; import type { LlmService, ModelGateway } from '@modules/llm'; import { createLlmInstanceRepository, createLlmService, createModelGateway, 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, createToolHandoffStore, createToolInvoker, createToolRegistry, createToolReplayStore, createToolRuntime, createToolboxService, } from '@modules/toolbox'; import type { MessageService } from '@modules/message'; import { createMessageRepository, createMessageService } from '@modules/message'; import type { HumanToolService } from '@modules/human-tool'; import { createCardStore, createHumanToolService } from '@modules/human-tool'; import type { InteractionService } from '@modules/interaction'; import { createInteractionService, createInteractionStore, templateIsSafe } from '@modules/interaction'; import type { ActivityRegistryService } from '@modules/activity-registry'; import { createSkillEngineService } from '@modules/skill-engine'; import { createRegistryService, createRegistryStore } from '@modules/activity-registry'; import type { ReleaseService } from '@modules/release'; import { createIntentStore, createReleaseService } from '@modules/release'; import type { TaskService } from '@modules/task'; import type { ProcessService } from '@modules/process'; import type { TriggerService } from '@modules/trigger'; import type { MinuteService } from '@modules/minute'; import type { ReportService } from '@modules/report'; import { FIRST_PARTY_TOOLS } from './tools/first-party.ts'; import { httpToolHandler } from './tools/http-tool.ts'; import { createMastraSpanFactory } from './mastra-spans.ts'; import { createTraceService, createTraceStore } from '@modules/trace'; import type { TraceService } from '@modules/trace'; import { wireEngines } from './wiring/engines.ts'; import { wireDeskElements } from './wiring/desk-elements.ts'; import type { ChannelService } from '@modules/channel'; import { createChannelRepository, createChannelService, createFileAttachmentArchive } from '@modules/channel'; import type { MailService } from '@modules/mail'; import { createImapSmtpProvider, createInboundStore, createMailAccountStore, createMailService, createOutboundStore, createPostalMimeParser, createWecomProvider, } from '@modules/mail'; import { channelSubscriber, channelTransport, mailSendTool } from './mail-wiring.ts'; import { readUpload, storeUpload } from './upload-store.ts'; import type { AppConfig } from './config.ts'; import { createIdGenerator, systemClock } from './runtime.ts'; import { createSecretBox, loadSecretKey } from './secret-box.ts'; import { createBroadcaster } from './broadcast.ts'; import { noteAnchors, noteAnchorTitle } from './note-anchors.ts'; import { bridgeBroadcaster } from './broadcast-bridge.ts'; import type { Broadcaster } from './broadcast.ts'; export type Container = { readonly config: AppConfig; /** 投递回执广播 —— 前端 SSE 的服务端一侧;不承担投递语义 */ readonly broadcaster: Broadcaster; readonly pool: Pool; readonly db: Db; readonly org: OrgService; /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 下载这两条带不了 Authorization 头的路 */ readonly tickets: TicketIssuer; readonly ledger: LedgerService; readonly event: EventService; readonly llm: LlmService; readonly modelGateway: ModelGateway; readonly memory: MemoryService; readonly toolbox: ToolboxService; readonly toolInvoker: ToolInvoker; readonly message: MessageService; readonly channel: ChannelService; /** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */ readonly mail: MailService; readonly humanTool: HumanToolService; readonly interaction: InteractionService; readonly registry: ActivityRegistryService; readonly release: ReleaseService; readonly task: TaskService; /** 案例引擎 —— 工作台按它算阶段进度;推进照旧走 task,不从这里走 */ readonly loop: LoopEngineService; /** 视图闸 —— 人在件上改完数据,按同一张模版用新数据重渲一帧(`views.repush`) */ readonly views: { repush(taskId: TaskId): Promise }; /** 此刻这一单的判据 —— 按当下数据现算(发卡那一刻算死的话,人补一格判据不会动) */ readonly checks: { of(taskId: TaskId): Promise> }; readonly process: ProcessService; readonly trigger: TriggerService; readonly minute: MinuteService; readonly report: ReportService; /** 全链观测:工作流 → 节点 → 步骤 / 工具 / 模型 / 人卡 */ readonly trace: TraceService; close(): Promise; }; /** * 在途工作探针 —— 停用前置检查要问的是 task(L6)与 human-tool(L3),二者层级在 org 之上, * 故以**延迟绑定**的端口接入:org 先构造,真身就位后回填(§5.2 跨层协作经端口)。 */ function createDeferredInFlightProbe(): InFlightWorkProbe & { bind(real: InFlightWorkProbe): void } { let real: InFlightWorkProbe | undefined; return { openTasksOf: async (id) => (real === undefined ? [] : real.openTasksOf(id)), openCardsOf: async (id) => (real === undefined ? [] : real.openCardsOf(id)), openApprovalsOf: async (id) => (real === undefined ? [] : real.openApprovalsOf(id)), bind(next) { real = next; }, }; } /** * 触发器判定的**延迟绑定**:`event`(L1)在 `trigger`(L7)之前构造, * 用一层可后填的间接层把环解开 —— 端口不变,装配顺序解耦(§5.2)。 */ function createDeferredTriggerMatcher(): TriggerMatcher & { bind(real: TriggerMatcher): void } { let real: TriggerMatcher | undefined; return { match: async (envelope) => (real === undefined ? [] : real.match(envelope)), bind(next) { real = next; }, }; } /** * 监听内容条件的求值 —— @sfeel 的求值语义**前后端同源**(§8.3)。 * 表达式写坏了不该让事件流停摆:求值异常按「不命中」处置并留痕。 */ const sfeelEvaluator = (expression: string, scope: Readonly>): boolean => { try { return evaluateCondition(expression, scope); } catch (error) { process.stderr.write( `[listener] 条件求值失败,按不命中处置:${expression} —— ${error instanceof Error ? error.message : String(error)}\n`, ); return false; } }; /** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */ export type ContainerOverrides = { readonly tokenVerifier?: TokenVerifier; readonly roster?: RosterSource; readonly directory?: KeycloakDirectory; }; export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise { /* * 广播器**先建** —— 装配期就要往它上面接线。 * * 它从前是在 `return` 里现造的,于是装配期谁也拿不到它: * 对话流推送、视图推送、发消息这三条**全都没有广播**, * 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。 */ const broadcaster = createBroadcaster(); const pool = new Pool({ connectionString: config.databaseUrl, max: 10 }); /* * 空闲连接被服务端掐断(重启、`pg_terminate_backend`、网络抖动)会以 'error' 事件抛到池上; * **没有处理器就是未捕获异常,整个进程随之退出** —— 一次运维操作能把 API 带崩。 * 池会自行剔除坏连接并在下次取用时重连,这里只负责让它别掀桌子。 */ pool.on('error', (error: Error) => { process.stderr.write(`[pg] 空闲连接异常(已剔除,下次取用自动重连):${error.message}\n`); }); const db = drizzle(pool); /* * **广播接到库上** —— 多个后端连同一个库时,别的进程动了同一张单, * 这边的浏览器也得知道(不然就停在「AI 推进中」直到人手动刷新,2026-08-24 用户实测)。 * 单进程部署时它只是空转一条 LISTEN 连接,没有副作用。 */ const bridge = bridgeBroadcaster(pool, broadcaster); const ids = createIdGenerator(); const masterKey = loadSecretKey(); const secrets = createSecretBox(masterKey); const tickets = createTicketIssuer(masterKey); // 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出 await pool.query(DRAFT_DDL); const inFlightProbe = createDeferredInFlightProbe(); /* * 身份归统一鉴权中心(ADR-012):验签走 jose 远程 JWKS(首次用到才拉、缓存 10 分钟、 * 30 秒冷却、5 秒超时 —— 手册 4.1);没配 KEYCLOAK_* 就装一个恒拒的 verifier: * 集成测试走用例不走 HTTP,不受影响;真跑起来缺配置的话 main.ts 早就拒绝启动了。 */ const keycloak = config.keycloak; const verifier: TokenVerifier = overrides.tokenVerifier ?? (keycloak === undefined ? { verify: async () => { throw new TokenRejectedError('未配置 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID'); }, } : createKeycloakTokenVerifier({ issuer: keycloak.issuer, audience: keycloak.clientId, getKey: createRemoteJWKSet(new URL(keycloak.jwksUrl), { cooldownDuration: 30_000, cacheMaxAge: 10 * 60_000, timeoutDuration: 5_000, }), })); const roster: RosterSource | undefined = overrides.roster ?? (keycloak?.provisioning === undefined ? undefined : createKeycloakRoster({ url: keycloak.url, realm: keycloak.realm, clientId: keycloak.provisioning.clientId, secret: keycloak.provisioning.secret, })); /* 成员目录与名册同步同一枚 provisioning 服务账号 —— 没配就都不注入,界面如实说不可用 */ const directory: KeycloakDirectory | undefined = overrides.directory ?? (keycloak?.provisioning === undefined ? undefined : createKeycloakDirectory({ url: keycloak.url, realm: keycloak.realm, clientId: keycloak.provisioning.clientId, secret: keycloak.provisioning.secret, })); const org = createOrgService({ verifier, ...(roster === undefined ? {} : { roster }), ...(directory === undefined ? {} : { directory }), positions: createPositionRepository(db), staff: createStaffRepository(db), roleAccess: createRoleAccessRepository(db), workGroups: createWorkGroupRepository(db), inFlight: inFlightProbe, clock: systemClock, ids, testAccounts: config.testAccounts, }); // 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 })); }, /* * 治理台收件人 = 能进运营中心的活跃员工(任务在那儿管;ADR-012 权限只到端)。 * **空集要出声**:全新安装、还没人以 admin 登录过时这里就是空的,而调用方是死信通知与 * 无主入站兜底 —— 收件人为空就等于把「出事了」这件事无声丢掉(铁规矩 8:界面不撒谎)。 */ governanceStaff: async () => { const recipients = await org.staffWithEnd('ops'); if (recipients.length === 0) { process.stderr.write( '[org] 没有能进运营中心的活跃员工 —— 死信与无主入站的通知这一次没有收件人。' + '去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。\n', ); } return recipients.map((s) => ({ id: s.id })); }, }, clock: systemClock, ids, }); const triggerMatcher = createDeferredTriggerMatcher(); const event = createEventService({ store: createEventStore(db, ids), clock: systemClock, ids, evaluate: sfeelEvaluator, triggers: triggerMatcher, messages: message, }); const ledger = createLedgerService({ store: createLedgerStore(pool), drafts: createDraftStore(pool), events: { publish: async (envelope) => void (await event.publish(envelope)) }, clock: systemClock, ids, /* * 试跑判定 —— 设计台有**两条**试跑路径,认漏一条就等于没挡: * 单活动试跑:起的是不挂工作流的单,标 `task.origin = 'testbed'` * 流程包试跑:节点任务挂在工作流上,标在**流**身上 `workflow.source->>'ref'` * 一条 SQL 两条都认。认不出就当生产单(宁可少挡也不误挡:误挡会让真单丢主数据)。 */ simulation: { isSimulated: async (taskId) => { const res = await pool.query<{ simulated: boolean }>( /* * **勾了「落真库」的试跑单不算试跑** —— 这道判断只服务一件事: * 放行合并时主数据要不要挡下来(见 `ledger-service`)。 * 2026-08-24 用户要的正是这个:演示时要看见 `customer.contact` 的亲密度真的变了, * 而不是单据里写着「3 → 10」、主数据还是 3。默认仍然挡 —— 破例得有人按一下。 */ `select exists ( select 1 from sys.task t left join sys.workflow w on w.id = t.workflow_id where t.id = $1 and (t.origin = 'testbed' or w.source->>'ref' = 'testbed') and coalesce(t.land_master, false) = false ) as simulated`, [taskId], ); return res.rows[0]?.simulated === true; }, }, }); const instances = createLlmInstanceRepository(db); const provider = createProviderInvoker(); const llm = createLlmService({ instances, provider, secrets, clock: systemClock, ids }); const modelGateway = createModelGateway({ instances, provider, replay: createReplayStore(db), secrets, clock: systemClock, 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, }); /* * 邮件收发(ADR-013)—— L1,只要库 / 密封 / 时钟;先于工具运行时装配,`mail.send` 的处理器要闭包持它。 * 变更经 ChangeNotifier 接到广播:轮询器不走 /api/gov/* 中间件,新来信 / 拉取出错得自己说一声。 * 附件落 sys.upload —— 与上传路由同一段存取(8MB 上限、文本抽取、下载路由一并复用)。 */ const mail = createMailService({ accounts: createMailAccountStore(db), inbound: createInboundStore(db), outbound: createOutboundStore(db), attachments: { put: async (file) => ({ ref: (await storeUpload(db, { name: file.name, contentType: file.contentType, bytes: file.bytes, uploadedBy: null, note: file.note })).ref, }), get: (ref) => readUpload(db, ref), }, providers: { 'imap-smtp': createImapSmtpProvider(), wecom: createWecomProvider() }, mime: createPostalMimeParser(), secrets, clock: systemClock, ids, notify: { changed: (reason) => broadcaster.publish({ kind: 'system', id: 'mail', reason }) }, }); const toolRegistry = createToolRegistry(db); /** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */ // 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去 /* * 差异评估的两枚比对工具已退役(2026-08-29,墓志铭见 `tools/first-party.ts`)—— * 规则改由 `part-substitution-assessment` 的 `S2_assess.auto` 配方现算,这里的 * `parameterLedger`(从台账取参数的只读口)随之一并撤,不再有第二处消费方。 */ const toolRuntime = createToolRuntime( new Map([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), httpToolHandler, ); const schemas = createSchemaValidator(); const toolbox = createToolboxService({ registry: toolRegistry, runtime: toolRuntime, schemas, secrets, clock: systemClock, }); /** 工具转人台(§6.2):系统里没有这件工具时,请人代办 —— 人答回的值就是这次调用的出参 */ const toolHandoff = createToolHandoffStore(db); 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: channelTransport(mail), archive: createFileAttachmentArchive(config.attachmentRoot), clock: systemClock, ids, }); // channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面 mail.subscribe('channel', channelSubscriber(channel)); /* ── L3/L4:交互、人类工具、活动库、放行门 ─────────────────────── */ const interaction = createInteractionService({ store: createInteractionStore(db), events: { publishHumanEvent: async (input) => { await event.publish({ type: 'human', correlation: { target: { kind: 'task', id: input.taskId }, cause: { kind: 'card-reply', ref: input.cardId }, keys: { cardId: input.cardId }, }, payload: { data: input.payload }, occurredAt: systemClock.now().toISOString(), source: { kind: 'desk' }, }); }, }, memory: { append: async (input) => void (await memory.append({ ...input, stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, })), // 对话流的 AI 发言与系统事件由此投影而来 —— 与工作日志同源 history: (taskId) => memory.history(taskId), }, audience: { canSee: async () => true }, clock: systemClock, ids, }); /* * **对话流与视图推送接进广播** —— `interaction.subscribe` 从前一个订阅者都没有: * AI 说的每一句、推的每一帧画面都广播给了空气,前端只好靠人点刷新。 * * 这里只做转接,不带业务载荷:推送说「这个任务动了」,画面照旧由服务端投影重建 * (§9.2 前端零过程状态)。收件人是谁不在信封里,所以按任务广播 —— * 前端按任务键失效,负责人那一屏自然重取。 */ interaction.onPush((envelope) => { // invalidate 信封自己说目标(task / workflow / desk);其余信封都以任务为轴 if (envelope.kind === 'invalidate') { broadcaster.publish({ kind: envelope.target === 'workflow' ? 'workflow' : 'task', id: envelope.id, reason: 'invalidate', }); return; } broadcaster.publish({ kind: 'task', id: envelope.taskId, reason: envelope.kind }); }); const humanTool = createHumanToolService({ cards: createCardStore(db), outbox: { /* * 跨人卡以系统消息送达对方 —— 送到了就**广播给那个人**: * 他的消息气泡该立刻多一条,而不是等他自己点刷新。 * 发消息不经事件投递(那条路才有广播),所以这里得自己说一声。 */ sendElicitation: async (input) => { const sent = await message.sendElicitation(input); broadcaster.publish({ kind: 'staff', id: input.staffId, reason: 'elicitation' }); return sent; }, }, memory: { append: async (input) => { const stamp = { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }; await memory.append({ ...input, stamp }); /* * **驳回意见点了谁,基座当场比对好摆进轨迹**(2026-08-24 柴浩实测:意见写 * 「测试111建议动作改成拜访CTO」,模型把改动落到了 Itelma 那一行)。 * 归属是纯字符串包含 —— 引擎有现成事实的,不让模型从一句没标点的话里自己抠。 * 观察挂了不拦回传:它是给下一拍的路标,不是这一笔的门。 */ if (input.kind === 'card-reply') { const detail = (input.detail ?? {}) as { note?: unknown; value?: unknown }; const custom = typeof detail.value === 'string' && detail.value.startsWith('custom:') ? detail.value.slice('custom:'.length) : ''; const note = typeof detail.note === 'string' && detail.note.trim() !== '' ? detail.note : custom; if (note.trim() !== '') { try { const hits = noteAnchors(note, await ledger.taskDrafts(input.taskId)); if (hits.length > 0) { await memory.append({ taskId: input.taskId, kind: 'observation', title: noteAnchorTitle(hits), stamp }); } } catch { /* 比对失败只是少一条路标 */ } } } }, }, events: { publishHumanEvent: async (input) => { /* * 转人代办的回传**就是工具出参**:先落转人台,再抛事件。 * * 顺序不能反 —— 事件会唤起下一拍,那一拍 invoke 必须立刻取到答案; * 先抛后写就会空跑一拍(拍里还是「等人」,而人其实已经答了)。 * 卡不是转人卡时 `answer` 查无此行,静默无事发生。 */ const values = input.payload['values']; if (typeof values === 'object' && values !== null && !Array.isArray(values)) { await toolHandoff.answer(input.cardId, values as Record); } await event.publish({ type: 'human', correlation: { target: { kind: 'task', id: input.taskId }, cause: { kind: 'card-reply', ref: input.cardId }, keys: { cardId: input.cardId }, }, payload: { data: input.payload }, occurredAt: systemClock.now().toISOString(), source: { kind: 'desk' }, }); }, }, clock: systemClock, ids, }); const registry = createRegistryService({ store: createRegistryStore(db), tools: { assertContractsAvailable: (names) => toolbox.assertContractsAvailable(names) }, models: { assertNamesBound: (names) => llm.assertNamesBound(names) }, ledger: { applyDeclarations: async (declarations) => { // 安装时依声明**自生长**:声明合并 → DDL → 只增不破坏 const results = await ledger.applyDeclarations(declarations); return results.map((r) => ({ applied: r.applied, conflicts: r.conflicts.map((c) => ({ field: c.field, message: c.message })), })); }, }, templates: { isSafe: templateIsSafe }, clock: systemClock, ids, // 首次安装无历史 → 免回放审(OS 11.2) replayCases: async () => [], sdkVersion: '0.1.0-m5', }); /* * skill-engine(L4)—— 技能是活动内部的构件格式,由两个范式引擎装配。 * 目录与白名单都取自**活动清单**:技能提及的工具不在本活动白名单者不可调, * 装配前查扩权 —— 载方法,不载权力(AM 08)。 */ const skills = createSkillEngineService({ catalog: { skillsOf: async (activityId) => [...(await registry.manifestOf(activityId)).assets.skills], allowlistOf: async (activityId) => (await registry.manifestOf(activityId)).declarations.tools.map((t) => t.name), }, tools: { invoke: (input) => toolInvoker.invoke(input), }, memory: { append: async (input) => void (await memory.append({ ...input, stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, })), }, clock: systemClock, }); const release = createReleaseService({ intents: createIntentStore(db), ledger: { checkConflicts: async (taskId) => (await ledger.checkConflicts(taskId)).map((c) => ({ field: c.field, key: c.key })), releaseWrite: (taskId) => ledger.releaseWrite(taskId), }, tools: { isInvocable: async (tool, activityId) => { const contract = await toolbox.get(tool); if (contract === undefined) return { ok: false, reason: `工具未注册:${tool}` }; const allowlist = await toolbox.allowlistOf(activityId); if (contract.pure !== true && !allowlist.includes(tool)) { return { ok: false, reason: `工具不在本活动白名单:${tool}` }; } return { ok: true }; }, validateInput: async (tool, input) => { const contract = await toolbox.get(tool); if (contract === undefined) return { ok: false, errors: [`工具未注册:${tool}`] }; return schemas.validate(contract.input, input); }, validateTarget: async (input) => { const result = await toolInvoker.validate({ tool: input.tool, input: input.payload, activityId: input.activityId, taskId: input.taskId }); if (result === undefined) return undefined; return { ok: result.ok, ...(result.error === undefined ? {} : { error: result.error.message }) }; }, execute: async (input) => { const result = await toolInvoker.invoke({ tool: input.tool, input: input.payload, activityId: input.activityId, taskId: input.taskId, }); return { id: input.idempotencyKey, ok: result.ok, ...(result.error === undefined ? {} : { error: result.error.message }), retryable: result.error?.retryable ?? false, }; }, }, cards: { /* * **这条链的最后一关:默认是「传过去」,不是「丢掉」**(2026-09-01 第三次踩同一个坑改的结构)。 * * 从前这儿是一个**手写字段清单**的显式对象:上游(契约 → submit → release)一路传下来的东西, * 只要这里没被点到名就在这一行静默落地,编译不报、运行时无声。已经吃过三次: * ① `fields` 被写死成 `[]` —— 验收报告只能看不能改(第 12 章③); * ② `blocks` 漏了一行 —— 卡面正文块恒空; * ③ `redoable` 漏了一行(2026-09-01 用户:「怎么还有重做??你没改上吗?」)—— * 装配层算对了、release 转对了、契约也声明了,到这儿被抹平,那颗钮照旧摆着。 * 两条注释就写在那几行旁边警告「少写一行就静默落地」,照样第三次。**措辞治不了的结构治**: * 把 context 三样拆出去,**其余整包转交** —— 以后端口上新增一个可选字段,这里不改也到得了。 * 剩下的两处 `as never` 是类型收窄(品牌类型 / 结构宽松的块),不是丢值。 */ issueAcceptance: async ({ taskId, ownerId, humanCallCap, blocks, checklist, ...draft }) => { const issued = await humanTool.issueAcceptance( { taskId, ownerId, humanCallCap }, { /* **整包转交** —— 端口上新增一个可选字段,这里不改也到得了(见上面那段头注) */ ...draft, /* 下面几行不是「挑字段」,是**类型收窄**:只读转可变、品牌类型、结构宽松的块各要一次 */ basis: [...draft.basis], deliverable: draft.deliverable as never, criteria: [...draft.criteria], actions: draft.actions.map((a) => ({ ...a })), fields: (draft.fields ?? []).map((f) => ({ ...f })) as never, ...(checklist === undefined || checklist.length === 0 ? {} : { checklist: [...checklist] }), ...(blocks === undefined || blocks.length === 0 ? {} : { blocks: blocks.map((b) => ({ ...b })) as never }), }, ); return { id: issued.order.id }; }, issueLandingFailure: async (input) => { const issued = await humanTool.issueLandingFailure( { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }, { failures: input.failures }, ); return { id: issued.order.id }; }, }, tasks: { activityIdOf: async (taskId) => (await engines.task.get(taskId)).activityId, outputPortsOf: async (taskId) => { const task = await engines.task.get(taskId); const manifest = await registry.manifestOf(task.activityId); return { mandatory: manifest.contract.output.mandatory.map((p) => p.name), selective: manifest.contract.output.selective.map((g) => ({ group: g.group, ports: g.ports.map((p) => p.name), })), }; }, }, ports: { emit: async (input) => { // 落地收敛才发射 —— 下游只见落地完成的任务 await event.publish({ type: 'data', correlation: { target: { kind: 'task', id: input.taskId }, cause: { kind: 'engine', ref: 'output-port' }, keys: { port: input.port }, }, payload: { data: input.payload }, occurredAt: systemClock.now().toISOString(), source: { kind: 'engine', detail: 'release' }, }); }, }, memory: { append: async (input) => void (await memory.append({ ...input, stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, })), }, clock: systemClock, ids, }); /* ── L5–L7:范式引擎、任务、流程层 ─────────────────────────────── */ /* * 全链观测:一条工作流一条 trace。span 由 Mastra 的 Observability 组件产出 * (我们的执行不经 Mastra 图,所以手动打点),一开就落库 —— 测试台要「点一下就看得见」。 */ const trace = createTraceService({ store: createTraceStore(db), clock: systemClock, spans: createMastraSpanFactory(), }); const engines = wireEngines({ db, trace, clock: systemClock, ids, event, ledger, memory, toolbox, toolInvoker, toolHandoff, humanTool, interaction, message, org, llm, modelGateway, registry, release, skills, schemas, }); // 停用前置检查的真身就位:在途任务问 task,待答卡问 human-tool inFlightProbe.bind({ openTasksOf: (staffId) => engines.task.openTasksOf(staffId), openCardsOf: (staffId) => humanTool.openCardsOf(staffId), openApprovalsOf: async () => [], }); // 触发器就位后回填 —— event 的三级消费自此真判定 triggerMatcher.bind({ match: (envelope) => engines.trigger.match({ type: envelope.type, keys: envelope.correlation.keys, payload: envelope.payload.data, source: envelope.source.kind, occurredAt: envelope.occurredAt, }), }); // L8:工作台后两个要素 —— 只读 memory 与 task 的投影,一行都不另记 const deskElements = wireDeskElements({ db, memory, task: engines.task, org, process: engines.process, ids, clock: systemClock, }); return { config, broadcaster, pool, db, org, tickets, ledger, event, llm, modelGateway, memory, toolbox, toolInvoker, message, channel, mail, humanTool, interaction, registry, release, task: engines.task, loop: engines.loop, /** 件是投影 —— 人在件上改完数据,得能按同一张模版用新数据重渲一帧(见 `views.repush`) */ views: engines.views, checks: engines.checks, process: engines.process, trigger: engines.trigger, minute: deskElements.minute, report: deskElements.report, trace, async close() { bridge.stop(); await pool.end(); }, }; }