forked from ChaiHao/BPOS-MVP
@activity-sdk(§8.2 / ADR-004): - 基座六件接口(契约 / 交互 / 记忆 / 数据 / 工具 / LLM)—— 活动看到的全部世界 - defineActivity() 的 ABI;本地开发宿主(mock 工具表 + 模拟工位 + 录制夹具), 宿主自身就守三规矩:裸调用拒发、视图含脚本拒推、监听无时限拒登记 activities/quote-intake —— 统一报价 A1 · RFQ 需求分析(flow): - 契约七件 / 步序声明五步(LLM 抽取 → 条件跳步补全 → 查历史价 → DMN 打标 → 验收放行) - 三分类规则 P-03 落 DMN 决策表(C 优先于 A,命中行留痕) - 台账声明两对象、监听时限必配、SLA 落 Harness depcruise 实测拦下活动包越界 import(activities → apps 双向隔离), 测试遂按边界拆分:活动侧只用 @sdk/@contracts/@sfeel 358 个测试通过,门禁五道全绿 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
133 lines
5.0 KiB
TypeScript
133 lines
5.0 KiB
TypeScript
/**
|
|
* 本地开发宿主 —— mock 工具表 + 模拟工位(工单 / 卡)+ 回放夹具(编码规范 §8.2)。
|
|
*
|
|
* 「系统外对着声明开发(本地 mock)」:开发者不必起系统就能跑活动;
|
|
* 夹具录下的出入参可直接喂给回放审(§7.3 两缝的同一副夹具)。
|
|
*/
|
|
import type { ActivityBase, ContractSpec } from './base.ts';
|
|
import type { ActivityDefinition, ActivityOutcome } from './define.ts';
|
|
import type { ChatCardDraft, LedgerKey, LedgerRow, ViewBlockLike, WorkOrderDraft } from './types.ts';
|
|
|
|
export type HostRecording = {
|
|
readonly views: ViewBlockLike[][];
|
|
readonly htmls: string[];
|
|
readonly says: string[];
|
|
readonly workOrders: WorkOrderDraft[];
|
|
readonly cards: ChatCardDraft[];
|
|
readonly toolCalls: Array<{ tool: string; input: Record<string, unknown> }>;
|
|
readonly intents: Array<{ tool: string; input: Record<string, unknown>; label: string }>;
|
|
readonly listeners: Array<{ eventType: string; keyFilter: Record<string, string>; deadline: string }>;
|
|
readonly llmCalls: Array<{ model: string; prompt: string }>;
|
|
readonly writes: Array<{ address: string; key: LedgerKey; patch: LedgerRow }>;
|
|
};
|
|
|
|
export type HostOptions = {
|
|
readonly contract: ContractSpec;
|
|
/** mock 工具表:工具名 → 固定出参 */
|
|
readonly tools?: Readonly<Record<string, (input: Record<string, unknown>) => Record<string, unknown>>>;
|
|
/** mock 模型:模型名 → 固定出参 */
|
|
readonly models?: Readonly<Record<string, (prompt: string) => { text: string; object?: unknown }>>;
|
|
/** 台账初值:地址 → 行 */
|
|
readonly ledger?: Readonly<Record<string, LedgerRow[]>>;
|
|
readonly memory?: ReadonlyArray<{ layer: 'fact' | 'pattern' | 'verdict' | 'exception'; statement: string }>;
|
|
};
|
|
|
|
export type MockHost = {
|
|
readonly base: ActivityBase;
|
|
readonly recording: HostRecording;
|
|
run(definition: ActivityDefinition): Promise<ActivityOutcome>;
|
|
};
|
|
|
|
export function createMockHost(options: HostOptions): MockHost {
|
|
const recording: HostRecording = {
|
|
views: [],
|
|
htmls: [],
|
|
says: [],
|
|
workOrders: [],
|
|
cards: [],
|
|
toolCalls: [],
|
|
intents: [],
|
|
listeners: [],
|
|
llmCalls: [],
|
|
writes: [],
|
|
};
|
|
const ledger = new Map<string, LedgerRow[]>(Object.entries(options.ledger ?? {}));
|
|
let cardSeq = 0;
|
|
|
|
const base: ActivityBase = {
|
|
contract: options.contract,
|
|
interaction: {
|
|
async pushView(blocks) {
|
|
recording.views.push([...blocks]);
|
|
},
|
|
async pushHtml(html) {
|
|
if (/<\s*script\b/i.test(html)) throw new Error('视图模版不得含 <script> —— 容器无脚本执行权');
|
|
recording.htmls.push(html);
|
|
},
|
|
async say(text) {
|
|
recording.says.push(text);
|
|
},
|
|
async issueWorkOrder(order) {
|
|
if (order.basis.length === 0) throw new Error('调用必附上下文:裸调用基座拒发');
|
|
recording.workOrders.push(order);
|
|
return { id: `wo-${(cardSeq += 1)}` };
|
|
},
|
|
async issueCard(card) {
|
|
recording.cards.push(card);
|
|
return { id: `card-${(cardSeq += 1)}` };
|
|
},
|
|
},
|
|
memory: {
|
|
async window() {
|
|
return [];
|
|
},
|
|
async recall(opts) {
|
|
const all = options.memory ?? [];
|
|
const layers = opts?.layers;
|
|
return all.filter((m) => layers === undefined || layers.includes(m.layer)).slice(0, opts?.limit ?? 50);
|
|
},
|
|
},
|
|
ledger: {
|
|
async read(address, where) {
|
|
const rows = ledger.get(address) ?? [];
|
|
if (where === undefined) return rows;
|
|
return rows.filter((row) => Object.entries(where).every(([k, v]) => row[k] === v));
|
|
},
|
|
async write(address, key, patch) {
|
|
recording.writes.push({ address, key, patch });
|
|
const rows = ledger.get(address) ?? [];
|
|
const index = rows.findIndex((row) => Object.entries(key).every(([k, v]) => row[k] === v));
|
|
if (index < 0) rows.push({ ...key, ...patch });
|
|
else rows[index] = { ...rows[index], ...patch };
|
|
ledger.set(address, rows);
|
|
},
|
|
},
|
|
tools: {
|
|
async invoke(tool, input) {
|
|
recording.toolCalls.push({ tool, input: { ...input } });
|
|
const impl = options.tools?.[tool];
|
|
if (impl === undefined) return { ok: false, error: { code: 'not-mocked', message: `工具未 mock:${tool}` } };
|
|
return { ok: true, output: impl({ ...input }) };
|
|
},
|
|
async intend(tool, input, label) {
|
|
recording.intents.push({ tool, input: { ...input }, label });
|
|
},
|
|
async listen(input) {
|
|
if (input.deadline.trim() === '') throw new Error('监听时限必配 —— 无死等');
|
|
recording.listeners.push({ ...input, keyFilter: { ...input.keyFilter } });
|
|
return { id: `listener-${(cardSeq += 1)}` };
|
|
},
|
|
},
|
|
llm: {
|
|
async generate(input) {
|
|
recording.llmCalls.push({ model: input.model, prompt: input.prompt });
|
|
const impl = options.models?.[input.model];
|
|
if (impl === undefined) throw new Error(`模型未 mock:${input.model}`);
|
|
return impl(input.prompt);
|
|
},
|
|
},
|
|
};
|
|
|
|
return { base, recording, run: (definition) => definition.run(base) };
|
|
}
|