Files
JX-BPOS/packages/activity-sdk/src/host.ts
T
JCAI Main AgentandClaude Opus 5 c6cdb4c310 M5(完成): @activity-sdk 与首个活动包 quote-intake
@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
2026-07-27 02:09:43 +08:00

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) };
}