From 2850e6d4ef4c91e3caa0fb8bb89c94b2e21fd4db Mon Sep 17 00:00:00 2001 From: JCAI Main Agent Date: Mon, 27 Jul 2026 01:36:05 +0800 Subject: [PATCH] =?UTF-8?q?M3(=E5=AE=8C=E6=88=90):=20interaction=20?= =?UTF-8?q?=E4=B8=8E=20activity-registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interaction(L3): - 控制权按态转交:活动在跑归活动,流转/审批/终态归系统;输入侧永远归系统(单入口) - 视图区净化门:含 ').map((v) => v.code)).toContain('script'); + }); + + it('含 on* 事件处理器即拒', () => { + expect(checkTemplate('').map((v) => v.code)).toContain('event-handler'); + }); + + it('引用外部资源即拒', () => { + expect(checkTemplate('').map((v) => v.code)).toContain('external-resource'); + expect(checkTemplate('').map((v) => v.code)).toContain('external-resource'); + }); + + it('相对路径资源不算外部', () => { + expect(templateIsSafe('')).toBe(true); + }); +}); diff --git a/apps/api/src/modules/interaction/contract.ts b/apps/api/src/modules/interaction/contract.ts new file mode 100644 index 0000000..2f4db3d --- /dev/null +++ b/apps/api/src/modules/interaction/contract.ts @@ -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; + /** + * 负责人的自由输入 —— 人事件的第二形态(卡回传之外): + * 写入任务记忆,loop 作为观察进入后续窗口;**不驱动 flow 步**。 + * 叫停等指令走控制事件,不经自由输入。 + */ + say(taskId: TaskId, by: StaffId, text: string): Promise; + + /** 视图流控:活动按局面推结构化块 */ + pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise; + /** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */ + pushViewHtml(taskId: TaskId, html: string): Promise; + + /** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */ + setPhase(taskId: TaskId, phase: InteractionPhase): Promise; + phaseOf(taskId: TaskId): Promise; + + /** SSE:单连接、按信封目标失效缓存 */ + subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription; + /** 供上层广播失效信号 */ + invalidate(target: 'task' | 'workflow' | 'desk', id: string): void; +}; diff --git a/apps/api/src/modules/interaction/index.ts b/apps/api/src/modules/interaction/index.ts new file mode 100644 index 0000000..15cd331 --- /dev/null +++ b/apps/api/src/modules/interaction/index.ts @@ -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'; diff --git a/apps/api/src/modules/interaction/internal/adapter/interaction-schema.ts b/apps/api/src/modules/interaction/internal/adapter/interaction-schema.ts new file mode 100644 index 0000000..c95562a --- /dev/null +++ b/apps/api/src/modules/interaction/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(), +}); diff --git a/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts b/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts new file mode 100644 index 0000000..32e61a5 --- /dev/null +++ b/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts @@ -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; + }, + }; +} diff --git a/apps/api/src/modules/interaction/internal/domain/control-handover.ts b/apps/api/src/modules/interaction/internal/domain/control-handover.ts new file mode 100644 index 0000000..8c5163c --- /dev/null +++ b/apps/api/src/modules/interaction/internal/domain/control-handover.ts @@ -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: '视图模版不得含