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,220 @@
|
||||
/**
|
||||
* deploy.ts — self-hosted Worker 部署(installer 的「下載 repo tarball + wrangler deploy」段)
|
||||
*
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6(commit wasm + codeload)
|
||||
*
|
||||
* 策略(richblack 2026-06-02):repo 自帶預編譯 wasm(.component-builds 下各 component.wasm,
|
||||
* 見 rule 05 慣例變更)→ CLI 從 GitHub codeload tarball 拿完整部署物 → 注入用戶的 KV id
|
||||
* → 用用戶自己的 CF token wrangler deploy。用戶不需 git / tinygo,只需 wrangler。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** GitHub repo(codeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。
|
||||
* 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/
|
||||
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun';
|
||||
|
||||
/** init 要建立的 7 個 KV namespace(title)。權威來源:.claude/rules/01-tech-stack.md 資料儲存表。*/
|
||||
export const REQUIRED_KV_NAMESPACES = [
|
||||
'WEBHOOKS',
|
||||
'CREDENTIALS_KV',
|
||||
'RECIPES',
|
||||
'USERS_KV',
|
||||
'SESSIONS_KV',
|
||||
'ANALYTICS_KV',
|
||||
'EXEC_CONTEXT',
|
||||
] as const;
|
||||
|
||||
/** init 要建立的 R2 bucket。*/
|
||||
export const REQUIRED_R2_BUCKET = 'WASM_BUCKET';
|
||||
|
||||
/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/
|
||||
export const SECRET_TARGET_WORKERS = [
|
||||
'arcrun-cypher-executor',
|
||||
'arcrun-auth-static-key',
|
||||
'arcrun-auth-service-account',
|
||||
] as const;
|
||||
|
||||
export interface DeployContext {
|
||||
accountId: string;
|
||||
apiToken: string;
|
||||
workerSubdomain: string;
|
||||
kvNamespaceIds: Record<string, string>; // title → id
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
implemented: boolean;
|
||||
cypherExecutorUrl?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
|
||||
export function wranglerAvailable(): boolean {
|
||||
try {
|
||||
execFileSync('wrangler', ['--version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下載 repo codeload tarball(含預編譯 wasm)→ 注入用戶 KV id → wrangler deploy 全部 Worker。
|
||||
*
|
||||
* SDD self-hosted-init.md §6.4:
|
||||
* 1. 下載 codeload tarball(ref 預設 main)→ 解壓到暫存目錄
|
||||
* 2. 各 wrangler.toml 注入 ctx.kvNamespaceIds + cypher-executor WORKER_SUBDOMAIN
|
||||
* 3. tier1=.component-builds/* 先 → tier2=cypher-executor/registry 後,逐一 wrangler deploy
|
||||
* 4. 回 cypherExecutorUrl = https://arcrun-cypher-executor.<subdomain>.workers.dev
|
||||
*
|
||||
* 誠實(mindset §7):任一 worker deploy 失敗會收集進 message 回報,不假裝全綠。
|
||||
*
|
||||
* @param ctx 部署上下文
|
||||
* @param ref git ref(branch / tag),預設 main;acr update 可帶 tag
|
||||
*/
|
||||
export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promise<DeployResult> {
|
||||
// 1. 下載 + 解壓 codeload tarball
|
||||
let root: string;
|
||||
try {
|
||||
root = await downloadRepoTarball(ref);
|
||||
} catch (e) {
|
||||
return {
|
||||
implemented: true,
|
||||
message: `下載部署物失敗(${e instanceof Error ? e.message : e})。確認網路 + ARCRUN_REPO=${ARCRUN_REPO} 可達。`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 列出要部署的 worker 目錄(含 wrangler.toml),分 tier
|
||||
const { tier1, tier2 } = discoverWorkerDirs(root);
|
||||
if (tier1.length === 0 && tier2.length === 0) {
|
||||
return { implemented: true, message: `部署物中找不到任何 wrangler.toml(root=${root})。` };
|
||||
}
|
||||
|
||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||
const failures: string[] = [];
|
||||
let deployed = 0;
|
||||
for (const dir of [...tier1, ...tier2]) {
|
||||
const tomlPath = join(dir, 'wrangler.toml');
|
||||
try {
|
||||
injectWranglerConfig(tomlPath, ctx);
|
||||
runWranglerDeploy(dir, ctx);
|
||||
deployed++;
|
||||
} catch (e) {
|
||||
failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const cypherExecutorUrl = ctx.workerSubdomain
|
||||
? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev`
|
||||
: undefined;
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
implemented: true,
|
||||
cypherExecutorUrl,
|
||||
message:
|
||||
`部署 ${deployed}/${tier1.length + tier2.length} 成功,${failures.length} 失敗(誠實回報,未假綠):\n` +
|
||||
failures.map(f => ` ✗ ${f}`).join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
implemented: true,
|
||||
cypherExecutorUrl,
|
||||
message: `部署完成:${deployed} 個 Worker 全部成功。`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/
|
||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
||||
const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
if (!res.ok) throw new Error(`codeload HTTP ${res.status}(${url})`);
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arcrun-deploy-'));
|
||||
const tarPath = join(dir, 'repo.tar.gz');
|
||||
writeFileSync(tarPath, buf);
|
||||
|
||||
// 用系統 tar 解壓(macOS/Linux 內建)。tarball 解出單一頂層目錄 {repo}-{ref}/。
|
||||
execFileSync('tar', ['-xzf', tarPath, '-C', dir], { stdio: 'ignore' });
|
||||
const entries = readdirSync(dir).filter(n => n !== 'repo.tar.gz');
|
||||
const top = entries.find(n => statSync(join(dir, n)).isDirectory());
|
||||
if (!top) throw new Error('tarball 解壓後找不到頂層目錄');
|
||||
return join(dir, top);
|
||||
}
|
||||
|
||||
/** 掃解壓出的部署物,回傳 tier1(.component-builds/*)與 tier2(cypher-executor/registry)目錄清單。*/
|
||||
function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } {
|
||||
const tier1: string[] = [];
|
||||
const tier2: string[] = [];
|
||||
|
||||
const cbRoot = join(root, '.component-builds');
|
||||
if (existsSync(cbRoot)) {
|
||||
for (const name of readdirSync(cbRoot)) {
|
||||
const dir = join(cbRoot, name);
|
||||
// 需同時有 wrangler.toml 且有 component.wasm 才部署。
|
||||
// 「錯做成零件」的(claude_api / km_writer / kbdb_upsert_block)wasm 沒 commit 進 repo
|
||||
// (.gitignore 排除,待降級成工作流/recipe)→ codeload 拿到的目錄缺 wasm → 自然跳過,
|
||||
// 不讓 wrangler deploy 因缺檔失敗。
|
||||
if (existsSync(join(dir, 'wrangler.toml')) && existsSync(join(dir, 'component.wasm'))) {
|
||||
tier1.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of ['cypher-executor', 'registry']) {
|
||||
const dir = join(root, name);
|
||||
if (existsSync(join(dir, 'wrangler.toml'))) tier2.push(dir);
|
||||
}
|
||||
return { tier1, tier2 };
|
||||
}
|
||||
|
||||
/** 注入用戶的 KV namespace id(取代 wrangler.toml 中各 binding 的 id)+ cypher WORKER_SUBDOMAIN。*/
|
||||
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
if (!existsSync(tomlPath)) return;
|
||||
let toml = readFileSync(tomlPath, 'utf8');
|
||||
|
||||
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
|
||||
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
|
||||
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
|
||||
if (!id) continue;
|
||||
const re = new RegExp(
|
||||
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
|
||||
'g',
|
||||
);
|
||||
toml = toml.replace(re, `$1${id}$2`);
|
||||
}
|
||||
|
||||
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
||||
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
/(WORKER_SUBDOMAIN\s*=\s*")[^"]*(")/,
|
||||
`$1${ctx.workerSubdomain}$2`,
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(tomlPath, toml, 'utf8');
|
||||
}
|
||||
|
||||
/** 在 worker 目錄跑 wrangler deploy(用用戶的 CF token + account)。*/
|
||||
function runWranglerDeploy(dir: string, ctx: DeployContext): void {
|
||||
// 先裝依賴(cypher-executor/registry 是 TS,wrangler 內建 esbuild bundle 需 node_modules)
|
||||
if (existsSync(join(dir, 'package.json'))) {
|
||||
const installer = existsSync(join(dir, 'pnpm-lock.yaml'))
|
||||
? ['pnpm', 'install', '--frozen-lockfile']
|
||||
: ['npm', 'install', '--no-audit', '--no-fund'];
|
||||
execFileSync(installer[0], installer.slice(1), { cwd: dir, stdio: 'ignore' });
|
||||
}
|
||||
execFileSync('wrangler', ['deploy'], {
|
||||
cwd: dir,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
CLOUDFLARE_API_TOKEN: ctx.apiToken,
|
||||
CLOUDFLARE_ACCOUNT_ID: ctx.accountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user