922a57fe34
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>
196 lines
6.8 KiB
TypeScript
196 lines
6.8 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|
||
}
|