M3(完成): interaction 与 activity-registry

interaction(L3):
- 控制权按态转交:活动在跑归活动,流转/审批/终态归系统;输入侧永远归系统(单入口)
- 视图区净化门:含 <script> / on* / 外部资源的模版一律拒推 —— 容器无脚本执行权
- 对话流单入口:自由输入同写任务记忆并抛人事件;SSE 单连接 + 按目标失效广播
- 前端零过程状态:对话流与视图都是服务端投影

activity-registry(L4):
- 安装两审:静态审(签名/SDK/工具契约齐备/模型名已绑定/schema/悬空监听/模版无脚本/
  零环境能力/动态执行/验收等级不得谎报)+ 回放审(旧成不坏是硬门,旧败能成只入报告)
- 资产与包分离版本化:包只携资产初始版,不覆盖已有更高资产版;装载 = 包版本 + 生效资产版
- 进化管线状态机:候选 → 回放 → 人放行 → 生效;回退是显式治理动作

245 个测试通过,门禁五道全绿

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:36:05 +08:00
co-authored by Claude Opus 5
parent d96c6e6dff
commit 2850e6d4ef
29 changed files with 4339 additions and 4 deletions
@@ -0,0 +1,27 @@
# module: interaction
职责: 交互协议:对话流单入口、视图区推送与净化门、控制权按态转交、SSE 会话与失效广播
不负责: 卡的内容与回传校验(见 human-tool)、任务记忆的权威存放(见 memory)、
前端渲染(见 apps/web);视图 HTML 的容器渲染由 `@ui` 的 `ViewContainer` 统一实现
公开出口: index.ts
- 类型:`InteractionService` `PushEnvelope` `InteractionPhase` `ControlHolder` `TemplateViolation`
- 错误:`TemplateRejectedError`
- 端口类型:`InteractionStore` `HumanEventPublisher` `MemoryRecorder` `Audience` `Clock` `IdGenerator`
- 用例工厂:`createInteractionService(deps): InteractionService`
- 纯函数:`outputControl` `inputControl` `handoverHappens` `checkTemplate` `templateIsSafe`
- adapter:`createInteractionStore`;Drizzle 表:`chatMessageTable` `taskViewTable`
层级: L3(编码规范 §4)
依赖: @contracts;@shared/db;port:`HumanEventPublisher`(app 注入 event)、
`MemoryRecorder`(app 注入 memory)、`Audience`(app 注入 org)
被依赖: release, loop-engine, flow-engine, task, app(SSE 路由)
不变量(OS 4.1 / 09,AM 07):
- **输入单入口**:`inputControl()` 恒为 system —— 输入侧不参与转交;
视图区不得产生回环输入,浏览性交互由前端容器本地处理
- **控制权按态转交**:活动在跑(active / awaiting-*)归活动,其余归系统;
转交随引擎事件自动发生,活动不感知交接
- **视图区无脚本执行权**:含 `<script>` / `on*` 事件处理器 / 外部资源的模版一律 `TemplateRejectedError`
- **前端零过程状态**:对话流与视图都是服务端投影,刷新即重建;SSE 只推失效与内容,不推状态机
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道
@@ -0,0 +1,66 @@
/** 控制权按态转交与视图净化门 —— domain 测试零 mock(OS 4.1,AM 07) */
import { describe, expect, it } from 'vitest';
import {
checkTemplate,
handoverHappens,
inputControl,
INTERACTION_PHASES,
outputControl,
templateIsSafe,
} from '../internal/domain/control-handover.ts';
describe('输出侧控制权', () => {
it('活动在跑时归活动', () => {
expect(outputControl('active')).toBe('activity');
expect(outputControl('awaiting-human')).toBe('activity');
expect(outputControl('awaiting-event')).toBe('activity');
});
it('流转 / 审批 / 终态归系统', () => {
expect(outputControl('activating')).toBe('system');
expect(outputControl('approving')).toBe('system');
expect(outputControl('transitioning')).toBe('system');
expect(outputControl('terminal')).toBe('system');
});
it('每一个态都有确定归属', () => {
for (const phase of INTERACTION_PHASES) expect(['activity', 'system']).toContain(outputControl(phase));
});
});
describe('输入侧永远归系统 —— 单入口,不参与转交', () => {
it('恒为 system', () => {
expect(inputControl()).toBe('system');
});
});
describe('转交判定', () => {
it('跨归属才算转交', () => {
expect(handoverHappens('active', 'transitioning')).toBe(true);
expect(handoverHappens('active', 'awaiting-human')).toBe(false);
expect(handoverHappens('approving', 'terminal')).toBe(false);
});
});
describe('视图模版净化门 —— 容器无脚本执行权', () => {
it('纯内容模版放行', () => {
expect(templateIsSafe('<div class="jc-card"><h3>报价草案</h3><p>12 条</p></div>')).toBe(true);
});
it('含 <script> 即拒', () => {
expect(checkTemplate('<div><script>alert(1)</script></div>').map((v) => v.code)).toContain('script');
});
it('含 on* 事件处理器即拒', () => {
expect(checkTemplate('<button onclick="go()">x</button>').map((v) => v.code)).toContain('event-handler');
});
it('引用外部资源即拒', () => {
expect(checkTemplate('<img src="https://evil.com/x.png">').map((v) => v.code)).toContain('external-resource');
expect(checkTemplate('<link href="//cdn/x.css">').map((v) => v.code)).toContain('external-resource');
});
it('相对路径资源不算外部', () => {
expect(templateIsSafe('<img src="/samples/quote.png">')).toBe(true);
});
});
@@ -0,0 +1,53 @@
/**
* module: interaction —— 对外类型(编码规范 §10.1;语义出处 OS 02 / 09,AM 07)。
*
* 交互的**结构**归基座,**内容**归活动:
* 对话流 = 任务的人机时间线、**一切回环输入的唯一入口**;
* 视图区 = 只读呈现容器,内容由后端流控推送。
*/
import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts';
import type { ControlHolder, InteractionPhase, TemplateViolation } from './internal/domain/control-handover.ts';
export type { ChatMessage, ControlHolder, InteractionPhase, TemplateViolation, ViewBlock };
export class TemplateRejectedError extends Error {
constructor(readonly violations: readonly TemplateViolation[]) {
super(`视图模版被拒:${violations.map((v) => v.message).join('; ')} —— 容器无脚本执行权(AM 07)`);
this.name = 'TemplateRejectedError';
}
}
/** SSE 推送的信封 —— 前端按目标失效对应缓存(§9.2) */
export type PushEnvelope =
| { readonly kind: 'chat'; readonly taskId: TaskId; readonly message: ChatMessage }
| { readonly kind: 'view'; readonly taskId: TaskId; readonly blocks: readonly ViewBlock[] }
| { readonly kind: 'view-html'; readonly taskId: TaskId; readonly html: string }
| { readonly kind: 'control'; readonly taskId: TaskId; readonly phase: InteractionPhase; readonly output: ControlHolder }
| { readonly kind: 'invalidate'; readonly target: 'task' | 'workflow' | 'desk'; readonly id: string };
export type Subscription = { close(): void };
export type InteractionService = {
/** 对话流:任务的人机时间线 —— 与任务记忆同源、不同筛选 */
timeline(taskId: TaskId): Promise<ChatMessage[]>;
/**
* 负责人的自由输入 —— 人事件的第二形态(卡回传之外):
* 写入任务记忆,loop 作为观察进入后续窗口;**不驱动 flow 步**。
* 叫停等指令走控制事件,不经自由输入。
*/
say(taskId: TaskId, by: StaffId, text: string): Promise<ChatMessage>;
/** 视图流控:活动按局面推结构化块 */
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
phaseOf(taskId: TaskId): Promise<InteractionPhase>;
/** SSE:单连接、按信封目标失效缓存 */
subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription;
/** 供上层广播失效信号 */
invalidate(target: 'task' | 'workflow' | 'desk', id: string): void;
};
+32
View File
@@ -0,0 +1,32 @@
/**
* module: interaction —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
ChatMessage,
ControlHolder,
InteractionPhase,
InteractionService,
PushEnvelope,
Subscription,
TemplateViolation,
ViewBlock,
} from './contract.ts';
export { TemplateRejectedError } from './contract.ts';
export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStore, MemoryRecorder } from './port.ts';
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts';
export {
CONTROL_HOLDERS,
checkTemplate,
handoverHappens,
inputControl,
INTERACTION_PHASES,
outputControl,
templateIsSafe,
} from './internal/domain/control-handover.ts';
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
@@ -0,0 +1,26 @@
/**
* interaction 的 sys schema 表(编码规范 §7 / ADR-002)。
* 前端零过程状态:对话流与视图区都是**服务端投影**,刷新即重建(§9.2)。
*/
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const chatMessageTable = sys.table(
'chat_message',
{
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
kind: text('kind').notNull(),
payload: jsonb('payload').notNull(),
at: timestamp('at', { withTimezone: true }).notNull(),
},
(t) => [index('chat_message_task_idx').on(t.taskId, t.at)],
);
export const taskViewTable = sys.table('task_view', {
taskId: text('task_id').primaryKey(),
blocks: jsonb('blocks'),
html: text('html'),
phase: text('phase').notNull().default('activating'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,61 @@
/**
* interaction 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { asc, eq } from 'drizzle-orm';
import type { ChatMessage, TaskId, ViewBlock } from '@contracts';
import { chatMessageSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { InteractionStore } from '../../port.ts';
import type { InteractionPhase } from '../domain/control-handover.ts';
import { chatMessageTable, taskViewTable } from './interaction-schema.ts';
export function createInteractionStore(db: Db): InteractionStore {
return {
async appendMessage(taskId: TaskId, message: ChatMessage) {
await db.insert(chatMessageTable).values({
id: message.id,
taskId,
kind: message.k,
payload: message,
at: new Date(message.at),
});
},
async listMessages(taskId: TaskId) {
const rows = await db
.select()
.from(chatMessageTable)
.where(eq(chatMessageTable.taskId, taskId))
.orderBy(asc(chatMessageTable.at));
return rows.map((row) => chatMessageSchema.parse(row.payload));
},
async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }) {
const values = {
taskId,
blocks: view.blocks === undefined ? null : [...view.blocks],
html: view.html ?? null,
updatedAt: new Date(),
};
await db
.insert(taskViewTable)
.values(values)
.onConflictDoUpdate({
target: taskViewTable.taskId,
set: { blocks: values.blocks, html: values.html, updatedAt: values.updatedAt },
});
},
async setPhase(taskId: TaskId, phase: InteractionPhase) {
await db
.insert(taskViewTable)
.values({ taskId, phase, updatedAt: new Date() })
.onConflictDoUpdate({ target: taskViewTable.taskId, set: { phase, updatedAt: new Date() } });
},
async phaseOf(taskId: TaskId) {
const rows = await db
.select({ phase: taskViewTable.phase })
.from(taskViewTable)
.where(eq(taskViewTable.taskId, taskId))
.limit(1);
return rows[0]?.phase as InteractionPhase | undefined;
},
};
}
@@ -0,0 +1,73 @@
/**
* 控制权按态转交 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 4.1,AM 07)。
*
* 「视图区容器与对话区输出框的控制权由活动与系统**共同持有**:
* 任务激活(Agent 机制运行)时归**活动** —— 视图流控与对话输出由活动驱动;
* 流程流转(节点间、审批点、终态前后)时归**系统** —— 流转、审批与终态回执由系统呈现。
* 转交随引擎的激活 / 完成事件自动发生,活动不感知交接。
* **输入侧不参与转交**:全部人类输入永远经系统的对话流卡片协议回传。」
*/
export const CONTROL_HOLDERS = ['activity', 'system'] as const;
export type ControlHolder = (typeof CONTROL_HOLDERS)[number];
/** 任务在交互层的态 —— 决定输出侧控制权归谁 */
export const INTERACTION_PHASES = [
'activating',
'active',
'awaiting-human',
'awaiting-event',
'approving',
'transitioning',
'terminal',
] as const;
export type InteractionPhase = (typeof INTERACTION_PHASES)[number];
/** 输出侧控制权:活动在跑就归活动,其余一律归系统 */
export function outputControl(phase: InteractionPhase): ControlHolder {
switch (phase) {
case 'active':
case 'awaiting-human':
case 'awaiting-event':
return 'activity';
case 'activating':
case 'approving':
case 'transitioning':
case 'terminal':
return 'system';
}
}
/** 输入侧**永远**归系统 —— 单入口,不参与转交 */
export function inputControl(): ControlHolder {
return 'system';
}
/** 转交是否发生 —— 引擎的激活 / 完成事件驱动;活动不感知 */
export function handoverHappens(from: InteractionPhase, to: InteractionPhase): boolean {
return outputControl(from) !== outputControl(to);
}
/* ── 视图区:只读呈现容器,无脚本执行权(AM 07) ────────────────────── */
export type TemplateViolation = { readonly code: 'script' | 'event-handler' | 'external-resource'; readonly message: string };
const SCRIPT = /<\s*script\b/i;
const EVENT_HANDLER = /\son[a-z]+\s*=/i;
const EXTERNAL = /(src|href)\s*=\s*["']?\s*(https?:)?\/\//i;
/**
* 模版无脚本 —— 安装静态审与推送前都用它。
* 「模版是**内容**不是代码:web 零活动代码,容器渲染净化、无脚本执行权。」
*/
export function checkTemplate(html: string): TemplateViolation[] {
const out: TemplateViolation[] = [];
if (SCRIPT.test(html)) out.push({ code: 'script', message: '视图模版不得含 <script>' });
if (EVENT_HANDLER.test(html)) out.push({ code: 'event-handler', message: '视图模版不得含 on* 事件处理器' });
if (EXTERNAL.test(html)) out.push({ code: 'external-resource', message: '视图模版不得引用外部资源' });
return out;
}
export function templateIsSafe(html: string): boolean {
return checkTemplate(html).length === 0;
}
@@ -0,0 +1,97 @@
/**
* interaction 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
*/
import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts';
import { cardIdSchema } from '@contracts';
import type { InteractionService, PushEnvelope, Subscription } from '../../contract.ts';
import { TemplateRejectedError } from '../../contract.ts';
import type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStore, MemoryRecorder } from '../../port.ts';
import type { InteractionPhase } from '../domain/control-handover.ts';
import { checkTemplate, outputControl } from '../domain/control-handover.ts';
export type InteractionServiceDeps = {
readonly store: InteractionStore;
readonly events: HumanEventPublisher;
readonly memory: MemoryRecorder;
readonly audience: Audience;
readonly clock: Clock;
readonly ids: IdGenerator;
};
type Subscriber = { staffId: StaffId; onPush: (envelope: PushEnvelope) => void };
export function createInteractionService(deps: InteractionServiceDeps): InteractionService {
// SSE 订阅者表是**连接级**状态,不是过程状态:进程内持有,刷新即从投影重建(§9.2)
const subscribers = new Set<Subscriber>();
function broadcast(envelope: PushEnvelope): void {
for (const subscriber of subscribers) {
try {
subscriber.onPush(envelope);
} catch {
subscribers.delete(subscriber);
}
}
}
return {
async timeline(taskId) {
return deps.store.listMessages(taskId);
},
/** 负责人的自由输入 —— 人事件的第二形态;写入任务记忆,不驱动 flow 步 */
async say(taskId: TaskId, by: StaffId, text: string): Promise<ChatMessage> {
const message: ChatMessage = {
k: 'human',
id: cardIdSchema.parse(deps.ids.next()),
at: deps.clock.now().toISOString(),
text,
by,
};
await deps.store.appendMessage(taskId, message);
await deps.memory.append({ taskId, kind: 'human-input', title: text, detail: { by } });
await deps.events.publishHumanEvent({ taskId, cardId: message.id, payload: { kind: 'free-input', text } });
broadcast({ kind: 'chat', taskId, message });
return message;
},
async pushView(taskId: TaskId, blocks: readonly ViewBlock[]) {
await deps.store.putView(taskId, { blocks });
broadcast({ kind: 'view', taskId, blocks });
},
/** 视图模版过净化门:含脚本 / 事件处理器 / 外部资源一律拒推 */
async pushViewHtml(taskId, html) {
const violations = checkTemplate(html);
if (violations.length > 0) throw new TemplateRejectedError(violations);
await deps.store.putView(taskId, { html });
broadcast({ kind: 'view-html', taskId, html });
},
async setPhase(taskId: TaskId, phase: InteractionPhase) {
await deps.store.setPhase(taskId, phase);
const output = outputControl(phase);
broadcast({ kind: 'control', taskId, phase, output });
return output;
},
async phaseOf(taskId) {
return (await deps.store.phaseOf(taskId)) ?? 'activating';
},
subscribe(staffId, onPush) {
const subscriber: Subscriber = { staffId, onPush };
subscribers.add(subscriber);
return { close: () => void subscribers.delete(subscriber) } satisfies Subscription;
},
invalidate(target, id) {
broadcast({ kind: 'invalidate', target, id });
},
};
}
/** 供 app 的 SSE 路由复用:按可见性过滤(避免把别人的任务推给不相干的人) */
export async function visibleTo(audience: Audience, staffId: StaffId, taskId: TaskId): Promise<boolean> {
return audience.canSee(staffId, taskId);
}
+31
View File
@@ -0,0 +1,31 @@
/**
* module: interaction —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts';
import type { InteractionPhase } from './internal/domain/control-handover.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type InteractionStore = {
appendMessage(taskId: TaskId, message: ChatMessage): Promise<void>;
listMessages(taskId: TaskId): Promise<ChatMessage[]>;
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }): Promise<void>;
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
};
/** 自由输入即人事件 —— app 注入 `event`(L1) */
export type HumanEventPublisher = {
publishHumanEvent(input: { taskId: TaskId; cardId: string; payload: Record<string, unknown> }): Promise<void>;
};
/** 留痕 —— app 注入 `memory`(L2):时间线即任务记忆的可读投影 */
export type MemoryRecorder = {
append(input: { taskId: TaskId; kind: 'human-input'; title: string; detail?: Record<string, unknown> }): Promise<void>;
};
/** 收件人解析 —— app 注入 `org`(L1):SSE 只推给有权看的人 */
export type Audience = {
canSee(staffId: StaffId, taskId: TaskId): Promise<boolean>;
};