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,37 @@
# module: activity-registry
职责: 活动库:包清单六区的落库、安装两审(静态审 + 回放审)、包版本 × 资产版本两条序列、
两维分类、.JS 产物存取、资产版本进化管线(候选 → 回放 → 人放行 → 生效 / 回退)
不负责: .JS 的执行(见范式运行器)、工具契约与模型绑定的真身(经端口问 toolbox / llm)、
台账物化(经端口问 ledger)、模版净化的实现(经端口问 interaction)
公开出口: index.ts
- 类型:`ActivityRegistryService` `ActivityCard` `LoadEnvelope` `AssetVersion` `PackageVersion`
`ReplayCase` `ReplayVerdict` `DependencyFile` `ExternalChecks`
- 错误:`InstallRejectedError` `ActivityNotInstalledError` `ToolNotDeclaredError`
`IllegalIncrementTransitionError`
- 端口类型:`RegistryStore` `ToolContractProbe` `ModelBindingProbe` `LedgerDeclarationApplier`
`TemplateGuard` `Clock` `IdGenerator`
- 用例工厂:`createRegistryService(deps): ActivityRegistryService`
- 纯函数:`staticAudit` `auditPassed` `danglingListeners` `acceptanceLevelFindings`
`flowFindings` `loopFindings` `scanArtifact` `buildLoadEnvelope` `shouldSeedAsset`
`canTransition` `assertTransition` `judgeReplay` `mayTakeEffect`
- adapter:`createRegistryStore`
- Drizzle 表:`activityPackageTable` `activityAssetTable` `assetIncrementTable`
层级: L4(编码规范 §4)
依赖: @contracts;@shared/db;port 注入:toolbox(L2)、llm(L1)、ledger(L1)、interaction(L3)
被依赖: loop-engine, flow-engine, skill-engine, task, process, app(治理台 · 活动库管理)
不变量(OS 11.2 / 11.3,AM 11.3):
- **安装两审**:静态审有 error 即不上架;「旧成不坏」是**生效硬门**,
「旧败能成」只入重演报告、不作门槛;首装无历史即免回放审
- **悬空监听拒绝**:无时限或无关联键与内容条件的监听一律拒装(AM 4.4)
- **装不齐不上架**:工具调用契约缺一即拒;模型名未绑定四元即拒
- **活动包零环境能力**:产物含 `process.env` / `fetch(` / node 核心模块 / 动态执行即拒(ADR-004)
- **模版无脚本**:含脚本或外部资源的视图模版即拒
- **验收等级不得谎报**:标为 L1/L2 却无可机检谓词即拒
- **资产与包分离**:包只携资产初始版;`shouldSeedAsset` 保证**不覆盖已有更高资产版**,
覆盖只能是显式的 `rollbackAsset` 治理动作
- **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放
- **安装恒为新增、版本并存**:卸载只置 state,在途任务按其启动时版本跑完
@@ -0,0 +1,152 @@
/** 资产与包分离版本化 + 进化管线 —— domain 测试零 mock(OS 11.3,AM 9.3) */
import { describe, expect, it } from 'vitest';
import {
assertTransition,
buildLoadEnvelope,
canTransition,
IllegalIncrementTransitionError,
judgeReplay,
mayTakeEffect,
shouldSeedAsset,
} from '../internal/domain/versioning.ts';
import type { AssetVersion, PackageVersion, ReplayCase } from '../internal/domain/versioning.ts';
import { scanArtifact } from '../internal/domain/static-audit.ts';
const pkg: PackageVersion = {
activityId: 'a1',
version: '1.2.0',
installedAt: '2026-07-27T10:00:00.000Z',
state: 'active',
};
const asset = (kind: AssetVersion['kind'], version: number, effective = true): AssetVersion => ({
activityId: 'a1',
kind,
version,
effective,
source: 'evolution',
createdAt: '2026-07-27T10:00:00.000Z',
});
describe('buildLoadEnvelope —— 装载 = 包版本 + 各资产当前生效版', () => {
it('只取生效版,且取最高', () => {
const envelope = buildLoadEnvelope(pkg, [
asset('business-model', 1, false),
asset('business-model', 3),
asset('dmn', 2),
]);
expect(envelope).toEqual({
activityId: 'a1',
packageVersion: '1.2.0',
assetVersions: { 'business-model': 3, dmn: 2, skill: 0 },
});
});
it('别的活动的资产不入本活动信封', () => {
const envelope = buildLoadEnvelope(pkg, [{ ...asset('dmn', 9), activityId: 'a2' }]);
expect(envelope.assetVersions.dmn).toBe(0);
});
});
describe('shouldSeedAsset —— 包新版的资产初始版不覆盖更高版', () => {
it('库里没有即播种', () => {
expect(shouldSeedAsset([], 'dmn', 1)).toBe(true);
});
it('库里已有更高版即不播种 —— 覆盖只能是显式回退', () => {
expect(shouldSeedAsset([asset('dmn', 3)], 'dmn', 1)).toBe(false);
});
it('同版也不重播', () => {
expect(shouldSeedAsset([asset('dmn', 1)], 'dmn', 1)).toBe(false);
});
});
describe('候选增量状态机 —— 进化必过治理', () => {
it('候选只能去回放或否决', () => {
expect(canTransition('candidate', 'replaying')).toBe(true);
expect(canTransition('candidate', 'rejected')).toBe(true);
// 回放先行:不许从候选直接放行
expect(canTransition('candidate', 'released')).toBe(false);
});
it('回放后可放行、可否决、可退回候选', () => {
expect(canTransition('replaying', 'released')).toBe(true);
expect(canTransition('replaying', 'rejected')).toBe(true);
expect(canTransition('replaying', 'candidate')).toBe(true);
});
it('终态不再迁移', () => {
expect(canTransition('released', 'rejected')).toBe(false);
expect(canTransition('rejected', 'released')).toBe(false);
});
it('非法迁移抛错', () => {
expect(() => assertTransition('candidate', 'released')).toThrow(IllegalIncrementTransitionError);
expect(() => assertTransition('candidate', 'replaying')).not.toThrow();
});
});
describe('judgeReplay —— 旧成不坏是硬门,旧败能成是参考项', () => {
it('无历史即免回放审', () => {
const verdict = judgeReplay([]);
expect(verdict.skippedNoHistory).toBe(true);
expect(verdict.oldPassStillPass).toBe(true);
});
it('历史成功单仍全绿即过门', () => {
const cases: ReplayCase[] = [{ taskId: 't1', historicallyPassed: true, nowPasses: true }];
expect(judgeReplay(cases).oldPassStillPass).toBe(true);
});
it('历史成功单在新版下不再全绿即退回', () => {
const cases: ReplayCase[] = [
{ taskId: 't1', historicallyPassed: true, nowPasses: true },
{ taskId: 't2', historicallyPassed: true, nowPasses: false },
];
const verdict = judgeReplay(cases);
expect(verdict.oldPassStillPass).toBe(false);
expect(verdict.regressions).toEqual(['t2']);
});
it('历史失败单仍被拦只入报告,不作门槛', () => {
const cases: ReplayCase[] = [{ taskId: 't3', historicallyPassed: false, nowPasses: false }];
const verdict = judgeReplay(cases);
expect(verdict.oldPassStillPass).toBe(true);
expect(verdict.newlyBlocked).toEqual(['t3']);
});
});
describe('mayTakeEffect —— 生效硬门', () => {
it('静态审不过一律不生效', () => {
expect(mayTakeEffect(false, judgeReplay([]))).toBe(false);
});
it('静态审过 + 无历史即可生效', () => {
expect(mayTakeEffect(true, judgeReplay([]))).toBe(true);
});
it('静态审过但旧成变坏即不生效', () => {
expect(mayTakeEffect(true, judgeReplay([{ taskId: 't1', historicallyPassed: true, nowPasses: false }]))).toBe(false);
});
});
describe('scanArtifact —— 活动包零环境能力(ADR-004)', () => {
it('干净产物无告警', () => {
expect(scanArtifact('export default defineActivity({ name: "a1" });')).toEqual({
capabilities: [],
dynamicEval: false,
});
});
it('揪出环境能力引用', () => {
const scan = scanArtifact('const k = process.env.SECRET; await fetch("http://x");');
expect(scan.capabilities).toContain('process.env');
expect(scan.capabilities).toContain('fetch(');
});
it('揪出动态执行', () => {
expect(scanArtifact('eval("1+1")').dynamicEval).toBe(true);
expect(scanArtifact('new Function("return 1")').dynamicEval).toBe(true);
});
it('揪出 node 核心模块', () => {
expect(scanArtifact('import fs from "node:fs";').capabilities).toContain('node:fs');
});
});
@@ -0,0 +1,98 @@
/**
* module: activity-registry —— 对外类型(编码规范 §10.1;语义出处 OS 11,AM 11)。
*
* 活动库:**独立的安装与卸载入口** —— 不在编排中发生。
* 安装恒为新增、版本并存;在途任务按其启动时版本跑完,切换零丢单。
*/
import type { ActivityManifest, InstallVerdict, Paradigm, StaticAuditFinding } from '@contracts';
import type {
AssetKind,
AssetVersion,
IncrementState,
LoadEnvelope,
PackageVersion,
ReplayCase,
ReplayVerdict,
} from './internal/domain/versioning.ts';
import type { ExternalChecks } from './internal/domain/static-audit.ts';
export type {
ActivityManifest,
AssetKind,
AssetVersion,
ExternalChecks,
IncrementState,
InstallVerdict,
LoadEnvelope,
PackageVersion,
Paradigm,
ReplayCase,
ReplayVerdict,
StaticAuditFinding,
};
export class InstallRejectedError extends Error {
constructor(readonly verdict: InstallVerdict) {
super(`活动包未过安装两审:${verdict.staticFindings.map((f) => f.message).join('; ')}`);
this.name = 'InstallRejectedError';
}
}
export class ActivityNotInstalledError extends Error {
constructor(readonly activityId: string) {
super(`活动未安装:${activityId}`);
this.name = 'ActivityNotInstalledError';
}
}
export class ToolNotDeclaredError extends Error {
constructor(readonly missing: readonly string[]) {
super(`装不齐不上架:缺工具调用契约 ${missing.join('、')}`);
this.name = 'ToolNotDeclaredError';
}
}
/** 库内两维管理:业务分类(一级,组织自定)× 活动类型(标准 / 案例)—— 两维平级、相互独立 */
export type ActivityCard = {
readonly activityId: string;
readonly name: string;
readonly category: string;
readonly paradigm: Paradigm;
readonly packageVersions: readonly PackageVersion[];
readonly assetVersions: readonly AssetVersion[];
readonly enabled: boolean;
};
/** 编排导出的依赖文件 —— 清单骨架:契约区 + 声明区(OS 11.1 第 2 步) */
export type DependencyFile = {
readonly activityId: string;
readonly contract: ActivityManifest['contract'];
readonly declarations: ActivityManifest['declarations'];
readonly compileOptions: { readonly sdkVersion: string; readonly format: 'esm'; readonly single: true };
};
export type ActivityRegistryService = {
/** 安装两审:静态审读清单,回放审重演历史 */
install(manifest: ActivityManifest, artifact: string): Promise<InstallVerdict>;
list(): Promise<ActivityCard[]>;
get(activityId: string): Promise<ActivityCard | undefined>;
setEnabled(activityId: string, enabled: boolean): Promise<void>;
uninstall(activityId: string): Promise<void>;
/** 任务装载 = 包版本 + 各资产当前生效版,全部入信封 */
loadEnvelope(activityId: string): Promise<LoadEnvelope>;
manifestOf(activityId: string, packageVersion?: string): Promise<ActivityManifest>;
/** 进化管线:候选 → 回放 → 人放行 → 生效;可回退 */
proposeIncrement(input: {
activityId: string;
kind: AssetKind;
diff: string;
evidenceEntryIds: readonly string[];
}): Promise<{ incrementId: string; state: IncrementState }>;
replayIncrement(incrementId: string, cases: readonly ReplayCase[]): Promise<ReplayVerdict>;
releaseIncrement(incrementId: string, by: string): Promise<AssetVersion>;
rejectIncrement(incrementId: string, reason: string): Promise<void>;
/** 行为回退 = 版本回退(显式治理动作) */
rollbackAsset(activityId: string, kind: AssetKind, toVersion: number): Promise<AssetVersion>;
};
@@ -0,0 +1,57 @@
/**
* module: activity-registry —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
ActivityCard,
ActivityManifest,
ActivityRegistryService,
AssetKind,
AssetVersion,
DependencyFile,
ExternalChecks,
IncrementState,
InstallVerdict,
LoadEnvelope,
PackageVersion,
ReplayCase,
ReplayVerdict,
StaticAuditFinding,
} from './contract.ts';
export { ActivityNotInstalledError, InstallRejectedError, ToolNotDeclaredError } from './contract.ts';
export type {
Clock,
IdGenerator,
LedgerDeclarationApplier,
ModelBindingProbe,
RegistryStore,
TemplateGuard,
ToolContractProbe,
} from './port.ts';
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
export { createRegistryService } from './internal/usecase/registry-service.ts';
export {
acceptanceLevelFindings,
auditPassed,
danglingListeners,
flowFindings,
loopFindings,
scanArtifact,
staticAudit,
} from './internal/domain/static-audit.ts';
export {
ASSET_KINDS,
assertTransition,
buildLoadEnvelope,
canTransition,
IllegalIncrementTransitionError,
INCREMENT_STATES,
judgeReplay,
mayTakeEffect,
shouldSeedAsset,
} from './internal/domain/versioning.ts';
export { createRegistryStore } from './internal/adapter/registry-store.ts';
export { activityAssetTable, activityPackageTable, assetIncrementTable } from './internal/adapter/registry-schema.ts';
@@ -0,0 +1,66 @@
/**
* activity-registry 的 sys schema 表(编码规范 §7 / ADR-002 / OS 11.3)。
* **资产与包分离,各自单写者** —— 两张表、两条版本序列。
*/
import { sql } from 'drizzle-orm';
import { boolean, index, integer, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const activityPackageTable = sys.table(
'activity_package',
{
activityId: text('activity_id').notNull(),
version: text('version').notNull(),
name: text('name').notNull(),
category: text('category').notNull(),
paradigm: text('paradigm').notNull(),
manifest: jsonb('manifest').notNull(),
/** .JS 产物 —— 装载时由 registry 交给范式运行器(ADR-004) */
artifact: text('artifact').notNull(),
state: text('state').notNull().default('active'),
enabled: boolean('enabled').notNull().default(true),
installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
primaryKey({ columns: [t.activityId, t.version] }),
index('activity_package_category_idx').on(t.category, t.paradigm),
],
);
export const activityAssetTable = sys.table(
'activity_asset',
{
activityId: text('activity_id').notNull(),
kind: text('kind').notNull(),
version: integer('version').notNull(),
body: text('body').notNull(),
effective: boolean('effective').notNull().default(false),
source: text('source').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
primaryKey({ columns: [t.activityId, t.kind, t.version] }),
// 每个「活动 × 资产类」至多一个生效版 —— 装载时不会取到两份
uniqueIndex('activity_asset_effective_key')
.on(t.activityId, t.kind)
.where(sql`${t.effective}`),
],
);
/** 候选增量:候选 → 回放中 → 已放行 / 已否决(OS 11.3) */
export const assetIncrementTable = sys.table(
'asset_increment',
{
id: text('id').primaryKey(),
activityId: text('activity_id').notNull(),
kind: text('kind').notNull(),
diff: text('diff').notNull(),
/** 证据链 —— 指回任务记忆的具体片段 */
evidenceEntryIds: jsonb('evidence_entry_ids').notNull().default([]),
state: text('state').notNull().default('candidate'),
replayReport: jsonb('replay_report'),
releasedBy: text('released_by'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('asset_increment_activity_idx').on(t.activityId, t.state)],
);
@@ -0,0 +1,187 @@
/**
* activity-registry 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { and, asc, eq } from 'drizzle-orm';
import type { ActivityManifest } from '@contracts';
import { activityManifestSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { RegistryStore } from '../../port.ts';
import type { AssetKind, AssetVersion, IncrementState, PackageVersion } from '../domain/versioning.ts';
import { activityAssetTable, activityPackageTable, assetIncrementTable } from './registry-schema.ts';
export function createRegistryStore(db: Db): RegistryStore {
return {
async savePackage(manifest: ActivityManifest, artifact: string) {
await db
.insert(activityPackageTable)
.values({
activityId: manifest.identity.activityId,
version: manifest.identity.packageVersion,
name: manifest.identity.name,
category: manifest.identity.category,
paradigm: manifest.identity.paradigm,
manifest,
artifact,
state: 'active',
enabled: true,
})
.onConflictDoNothing();
},
async listPackages(activityId) {
const rows = await (activityId === undefined
? db.select().from(activityPackageTable).orderBy(asc(activityPackageTable.installedAt))
: db
.select()
.from(activityPackageTable)
.where(eq(activityPackageTable.activityId, activityId))
.orderBy(asc(activityPackageTable.installedAt)));
return rows.map(
(r): PackageVersion => ({
activityId: r.activityId,
version: r.version,
installedAt: r.installedAt.toISOString(),
state: r.state as PackageVersion['state'],
}),
);
},
async manifest(activityId, packageVersion) {
const conditions = [eq(activityPackageTable.activityId, activityId)];
if (packageVersion !== undefined) conditions.push(eq(activityPackageTable.version, packageVersion));
const rows = await db
.select()
.from(activityPackageTable)
.where(and(...conditions))
.orderBy(asc(activityPackageTable.installedAt));
const row = rows.at(-1);
return row === undefined ? undefined : activityManifestSchema.parse(row.manifest);
},
async artifact(activityId, packageVersion) {
const rows = await db
.select({ artifact: activityPackageTable.artifact })
.from(activityPackageTable)
.where(
and(eq(activityPackageTable.activityId, activityId), eq(activityPackageTable.version, packageVersion)),
)
.limit(1);
return rows[0]?.artifact;
},
async setEnabled(activityId, enabled) {
await db.update(activityPackageTable).set({ enabled }).where(eq(activityPackageTable.activityId, activityId));
},
async setState(activityId, version, state) {
await db
.update(activityPackageTable)
.set({ state })
.where(and(eq(activityPackageTable.activityId, activityId), eq(activityPackageTable.version, version)));
},
async enabledOf(activityId) {
const rows = await db
.select({ enabled: activityPackageTable.enabled })
.from(activityPackageTable)
.where(eq(activityPackageTable.activityId, activityId))
.limit(1);
return rows[0]?.enabled ?? false;
},
async listAssets(activityId) {
const rows = await db
.select()
.from(activityAssetTable)
.where(eq(activityAssetTable.activityId, activityId))
.orderBy(asc(activityAssetTable.version));
return rows.map(
(r): AssetVersion => ({
activityId: r.activityId,
kind: r.kind as AssetKind,
version: r.version,
effective: r.effective,
source: r.source as AssetVersion['source'],
createdAt: r.createdAt.toISOString(),
}),
);
},
async saveAsset(asset, body) {
await db.transaction(async (tx) => {
if (asset.effective) {
await tx
.update(activityAssetTable)
.set({ effective: false })
.where(and(eq(activityAssetTable.activityId, asset.activityId), eq(activityAssetTable.kind, asset.kind)));
}
await tx
.insert(activityAssetTable)
.values({
activityId: asset.activityId,
kind: asset.kind,
version: asset.version,
body,
effective: asset.effective,
source: asset.source,
})
.onConflictDoNothing();
});
},
async setAssetEffective(activityId, kind, version) {
await db.transaction(async (tx) => {
await tx
.update(activityAssetTable)
.set({ effective: false })
.where(and(eq(activityAssetTable.activityId, activityId), eq(activityAssetTable.kind, kind)));
await tx
.update(activityAssetTable)
.set({ effective: true })
.where(
and(
eq(activityAssetTable.activityId, activityId),
eq(activityAssetTable.kind, kind),
eq(activityAssetTable.version, version),
),
);
});
},
async saveIncrement(input) {
await db.insert(assetIncrementTable).values({
id: input.id,
activityId: input.activityId,
kind: input.kind,
diff: input.diff,
evidenceEntryIds: [...input.evidenceEntryIds],
state: input.state,
createdAt: input.createdAt,
});
},
async getIncrement(id) {
const rows = await db.select().from(assetIncrementTable).where(eq(assetIncrementTable.id, id)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
return {
id: row.id,
activityId: row.activityId,
kind: row.kind as AssetKind,
diff: row.diff,
state: row.state as IncrementState,
};
},
async setIncrementState(id, state, report, by) {
await db
.update(assetIncrementTable)
.set({
state,
...(report === undefined ? {} : { replayReport: report }),
...(by === undefined ? {} : { releasedBy: by }),
})
.where(eq(assetIncrementTable.id, id));
},
};
}
@@ -0,0 +1,181 @@
/**
* 安装静态审 —— 纯判定,零 IO(编码规范 §5.3;语义出处 OS 11.2,AM 11.3)。
*
* 「静态审:声明齐备(契约 / 业务模型 / Harness)、工具调用契约核对(装不齐不上架)、
* schema 校验、无越权代码(无环境能力、无动态执行)、**悬空监听拒绝**、模版无脚本。」
*
* 需要查库的两项(工具契约是否已注册、模型名是否已绑定)由 usecase 补进来 ——
* 本文件只做**纯**判定。
*/
import type { ActivityManifest, StaticAuditFinding } from '@contracts';
export type ExternalChecks = {
/** 声明引用但工具箱里没有的工具 */
readonly missingTools: readonly string[];
/** 声明引用但未绑定四元的模型名 */
readonly unboundModels: readonly string[];
/** 台账声明合并后语义互斥的字段 */
readonly ledgerConflicts: readonly string[];
/** 视图模版的净化门结果 */
readonly unsafeTemplates: readonly string[];
/** 代码产物里出现的环境能力引用 */
readonly environmentCapabilities: readonly string[];
/** 代码产物里出现的动态执行 */
readonly dynamicEval: boolean;
/** 签名与校验和是否可信 */
readonly signatureValid: boolean;
/** SDK 版本是否兼容 */
readonly sdkCompatible: boolean;
};
/** ISO-8601 duration,与 event 的 deadlineMillis 同一子集 */
const DURATION = /^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)$|^P\d+D$/;
/**
* 悬空监听禁止(AM 4.4):
* 「凡 onPart 监听的数据项,必须存在至少一条**可达的写入来源**;凡等环外处,必须配时间监听。
* 无来源又无时限的监听即『幽灵等待』—— 永不苏醒的支线,安装静态审必须拒绝。」
*/
export function danglingListeners(manifest: ActivityManifest): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
for (const [index, listener] of manifest.declarations.listeners.entries()) {
const at = `declarations.listeners[${index}]`;
if (listener.deadline.trim() === '' || !DURATION.test(listener.deadline)) {
out.push({
code: 'dangling-listener',
severity: 'error',
message: `监听时限缺失或非法(${listener.deadline || '空'})—— 无死等,悬空监听拒绝上架`,
at,
});
continue;
}
if (Object.keys(listener.keyFilter).length === 0 && listener.condition === undefined) {
out.push({
code: 'dangling-listener',
severity: 'error',
message: '监听既无关联键过滤也无内容条件 —— 认不出「就是它」,拒绝上架',
at,
});
}
}
return out;
}
/** 验收等级不得谎报:L4 不得标注为 L1–L3;L1/L2 必须给出可机检的表达式 */
export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
for (const [index, criterion] of manifest.contract.acceptanceCriteria.entries()) {
const at = `contract.acceptanceCriteria[${index}]`;
if ((criterion.level === 'L1' || criterion.level === 'L2') && criterion.predicate === undefined && criterion.modelRef === undefined) {
out.push({
code: 'acceptance-level-misreported',
severity: 'error',
message: `判据「${criterion.label}」标注为 ${criterion.level}(机检)却没有可机检的谓词或模型引用`,
at,
});
}
}
return out;
}
/** flow 判据:每步的六件齐备;复杂路由不得入 flow(AM 12.3) */
export function flowFindings(manifest: ActivityManifest): StaticAuditFinding[] {
const model = manifest.assets.businessModel;
if (model.paradigm !== 'flow') return [];
const out: StaticAuditFinding[] = [];
const names = new Set<string>();
for (const [index, step] of model.steps.entries()) {
const at = `assets.businessModel.steps[${index}]`;
if (names.has(step.name)) out.push({ code: 'schema-invalid', severity: 'error', message: `步名重复:${step.name}`, at });
names.add(step.name);
if (step.kind === 'human' && (step.deadline.trim() === '' || !DURATION.test(step.deadline))) {
out.push({ code: 'dangling-listener', severity: 'error', message: `人类工具步 ${step.name} 缺时限 —— 无死等`, at });
}
if (step.kind === 'llm' && Object.keys(step.outputSchema).length === 0) {
out.push({ code: 'schema-invalid', severity: 'error', message: `LLM 步 ${step.name} 缺出参 schema —— 模板与出参随版本冻结`, at });
}
}
return out;
}
/** loop 判据:双表示同版本,不允许分叉(AM 12.2) */
export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
const model = manifest.assets.businessModel;
if (model.paradigm !== 'loop') return [];
const out: StaticAuditFinding[] = [];
if (model.cmmnXml.trim() === '') {
out.push({ code: 'schema-invalid', severity: 'error', message: '案例模型缺 CMMN XML', at: 'assets.businessModel.cmmnXml' });
}
if (model.narrative.trim() === '') {
out.push({
code: 'schema-invalid',
severity: 'error',
message: '案例说明书缺失 —— XML 与说明书同版本发布,不允许分叉',
at: 'assets.businessModel.narrative',
});
}
return out;
}
export function staticAudit(manifest: ActivityManifest, external: ExternalChecks): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
if (!external.signatureValid) {
out.push({ code: 'signature-invalid', severity: 'error', message: '签名或校验和不可信' });
}
if (!external.sdkCompatible) {
out.push({ code: 'sdk-incompatible', severity: 'error', message: `SDK 版本不兼容:${manifest.identity.sdkVersion}` });
}
for (const tool of external.missingTools) {
// 工具缺口只阻塞上架,不阻塞开发(OS 7.1 契约先行)
out.push({ code: 'tool-contract-missing', severity: 'error', message: `工具调用契约未注册:${tool}` });
}
for (const model of external.unboundModels) {
out.push({ code: 'model-name-unbound', severity: 'error', message: `模型名未绑定四元:${model}` });
}
for (const field of external.ledgerConflicts) {
out.push({ code: 'ledger-type-conflict', severity: 'error', message: `台账字段语义互斥,交数据管理裁决:${field}` });
}
for (const name of external.unsafeTemplates) {
out.push({ code: 'template-has-script', severity: 'error', message: `视图模版含脚本或外部资源:${name}` });
}
for (const capability of external.environmentCapabilities) {
out.push({ code: 'environment-capability', severity: 'error', message: `活动包不得持有环境能力:${capability}` });
}
if (external.dynamicEval) {
out.push({ code: 'dynamic-eval', severity: 'error', message: '活动包不得含动态执行(eval / new Function)' });
}
if (manifest.samples.length === 0) {
out.push({ code: 'schema-invalid', severity: 'error', message: '样例集为空 —— 首装审以静态审 + 样例集替代回放审' });
}
out.push(...danglingListeners(manifest));
out.push(...acceptanceLevelFindings(manifest));
out.push(...flowFindings(manifest));
out.push(...loopFindings(manifest));
return out;
}
export function auditPassed(findings: readonly StaticAuditFinding[]): boolean {
return !findings.some((f) => f.severity === 'error');
}
/** 产物里的环境能力与动态执行扫描(ADR-004 静态审扫描产物 import 与全局引用) */
const FORBIDDEN_GLOBALS = [
'process.env',
'require(',
'node:fs',
'node:net',
'node:child_process',
'globalThis.fetch',
'XMLHttpRequest',
];
export function scanArtifact(code: string): { capabilities: string[]; dynamicEval: boolean } {
const capabilities = FORBIDDEN_GLOBALS.filter((token) => code.includes(token));
if (/\bfetch\s*\(/.test(code)) capabilities.push('fetch(');
const dynamicEval = /\beval\s*\(/.test(code) || /new\s+Function\s*\(/.test(code);
return { capabilities: [...new Set(capabilities)], dynamicEval };
}
@@ -0,0 +1,126 @@
/**
* 资产与包分离版本化 + 进化管线 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 11.3,AM 9.3/11.3)。
*
* 「每活动两条版本序列,各有各的门、各自单写者:
* **包版本**管代码 / 契约 / 交互模版 / Harness —— 变更必系统外重编、走安装两审;
* **资产版本**管业务模型 / DMN / 技能 —— 包只携初始版,此后由进化管线在系统内产生(回放 + 人放行);
* 任务装载 = 包版本 + 各资产当前生效版,全部入信封,行为可归因;
* 包新版携带的资产初始版**不覆盖已有更高的资产版** —— 覆盖只能是显式的资产回退治理动作。」
*/
export const ASSET_KINDS = ['business-model', 'dmn', 'skill'] as const;
export type AssetKind = (typeof ASSET_KINDS)[number];
export type AssetVersion = {
readonly activityId: string;
readonly kind: AssetKind;
/** 单调递增 */
readonly version: number;
readonly effective: boolean;
readonly source: 'package-seed' | 'evolution' | 'rollback';
readonly createdAt: string;
};
export type PackageVersion = {
readonly activityId: string;
readonly version: string;
readonly installedAt: string;
readonly state: 'active' | 'disabled' | 'uninstalled';
};
/** 装载信封:行为可归因公式的载体(AM 9.3) */
export type LoadEnvelope = {
readonly activityId: string;
readonly packageVersion: string;
readonly assetVersions: Readonly<Record<AssetKind, number>>;
};
export function buildLoadEnvelope(pkg: PackageVersion, assets: readonly AssetVersion[]): LoadEnvelope {
const effective: Record<AssetKind, number> = { 'business-model': 0, dmn: 0, skill: 0 };
for (const asset of assets) {
if (asset.activityId !== pkg.activityId || !asset.effective) continue;
effective[asset.kind] = Math.max(effective[asset.kind], asset.version);
}
return { activityId: pkg.activityId, packageVersion: pkg.version, assetVersions: effective };
}
/**
* 播种判定:包新版携带的资产初始版**不覆盖**已有更高的资产版。
* 返回 true 表示应当播种(库里还没有该类资产,或现有版本更低)。
*/
export function shouldSeedAsset(existing: readonly AssetVersion[], kind: AssetKind, seedVersion: number): boolean {
const highest = existing.filter((a) => a.kind === kind).reduce((max, a) => Math.max(max, a.version), 0);
return seedVersion > highest;
}
/* ── 候选增量状态机(OS 11.3):候选 → 回放中 → 已放行 / 已否决 ───────── */
export const INCREMENT_STATES = ['candidate', 'replaying', 'released', 'rejected'] as const;
export type IncrementState = (typeof INCREMENT_STATES)[number];
const TRANSITIONS: Readonly<Record<IncrementState, readonly IncrementState[]>> = {
candidate: ['replaying', 'rejected'],
// 回放先行 —— 重演过了才能到人放行的门前
replaying: ['released', 'rejected', 'candidate'],
released: [],
rejected: [],
};
export function canTransition(from: IncrementState, to: IncrementState): boolean {
return TRANSITIONS[from].includes(to);
}
export class IllegalIncrementTransitionError extends Error {
constructor(
readonly from: IncrementState,
readonly to: IncrementState,
) {
super(`候选增量状态迁移非法:${from} → ${to}(进化必过治理:候选 → 回放 → 人放行)`);
this.name = 'IllegalIncrementTransitionError';
}
}
export function assertTransition(from: IncrementState, to: IncrementState): void {
if (!canTransition(from, to)) throw new IllegalIncrementTransitionError(from, to);
}
/* ── 回放审判定(OS 11.2) ─────────────────────────────────────────── */
export type ReplayCase = {
readonly taskId: string;
/** 历史结局:成功单 / 失败单 */
readonly historicallyPassed: boolean;
/** 新版本对该单的重演结论 */
readonly nowPasses: boolean;
};
export type ReplayVerdict = {
/** 「旧成不坏」是**生效硬门** */
readonly oldPassStillPass: boolean;
readonly regressions: readonly string[];
/** 「旧败能成」是报告参考项,**不作门槛** */
readonly newlyBlocked: readonly string[];
readonly checked: number;
/** 首次安装无历史:免回放审 */
readonly skippedNoHistory: boolean;
};
export function judgeReplay(cases: readonly ReplayCase[]): ReplayVerdict {
if (cases.length === 0) {
return { oldPassStillPass: true, regressions: [], newlyBlocked: [], checked: 0, skippedNoHistory: true };
}
const regressions = cases.filter((c) => c.historicallyPassed && !c.nowPasses).map((c) => c.taskId);
const newlyBlocked = cases.filter((c) => !c.historicallyPassed && !c.nowPasses).map((c) => c.taskId);
return {
oldPassStillPass: regressions.length === 0,
regressions,
newlyBlocked,
checked: cases.length,
skippedNoHistory: false,
};
}
/** 生效硬门:静态审全过 且(无历史 或 旧成不坏) */
export function mayTakeEffect(staticPassed: boolean, replay: ReplayVerdict): boolean {
return staticPassed && (replay.skippedNoHistory || replay.oldPassStillPass);
}
@@ -0,0 +1,241 @@
/**
* activity-registry 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
*/
import type { ActivityManifest, InstallVerdict } from '@contracts';
import type { ActivityCard, ActivityRegistryService } from '../../contract.ts';
import { ActivityNotInstalledError, InstallRejectedError } from '../../contract.ts';
import type {
Clock,
IdGenerator,
LedgerDeclarationApplier,
ModelBindingProbe,
RegistryStore,
TemplateGuard,
ToolContractProbe,
} from '../../port.ts';
import { auditPassed, scanArtifact, staticAudit } from '../domain/static-audit.ts';
import type { AssetKind, AssetVersion, ReplayCase } from '../domain/versioning.ts';
import {
assertTransition,
buildLoadEnvelope,
judgeReplay,
mayTakeEffect,
shouldSeedAsset,
} from '../domain/versioning.ts';
export type RegistryServiceDeps = {
readonly store: RegistryStore;
readonly tools: ToolContractProbe;
readonly models: ModelBindingProbe;
readonly ledger: LedgerDeclarationApplier;
readonly templates: TemplateGuard;
readonly clock: Clock;
readonly ids: IdGenerator;
/** 回放审的历史样本;首装无历史即空(由 app 注入 memory 的查询) */
readonly replayCases: (activityId: string) => Promise<ReplayCase[]>;
readonly sdkVersion: string;
};
const SEED_KINDS: readonly AssetKind[] = ['business-model', 'dmn', 'skill'];
export function createRegistryService(deps: RegistryServiceDeps): ActivityRegistryService {
async function requireManifest(activityId: string, version?: string): Promise<ActivityManifest> {
const manifest = await deps.store.manifest(activityId, version);
if (manifest === undefined) throw new ActivityNotInstalledError(activityId);
return manifest;
}
return {
/** 安装两审:静态审读清单,回放审重演历史 —— 「旧成不坏」是生效硬门 */
async install(manifest, artifact): Promise<InstallVerdict> {
const [toolCheck, modelCheck, ledgerResults] = await Promise.all([
deps.tools.assertContractsAvailable(manifest.declarations.tools.map((t) => t.name)),
deps.models.assertNamesBound(manifest.declarations.models),
deps.ledger.applyDeclarations(manifest.declarations.ledger),
]);
const scan = scanArtifact(artifact);
const unsafeTemplates = manifest.assets.viewTemplates
.filter((t) => !deps.templates.isSafe(t.html))
.map((t) => t.name);
const findings = staticAudit(manifest, {
missingTools: toolCheck.missing,
unboundModels: modelCheck.missing,
ledgerConflicts: ledgerResults.flatMap((r) => r.conflicts.map((c) => c.field)),
unsafeTemplates,
environmentCapabilities: scan.capabilities,
dynamicEval: scan.dynamicEval,
signatureValid: manifest.identity.checksum.trim() !== '',
sdkCompatible: manifest.identity.sdkVersion === deps.sdkVersion,
});
const staticOk = auditPassed(findings);
// 首次安装无历史:免回放审,以静态审 + 样例集替代(OS 11.2)
const replay = judgeReplay(staticOk ? await deps.replayCases(manifest.identity.activityId) : []);
const verdict: InstallVerdict = {
accepted: mayTakeEffect(staticOk, replay),
staticFindings: findings,
replay: {
oldPassStillPass: replay.oldPassStillPass,
checked: replay.checked,
regressions: replay.regressions.map((taskId) => ({ taskId, detail: '历史成功单在新版下不再全绿' })),
newlyBlocked: replay.newlyBlocked.map((taskId) => ({ taskId, detail: '历史失败单在新版下仍被拦(参考项)' })),
skippedNoHistory: replay.skippedNoHistory,
},
};
if (!verdict.accepted) throw new InstallRejectedError(verdict);
// 安装恒为新增、版本并存;在途任务按其启动时版本跑完
await deps.store.savePackage(manifest, artifact);
// 资产初始版随包播种 —— **不覆盖已有更高的资产版**
const existing = await deps.store.listAssets(manifest.identity.activityId);
const now = deps.clock.now().toISOString();
for (const kind of SEED_KINDS) {
if (!shouldSeedAsset(existing, kind, 1)) continue;
const asset: AssetVersion = {
activityId: manifest.identity.activityId,
kind,
version: 1,
effective: true,
source: 'package-seed',
createdAt: now,
};
await deps.store.saveAsset(asset, serializeAsset(manifest, kind));
}
return verdict;
},
async list(): Promise<ActivityCard[]> {
const packages = await deps.store.listPackages();
const byActivity = new Map<string, typeof packages>();
for (const pkg of packages) {
const bucket = byActivity.get(pkg.activityId) ?? [];
bucket.push(pkg);
byActivity.set(pkg.activityId, bucket);
}
const cards: ActivityCard[] = [];
for (const [activityId, versions] of byActivity) {
const manifest = await deps.store.manifest(activityId);
if (manifest === undefined) continue;
cards.push({
activityId,
name: manifest.identity.name,
category: manifest.identity.category,
paradigm: manifest.identity.paradigm,
packageVersions: versions,
assetVersions: await deps.store.listAssets(activityId),
enabled: await deps.store.enabledOf(activityId),
});
}
return cards;
},
async get(activityId) {
return (await this.list()).find((c) => c.activityId === activityId);
},
async setEnabled(activityId, enabled) {
await deps.store.setEnabled(activityId, enabled);
},
async uninstall(activityId) {
for (const pkg of await deps.store.listPackages(activityId)) {
await deps.store.setState(activityId, pkg.version, 'uninstalled');
}
},
async loadEnvelope(activityId) {
const packages = await deps.store.listPackages(activityId);
const active = packages.filter((p) => p.state === 'active').at(-1);
if (active === undefined) throw new ActivityNotInstalledError(activityId);
return buildLoadEnvelope(active, await deps.store.listAssets(activityId));
},
async manifestOf(activityId, packageVersion) {
return requireManifest(activityId, packageVersion);
},
/* ── 进化管线:候选 → 回放 → 人放行 → 生效;可回退 ─────────────── */
async proposeIncrement(input) {
const id = deps.ids.next();
await deps.store.saveIncrement({
id,
activityId: input.activityId,
kind: input.kind,
diff: input.diff,
evidenceEntryIds: input.evidenceEntryIds,
state: 'candidate',
createdAt: deps.clock.now(),
});
return { incrementId: id, state: 'candidate' };
},
/** 回放先行 —— 重演报告随增量入证据包 */
async replayIncrement(incrementId, cases) {
const increment = await deps.store.getIncrement(incrementId);
if (increment === undefined) throw new ActivityNotInstalledError(incrementId);
assertTransition(increment.state, 'replaying');
const verdict = judgeReplay(cases);
await deps.store.setIncrementState(incrementId, 'replaying', verdict);
return verdict;
},
/** 人放行 —— 放行即生效为新版本,下一单装载即行为改变,引擎零改动 */
async releaseIncrement(incrementId, by) {
const increment = await deps.store.getIncrement(incrementId);
if (increment === undefined) throw new ActivityNotInstalledError(incrementId);
assertTransition(increment.state, 'released');
const assets = await deps.store.listAssets(increment.activityId);
const nextVersion =
assets.filter((a) => a.kind === increment.kind).reduce((max, a) => Math.max(max, a.version), 0) + 1;
const asset: AssetVersion = {
activityId: increment.activityId,
kind: increment.kind,
version: nextVersion,
effective: true,
source: 'evolution',
createdAt: deps.clock.now().toISOString(),
};
await deps.store.saveAsset(asset, increment.diff);
await deps.store.setAssetEffective(increment.activityId, increment.kind, nextVersion);
await deps.store.setIncrementState(incrementId, 'released', undefined, by);
return asset;
},
async rejectIncrement(incrementId, reason) {
const increment = await deps.store.getIncrement(incrementId);
if (increment === undefined) throw new ActivityNotInstalledError(incrementId);
assertTransition(increment.state, 'rejected');
await deps.store.setIncrementState(incrementId, 'rejected', undefined, reason);
},
/** 行为回退 = 版本回退 —— 显式治理动作,不由包新版隐式覆盖 */
async rollbackAsset(activityId, kind, toVersion) {
const assets = await deps.store.listAssets(activityId);
const target = assets.find((a) => a.kind === kind && a.version === toVersion);
if (target === undefined) throw new ActivityNotInstalledError(`${activityId}#${kind}@${toVersion}`);
await deps.store.setAssetEffective(activityId, kind, toVersion);
return { ...target, effective: true, source: 'rollback' };
},
};
}
function serializeAsset(manifest: ActivityManifest, kind: AssetKind): string {
switch (kind) {
case 'business-model':
return JSON.stringify(manifest.assets.businessModel);
case 'dmn':
return JSON.stringify(
manifest.assets.businessModel.paradigm === 'loop' ? manifest.assets.businessModel.dmn : { drd: [], tables: [] },
);
case 'skill':
return JSON.stringify(manifest.assets.skills);
}
}
@@ -0,0 +1,63 @@
/**
* module: activity-registry —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { ActivityManifest } from '@contracts';
import type { AssetKind, AssetVersion, IncrementState, PackageVersion, ReplayVerdict } from './internal/domain/versioning.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type RegistryStore = {
savePackage(manifest: ActivityManifest, artifact: string): Promise<void>;
listPackages(activityId?: string): Promise<PackageVersion[]>;
manifest(activityId: string, packageVersion?: string): Promise<ActivityManifest | undefined>;
artifact(activityId: string, packageVersion: string): Promise<string | undefined>;
setEnabled(activityId: string, enabled: boolean): Promise<void>;
setState(activityId: string, version: string, state: PackageVersion['state']): Promise<void>;
enabledOf(activityId: string): Promise<boolean>;
listAssets(activityId: string): Promise<AssetVersion[]>;
saveAsset(asset: AssetVersion, body: string): Promise<void>;
setAssetEffective(activityId: string, kind: AssetKind, version: number): Promise<void>;
saveIncrement(input: {
id: string;
activityId: string;
kind: AssetKind;
diff: string;
evidenceEntryIds: readonly string[];
state: IncrementState;
createdAt: Date;
}): Promise<void>;
getIncrement(id: string): Promise<
| {
id: string;
activityId: string;
kind: AssetKind;
diff: string;
state: IncrementState;
}
| undefined
>;
setIncrementState(id: string, state: IncrementState, report?: ReplayVerdict, by?: string): Promise<void>;
};
/** 工具契约核对 —— app 注入 `toolbox`(L2):装不齐不上架 */
export type ToolContractProbe = {
assertContractsAvailable(names: readonly string[]): Promise<{ ok: boolean; missing: string[] }>;
};
/** 模型名核对 —— app 注入 `llm`(L1) */
export type ModelBindingProbe = {
assertNamesBound(names: readonly string[]): Promise<{ ok: boolean; missing: string[] }>;
};
/** 台账声明合并 —— app 注入 `ledger`(L1):安装时依声明自生长 */
export type LedgerDeclarationApplier = {
applyDeclarations(
declarations: ActivityManifest['declarations']['ledger'],
): Promise<Array<{ applied: boolean; conflicts: ReadonlyArray<{ field: string; message: string }> }>>;
};
/** 模版净化门 —— app 注入 `interaction`(L3) */
export type TemplateGuard = { isSafe(html: string): boolean };
@@ -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>;
};