forked from ChaiHao/BPOS-MVP
M2: 基座能力 —— memory / toolbox / channel / message
- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
14d4fa046a
commit
f0444f1417
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
+123
-28
@@ -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<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string, ToolHandler>();
|
||||
|
||||
export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
|
||||
const db = drizzle(pool);
|
||||
@@ -84,13 +98,44 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
// 草稿层的建表: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<Container> {
|
||||
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<Container> {
|
||||
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<Container> {
|
||||
event,
|
||||
llm,
|
||||
modelGateway,
|
||||
memory,
|
||||
toolbox,
|
||||
toolInvoker,
|
||||
message,
|
||||
channel,
|
||||
async close() {
|
||||
await pool.end();
|
||||
},
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> => {
|
||||
dispatcher.stop();
|
||||
await container.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
@@ -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<AppEnv>, 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<AppEnv> {
|
||||
|
||||
registerWorkbenchRoutes(app, container);
|
||||
registerGovernanceRoutes(app, container);
|
||||
registerIngestRoutes(app, container);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
export type DispatcherDeps = {
|
||||
readonly container: Container;
|
||||
readonly consumers: ReadonlyMap<Delivery['target']['kind'], Consumer>;
|
||||
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<number> {
|
||||
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<void> => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 未变 → 对外零影响
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ChannelInstanceConfig[]>;
|
||||
upsert(config: ChannelInstanceConfig): Promise<ChannelInstanceConfig>;
|
||||
/**
|
||||
* 入站:归属解析 → 归一为**系统写** → 数据事件。
|
||||
* 「环外回音经归属解析归一为数据写入后,以数据事件抛出 —— 活动认数据,不认通道。」
|
||||
*/
|
||||
ingestMail(channelId: string, mail: InboundMail): Promise<InboundOutcome>;
|
||||
/** 出站:触达世界的动作;发送即登记出站关联记录(归属解析①③级的对表来源) */
|
||||
sendMail(channelId: string, mail: OutboundMail): Promise<{ messageId: string; threadId: string }>;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string>;
|
||||
readonly values: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 归一:提取结果 + 映射 → 一组「域.对象」的写入。
|
||||
* 同一对象的多个字段合并成一次写入;主键字段进 key,其余进 values。
|
||||
*/
|
||||
export function normalize(
|
||||
extracted: Readonly<Record<string, string>>,
|
||||
mapping: readonly LedgerMapping[],
|
||||
): NormalizedWrite[] {
|
||||
const byObject = new Map<string, { domain: string; object: string; key: Record<string, string>; values: Record<string, string> }>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<InboundOutcome> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/channel",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.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<ChannelInstanceConfig[]>;
|
||||
get(channelId: string): Promise<ChannelInstanceConfig | undefined>;
|
||||
upsert(config: ChannelInstanceConfig): Promise<ChannelInstanceConfig>;
|
||||
};
|
||||
|
||||
/** 事件入口 —— 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<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 台账系统写 —— app 注入 `ledger`(L1)的 `systemWrite`。
|
||||
* 入站归一是「登记世界已发生的事实」,走**系统写**:不过放行门,写正式版并即时抛数据事件(OS 09)。
|
||||
*/
|
||||
export type LedgerSystemWriter = {
|
||||
systemWrite(input: {
|
||||
address: { domain: string; object: string };
|
||||
key: Record<string, string | number>;
|
||||
values: Record<string, unknown>;
|
||||
cause: { kind: 'inbound'; ref: string };
|
||||
taskId?: TaskId;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/** 邮件收发 —— 外界。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<string>;
|
||||
};
|
||||
@@ -16,7 +16,7 @@ export type OwnershipKeyRules = {
|
||||
/** 地址 + 时间窗的窗口长度(毫秒) */
|
||||
readonly addressWindowMs: number;
|
||||
/** 无主入站的默认认领岗位 */
|
||||
readonly fallbackPosition?: PositionId;
|
||||
readonly fallbackPosition?: PositionId | undefined;
|
||||
};
|
||||
|
||||
export type InboundFacts = {
|
||||
|
||||
@@ -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 未变 → 对外零影响
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
readonly stamp: VersionStamp;
|
||||
readonly governance?: Partial<GovernanceMark>;
|
||||
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<MemoryEntry>;
|
||||
history(taskId: TaskId): Promise<MemoryEntry[]>;
|
||||
/** 活动侧唯一可配置项:窗口大小 —— 每次 LLM 调用加载多少轮 */
|
||||
window(taskId: TaskId, size: number): Promise<MemoryEntry[]>;
|
||||
seal(taskId: TaskId): Promise<void>;
|
||||
|
||||
/* 任务日志:任务记忆的两级投影 */
|
||||
taskLog(taskId: TaskId, options?: { canSeeMasked?: boolean }): Promise<LogLine[]>;
|
||||
/** 叙述性小结(LLM,可选)—— 叙述是视图不是事实,不入事实库 */
|
||||
narrate(taskId: TaskId): Promise<string | undefined>;
|
||||
|
||||
/* 活动记忆:四层存取;写入唯蒸馏 */
|
||||
loadActivityMemory(
|
||||
activityId: string,
|
||||
options?: { layers?: readonly MemoryLayer[]; limit?: number },
|
||||
): Promise<ActivityMemoryEntry[]>;
|
||||
/** **不设直写旁路**:唯一写入口是蒸馏钩子 */
|
||||
distill(input: DistillInput): Promise<ActivityMemoryEntry[]>;
|
||||
/** 人可修剪 —— 治理台 · 记忆管理 */
|
||||
prune(entryId: string): Promise<void>;
|
||||
setIncrementState(entryId: string, state: CandidateIncrement['state']): Promise<void>;
|
||||
|
||||
metrics(input: Omit<MetricsInput, 'entries'> & { taskIds: readonly TaskId[] }): Promise<Metrics>;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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<string, unknown> }),
|
||||
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<ActivityMemoryEntry['increment']> }),
|
||||
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<number | null>`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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Record<MemoryLayer, string>> = {
|
||||
fact: '事实层',
|
||||
pattern: '模式层',
|
||||
verdict: '裁决层',
|
||||
exception: '例外层',
|
||||
};
|
||||
|
||||
/** 每层优化谁 —— 记忆天然是模型优化的原料堆(AM 9.2) */
|
||||
export const MEMORY_LAYER_TARGET: Readonly<Record<MemoryLayer, string>> = {
|
||||
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<ActivityMemoryEntry, 'statement' | 'evidence'>): 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';
|
||||
}
|
||||
@@ -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<EntryKind> = new Set<EntryKind>([
|
||||
'engine-transition',
|
||||
'action',
|
||||
'result',
|
||||
'card-sent',
|
||||
'card-reply',
|
||||
'ledger-write',
|
||||
'action-intent',
|
||||
'release-verdict',
|
||||
'terminal',
|
||||
'human-input',
|
||||
]);
|
||||
|
||||
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => 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<AttributionSide, LogLine[]> {
|
||||
const out: Record<AttributionSide, LogLine[]> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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: {} }) };
|
||||
}
|
||||
@@ -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<MemoryEntry> {
|
||||
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<ActivityMemoryEntry[]> {
|
||||
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<string, unknown>;
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/memory",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.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<number>;
|
||||
append(entry: MemoryEntry): Promise<void>;
|
||||
list(taskId: TaskId): Promise<MemoryEntry[]>;
|
||||
tail(taskId: TaskId, size: number): Promise<MemoryEntry[]>;
|
||||
seal(taskId: TaskId, at: Date): Promise<void>;
|
||||
isSealed(taskId: TaskId): Promise<boolean>;
|
||||
};
|
||||
|
||||
export type ActivityMemoryStore = {
|
||||
list(activityId: string, layers: readonly MemoryLayer[] | undefined, limit: number): Promise<ActivityMemoryEntry[]>;
|
||||
insertMany(entries: readonly ActivityMemoryEntry[]): Promise<void>;
|
||||
prune(entryId: string, at: Date): Promise<void>;
|
||||
setIncrementState(entryId: string, state: string): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 模型调用 —— app 注入 `llm`(L1)的 ModelGateway。
|
||||
* 蒸馏钩子与叙述性小结用**系统用途**绑定实例:计量入系统账、不占活动预算(OS 02)。
|
||||
*/
|
||||
export type SystemModel = {
|
||||
generate(input: {
|
||||
readonly model: string;
|
||||
readonly prompt: string;
|
||||
readonly outputSchema?: Record<string, unknown>;
|
||||
}): Promise<{ text: string; object?: unknown }>;
|
||||
/** 系统用途模型名;未配置即 undefined —— 此时叙述与蒸馏静默跳过,不报错 */
|
||||
systemModelName(): Promise<string | undefined>;
|
||||
};
|
||||
@@ -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 未变 → 对外零影响
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
export type MessageService = {
|
||||
inbox(staffId: StaffId, options?: { unreadOnly?: boolean; limit?: number }): Promise<SystemMessage[]>;
|
||||
markRead(messageId: string): Promise<void>;
|
||||
notify(input: SendNotice): Promise<SystemMessage>;
|
||||
/** 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」(OS 2.1) */
|
||||
sendElicitation(input: SendElicitation): Promise<SystemMessage>;
|
||||
answer(messageId: string, staffId: StaffId, answer: Record<string, unknown>): Promise<SystemMessage>;
|
||||
/** 驳回重答 = 新发一条系统消息(新卡实例),原卡闭合 —— 只增不改 */
|
||||
rejectAndReask(messageId: string, reason: string): Promise<SystemMessage>;
|
||||
|
||||
/* ── event 的 `MessageFallback` 端口的实现面(OS 06) ─────────────── */
|
||||
/** 死信:重试超限 → 系统通知送达相关人的消息要素与治理台 */
|
||||
deadLetter(envelope: Envelope, error: string): Promise<void>;
|
||||
/** 无主兜底:相关人 = 通道实例配置的默认认领岗位 */
|
||||
unowned(envelope: Envelope, fallbackPosition: string | undefined): Promise<void>;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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<SystemMessage['card']> }),
|
||||
...(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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
await db
|
||||
.update(messageTable)
|
||||
.set({ card: { ...card, supersededBy: byMessageId, rejectedReason: reason } })
|
||||
.where(eq(messageTable.id, messageId));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Record<MessageKind, string>> = {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/message",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.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<SystemMessage[]>;
|
||||
get(messageId: string): Promise<SystemMessage | undefined>;
|
||||
insert(message: SystemMessage): Promise<SystemMessage>;
|
||||
markRead(messageId: string, at: Date): Promise<void>;
|
||||
markAnswered(messageId: string, at: Date, answer: Record<string, unknown>): Promise<void>;
|
||||
supersede(messageId: string, byMessageId: string, reason: string): Promise<void>;
|
||||
};
|
||||
|
||||
/** 岗位解析 —— app 注入 `org`(L1):无主兜底要把消息落到默认认领岗位上的人 */
|
||||
export type PositionResolver = {
|
||||
staffOfPosition(positionId: string): Promise<Array<{ id: StaffId }>>;
|
||||
/** 治理台收件人(任务管理岗)—— 死信同时送治理台 */
|
||||
governanceStaff(): Promise<Array<{ id: StaffId }>>;
|
||||
};
|
||||
@@ -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 未变 → 对外零影响
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
readonly activityId: string;
|
||||
readonly taskId?: TaskId;
|
||||
readonly stepNo?: number;
|
||||
};
|
||||
|
||||
export type ToolResult = {
|
||||
readonly ok: boolean;
|
||||
readonly output?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
readonly result: ToolResult;
|
||||
readonly at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具调用器 —— 活动基座六件里的「工具(调用契约)」。
|
||||
* **读类**过程中自由调用;**写类**登记为动作意向,放行后落地(除契约显式预授权者)。
|
||||
*/
|
||||
export type ToolInvoker = {
|
||||
readonly mode: InvokerMode;
|
||||
/** 判定运行时待遇 —— 调用方据此决定是即调还是登记意向 */
|
||||
disposition(call: ToolCall, preauthorized: readonly Preauthorization[]): Promise<CallDisposition>;
|
||||
invoke(call: ToolCall): Promise<ToolResult>;
|
||||
/** 落地预检第四项:工具契约含 validate 能力时调之(ADR-005) */
|
||||
validate(call: ToolCall): Promise<ToolResult | undefined>;
|
||||
withMode(mode: InvokerMode): ToolInvoker;
|
||||
};
|
||||
|
||||
export type RegisterToolInput = {
|
||||
readonly contract: ToolContract;
|
||||
/** 契约测试集:样例入参 → 期望出参形状;试运行审的依据 */
|
||||
readonly samples: ReadonlyArray<{ input: Record<string, unknown>; expectShape: Record<string, unknown> }>;
|
||||
/** 连接配置的值 —— 只存治理台,加密落库(ADR-009) */
|
||||
readonly connectionValues?: Readonly<Record<string, string>>;
|
||||
};
|
||||
|
||||
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<RegistrationVerdict>;
|
||||
list(): Promise<ToolContract[]>;
|
||||
get(name: string): Promise<ToolContract | undefined>;
|
||||
/** 有活动声明引用的工具不得删除;可停用 —— 停用后调用返回结构化错误 */
|
||||
setEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
bindConnection(name: string, values: Readonly<Record<string, string>>): Promise<void>;
|
||||
/** 白名单:活动只经调用契约用工具,授权在白名单与契约,不在活动代码 */
|
||||
setAllowlist(activityId: string, tools: readonly string[]): Promise<void>;
|
||||
allowlistOf(activityId: string): Promise<string[]>;
|
||||
/** 安装静态审:活动声明的工具契约齐备?装不齐不上架 */
|
||||
assertContractsAvailable(names: readonly string[]): Promise<{ ok: boolean; missing: string[] }>;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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<string, unknown>,
|
||||
output: row.output as Record<string, unknown>,
|
||||
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<string, string>);
|
||||
},
|
||||
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<string, unknown>;
|
||||
readonly connection: Readonly<Record<string, string>>;
|
||||
readonly operation: string | undefined;
|
||||
readonly idempotencyKey: string | undefined;
|
||||
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
|
||||
|
||||
export function createToolRuntime(handlers: ReadonlyMap<string, ToolHandler>): 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<string, string> = {};
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown>): boolean {
|
||||
return schema['type'] === 'object' && typeof schema['properties'] === 'object' && schema['properties'] !== null;
|
||||
}
|
||||
@@ -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<Record<EffectLevel, string>> = { read: '读', write: '写' };
|
||||
|
||||
export type ToolContract = {
|
||||
/** 全局唯一逻辑名 */
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
/** 面向 AI 的适用说明:何时用、语义、边界 */
|
||||
readonly description: string;
|
||||
readonly input: Record<string, unknown>;
|
||||
readonly output: Record<string, unknown>;
|
||||
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, unknown>): string[] {
|
||||
const props = schema['properties'];
|
||||
if (props === null || typeof props !== 'object') return [];
|
||||
return Object.keys(props as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function requiredNames(schema: Record<string, unknown>): string[] {
|
||||
const req = schema['required'];
|
||||
return Array.isArray(req) ? req.filter((x): x is string => typeof x === 'string') : [];
|
||||
}
|
||||
@@ -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<ToolResult> {
|
||||
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<string, string> = {};
|
||||
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<CallDisposition> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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);
|
||||
}
|
||||
@@ -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<RegistrationVerdict> {
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/toolbox",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.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<ToolContract[]>;
|
||||
get(name: string): Promise<ToolContract | undefined>;
|
||||
isEnabled(name: string): Promise<boolean>;
|
||||
upsert(contract: ToolContract, connectionCipher: Record<string, string> | null): Promise<void>;
|
||||
setEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
connectionCipher(name: string): Promise<Record<string, string> | undefined>;
|
||||
|
||||
setAllowlist(activityId: string, tools: readonly string[]): Promise<void>;
|
||||
allowlistOf(activityId: string): Promise<string[]>;
|
||||
/** 有活动声明引用即不得删除 */
|
||||
referencedBy(name: string): Promise<string[]>;
|
||||
};
|
||||
|
||||
/** 工具录制回放存储 —— 与 llm 的 ReplayStore 同构,另存一表(§7.3) */
|
||||
export type ToolReplayStore = {
|
||||
put(record: { key: string; call: ToolCall; result: ToolResult; recordedAt: Date }): Promise<void>;
|
||||
get(key: string): Promise<{ call: ToolCall; result: ToolResult } | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
|
||||
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
|
||||
*/
|
||||
export type ToolRuntime = {
|
||||
execute(input: {
|
||||
readonly contract: ToolContract;
|
||||
readonly connection: Readonly<Record<string, string>>;
|
||||
readonly call: ToolCall;
|
||||
readonly idempotencyKey?: string;
|
||||
}): Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
|
||||
};
|
||||
|
||||
/** JSON Schema 校验 —— ajv 在 adapter,usecase 只见本端口(ADR-003) */
|
||||
export type SchemaValidator = {
|
||||
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
|
||||
};
|
||||
Reference in New Issue
Block a user