Files
Arcrun/cypher-executor/src/lib/paused-runs.ts
uncle6me-web 922a57fe34 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>
2026-06-03 15:52:38 +08:00

196 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}
}