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:
uncle6me-web
2026-06-03 15:52:38 +08:00
commit 922a57fe34
485 changed files with 89356 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
/**
* acr recipe push <file> — 上傳 recipe YAML 到 arcrun.dev
* acr recipe list — 列出已上傳的 recipe
* acr recipe delete <id> — 刪除 recipecanonical_id 或 rec_hash
*/
import chalk from 'chalk';
import ora from 'ora';
import { readFileSync, existsSync } from 'node:fs';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
import { obtainExposureConsent } from '../lib/exposure-warning.js';
import yaml from 'js-yaml';
interface RecipeYaml {
canonical_id?: string;
display_name?: string;
description?: string;
endpoint?: string;
method?: string;
headers?: Record<string, string>;
body?: Record<string, unknown>;
credentials_required?: Array<{ key: string; inject_as: string }>;
}
interface RecipeDefinition {
canonical_id: string;
hash_id: string;
display_name?: string;
description?: string;
endpoint: string;
method?: string;
credentials_required?: Array<{ key: string; inject_as: string }>;
created_at: number;
updated_at: number;
}
export async function cmdRecipePush(filePath: string): Promise<void> {
const config = loadConfig();
if (!config.api_key) {
console.error(chalk.red('缺少 API Key,請先執行 acr init 取得 API Key'));
process.exit(1);
}
if (!existsSync(filePath)) {
console.error(chalk.red(`找不到檔案:${filePath}`));
process.exit(1);
}
// 讀取並解析 YAML
let recipe: RecipeYaml;
try {
const raw = readFileSync(filePath, 'utf8');
recipe = yaml.load(raw) as RecipeYaml;
} catch (e) {
console.error(chalk.red(`YAML 解析失敗:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
if (!recipe.canonical_id) {
console.error(chalk.red('recipe YAML 缺少 canonical_id 欄位'));
process.exit(1);
}
if (!recipe.endpoint) {
console.error(chalk.red('recipe YAML 缺少 endpoint 欄位'));
process.exit(1);
}
const executorUrl = getCypherExecutorUrl(config);
// 資料外流警示:recipe 定義一個資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
const consent = await obtainExposureConsent({
kind: 'recipe',
resourceName: recipe.canonical_id,
destination: recipe.endpoint,
});
if (!consent) {
process.exit(1);
}
const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start();
try {
const res = await fetch(`${executorUrl}/recipes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Arcrun-API-Key': config.api_key,
},
body: JSON.stringify({ ...recipe, exposure_consent: consent ?? undefined }),
});
const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
if (!data.success || !data.recipe) {
spinner.fail(chalk.red(`上傳失敗:${data.error ?? '未知錯誤'}`));
process.exit(1);
}
spinner.succeed(chalk.green(`✓ recipe "${data.recipe.canonical_id}" 上傳成功`));
console.log(`\n Hash ID${chalk.cyan(data.recipe.hash_id)} (穩定引用,不受改名影響)`);
console.log(` Endpoint${chalk.gray(data.recipe.endpoint)}`);
// 打通檢查(SDD recipe-push-gatekeeping §1.2):recipe 是「指向外部 API 的指針」,
// 正確性一半在「打不打得通」(DECISIONS §1 recipe 驗收 = 2xx)。
// self-hosted 是提醒級:不硬擋、誠實標原因(缺 credential 打不到 2xx 就誠實說,不假綠 — mindset §7)。
await probeRecipeEndpoint(recipe);
console.log(chalk.bold('\n 在 workflow config 中使用:\n'));
console.log(chalk.cyan(` config:`));
console.log(chalk.cyan(` my_node:`));
console.log(chalk.cyan(` component: ${data.recipe.canonical_id} # 或用 hash: ${data.recipe.hash_id}`));
if (data.recipe.credentials_required?.length) {
console.log(chalk.yellow(`\n 此 recipe 需要 credentials${data.recipe.credentials_required.map(c => c.key).join(', ')}`));
console.log(chalk.gray(' 執行 acr creds push 上傳 token'));
}
console.log('');
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/**
* 打通檢查:push 時對 recipe endpoint 實打一次,回報是否 2xx。
*
* 提醒級(self-hosted):只回報、不硬擋(用戶可能就是要先 push 再設 credential)。
* 誠實(mindset §7):
* - endpoint 含未填模板({{_path}} / {{auth.x}} 等)→ 執行期才有值,push 時無法驗,誠實說明。
* - 打不到 2xx → 誠實標 HTTP status(如 401 多半是缺 credential),不假裝成功。
* - arcrun 不做授權判斷:401/403 是對方服務裁決,不是 recipe 的 bugDECISIONS / mindset §3)。
*/
async function probeRecipeEndpoint(recipe: RecipeYaml): Promise<void> {
const endpoint = recipe.endpoint ?? '';
if (/\{\{.*?\}\}/.test(endpoint)) {
console.log(chalk.gray('\n 打通檢查:endpoint 含執行期變數({{...}}),push 時無法預打。'));
console.log(chalk.gray(' 實際是否打通待 acr run 時才知(recipe 驗收標準 = 執行回 2xx)。'));
return;
}
process.stdout.write(chalk.gray('\n 打通檢查(實打 endpoint...'));
try {
const method = (recipe.method ?? 'POST').toUpperCase();
const res = await fetch(endpoint, {
method,
headers: recipe.headers,
// 不帶 credential(push 端沒有明文)→ 打不通多半是缺 auth,下面誠實標
...(method !== 'GET' && method !== 'HEAD'
? { body: JSON.stringify(recipe.body ?? {}) }
: {}),
signal: AbortSignal.timeout(10_000),
});
if (res.ok) {
console.log(chalk.green(` ✓ HTTP ${res.status}(打通)`));
} else if (res.status === 401 || res.status === 403) {
console.log(chalk.yellow(` ⚠ HTTP ${res.status}`));
console.log(chalk.gray(' 未驗收:多半是缺 credential(過認證後才會 2xx)。先 acr creds push 對應 token。'));
console.log(chalk.gray(' 註:401/403 是對方服務在行使授權,不是 recipe 的 bug。'));
} else {
console.log(chalk.yellow(` ⚠ HTTP ${res.status}(未打通)`));
console.log(chalk.gray(' recipe 已上傳,但 endpoint 目前未回 2xx。請確認 endpoint / method 正確。'));
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(` ⚠ 無法連線`));
console.log(chalk.gray(` ${msg.slice(0, 120)}(recipe 已上傳;連線問題不擋 push)`));
}
}
export async function cmdRecipeList(): Promise<void> {
const config = loadConfig();
const executorUrl = getCypherExecutorUrl(config);
const spinner = ora('取得 recipe 清單').start();
try {
const headers: Record<string, string> = {};
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
const res = await fetch(`${executorUrl}/recipes`, { headers });
const data = await res.json() as { success: boolean; recipes?: RecipeDefinition[]; error?: string };
spinner.stop();
if (!data.success) {
console.error(chalk.red(`錯誤:${data.error}`));
process.exit(1);
}
const recipes = data.recipes ?? [];
if (recipes.length === 0) {
console.log(chalk.gray('\n 尚無 recipe。執行 acr recipe push <file> 上傳。\n'));
return;
}
console.log(chalk.bold(`\n arcrun recipes${recipes.length} 個)\n`));
for (const r of recipes) {
console.log(`${chalk.cyan(r.canonical_id.padEnd(20))} ${chalk.gray(r.hash_id)} ${r.display_name ?? ''}`);
console.log(` ${chalk.gray(r.endpoint)}`);
if (r.credentials_required?.length) {
console.log(` ${chalk.yellow('🔑 需要:' + r.credentials_required.map(c => c.key).join(', '))}`);
}
}
console.log('');
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
export async function cmdRecipeDelete(id: string): Promise<void> {
const config = loadConfig();
if (!config.api_key) {
console.error(chalk.red('缺少 API Key,請先執行 acr init'));
process.exit(1);
}
const executorUrl = getCypherExecutorUrl(config);
const spinner = ora(`刪除 recipe "${id}"`).start();
try {
const res = await fetch(`${executorUrl}/recipes/${id}`, {
method: 'DELETE',
headers: { 'X-Arcrun-API-Key': config.api_key },
});
const data = await res.json() as { success: boolean; deleted?: string; error?: string };
if (!data.success) {
spinner.fail(chalk.red(`刪除失敗:${data.error ?? '未知錯誤'}`));
process.exit(1);
}
spinner.succeed(chalk.green(`✓ recipe "${data.deleted}" 已刪除`));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}