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
+195
View File
@@ -0,0 +1,195 @@
/**
* Paused workflow runs:節點回 pending 時把 run state 持久化進 KV
* webhook callback 進來時撿回繼續執行
*
* SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md §2.1
*
* KV key: paused_run:{task_id}
* TTL: 24h(避免 KV 累積,超過就 GC)
*
* 設計筆記:
* - 用 task_id 當 keydaemon 派的 unique id),不用 run_id(同 run 可能多 paused 節點 v2
* - consume = load + delete 原子操作(避免重複 callback 重複執行)
*/
import type { ExecutionGraph, TraceStep } from '../types';
export interface PausedRunState {
run_id: string;
graph: ExecutionGraph;
paused_node_id: string;
paused_context: Record<string, unknown>;
paused_pending_result: Record<string, unknown>; // 節點回的 {pending, task_id, ...}
trace_so_far: TraceStep[];
api_key?: string;
expires_at: number; // unix ms
// resume 時用來 parse callback result 的 recipe output 規格(resumable + recipe 整合)
recipe_output_format?: 'text' | 'json';
recipe_output_required_fields?: string[];
}
const KEY_PREFIX = 'paused_run:';
/**
* Per-user paused index:列出某 api_key 當前 paused tasks 不依賴 CF KV list(強 eventual
* consistent30-60s 延遲)。改維護一個 user-keyed JSON listlist 操作改 single KV.get。
*
* Key: `paused_idx:{api_key}`
* Value: JSON Array<{task_id, paused_node_id, run_id, workflow_name?, expires_at, persisted_at}>
*
* 對應 LI SDD M2.1 — /executions/paused endpoint 即時性。
*/
const IDX_PREFIX = 'paused_idx:';
const TTL_SECONDS = 24 * 60 * 60;
export type PausedIndexEntry = {
task_id: string;
run_id: string;
paused_node_id: string;
workflow_name?: string;
expires_at: number;
persisted_at: number;
};
type KvBinding = {
get: (key: string) => Promise<string | null>;
put: (key: string, value: string, options?: { expirationTtl?: number }) => Promise<void>;
delete: (key: string) => Promise<void>;
};
async function readIndex(kv: KvBinding, apiKey: string): Promise<PausedIndexEntry[]> {
const raw = await kv.get(`${IDX_PREFIX}${apiKey}`);
if (!raw) return [];
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
async function writeIndex(kv: KvBinding, apiKey: string, entries: PausedIndexEntry[]): Promise<void> {
// 過濾過期項目(避免 index 爆量)
const now = Date.now();
const fresh = entries.filter((e) => e.expires_at > now);
await kv.put(`${IDX_PREFIX}${apiKey}`, JSON.stringify(fresh), { expirationTtl: TTL_SECONDS });
}
export async function persistPausedRun(
kv: KvBinding,
taskId: string,
state: PausedRunState,
): Promise<void> {
// 1) 寫單一 task state
await kv.put(`${KEY_PREFIX}${taskId}`, JSON.stringify(state), { expirationTtl: TTL_SECONDS });
// 2) 維護 per-user index(讓 /executions/paused list 不靠 KV list 即時拿到)
if (state.api_key) {
const idx = await readIndex(kv, state.api_key);
// 去重(重複 paused 同 task_id 時覆蓋)
const filtered = idx.filter((e) => e.task_id !== taskId);
filtered.unshift({
task_id: taskId,
run_id: state.run_id,
paused_node_id: state.paused_node_id,
workflow_name: state.graph.name,
expires_at: state.expires_at,
persisted_at: Date.now(),
});
// 限 100 筆避免 index 無限長(超過捨棄最舊)
await writeIndex(kv, state.api_key, filtered.slice(0, 100));
}
}
export async function loadPausedRun(
kv: KvBinding,
taskId: string,
): Promise<PausedRunState | null> {
const raw = await kv.get(`${KEY_PREFIX}${taskId}`);
if (!raw) return null;
try {
return JSON.parse(raw) as PausedRunState;
} catch {
return null;
}
}
/**
* 列某 api_key 當前 paused tasks。走 per-user index(強 consistent,無 KV list 延遲)
*/
export async function listPausedRunsByApiKey(
kv: KvBinding,
apiKey: string,
limit = 20,
): Promise<PausedIndexEntry[]> {
const idx = await readIndex(kv, apiKey);
const now = Date.now();
return idx.filter((e) => e.expires_at > now).slice(0, limit);
}
/**
* 原子讀+刪:避免同 task_id 重複 callback 重複執行下游
* (CF KV 沒真原子操作,但 delete 失敗不影響 load 已成功)
*/
export async function consumePausedRun(
kv: KvBinding,
taskId: string,
): Promise<PausedRunState | null> {
const state = await loadPausedRun(kv, taskId);
if (!state) return null;
await kv.delete(`${KEY_PREFIX}${taskId}`).catch(() => {
// delete 失敗不擋,最多就重複執行一次(接受)
});
// 同步從 per-user index 移除
if (state.api_key) {
const idx = await readIndex(kv, state.api_key);
const filtered = idx.filter((e) => e.task_id !== taskId);
await writeIndex(kv, state.api_key, filtered).catch(() => {});
}
return state;
}
/** 偵測 component result 是否為「需要 resume」的 pending pattern */
export function isResumablePending(result: unknown): { task_id: string } | null {
if (!result || typeof result !== 'object') return null;
const r = result as Record<string, unknown>;
if (r.pending !== true) return null;
if (typeof r.task_id !== 'string' || !r.task_id) return null;
return { task_id: r.task_id };
}
/**
* Parse claude_api result with recipe output format.
* 同步路徑跟 resume 路徑都用同一個解析器,避免邏輯歪掉。
*
* 輸入:result(可能是 {data:{text:"..."}} 或 {text:"..."}
* 輸出:parsed object 或 fallback 結構
*/
export function parseRecipeOutput(
result: unknown,
format: 'text' | 'json' | undefined,
requiredFields: string[] | undefined,
): unknown {
if (format !== 'json' || !result || typeof result !== 'object') return result;
const r = result as Record<string, unknown>;
const text = (r.data as Record<string, unknown> | undefined)?.text ?? r.text;
if (typeof text !== 'string') return result;
// 剝除 ```json ... ``` markdown fenceClaude 常這樣包)
let jsonText = String(text).trim();
const fenceMatch = jsonText.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
if (fenceMatch) jsonText = fenceMatch[1].trim();
try {
const parsed = JSON.parse(jsonText);
if (requiredFields && parsed && typeof parsed === 'object') {
const missing = requiredFields.filter((f) => !(f in (parsed as Record<string, unknown>)));
if (missing.length > 0) {
return { success: false, error: `recipe output 缺欄位: ${missing.join(', ')}`, raw: parsed };
}
}
// 把 parsed 的欄位 spread 到 top-levelFOREACH / 下游 {{var}} 都好取
return { success: true, data: parsed, ...(parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {}) };
} catch (e) {
return { success: false, error: `recipe output JSON parse 失敗: ${e instanceof Error ? e.message : String(e)}`, raw_text: text };
}
}