Archived
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:
co-authored by
Claude Opus 5
parent
d96c6e6dff
commit
2850e6d4ef
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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
@@ -29,6 +29,13 @@
|
||||
"when": 1785086925397,
|
||||
"tag": "0003_youthful_swarm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1785087362238,
|
||||
"tag": "0004_nice_ravenous",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-4
@@ -10,7 +10,7 @@
|
||||
| M0 | 工作区骨架 + 五道门禁 + 环境自举 | ✅ 完成 |
|
||||
| M1 | `@contracts` 首版、`event`、`ledger`、`org`、`llm` | ✅ 完成 |
|
||||
| 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` | ⬜ 未开始 |
|
||||
| M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | ⬜ 未开始 |
|
||||
| M6 | 前端:工作台五要素 → 治理台九功能;`minute`、`report` | ⬜ 未开始 |
|
||||
@@ -46,10 +46,11 @@ ledger 对真实 PG 的 schema 自生长与写三分。
|
||||
app 侧接上派发器(advisory lock 串行 + 消费幂等)与 `/api/ingest` 模拟通道。
|
||||
已验证:一封无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落到 OP 岗位李元初的消息要素。
|
||||
|
||||
**M3(进行中)** — `@sfeel` 全套(lexer / parser / evaluate / static-check,32 测试);
|
||||
**M3** — `@sfeel` 全套(lexer / parser / evaluate / static-check);
|
||||
`human-tool` 三规矩机检 + 工单两模式 + 对话卡族 + 到达面两路 + 驳回重答;
|
||||
`release` 落地预检五项 + 幂等落地 + 落地收敛才发射输出口。
|
||||
剩 `interaction`(对话流单入口 / 视图区推送 / SSE)与 `activity-registry`(活动库两审 / 资产版本进化管线)。
|
||||
`release` 落地预检五项 + 幂等落地 + 落地收敛才发射输出口;
|
||||
`interaction` 控制权按态转交 + 视图净化门 + SSE 推送;
|
||||
`activity-registry` 安装两审 + 悬空监听拒绝 + 零环境能力扫描 + 资产与包分离版本化 + 进化管线状态机。
|
||||
|
||||
## 待接的占位(详见 docs/debt.md)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user