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,69 @@
/**
* 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 declare class InstallRejectedError extends Error {
readonly verdict: InstallVerdict;
constructor(verdict: InstallVerdict);
}
export declare class ActivityNotInstalledError extends Error {
readonly activityId: string;
constructor(activityId: string);
}
export declare class ToolNotDeclaredError extends Error {
readonly missing: readonly string[];
constructor(missing: readonly string[]);
}
/** 库内两维管理:业务分类(一级,组织自定)× 活动类型(标准 / 案例)—— 两维平级、相互独立 */
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,12 @@
/**
* 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,68 @@
/**
* 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,61 @@
/**
* 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 declare class TemplateRejectedError extends Error {
readonly violations: readonly TemplateViolation[];
constructor(violations: readonly TemplateViolation[]);
}
/** 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;
};
+11
View File
@@ -0,0 +1,11 @@
/**
* 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';
+42
View File
@@ -0,0 +1,42 @@
/**
* 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>;
};
@@ -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>;
};
+57
View File
@@ -0,0 +1,57 @@
CREATE TABLE "sys"."activity_asset" (
"activity_id" text NOT NULL,
"kind" text NOT NULL,
"version" integer NOT NULL,
"body" text NOT NULL,
"effective" boolean DEFAULT false NOT NULL,
"source" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "activity_asset_activity_id_kind_version_pk" PRIMARY KEY("activity_id","kind","version")
);
--> statement-breakpoint
CREATE TABLE "sys"."activity_package" (
"activity_id" text NOT NULL,
"version" text NOT NULL,
"name" text NOT NULL,
"category" text NOT NULL,
"paradigm" text NOT NULL,
"manifest" jsonb NOT NULL,
"artifact" text NOT NULL,
"state" text DEFAULT 'active' NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"installed_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "activity_package_activity_id_version_pk" PRIMARY KEY("activity_id","version")
);
--> statement-breakpoint
CREATE TABLE "sys"."asset_increment" (
"id" text PRIMARY KEY NOT NULL,
"activity_id" text NOT NULL,
"kind" text NOT NULL,
"diff" text NOT NULL,
"evidence_entry_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
"state" text DEFAULT 'candidate' NOT NULL,
"replay_report" jsonb,
"released_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sys"."chat_message" (
"id" text PRIMARY KEY NOT NULL,
"task_id" text NOT NULL,
"kind" text NOT NULL,
"payload" jsonb NOT NULL,
"at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sys"."task_view" (
"task_id" text PRIMARY KEY NOT NULL,
"blocks" jsonb,
"html" text,
"phase" text DEFAULT 'activating' NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "activity_asset_effective_key" ON "sys"."activity_asset" USING btree ("activity_id","kind") WHERE "sys"."activity_asset"."effective";--> statement-breakpoint
CREATE INDEX "activity_package_category_idx" ON "sys"."activity_package" USING btree ("category","paradigm");--> statement-breakpoint
CREATE INDEX "asset_increment_activity_idx" ON "sys"."asset_increment" USING btree ("activity_id","state");--> statement-breakpoint
CREATE INDEX "chat_message_task_idx" ON "sys"."chat_message" USING btree ("task_id","at");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1785086925397, "when": 1785086925397,
"tag": "0003_youthful_swarm", "tag": "0003_youthful_swarm",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1785087362238,
"tag": "0004_nice_ravenous",
"breakpoints": true
} }
] ]
} }
+5 -4
View File
@@ -10,7 +10,7 @@
| M0 | 工作区骨架 + 五道门禁 + 环境自举 | ✅ 完成 | | M0 | 工作区骨架 + 五道门禁 + 环境自举 | ✅ 完成 |
| M1 | `@contracts` 首版、`event`、`ledger`、`org`、`llm` | ✅ 完成 | | M1 | `@contracts` 首版、`event`、`ledger`、`org`、`llm` | ✅ 完成 |
| M2 | `memory`、`toolbox`、`channel`、`message` | ✅ 完成 | | M2 | `memory`、`toolbox`、`channel`、`message` | ✅ 完成 |
| M3 | `interaction`、`human-tool`、`release`、`activity-registry`、`@sfeel` | 🟡 3/5(`@sfeel` · `human-tool` · `release` 已落) | | M3 | `interaction`、`human-tool`、`release`、`activity-registry`、`@sfeel` | ✅ 完成 |
| M4 | `skill-engine`、`loop-engine`、`flow-engine`、`task` | ⬜ 未开始 | | M4 | `skill-engine`、`loop-engine`、`flow-engine`、`task` | ⬜ 未开始 |
| M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | ⬜ 未开始 | | M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | ⬜ 未开始 |
| M6 | 前端:工作台五要素 → 治理台九功能;`minute`、`report` | ⬜ 未开始 | | M6 | 前端:工作台五要素 → 治理台九功能;`minute`、`report` | ⬜ 未开始 |
@@ -46,10 +46,11 @@ ledger 对真实 PG 的 schema 自生长与写三分。
app 侧接上派发器(advisory lock 串行 + 消费幂等)与 `/api/ingest` 模拟通道。 app 侧接上派发器(advisory lock 串行 + 消费幂等)与 `/api/ingest` 模拟通道。
已验证:一封无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落到 OP 岗位李元初的消息要素。 已验证:一封无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落到 OP 岗位李元初的消息要素。
**M3(进行中)** — `@sfeel` 全套(lexer / parser / evaluate / static-check,32 测试); **M3** — `@sfeel` 全套(lexer / parser / evaluate / static-check);
`human-tool` 三规矩机检 + 工单两模式 + 对话卡族 + 到达面两路 + 驳回重答; `human-tool` 三规矩机检 + 工单两模式 + 对话卡族 + 到达面两路 + 驳回重答;
`release` 落地预检五项 + 幂等落地 + 落地收敛才发射输出口。 `release` 落地预检五项 + 幂等落地 + 落地收敛才发射输出口;
剩 `interaction`(对话流单入口 / 视图区推送 / SSE)与 `activity-registry`(活动库两审 / 资产版本进化管线)。 `interaction` 控制权按态转交 + 视图净化门 + SSE 推送;
`activity-registry` 安装两审 + 悬空监听拒绝 + 零环境能力扫描 + 资产与包分离版本化 + 进化管线状态机。
## 待接的占位(详见 docs/debt.md) ## 待接的占位(详见 docs/debt.md)