arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Recipe expander:把 prompt_recipe 展開成 claude_api 的實際 input
|
||||
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.2 + Phase 2.1
|
||||
*
|
||||
* 流程:
|
||||
* 1. loadPromptRecipe 取定義
|
||||
* 2. fragments → 用 KBDB API 抓 block content
|
||||
* 3. inputs → 從 workflow context 取值 + 跑 transform
|
||||
* 4. 套進 prompt_assembly.system / .user 的 {{var}} 模板
|
||||
* 5. 回傳 { prompt, model, output_format, output_required_fields }
|
||||
*/
|
||||
|
||||
import { loadPromptRecipe, RecipeLoadError } from './recipe-loader';
|
||||
import { applyTransform } from './recipe-transforms';
|
||||
import type { Fragment, RecipeInput } from './prompt-recipe-schema';
|
||||
|
||||
type ExpanderEnv = {
|
||||
RECIPES: { get: (key: string) => Promise<string | null> };
|
||||
KBDB_BASE_URL?: string;
|
||||
};
|
||||
|
||||
export interface ExpandedRecipe {
|
||||
prompt: string; // user prompt(system + user 用 \n\n--- system ---\n 分隔)
|
||||
model: 'haiku' | 'sonnet' | 'opus';
|
||||
output_format: 'text' | 'json';
|
||||
output_required_fields?: string[];
|
||||
}
|
||||
|
||||
/** 從 path 取嵌套值,例如 "ctx.read_drafts.blocks" / "loop.item" */
|
||||
function getByPath(ctx: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let cur: unknown = ctx;
|
||||
for (const p of parts) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
if (typeof cur !== 'object') return undefined;
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** {{var}} 模板替換(top-level vars 物件) */
|
||||
function interpolate(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (vars[key] !== undefined ? vars[key] : `{{${key}}}`));
|
||||
}
|
||||
|
||||
async function fetchKbdbBlock(
|
||||
env: ExpanderEnv,
|
||||
apiKey: string,
|
||||
fragment: Extract<Fragment, { source: 'kbdb_block' }>,
|
||||
): Promise<unknown> {
|
||||
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
|
||||
let url: string;
|
||||
if (fragment.block_id) {
|
||||
url = `${base}/blocks/${encodeURIComponent(fragment.block_id)}`;
|
||||
} else {
|
||||
url = `${base}/blocks?page_name=${encodeURIComponent(fragment.block_page_name!)}&limit=1`;
|
||||
}
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
|
||||
if (!res.ok) throw new Error(`KBDB fragment 抓取失敗 (${res.status}): ${url}`);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
// page_name 模式回 {blocks:[]},block_id 模式直接回 block 物件
|
||||
const block: Record<string, unknown> = fragment.block_id
|
||||
? data
|
||||
: ((data.blocks as unknown[])?.[0] as Record<string, unknown>) ?? {};
|
||||
if (!block) throw new Error(`KBDB block 不存在: ${fragment.block_id ?? fragment.block_page_name}`);
|
||||
|
||||
const fieldVal = block[fragment.field];
|
||||
if (fieldVal === undefined) throw new Error(`block 缺欄位 "${fragment.field}"`);
|
||||
return fieldVal;
|
||||
}
|
||||
|
||||
async function resolveFragment(
|
||||
env: ExpanderEnv,
|
||||
apiKey: string,
|
||||
frag: Fragment,
|
||||
): Promise<{ var: string; value: unknown }> {
|
||||
if (frag.source === 'kv') {
|
||||
const val = await env.RECIPES.get(frag.key);
|
||||
if (val === null) throw new Error(`KV 找不到 key: ${frag.key}`);
|
||||
return { var: frag.var, value: val };
|
||||
}
|
||||
return { var: frag.var, value: await fetchKbdbBlock(env, apiKey, frag) };
|
||||
}
|
||||
|
||||
function resolveInput(input: RecipeInput, ctx: Record<string, unknown>): { var: string; value: unknown } {
|
||||
let val = getByPath(ctx, input.from);
|
||||
const beforeDefault = val;
|
||||
if (val === undefined) val = input.default;
|
||||
try {
|
||||
if (input.transform) val = applyTransform(val, input.transform);
|
||||
return { var: input.var, value: val };
|
||||
} catch (e) {
|
||||
// 把 path 跟原值放進錯誤訊息,方便 debug recipe
|
||||
const valType = Array.isArray(beforeDefault) ? `array(${beforeDefault.length})`
|
||||
: beforeDefault === undefined ? 'undefined(default applied)'
|
||||
: typeof beforeDefault;
|
||||
throw new Error(`${e instanceof Error ? e.message : String(e)} [path=${input.from}, type=${valType}]`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 主入口:展開 recipe → 組 prompt */
|
||||
export async function expandPromptRecipe(
|
||||
recipeRef: string,
|
||||
ctx: Record<string, unknown>,
|
||||
env: ExpanderEnv,
|
||||
apiKey: string, // KBDB partner key(從 workflow auth 來)
|
||||
): Promise<ExpandedRecipe> {
|
||||
const recipe = await loadPromptRecipe(recipeRef, env.RECIPES);
|
||||
|
||||
const vars: Record<string, string> = {};
|
||||
|
||||
for (const frag of recipe.fragments) {
|
||||
const { var: name, value } = await resolveFragment(env, apiKey, frag);
|
||||
vars[name] = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
for (const inp of recipe.inputs) {
|
||||
const { var: name, value } = resolveInput(inp, ctx);
|
||||
vars[name] = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
const system = interpolate(recipe.prompt_assembly.system, vars);
|
||||
const user = interpolate(recipe.prompt_assembly.user, vars);
|
||||
|
||||
// claude_api 容器目前吃單一 prompt 字串 → system + user 用分隔線拼
|
||||
const prompt = `${system}\n\n--- USER ---\n\n${user}`;
|
||||
|
||||
return {
|
||||
prompt,
|
||||
model: recipe.model,
|
||||
output_format: recipe.output.format,
|
||||
output_required_fields: recipe.output.required_fields,
|
||||
};
|
||||
}
|
||||
|
||||
export { RecipeLoadError };
|
||||
Reference in New Issue
Block a user