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:
JCAI Main Agent
2026-07-27 01:16:52 +08:00
co-authored by Claude Opus 5
parent 14d4fa046a
commit f0444f1417
73 changed files with 7488 additions and 31 deletions
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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);
};
+42
View File
@@ -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;
}
});
}
+2
View File
@@ -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;
}
+87
View File
@@ -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);
},
};
}