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,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 當 key(daemon 派的 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
|
||||
* consistent,30-60s 延遲)。改維護一個 user-keyed JSON list,list 操作改 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 fence(Claude 常這樣包)
|
||||
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-level,FOREACH / 下游 {{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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user