Archived
用户:「评审放行与落账确认这一步的验收卡怎么还有重做??你没改上吗?」—— 库里的卡确实没有那面旗:装配层算对了、release 转对了、契约也声明了,而**容器那个显式对象** 只转它点到名的那几样,`redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。 同一处已经吃过三次:①fields 写死成 [](验收报告只能看不能改)②blocks 漏一行(卡面块恒空) ③这次。那几行旁边就写着两条「少写一行就静默落地」的警告 —— 措辞治不了的结构治: context 三样拆出去、**其余整包转交**,以后端口新增可选字段不改这里也到得了。 断言钉在最末端:试跑观测面的卡带上 redoable,e2e 断言「一路到了卡上」。 同批修另一种时序的卡死(同一夜第二次,停掉了一整个 e2e 案例): **决策卡的回传不发事件、只调 wake**,而 wake 只翻停着的单子 —— 卡刚发出那一刻这一拍还在 running,它空转了一次;随后这一拍把任务 park 回 awaiting,再没有第三样东西会来叫 (实测:卡 02:06:13.250 发出、219ms 后被答,这一拍 .636 才收尾,停到 20 分钟超时)。 修:配方发卡收尾前再看一眼刚发的那张卡还在不在等 —— 已经答了就照实说「还在跑」; 只认这一张卡,别的「等外部回音」照旧是等,不会空转成热循环。 假端口同批改老实:发出去的卡默认就在等(从前发完卡还回「一张待答的都没有」)。 验收:e2e w05 5/5 released、被拒 0,案例 0 四条断言全绿(不摆重做 / 对话区说了话 / 点名要的结论落下去了 / 只动被点名的行);两处变异验证;契约变更 api-report 已刷新。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
859 lines
34 KiB
TypeScript
859 lines
34 KiB
TypeScript
/**
|
|
* 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 与 <a href> 下载这两条带不了 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<boolean> };
|
|
/** 此刻这一单的判据 —— 按当下数据现算(发卡那一刻算死的话,人补一格判据不会动) */
|
|
readonly checks: { of(taskId: TaskId): Promise<ReadonlyArray<{ key: string; label: string; level: string; pass: boolean; settled?: boolean; rationale?: string }>> };
|
|
readonly process: ProcessService;
|
|
readonly trigger: TriggerService;
|
|
readonly minute: MinuteService;
|
|
readonly report: ReportService;
|
|
/** 全链观测:工作流 → 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
|
readonly trace: TraceService;
|
|
close(): Promise<void>;
|
|
};
|
|
|
|
/**
|
|
* 在途工作探针 —— 停用前置检查要问的是 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<Record<string, unknown>>): 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<Container> {
|
|
/*
|
|
* 广播器**先建** —— 装配期就要往它上面接线。
|
|
*
|
|
* 它从前是在 `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<string, ToolHandler>([...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<string, unknown>);
|
|
}
|
|
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();
|
|
},
|
|
};
|
|
}
|