diff --git a/api-report/apps/api/src/modules/activity-registry/contract.d.ts b/api-report/apps/api/src/modules/activity-registry/contract.d.ts new file mode 100644 index 0000000..06d4469 --- /dev/null +++ b/api-report/apps/api/src/modules/activity-registry/contract.d.ts @@ -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; + list(): Promise; + get(activityId: string): Promise; + setEnabled(activityId: string, enabled: boolean): Promise; + uninstall(activityId: string): Promise; + /** 任务装载 = 包版本 + 各资产当前生效版,全部入信封 */ + loadEnvelope(activityId: string): Promise; + manifestOf(activityId: string, packageVersion?: string): Promise; + /** 进化管线:候选 → 回放 → 人放行 → 生效;可回退 */ + proposeIncrement(input: { + activityId: string; + kind: AssetKind; + diff: string; + evidenceEntryIds: readonly string[]; + }): Promise<{ + incrementId: string; + state: IncrementState; + }>; + replayIncrement(incrementId: string, cases: readonly ReplayCase[]): Promise; + releaseIncrement(incrementId: string, by: string): Promise; + rejectIncrement(incrementId: string, reason: string): Promise; + /** 行为回退 = 版本回退(显式治理动作) */ + rollbackAsset(activityId: string, kind: AssetKind, toVersion: number): Promise; +}; diff --git a/api-report/apps/api/src/modules/activity-registry/index.d.ts b/api-report/apps/api/src/modules/activity-registry/index.d.ts new file mode 100644 index 0000000..39f4376 --- /dev/null +++ b/api-report/apps/api/src/modules/activity-registry/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/activity-registry/port.d.ts b/api-report/apps/api/src/modules/activity-registry/port.d.ts new file mode 100644 index 0000000..4433c11 --- /dev/null +++ b/api-report/apps/api/src/modules/activity-registry/port.d.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; + listPackages(activityId?: string): Promise; + manifest(activityId: string, packageVersion?: string): Promise; + artifact(activityId: string, packageVersion: string): Promise; + setEnabled(activityId: string, enabled: boolean): Promise; + setState(activityId: string, version: string, state: PackageVersion['state']): Promise; + enabledOf(activityId: string): Promise; + listAssets(activityId: string): Promise; + saveAsset(asset: AssetVersion, body: string): Promise; + setAssetEffective(activityId: string, kind: AssetKind, version: number): Promise; + saveIncrement(input: { + id: string; + activityId: string; + kind: AssetKind; + diff: string; + evidenceEntryIds: readonly string[]; + state: IncrementState; + createdAt: Date; + }): Promise; + 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; +}; +/** 工具契约核对 —— 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; + }>>; +}; +/** 模版净化门 —— app 注入 `interaction`(L3) */ +export type TemplateGuard = { + isSafe(html: string): boolean; +}; diff --git a/api-report/apps/api/src/modules/interaction/contract.d.ts b/api-report/apps/api/src/modules/interaction/contract.d.ts new file mode 100644 index 0000000..707fec0 --- /dev/null +++ b/api-report/apps/api/src/modules/interaction/contract.d.ts @@ -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; + /** + * 负责人的自由输入 —— 人事件的第二形态(卡回传之外): + * 写入任务记忆,loop 作为观察进入后续窗口;**不驱动 flow 步**。 + * 叫停等指令走控制事件,不经自由输入。 + */ + say(taskId: TaskId, by: StaffId, text: string): Promise; + /** 视图流控:活动按局面推结构化块 */ + pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise; + /** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */ + pushViewHtml(taskId: TaskId, html: string): Promise; + /** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */ + setPhase(taskId: TaskId, phase: InteractionPhase): Promise; + phaseOf(taskId: TaskId): Promise; + /** SSE:单连接、按信封目标失效缓存 */ + subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription; + /** 供上层广播失效信号 */ + invalidate(target: 'task' | 'workflow' | 'desk', id: string): void; +}; diff --git a/api-report/apps/api/src/modules/interaction/index.d.ts b/api-report/apps/api/src/modules/interaction/index.d.ts new file mode 100644 index 0000000..9ecd7f1 --- /dev/null +++ b/api-report/apps/api/src/modules/interaction/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/interaction/port.d.ts b/api-report/apps/api/src/modules/interaction/port.d.ts new file mode 100644 index 0000000..2408c70 --- /dev/null +++ b/api-report/apps/api/src/modules/interaction/port.d.ts @@ -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; + listMessages(taskId: TaskId): Promise; + putView(taskId: TaskId, view: { + blocks?: readonly ViewBlock[]; + html?: string; + }): Promise; + setPhase(taskId: TaskId, phase: InteractionPhase): Promise; + phaseOf(taskId: TaskId): Promise; +}; +/** 自由输入即人事件 —— app 注入 `event`(L1) */ +export type HumanEventPublisher = { + publishHumanEvent(input: { + taskId: TaskId; + cardId: string; + payload: Record; + }): Promise; +}; +/** 留痕 —— app 注入 `memory`(L2):时间线即任务记忆的可读投影 */ +export type MemoryRecorder = { + append(input: { + taskId: TaskId; + kind: 'human-input'; + title: string; + detail?: Record; + }): Promise; +}; +/** 收件人解析 —— app 注入 `org`(L1):SSE 只推给有权看的人 */ +export type Audience = { + canSee(staffId: StaffId, taskId: TaskId): Promise; +}; diff --git a/apps/api/src/modules/activity-registry/CONTRACT.md b/apps/api/src/modules/activity-registry/CONTRACT.md new file mode 100644 index 0000000..a195589 --- /dev/null +++ b/apps/api/src/modules/activity-registry/CONTRACT.md @@ -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,在途任务按其启动时版本跑完 diff --git a/apps/api/src/modules/activity-registry/__tests__/versioning.test.ts b/apps/api/src/modules/activity-registry/__tests__/versioning.test.ts new file mode 100644 index 0000000..9d83da2 --- /dev/null +++ b/apps/api/src/modules/activity-registry/__tests__/versioning.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/modules/activity-registry/contract.ts b/apps/api/src/modules/activity-registry/contract.ts new file mode 100644 index 0000000..b1f9f2a --- /dev/null +++ b/apps/api/src/modules/activity-registry/contract.ts @@ -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; + list(): Promise; + get(activityId: string): Promise; + setEnabled(activityId: string, enabled: boolean): Promise; + uninstall(activityId: string): Promise; + + /** 任务装载 = 包版本 + 各资产当前生效版,全部入信封 */ + loadEnvelope(activityId: string): Promise; + manifestOf(activityId: string, packageVersion?: string): Promise; + + /** 进化管线:候选 → 回放 → 人放行 → 生效;可回退 */ + proposeIncrement(input: { + activityId: string; + kind: AssetKind; + diff: string; + evidenceEntryIds: readonly string[]; + }): Promise<{ incrementId: string; state: IncrementState }>; + replayIncrement(incrementId: string, cases: readonly ReplayCase[]): Promise; + releaseIncrement(incrementId: string, by: string): Promise; + rejectIncrement(incrementId: string, reason: string): Promise; + /** 行为回退 = 版本回退(显式治理动作) */ + rollbackAsset(activityId: string, kind: AssetKind, toVersion: number): Promise; +}; diff --git a/apps/api/src/modules/activity-registry/index.ts b/apps/api/src/modules/activity-registry/index.ts new file mode 100644 index 0000000..d6583d6 --- /dev/null +++ b/apps/api/src/modules/activity-registry/index.ts @@ -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'; diff --git a/apps/api/src/modules/activity-registry/internal/adapter/registry-schema.ts b/apps/api/src/modules/activity-registry/internal/adapter/registry-schema.ts new file mode 100644 index 0000000..5feb1a0 --- /dev/null +++ b/apps/api/src/modules/activity-registry/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)], +); diff --git a/apps/api/src/modules/activity-registry/internal/adapter/registry-store.ts b/apps/api/src/modules/activity-registry/internal/adapter/registry-store.ts new file mode 100644 index 0000000..8c6c216 --- /dev/null +++ b/apps/api/src/modules/activity-registry/internal/adapter/registry-store.ts @@ -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)); + }, + }; +} diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts new file mode 100644 index 0000000..57a0932 --- /dev/null +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -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(); + 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 }; +} diff --git a/apps/api/src/modules/activity-registry/internal/domain/versioning.ts b/apps/api/src/modules/activity-registry/internal/domain/versioning.ts new file mode 100644 index 0000000..2126ba5 --- /dev/null +++ b/apps/api/src/modules/activity-registry/internal/domain/versioning.ts @@ -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>; +}; + +export function buildLoadEnvelope(pkg: PackageVersion, assets: readonly AssetVersion[]): LoadEnvelope { + const effective: Record = { '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> = { + 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); +} diff --git a/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts b/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts new file mode 100644 index 0000000..c4db8f8 --- /dev/null +++ b/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts @@ -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; + 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 { + const manifest = await deps.store.manifest(activityId, version); + if (manifest === undefined) throw new ActivityNotInstalledError(activityId); + return manifest; + } + + return { + /** 安装两审:静态审读清单,回放审重演历史 —— 「旧成不坏」是生效硬门 */ + async install(manifest, artifact): Promise { + 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 { + const packages = await deps.store.listPackages(); + const byActivity = new Map(); + 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); + } +} diff --git a/apps/api/src/modules/activity-registry/port.ts b/apps/api/src/modules/activity-registry/port.ts new file mode 100644 index 0000000..a532b76 --- /dev/null +++ b/apps/api/src/modules/activity-registry/port.ts @@ -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; + listPackages(activityId?: string): Promise; + manifest(activityId: string, packageVersion?: string): Promise; + artifact(activityId: string, packageVersion: string): Promise; + setEnabled(activityId: string, enabled: boolean): Promise; + setState(activityId: string, version: string, state: PackageVersion['state']): Promise; + enabledOf(activityId: string): Promise; + + listAssets(activityId: string): Promise; + saveAsset(asset: AssetVersion, body: string): Promise; + setAssetEffective(activityId: string, kind: AssetKind, version: number): Promise; + + saveIncrement(input: { + id: string; + activityId: string; + kind: AssetKind; + diff: string; + evidenceEntryIds: readonly string[]; + state: IncrementState; + createdAt: Date; + }): Promise; + 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; +}; + +/** 工具契约核对 —— 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 }>>; +}; + +/** 模版净化门 —— app 注入 `interaction`(L3) */ +export type TemplateGuard = { isSafe(html: string): boolean }; diff --git a/apps/api/src/modules/interaction/CONTRACT.md b/apps/api/src/modules/interaction/CONTRACT.md new file mode 100644 index 0000000..eb44d14 --- /dev/null +++ b/apps/api/src/modules/interaction/CONTRACT.md @@ -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-*)归活动,其余归系统; + 转交随引擎事件自动发生,活动不感知交接 + - **视图区无脚本执行权**:含 `').map((v) => v.code)).toContain('script'); + }); + + it('含 on* 事件处理器即拒', () => { + expect(checkTemplate('').map((v) => v.code)).toContain('event-handler'); + }); + + it('引用外部资源即拒', () => { + expect(checkTemplate('').map((v) => v.code)).toContain('external-resource'); + expect(checkTemplate('').map((v) => v.code)).toContain('external-resource'); + }); + + it('相对路径资源不算外部', () => { + expect(templateIsSafe('')).toBe(true); + }); +}); diff --git a/apps/api/src/modules/interaction/contract.ts b/apps/api/src/modules/interaction/contract.ts new file mode 100644 index 0000000..2f4db3d --- /dev/null +++ b/apps/api/src/modules/interaction/contract.ts @@ -0,0 +1,53 @@ +/** + * module: interaction —— 对外类型(编码规范 §10.1;语义出处 OS 02 / 09,AM 07)。 + * + * 交互的**结构**归基座,**内容**归活动: + * 对话流 = 任务的人机时间线、**一切回环输入的唯一入口**; + * 视图区 = 只读呈现容器,内容由后端流控推送。 + */ +import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts'; +import type { ControlHolder, InteractionPhase, TemplateViolation } from './internal/domain/control-handover.ts'; + +export type { ChatMessage, ControlHolder, InteractionPhase, TemplateViolation, ViewBlock }; + +export class TemplateRejectedError extends Error { + constructor(readonly violations: readonly TemplateViolation[]) { + super(`视图模版被拒:${violations.map((v) => v.message).join('; ')} —— 容器无脚本执行权(AM 07)`); + this.name = 'TemplateRejectedError'; + } +} + +/** SSE 推送的信封 —— 前端按目标失效对应缓存(§9.2) */ +export type PushEnvelope = + | { readonly kind: 'chat'; readonly taskId: TaskId; readonly message: ChatMessage } + | { readonly kind: 'view'; readonly taskId: TaskId; readonly blocks: readonly ViewBlock[] } + | { readonly kind: 'view-html'; readonly taskId: TaskId; readonly html: string } + | { readonly kind: 'control'; readonly taskId: TaskId; readonly phase: InteractionPhase; readonly output: ControlHolder } + | { readonly kind: 'invalidate'; readonly target: 'task' | 'workflow' | 'desk'; readonly id: string }; + +export type Subscription = { close(): void }; + +export type InteractionService = { + /** 对话流:任务的人机时间线 —— 与任务记忆同源、不同筛选 */ + timeline(taskId: TaskId): Promise; + /** + * 负责人的自由输入 —— 人事件的第二形态(卡回传之外): + * 写入任务记忆,loop 作为观察进入后续窗口;**不驱动 flow 步**。 + * 叫停等指令走控制事件,不经自由输入。 + */ + say(taskId: TaskId, by: StaffId, text: string): Promise; + + /** 视图流控:活动按局面推结构化块 */ + pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise; + /** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */ + pushViewHtml(taskId: TaskId, html: string): Promise; + + /** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */ + setPhase(taskId: TaskId, phase: InteractionPhase): Promise; + phaseOf(taskId: TaskId): Promise; + + /** SSE:单连接、按信封目标失效缓存 */ + subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription; + /** 供上层广播失效信号 */ + invalidate(target: 'task' | 'workflow' | 'desk', id: string): void; +}; diff --git a/apps/api/src/modules/interaction/index.ts b/apps/api/src/modules/interaction/index.ts new file mode 100644 index 0000000..15cd331 --- /dev/null +++ b/apps/api/src/modules/interaction/index.ts @@ -0,0 +1,32 @@ +/** + * module: interaction —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + ChatMessage, + ControlHolder, + InteractionPhase, + InteractionService, + PushEnvelope, + Subscription, + TemplateViolation, + ViewBlock, +} from './contract.ts'; +export { TemplateRejectedError } from './contract.ts'; + +export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStore, MemoryRecorder } from './port.ts'; + +export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts'; +export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts'; + +export { + CONTROL_HOLDERS, + checkTemplate, + handoverHappens, + inputControl, + INTERACTION_PHASES, + outputControl, + templateIsSafe, +} from './internal/domain/control-handover.ts'; + +export { createInteractionStore } from './internal/adapter/interaction-store.ts'; +export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts'; diff --git a/apps/api/src/modules/interaction/internal/adapter/interaction-schema.ts b/apps/api/src/modules/interaction/internal/adapter/interaction-schema.ts new file mode 100644 index 0000000..c95562a --- /dev/null +++ b/apps/api/src/modules/interaction/internal/adapter/interaction-schema.ts @@ -0,0 +1,26 @@ +/** + * interaction 的 sys schema 表(编码规范 §7 / ADR-002)。 + * 前端零过程状态:对话流与视图区都是**服务端投影**,刷新即重建(§9.2)。 + */ +import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core'; +import { sys } from '@shared/db.ts'; + +export const chatMessageTable = sys.table( + 'chat_message', + { + id: text('id').primaryKey(), + taskId: text('task_id').notNull(), + kind: text('kind').notNull(), + payload: jsonb('payload').notNull(), + at: timestamp('at', { withTimezone: true }).notNull(), + }, + (t) => [index('chat_message_task_idx').on(t.taskId, t.at)], +); + +export const taskViewTable = sys.table('task_view', { + taskId: text('task_id').primaryKey(), + blocks: jsonb('blocks'), + html: text('html'), + phase: text('phase').notNull().default('activating'), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts b/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts new file mode 100644 index 0000000..32e61a5 --- /dev/null +++ b/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts @@ -0,0 +1,61 @@ +/** + * interaction 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。 + */ +import { asc, eq } from 'drizzle-orm'; +import type { ChatMessage, TaskId, ViewBlock } from '@contracts'; +import { chatMessageSchema } from '@contracts'; +import type { Db } from '@shared/db.ts'; +import type { InteractionStore } from '../../port.ts'; +import type { InteractionPhase } from '../domain/control-handover.ts'; +import { chatMessageTable, taskViewTable } from './interaction-schema.ts'; + +export function createInteractionStore(db: Db): InteractionStore { + return { + async appendMessage(taskId: TaskId, message: ChatMessage) { + await db.insert(chatMessageTable).values({ + id: message.id, + taskId, + kind: message.k, + payload: message, + at: new Date(message.at), + }); + }, + async listMessages(taskId: TaskId) { + const rows = await db + .select() + .from(chatMessageTable) + .where(eq(chatMessageTable.taskId, taskId)) + .orderBy(asc(chatMessageTable.at)); + return rows.map((row) => chatMessageSchema.parse(row.payload)); + }, + async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }) { + const values = { + taskId, + blocks: view.blocks === undefined ? null : [...view.blocks], + html: view.html ?? null, + updatedAt: new Date(), + }; + await db + .insert(taskViewTable) + .values(values) + .onConflictDoUpdate({ + target: taskViewTable.taskId, + set: { blocks: values.blocks, html: values.html, updatedAt: values.updatedAt }, + }); + }, + async setPhase(taskId: TaskId, phase: InteractionPhase) { + await db + .insert(taskViewTable) + .values({ taskId, phase, updatedAt: new Date() }) + .onConflictDoUpdate({ target: taskViewTable.taskId, set: { phase, updatedAt: new Date() } }); + }, + async phaseOf(taskId: TaskId) { + const rows = await db + .select({ phase: taskViewTable.phase }) + .from(taskViewTable) + .where(eq(taskViewTable.taskId, taskId)) + .limit(1); + return rows[0]?.phase as InteractionPhase | undefined; + }, + }; +} diff --git a/apps/api/src/modules/interaction/internal/domain/control-handover.ts b/apps/api/src/modules/interaction/internal/domain/control-handover.ts new file mode 100644 index 0000000..8c5163c --- /dev/null +++ b/apps/api/src/modules/interaction/internal/domain/control-handover.ts @@ -0,0 +1,73 @@ +/** + * 控制权按态转交 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 4.1,AM 07)。 + * + * 「视图区容器与对话区输出框的控制权由活动与系统**共同持有**: + * 任务激活(Agent 机制运行)时归**活动** —— 视图流控与对话输出由活动驱动; + * 流程流转(节点间、审批点、终态前后)时归**系统** —— 流转、审批与终态回执由系统呈现。 + * 转交随引擎的激活 / 完成事件自动发生,活动不感知交接。 + * **输入侧不参与转交**:全部人类输入永远经系统的对话流卡片协议回传。」 + */ + +export const CONTROL_HOLDERS = ['activity', 'system'] as const; +export type ControlHolder = (typeof CONTROL_HOLDERS)[number]; + +/** 任务在交互层的态 —— 决定输出侧控制权归谁 */ +export const INTERACTION_PHASES = [ + 'activating', + 'active', + 'awaiting-human', + 'awaiting-event', + 'approving', + 'transitioning', + 'terminal', +] as const; +export type InteractionPhase = (typeof INTERACTION_PHASES)[number]; + +/** 输出侧控制权:活动在跑就归活动,其余一律归系统 */ +export function outputControl(phase: InteractionPhase): ControlHolder { + switch (phase) { + case 'active': + case 'awaiting-human': + case 'awaiting-event': + return 'activity'; + case 'activating': + case 'approving': + case 'transitioning': + case 'terminal': + return 'system'; + } +} + +/** 输入侧**永远**归系统 —— 单入口,不参与转交 */ +export function inputControl(): ControlHolder { + return 'system'; +} + +/** 转交是否发生 —— 引擎的激活 / 完成事件驱动;活动不感知 */ +export function handoverHappens(from: InteractionPhase, to: InteractionPhase): boolean { + return outputControl(from) !== outputControl(to); +} + +/* ── 视图区:只读呈现容器,无脚本执行权(AM 07) ────────────────────── */ + +export type TemplateViolation = { readonly code: 'script' | 'event-handler' | 'external-resource'; readonly message: string }; + +const SCRIPT = /<\s*script\b/i; +const EVENT_HANDLER = /\son[a-z]+\s*=/i; +const EXTERNAL = /(src|href)\s*=\s*["']?\s*(https?:)?\/\//i; + +/** + * 模版无脚本 —— 安装静态审与推送前都用它。 + * 「模版是**内容**不是代码:web 零活动代码,容器渲染净化、无脚本执行权。」 + */ +export function checkTemplate(html: string): TemplateViolation[] { + const out: TemplateViolation[] = []; + if (SCRIPT.test(html)) out.push({ code: 'script', message: '视图模版不得含