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
+475
View File
@@ -0,0 +1,475 @@
// arcrun OAuth 登入路由
// GET /auth/google/start → redirect to Google OAuth
// GET /auth/github/start → redirect to GitHub OAuth
// GET /auth/callback → exchange code, create session
// POST /auth/logout → clear session cookie
// GET /me → current user info
// PUT /me/api-key/rotate → generate new api key
// DELETE /me/api-key → revoke api key
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const authRouter = new Hono<{ Bindings: Bindings }>();
// ─── Types ────────────────────────────────────────────────────────────────────
type UserRecord = {
email: string;
display_name: string;
avatar_url?: string;
api_key: string;
provider: 'google' | 'github';
provider_id: string;
created_at: string;
revoked?: boolean;
};
type SessionRecord = {
user_key: string; // "user:{provider}:{provider_id}"
api_key: string;
email: string;
expires_at: number; // unix timestamp ms
};
type OAuthStateRecord = {
provider: 'google' | 'github';
redirect_back: string;
created_at: number;
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getLandingOrigin(c: { req: { raw: Request } }): string {
const origin = c.req.raw.headers.get('origin');
// 允許的 landing origins
const allowed = ['https://arcrun.dev', 'https://www.arcrun.dev'];
if (origin && allowed.includes(origin)) return origin;
return 'https://arcrun.dev';
}
/** 產生 API KeyHMAC-SHA256 of email,與 /register 相同邏輯) */
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
const msgData = new TextEncoder().encode(email);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'ak_' + hex.slice(0, 32);
}
/** AES-GCM 加密,回傳 {encrypted, iv}base64),與 SDK 格式相同 */
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext));
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
return { encrypted: toB64(enc), iv: toB64(iv) };
}
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
async function upsertAuthRecipe(recipes: KVNamespace, recipe: Record<string, unknown>): Promise<void> {
const key = `auth_recipe:${recipe.service}`;
const existing = await recipes.get(key);
if (existing) return; // 已存在,不覆蓋(用戶可能已自訂)
await recipes.put(key, JSON.stringify({ ...recipe, created_at: Date.now(), updated_at: Date.now() }));
}
/** 產生隨機 token(用於 session ID 和 state */
function randomToken(bytes = 32): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
/** 從 Cookie header 取 session ID */
function getSessionId(req: Request): string | null {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/arcrun_session=([a-f0-9]+)/);
return match ? match[1] : null;
}
/** 從 Request 取 API KeyX-Arcrun-API-Key header 或 Authorization: Bearer */
function getApiKeyFromRequest(req: Request): string | null {
const direct = req.headers.get('x-arcrun-api-key');
if (direct) return direct;
const auth = req.headers.get('authorization') ?? '';
const match = auth.match(/^Bearer\s+(ak_\S+)/i);
return match ? match[1] : null;
}
/** 驗證 session → 回傳 user record,或 null */
async function resolveSession(c: { req: { raw: Request }; env: Bindings }): Promise<UserRecord | null> {
const sessId = getSessionId(c.req.raw);
if (sessId) {
const sess = await c.env.SESSIONS_KV.get<SessionRecord>(`sess:${sessId}`, 'json');
if (sess && sess.expires_at > Date.now()) {
const user = await c.env.USERS_KV.get<UserRecord>(sess.user_key, 'json');
if (user && !user.revoked) return user;
}
}
// Fallback: API Key header
const apiKey = getApiKeyFromRequest(c.req.raw);
if (apiKey) {
// 掃描 USERS_KV by api_key 太慢;改用 reverse index: apikey:{ak_...} → user_key
const userKey = await c.env.USERS_KV.get(`apikey:${apiKey}`);
if (userKey) {
const user = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
if (user && !user.revoked && user.api_key === apiKey) return user;
}
}
return null;
}
// ─── Google OAuth ─────────────────────────────────────────────────────────────
authRouter.get('/auth/google/start', async (c) => {
const clientId = c.env.GOOGLE_CLIENT_ID;
if (!clientId) return c.json({ error: 'Google OAuth not configured' }, 503);
const state = randomToken(16);
const stateRecord: OAuthStateRecord = {
provider: 'google',
redirect_back: c.req.query('redirect') ?? '/dashboard',
created_at: Date.now(),
};
// state TTL = 10 minutes
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile email',
state,
access_type: 'offline',
prompt: 'consent',
});
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
});
// ─── GitHub OAuth ─────────────────────────────────────────────────────────────
authRouter.get('/auth/github/start', async (c) => {
const clientId = c.env.GITHUB_CLIENT_ID;
if (!clientId) return c.json({ error: 'GitHub OAuth not configured' }, 503);
const state = randomToken(16);
const stateRecord: OAuthStateRecord = {
provider: 'github',
redirect_back: c.req.query('redirect') ?? '/dashboard',
created_at: Date.now(),
};
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user user:email',
state,
});
return Response.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
});
// ─── OAuth Callback ───────────────────────────────────────────────────────────
authRouter.get('/auth/callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
const error = c.req.query('error');
const landingOrigin = getLandingOrigin(c);
if (error || !code || !state) {
return Response.redirect(`${landingOrigin}/login?error=${encodeURIComponent(error ?? 'cancelled')}`, 302);
}
// Validate state
const stateRecord = await c.env.SESSIONS_KV.get<OAuthStateRecord>(`state:${state}`, 'json');
if (!stateRecord) {
return Response.redirect(`${landingOrigin}/login?error=invalid_state`, 302);
}
await c.env.SESSIONS_KV.delete(`state:${state}`);
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey) {
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
try {
let email: string;
let displayName: string;
let avatarUrl: string | undefined;
let providerId: string;
const provider = stateRecord.provider;
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
if (provider === 'google') {
// Exchange code for token
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenRes.ok) throw new Error('google token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string };
// Get user info
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userRes.ok) throw new Error('google userinfo failed');
const userInfo = await userRes.json() as {
sub: string; email: string; name: string; picture?: string;
};
email = userInfo.email.toLowerCase();
displayName = userInfo.name;
avatarUrl = userInfo.picture;
providerId = userInfo.sub;
// 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
if (tokenData.refresh_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2
void upsertAuthRecipe(c.env.RECIPES, {
kind: 'auth_recipe',
service: 'google_user',
version: 1,
primitive: 'oauth2',
base_url: 'https://www.googleapis.com',
display_name: 'Google(用戶帳號)',
oauth2: {
token_endpoint: 'https://oauth2.googleapis.com/token',
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
scopes: ['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets'],
},
required_secrets: [{ key: 'google_refresh_token', label: 'Google Refresh Token' }],
inject: { header: { Authorization: 'Bearer {{runtime.access_token}}' } },
});
}
} else {
// GitHub: exchange code for token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: new URLSearchParams({
code,
client_id: c.env.GITHUB_CLIENT_ID ?? '',
client_secret: c.env.GITHUB_CLIENT_SECRET ?? '',
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) throw new Error('github token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string; token_type?: string };
// Get user info
const userRes = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'User-Agent': 'arcrun',
'Accept': 'application/vnd.github+json',
},
});
if (!userRes.ok) throw new Error('github user fetch failed');
const userInfo = await userRes.json() as {
id: number; login: string; name?: string; avatar_url?: string; email?: string;
};
// GitHub email might be null if private; fetch emails list
let ghEmail = userInfo.email ?? '';
if (!ghEmail) {
const emailsRes = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'User-Agent': 'arcrun',
'Accept': 'application/vnd.github+json',
},
});
if (emailsRes.ok) {
const emails = await emailsRes.json() as { email: string; primary: boolean; verified: boolean }[];
const primary = emails.find(e => e.primary && e.verified);
ghEmail = primary?.email ?? emails[0]?.email ?? '';
}
}
if (!ghEmail) throw new Error('github email not available');
email = ghEmail.toLowerCase();
displayName = userInfo.name ?? userInfo.login;
avatarUrl = userInfo.avatar_url;
providerId = String(userInfo.id);
// 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// GitHub 沒有 refresh_tokenaccess_token 長效(直到 revoke
if (tokenData.access_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
void upsertAuthRecipe(c.env.RECIPES, {
kind: 'auth_recipe',
service: 'github_user',
version: 1,
primitive: 'static_key',
base_url: 'https://api.github.com',
display_name: 'GitHub(用戶帳號)',
required_secrets: [{ key: 'github_access_token', label: 'GitHub Access Token' }],
inject: { header: { Authorization: 'Bearer {{secret.github_access_token}}' } },
});
}
}
// Upsert user record
const userKey = `user:${provider}:${providerId}`;
const existing = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
let apiKey: string;
if (existing && !existing.revoked) {
// Existing user — keep their api key
apiKey = existing.api_key;
// Update display info
const updated: UserRecord = { ...existing, display_name: displayName, avatar_url: avatarUrl };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
} else {
// New user — generate api key (same HMAC logic as /register)
apiKey = await generateApiKey(email, encryptionKey);
const newUser: UserRecord = {
email, display_name: displayName, avatar_url: avatarUrl,
api_key: apiKey, provider, provider_id: providerId,
created_at: new Date().toISOString(),
};
await c.env.USERS_KV.put(userKey, JSON.stringify(newUser));
// Reverse index for API-Key-based auth
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
}
// Create session (TTL 7 days)
const sessionId = randomToken(32);
const session: SessionRecord = {
user_key: userKey,
api_key: apiKey,
email,
expires_at: Date.now() + 7 * 24 * 60 * 60 * 1000,
};
await c.env.SESSIONS_KV.put(`sess:${sessionId}`, JSON.stringify(session), {
expirationTtl: 7 * 24 * 60 * 60,
});
const redirectBack = stateRecord.redirect_back.startsWith('/') ? stateRecord.redirect_back : '/dashboard';
return new Response(null, {
status: 302,
headers: {
Location: `${landingOrigin}${redirectBack}`,
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=${7 * 24 * 60 * 60}`,
},
});
} catch (err) {
console.error('[auth/callback]', err);
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
});
// ─── Logout ───────────────────────────────────────────────────────────────────
authRouter.post('/auth/logout', async (c) => {
const sessId = getSessionId(c.req.raw);
if (sessId) {
await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
}
const landingOrigin = getLandingOrigin(c);
return new Response(null, {
status: 302,
headers: {
Location: `${landingOrigin}/`,
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
},
});
});
// ─── /me ──────────────────────────────────────────────────────────────────────
authRouter.get('/me', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
return c.json({
email: user.email,
display_name: user.display_name,
avatar_url: user.avatar_url,
api_key: user.api_key,
provider: user.provider,
created_at: user.created_at,
});
});
// ─── Rotate API Key ───────────────────────────────────────────────────────────
authRouter.put('/me/api-key/rotate', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
// Generate new random key (not HMAC — rotated keys are random)
const newRaw = randomToken(24);
const newKey = 'ak_' + newRaw;
const oldKey = user.api_key;
const userKey = `user:${user.provider}:${user.provider_id}`;
const updated: UserRecord = { ...user, api_key: newKey };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
// Update reverse index
await c.env.USERS_KV.delete(`apikey:${oldKey}`);
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
return c.json({
success: true,
api_key: newKey,
message: 'API Key rotated. Your existing workflow credentials are still stored under the old key namespace.',
});
});
// ─── Revoke API Key ───────────────────────────────────────────────────────────
authRouter.delete('/me/api-key', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
const userKey = `user:${user.provider}:${user.provider_id}`;
const revoked: UserRecord = { ...user, revoked: true };
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
// Clear session cookie
const sessId = getSessionId(c.req.raw);
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
return new Response(JSON.stringify({ success: true, message: 'API Key revoked.' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
},
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* Credentials API — 多租戶 credential 管理
*
* POST /credentials
* Body: { name: string, encrypted: string, iv: string }
* Header: X-Arcrun-API-Key
* → 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV
*
* DELETE /credentials/:name
* Header: X-Arcrun-API-Key
* → 刪除 {api_key}:cred:{name}
*
* GET /credentials
* Header: X-Arcrun-API-Key
* → 列出當前 api_key 下所有 credential 名稱(不含加密值)
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
// POST /credentials — 上傳加密 credential
credentialsRouter.post('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const body = await c.req.json().catch(() => null) as {
name?: string;
encrypted?: string;
iv?: string;
} | null;
if (!body?.name || !body.encrypted || !body.iv) {
return c.json({ error: '缺少必要欄位:name, encrypted, iv' }, 400);
}
const name = body.name.trim();
if (!/^\w+$/.test(name)) {
return c.json({ error: 'credential name 只能包含英文字母、數字和底線' }, 400);
}
const kvKey = `${apiKey}:cred:${name}`;
const record = JSON.stringify({ encrypted: body.encrypted, iv: body.iv });
await c.env.CREDENTIALS_KV.put(kvKey, record);
return c.json({ success: true, name });
});
// DELETE /credentials/:name — 刪除 credential
credentialsRouter.delete('/credentials/:name', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const name = c.req.param('name');
const kvKey = `${apiKey}:cred:${name}`;
await c.env.CREDENTIALS_KV.delete(kvKey);
return c.json({ success: true, name });
});
// GET /credentials — 列出 credential 名稱(不含值)
credentialsRouter.get('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const prefix = `${apiKey}:cred:`;
const list = await c.env.CREDENTIALS_KV.list({ prefix });
const names = list.keys.map(k => k.name.slice(prefix.length));
return c.json({ credentials: names });
});
+94
View File
@@ -0,0 +1,94 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { handleCypherSearch, handleCypherExecute } from '../actions/cypher-handlers';
export const cypherRouter = new Hono<{ Bindings: Bindings }>();
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
cypherRouter.post('/cypher/search', async (c) => {
const body = await c.req.json() as { triplets?: unknown };
const rawTriplets = body?.triplets;
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
}
try {
const now = new Date();
const timestamp = now.toISOString();
const versionId = `search-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const result = await handleCypherSearch(rawTriplets, c.env);
const response = {
version: versionId,
timestamp,
triplets: rawTriplets,
nodes: result.nodes,
cypher: result.cypher,
missing: result.missing,
};
return c.json(response);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
return c.json({ error: errMsg }, 400);
}
});
// POST /cypher/execute — 三元組 → 一步執行(search + execute 合一)
cypherRouter.post('/cypher/execute', async (c) => {
const body = await c.req.json() as {
triplets?: unknown;
context?: Record<string, unknown>;
config?: Record<string, Record<string, unknown>>; // node_name → {component, ...params}
graph_id?: string;
graph_name?: string;
};
if (!Array.isArray(body?.triplets) || body.triplets.length === 0) {
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
}
const graphId = typeof body.graph_id === 'string' ? body.graph_id : `triplet-exec-${Date.now()}`;
const graphName = typeof body.graph_name === 'string' ? body.graph_name : 'Triplet Execution';
const now = new Date();
const timestamp = now.toISOString();
// 版本號格式:execute-v1-20260327-143022
const versionId = `execute-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
try {
const result = await handleCypherExecute(
body.triplets as unknown[],
body.context,
graphId,
graphName,
body.config,
c.env,
(p) => c.executionCtx.waitUntil(p),
apiKey,
);
// 包裝成開發友善格式(execute 成功時)
const response = {
version: versionId,
timestamp,
...result,
};
return c.json(response);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
try {
const parsed = JSON.parse(errMsg);
const response = {
version: versionId,
timestamp,
...parsed,
};
return c.json(response, 500);
} catch {
return c.json({ version: versionId, timestamp, success: false, error: errMsg, duration_ms: 0 }, 500);
}
}
});
+49
View File
@@ -0,0 +1,49 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { OPENAPI_SPEC } from '../lib/openapi';
export const docsRouter = new Hono<{ Bindings: Bindings }>();
// GET /openapi.json
docsRouter.get('/openapi.json', (c) => {
return c.json(OPENAPI_SPEC);
});
// GET /docs — Swagger UI
docsRouter.get('/docs', (c) => {
const specStr = JSON.stringify(OPENAPI_SPEC);
const htmlStr = `<!doctype html>
<html>
<head>
<title>Cypher Executor API Docs</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4/swagger-ui.css">
<style>html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; } *, *:before, *:after { box-sizing: inherit; } body { margin:0; padding:0; }</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-bundle.js"> </script>
<script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-standalone-preset.js"> </script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
spec: ${specStr},
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "BaseLayout"
})
}
</script>
</body>
</html>
`;
return c.html(htmlStr);
});
+56
View File
@@ -0,0 +1,56 @@
import { Hono } from 'hono';
import type { Bindings, ExecutionGraph } from '../types';
import { ExecutionError } from '../types';
import { GraphExecutor } from '../graph-executor';
import { executeSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
import { writeExecutionVerdict } from '../actions/execution-logger';
export const executeRouter = new Hono<{ Bindings: Bindings }>();
// POST /execute — 執行一個完整的圖
executeRouter.post('/execute', async (c) => {
const body = await c.req.json();
const parsed = executeSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: '圖定義驗證失敗', details: parsed.error.issues }, 400);
}
const { graph, context } = parsed.data;
const apiKey = c.req.header('x-arcrun-api-key') ?? undefined;
const loader = createComponentLoader(c.env);
const executor = new GraphExecutor(loader, undefined, c.env, apiKey);
const start = Date.now();
try {
// BUILD-006:傳入 KV namespace(若不存在則 fallback 到記憶體 merge
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
const duration_ms = Date.now() - start;
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成')
);
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
} catch (err) {
const duration_ms = Date.now() - start;
const errMsg = err instanceof Error ? err.message : String(err);
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100))
);
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
status: s.error ? 'failed' : 'success',
...(s.error ? { error: s.error } : {}),
}));
return c.json({
success: false,
error: errMsg,
failed_node: err.failed_node,
failed_input: err.failed_input,
trace: traceFormatted,
duration_ms,
}, 500);
}
return c.json({ success: false, error: errMsg, failed_node: null, trace: [], duration_ms }, 500);
}
});
+203
View File
@@ -0,0 +1,203 @@
/**
* Executions routes — LI SDD M2.1
*
* 對應 .agents/specs/llm-interface/ Milestone 2.1。給 AI 看 workflow 執行狀態的端點。
*
* - GET /executions/paused — 列當前所有 paused 的 workflow(等 callback resume
* - GET /executions/:task_id — 看單一 paused state 細節(含 trace、graph、node id
* - GET /workflows/:name/executions — 列某 workflow 最近 N 次執行 verdict
*
* 設計:純讀,無 side effect。所有路由要 api_key auth(防偷看他人 workflow state)。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { listPausedRunsByApiKey } from '../lib/paused-runs';
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
/**
* GET /executions/paused — 列當前 api_key 下所有 paused workflow
*
* 走 per-user index `paused_idx:{api_key}`(單 KV get,強 consistent,無 KV list 延遲)
* 取代舊的 `paused_run:*` prefix scanCF KV list 30-60 秒 eventual consistent
*/
executionsRouter.get('/executions/paused', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({
ok: false,
error_code: 'auth_missing',
human_message: '缺 X-Arcrun-API-Key header',
next_actions: ['call /me 取得你的 ak_xxx,加進 header'],
}, 401);
}
const limitParam = c.req.query('limit');
const limit = Math.min(Math.max(parseInt(limitParam || '20', 10), 1), 100);
const paused = await listPausedRunsByApiKey(c.env.EXEC_CONTEXT, apiKey, limit);
return c.json({
ok: true,
data: { count: paused.length, paused },
hints: paused.length > 0
? [`${paused.length} 個 workflow 等 callback resume。call get_execution_trace(task_id) 看細節`]
: ['沒有任何 paused workflow'],
});
});
/**
* GET /executions/:task_id — 看單一 paused workflow 的 statetrace、graph、context
*
* task_id 來源:trigger workflow 時 response 含 paused 結果,task_id 在 error 字串裡,
* 或前端 list_paused_executions 回的 task_id。
*
* 隔離:只能讀自己 api_key 的 state。
*/
executionsRouter.get('/executions/:task_id', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({
ok: false,
error_code: 'auth_missing',
human_message: '缺 X-Arcrun-API-Key header',
next_actions: ['加 X-Arcrun-API-Key header'],
}, 401);
}
const taskId = c.req.param('task_id');
const raw = await c.env.EXEC_CONTEXT.get(`paused_run:${taskId}`);
if (!raw) {
return c.json({
ok: false,
error_code: 'not_found',
human_message: `task_id "${taskId}" 沒對應的 paused state(可能已 resume 完、過 24h TTL 被 GC、或從未存在)`,
next_actions: [
'call /executions/paused 看當前所有 paused,確認 task_id 正確',
'若該 workflow 不是 paused 型,看 /workflows/:name/executions 查歷史 verdict',
],
}, 404);
}
let state: {
run_id: string;
graph?: unknown;
paused_node_id: string;
paused_context?: Record<string, unknown>;
paused_pending_result?: Record<string, unknown>;
trace_so_far?: unknown;
api_key?: string;
expires_at?: number;
};
try {
state = JSON.parse(raw);
} catch {
return c.json({
ok: false,
error_code: 'internal_error',
human_message: 'paused state JSON 損毀',
next_actions: ['告訴 leo / 平台維護者'],
}, 500);
}
if (state.api_key !== apiKey) {
return c.json({
ok: false,
error_code: 'not_found', // 不洩漏存在性
human_message: `task_id "${taskId}" 找不到`,
next_actions: ['確認 task_id 屬於你 (用 /executions/paused 列出)'],
}, 404);
}
return c.json({
ok: true,
data: {
task_id: taskId,
run_id: state.run_id,
paused_node_id: state.paused_node_id,
paused_context: state.paused_context,
paused_pending_result: state.paused_pending_result,
trace_so_far: state.trace_so_far,
expires_at: state.expires_at,
},
hints: [
'paused 狀態 = workflow 等 daemon callback。等對應 service 回 POST /workflows/resume 即可繼續',
'若 daemon 掛了,看 expires_at — 過 24h KV TTL 會 GC 此 state',
],
});
});
/**
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
*
* 走 ANALYTICS_KV `stats:{workflowId}:*` prefix scan。
*
* workflowId 等於 webhook nameexecution-logger 寫入時用 graph.id ?? name)。
*
* 限制:ANALYTICS_KV list 沒辦法依 timestamp 排序,只能拿 key 後段 timestamp 排。
*/
executionsRouter.get('/workflows/:name/executions', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({
ok: false,
error_code: 'auth_missing',
human_message: '缺 X-Arcrun-API-Key header',
next_actions: ['加 X-Arcrun-API-Key header'],
}, 401);
}
const name = c.req.param('name');
const limitParam = c.req.query('limit');
const limit = Math.min(Math.max(parseInt(limitParam || '10', 10), 1), 100);
// 確認 workflow 是該 api_key 的(防偷看他人)
const wfRaw = await c.env.WEBHOOKS.get(`${apiKey}:wf:${name}`, 'text');
if (!wfRaw) {
return c.json({
ok: false,
error_code: 'not_found',
human_message: `workflow "${name}" 不存在或不屬於你`,
next_actions: ['call /webhooks/named 看你有什麼 workflow'],
}, 404);
}
// 撈 stats:{name}:* 全 list(每個 key 含 timestamp 後綴)
const list = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
// 按 timestamp 降序(key suffix 是 unix ms
const sorted = [...list.keys].sort((a, b) => {
const ta = parseInt(a.name.split(':').pop() ?? '0', 10);
const tb = parseInt(b.name.split(':').pop() ?? '0', 10);
return tb - ta;
}).slice(0, limit);
const executions = [];
for (const key of sorted) {
const raw = await c.env.ANALYTICS_KV.get(key.name);
if (!raw) continue;
try {
const record = JSON.parse(raw);
executions.push({
timestamp: key.name.split(':').pop(),
...record,
});
} catch {
// skip
}
}
return c.json({
ok: true,
data: {
workflow_name: name,
count: executions.length,
executions,
},
hints: executions.length === 0
? ['尚未有任何執行紀錄(或都過了 90d TTL)。先 call /webhooks/named/:name/trigger 跑一次']
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
});
});
+16
View File
@@ -0,0 +1,16 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const healthRouter = new Hono<{ Bindings: Bindings }>();
healthRouter.get('/health', (c) =>
c.json({ ok: true })
);
healthRouter.get('/', (c) =>
c.json({
service: 'arcrun-cypher-executor',
version: '1.0.0',
status: 'ok',
})
);
+259
View File
@@ -0,0 +1,259 @@
/**
* /recipes — API recipe CRUD
*
* recipe 是「http_request + 參數模板」的具名封裝。
* 不需要 deploy Worker,執行時由 cypher-executor 直接 fetch。
*
* KV 結構:
* recipe:{canonical_id} → RecipeDefinition JSON
* idx:{rec_hash} → canonical_id (反查索引)
*
* 引用方式(workflow config):
* component: "rec_f7e2a1b3" → 永久穩定,不受改名影響
* component: "slack" → 向前兼容,直接用 canonical_id 查
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { deriveRecipeHash } from '../lib/hash';
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
import type { ExposureConsent } from '../lib/exposure-consent';
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
export interface RecipeDefinition {
canonical_id: string;
hash_id: string; // rec_xxxxxxxx
display_name?: string;
description?: string;
endpoint: string;
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
headers?: Record<string, string>;
body?: Record<string, unknown>;
/**
* 此 recipe 要用哪個 auth recipeauth_recipe:{auth_service})。
* 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。
* 未設時 auth-dispatcher fallback 到把 canonical_id 當 service name(向後相容)。
*/
auth_service?: string;
credentials_required?: Array<{
key: string;
inject_as: string;
}>;
// 資料外流警示:recipe 定義一個資料去向(endpoint)。push 需人類明示同意(法律憑證)。
// SDD: data-exfil-warning §7(公私一視同仁)
exposure_consent?: ExposureConsent;
created_at: number;
updated_at: number;
}
// POST /recipes — 新增或更新 recipe
recipesRouter.post('/recipes', async (c) => {
let body: Partial<RecipeDefinition>;
try {
body = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
const canonicalId = (body.canonical_id ?? '').trim().toLowerCase();
if (!canonicalId) return c.json({ success: false, error: 'canonical_id 必填' }, 400);
if (!body.endpoint) return c.json({ success: false, error: 'endpoint 必填' }, 400);
const hashId = await deriveRecipeHash(canonicalId);
const now = Date.now();
// 讀取現有版本(保留 created_at + 既有同意憑證)
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
if (consentError !== null) {
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
}
const recipe: RecipeDefinition = {
canonical_id: canonicalId,
hash_id: hashId,
display_name: body.display_name,
description: body.description,
endpoint: body.endpoint,
method: (body.method ?? 'POST').toUpperCase(),
headers: body.headers,
body: body.body,
auth_service: body.auth_service,
credentials_required: body.credentials_required,
exposure_consent: resolveConsentForRecord(body.exposure_consent, existing?.exposure_consent),
created_at: existing?.created_at ?? now,
updated_at: now,
};
// 寫入兩個 KV key
await Promise.all([
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
]);
return c.json({ success: true, recipe });
});
// GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash
recipesRouter.get('/recipes/:id', async (c) => {
const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
return c.json({ success: true, recipe });
});
// GET /recipes — 列出所有 recipe
recipesRouter.get('/recipes', async (c) => {
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
const recipes = await Promise.all(
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
);
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
});
// DELETE /recipes/:id — 刪除 recipe
recipesRouter.delete('/recipes/:id', async (c) => {
const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
await Promise.all([
c.env.RECIPES.delete(`recipe:${recipe.canonical_id}`),
c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
]);
return c.json({ success: true, deleted: recipe.canonical_id });
});
/** 用 canonical_id 或 rec_hash 查 recipe */
export async function resolveRecipe(
id: string,
kv: KVNamespace,
): Promise<RecipeDefinition | null> {
// rec_xxxxxxxx → 先查 idx 反查 canonical_id
if (id.startsWith('rec_')) {
const canonicalId = await kv.get(`idx:${id}`);
if (!canonicalId) return null;
return kv.get(`recipe:${canonicalId}`, 'json');
}
// 直接用 canonical_id
return kv.get(`recipe:${id}`, 'json');
}
// ── Auth Recipe ────────────────────────────────────────────────────────────────
export type AuthPrimitive = 'static_key' | 'oauth2' | 'service_account' | 'mtls';
export interface SecretRequirement {
key: string; // CREDENTIALS_KV 的名稱(e.g. "notion_token"
label: string; // CLI/UI 顯示(e.g. "Internal Integration Token"
type?: 'string' | 'json_blob'; // default: string
help?: string;
help_url?: string;
optional?: boolean;
}
export interface AuthInjectSpec {
header?: Record<string, string>; // e.g. { Authorization: "Bearer {{secret.token}}" }
query?: Record<string, string>;
body?: Record<string, string>;
}
export interface AuthRecipeDefinition {
kind: 'auth_recipe';
service: string; // canonical_ide.g. "notion"
version: number;
primitive: AuthPrimitive;
base_url: string;
display_name?: string;
description?: string;
// service_account 專用
service_account_kind?: 'google_jwt';
token_exchange?: {
endpoint: string;
scopes: string[];
};
required_secrets: SecretRequirement[];
inject: AuthInjectSpec;
created_at: number;
updated_at: number;
}
/** 查 auth recipeKV key: auth_recipe:{service}*/
export async function resolveAuthRecipe(
service: string,
kv: KVNamespace,
): Promise<AuthRecipeDefinition | null> {
return kv.get(`auth_recipe:${service}`, 'json');
}
// POST /auth-recipes — 新增或更新 auth recipe
recipesRouter.post('/auth-recipes', async (c) => {
let body: Partial<AuthRecipeDefinition>;
try {
body = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
const service = (body.service ?? '').trim().toLowerCase();
if (!service) return c.json({ success: false, error: 'service 必填' }, 400);
if (!body.primitive) return c.json({ success: false, error: 'primitive 必填' }, 400);
if (!body.base_url) return c.json({ success: false, error: 'base_url 必填' }, 400);
if (!body.required_secrets?.length) return c.json({ success: false, error: 'required_secrets 必填' }, 400);
if (!body.inject) return c.json({ success: false, error: 'inject 必填' }, 400);
const now = Date.now();
const existing = await c.env.RECIPES.get(`auth_recipe:${service}`, 'json') as AuthRecipeDefinition | null;
const recipe: AuthRecipeDefinition = {
kind: 'auth_recipe',
service,
version: body.version ?? 1,
primitive: body.primitive,
base_url: body.base_url,
display_name: body.display_name,
description: body.description,
service_account_kind: body.service_account_kind,
token_exchange: body.token_exchange,
required_secrets: body.required_secrets,
inject: body.inject,
created_at: existing?.created_at ?? now,
updated_at: now,
};
await c.env.RECIPES.put(`auth_recipe:${service}`, JSON.stringify(recipe));
return c.json({ success: true, recipe });
});
// GET /auth-recipes — 列出所有 auth recipe
recipesRouter.get('/auth-recipes', async (c) => {
const list = await c.env.RECIPES.list({ prefix: 'auth_recipe:' });
const recipes = await Promise.all(
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
);
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
});
// GET /auth-recipes/:service — 讀取單一 auth recipe
recipesRouter.get('/auth-recipes/:service', async (c) => {
const service = c.req.param('service');
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
return c.json({ success: true, recipe });
});
// DELETE /auth-recipes/:service — 刪除 auth recipe
recipesRouter.delete('/auth-recipes/:service', async (c) => {
const service = c.req.param('service');
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
await c.env.RECIPES.delete(`auth_recipe:${service}`);
return c.json({ success: true, deleted: service });
});
+46
View File
@@ -0,0 +1,46 @@
// POST /register — API Key 發放
// email → HMAC-SHA256(email, ENCRYPTION_KEY) → api_key (ak_ 前綴)
// 同一個 email 永遠得到相同的 Key,無需資料庫
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const registerRouter = new Hono<{ Bindings: Bindings }>();
registerRouter.post('/register', async (c) => {
let email: string;
try {
const body = await c.req.json() as { email?: string };
email = (body.email ?? '').trim().toLowerCase();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
if (!email || !email.includes('@')) {
return c.json({ success: false, error: 'email 格式不正確' }, 400);
}
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey || encryptionKey.length < 32) {
return c.json({ success: false, error: 'server configuration error' }, 500);
}
// HMAC-SHA256(email, ENCRYPTION_KEY) → hex → 取前 32 字元 → ak_ 前綴
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
const msgData = new TextEncoder().encode(email);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
const apiKey = 'ak_' + hex.slice(0, 32);
return c.json({
success: true,
api_key: apiKey,
encryption_key: encryptionKey, // 用戶需要此 key 才能加密上傳 credential
email,
message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。',
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* POST /workflows/resume
* Webhook callback 進來時,從 paused state 撿起來繼續跑下游節點
* SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md Phase 3
*
* 安全:因為這是 daemon 主動 callback,沒有 partner keydaemon 不知道用戶 key
* 靠 task_id 為 nonce + 24h TTL + idempotent consume 保護
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { WorkflowPaused } from '../types';
import { GraphExecutor } from '../graph-executor';
import { createComponentLoader } from '../lib/component-loader';
import { consumePausedRun } from '../lib/paused-runs';
export const resumeRouter = new Hono<{ Bindings: Bindings }>();
resumeRouter.post('/workflows/resume', async (c) => {
let body: Record<string, unknown>;
try {
body = await c.req.json();
} catch {
return c.json({ error: 'request body 必須為 JSON' }, 400);
}
const taskId = typeof body.task_id === 'string' ? body.task_id : undefined;
if (!taskId) {
return c.json({ error: 'task_id 必填' }, 400);
}
// consume = load + deleteidempotent:重複 callback 第二次找不到 state,回 200
const state = await consumePausedRun(c.env.EXEC_CONTEXT, taskId);
if (!state) {
return c.json({
success: true,
noop: true,
reason: `paused state 不存在或已過期 (task_id=${taskId})`,
});
}
const callbackResult = {
success: body.success ?? true,
data: body.data,
error: body.error,
};
const loader = createComponentLoader(c.env);
const executor = new GraphExecutor(loader, undefined, c.env, state.api_key);
const start = Date.now();
try {
const result = await executor.resumeFromPaused({
graph: state.graph,
paused_node_id: state.paused_node_id,
paused_context: state.paused_context,
callback_result: callbackResult,
prior_trace: state.trace_so_far,
kvNamespace: c.env.EXEC_CONTEXT,
recipe_output_format: state.recipe_output_format,
recipe_output_required_fields: state.recipe_output_required_fields,
});
const duration_ms = Date.now() - start;
return c.json({
success: true,
resumed: true,
task_id: taskId,
run_id: state.run_id,
data: result.data,
trace: result.trace,
duration_ms,
});
} catch (err) {
if (err instanceof WorkflowPaused) {
// resume 後又遇到 pendingv2 nested 情境)— v1 仍持久化但回 paused-again
return c.json({
success: true,
paused_again: true,
task_id: err.task_id,
run_id: err.run_id,
paused_node_id: err.paused_node_id,
});
}
const errMsg = err instanceof Error ? err.message : String(err);
return c.json({ success: false, error: errMsg, task_id: taskId, run_id: state.run_id }, 500);
}
});
+43
View File
@@ -0,0 +1,43 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { graphSchema } from '../lib/schemas';
import { recordTelemetry } from '../lib/telemetry';
export const validateRouter = new Hono<{ Bindings: Bindings }>();
// POST /validate — 驗證圖定義(不執行)
validateRouter.post('/validate', async (c) => {
const start = Date.now();
const apiKey = c.req.header('X-Arcrun-API-Key');
const userAgent = c.req.header('User-Agent') ?? undefined;
const body = await c.req.json();
const parsed = graphSchema.safeParse(body);
if (!parsed.success) {
recordTelemetry(c.env, apiKey, {
event_type: 'validation_error',
error_code: 'schema_failed',
duration_ms: Date.now() - start,
agent_user_agent: userAgent,
}, c.executionCtx);
return c.json({ valid: false, errors: parsed.error.issues }, 400);
}
const nodeIds = new Set(parsed.data.nodes.map(n => n.id));
const invalidEdges = parsed.data.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
if (invalidEdges.length > 0) {
recordTelemetry(c.env, apiKey, {
event_type: 'validation_error',
error_code: 'edge_node_missing',
duration_ms: Date.now() - start,
agent_user_agent: userAgent,
}, c.executionCtx);
return c.json({
valid: false,
errors: invalidEdges.map(e => `${e.from}${e.to} 指向不存在的節點`),
}, 400);
}
return c.json({ valid: true, nodeCount: parsed.data.nodes.length, edgeCount: parsed.data.edges.length });
});
@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { validateAndParseWebhook } from '../actions/webhook-handlers';
export const webhooksCrudRouter = new Hono<{ Bindings: Bindings }>();
type WebhookRecord = {
graph: Record<string, unknown>;
description: string;
created_at: string;
};
// GET /webhooks/:token — 查詢 Webhook 基本資訊
webhooksCrudRouter.get('/webhooks/:token', async (c) => {
const token = c.req.param('token');
const raw = await c.env.WEBHOOKS.get(token, 'text');
if (!raw) return c.json({ error: 'not found' }, 404);
const record = await validateAndParseWebhook(raw);
if (!record) return c.json({ error: '資料損毀' }, 500);
return c.json({
token,
description: record.description,
created_at: record.created_at,
});
});
// PUT /webhooks/:token — 更新 Webhook 定義
webhooksCrudRouter.put('/webhooks/:token', async (c) => {
const token = c.req.param('token');
if (!token || token.length < 16) {
return c.json({ error: 'invalid token' }, 400);
}
const raw = await c.env.WEBHOOKS.get(token, 'text');
if (!raw) return c.json({ error: 'webhook not found' }, 404);
const existing = await validateAndParseWebhook(raw);
if (!existing) return c.json({ error: 'webhook 定義損毀' }, 500);
const body = await c.req.json().catch(() => null);
if (!body) return c.json({ error: 'invalid json' }, 400);
const updatedRecord: WebhookRecord = {
graph: existing.graph,
description: existing.description,
created_at: existing.created_at,
};
if (body.description !== undefined) {
updatedRecord.description = typeof body.description === 'string' ? body.description : existing.description;
}
if (body.graph !== undefined) {
updatedRecord.graph = body.graph;
}
await c.env.WEBHOOKS.put(token, JSON.stringify(updatedRecord));
const baseUrl = new URL(c.req.url).origin;
return c.json({
token,
webhook_url: `${baseUrl}/webhooks/${token}/trigger`,
description: updatedRecord.description,
created_at: updatedRecord.created_at,
updated: true,
});
});
// DELETE /webhooks/:token — 刪除 Webhook
webhooksCrudRouter.delete('/webhooks/:token', async (c) => {
const token = c.req.param('token');
if (!token || token.length < 16) {
return c.json({ error: 'invalid token' }, 400);
}
const existing = await c.env.WEBHOOKS.get(token, 'text');
if (!existing) return c.json({ error: 'webhook not found' }, 404);
await c.env.WEBHOOKS.delete(token);
return c.json({ deleted: true, token });
});
@@ -0,0 +1,32 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { validateAndParseWebhook } from '../actions/webhook-handlers';
export const webhooksListRouter = new Hono<{ Bindings: Bindings }>();
// GET /webhooks — 列出所有 Webhooks(需要授權標頭)
webhooksListRouter.get('/webhooks', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader) {
return c.json({ error: 'unauthorized: missing Authorization header' }, 401);
}
const list = await c.env.WEBHOOKS.list();
const webhooks = [];
for (const key of list.keys) {
const raw = await c.env.WEBHOOKS.get(key.name, 'text');
if (!raw) continue;
const record = await validateAndParseWebhook(raw);
if (!record) continue;
webhooks.push({
token: key.name,
description: record.description,
created_at: record.created_at,
});
}
return c.json({ webhooks, total: webhooks.length });
});
@@ -0,0 +1,237 @@
/**
* Named Webhookacr push 使用)
*
* POST /webhooks/named
* Header: X-Arcrun-API-Key
* Body: { name, graph, config?, description? }
* → 以 {api_key}:wf:{name} 存入 WEBHOOKS KV
* → 回傳 webhook_url
*
* POST /webhooks/named/:name/trigger
* Header: X-Arcrun-API-Key
* Body: 任意 JSON(作為 trigger context
* → 以 {api_key}:wf:{name} 讀取執行圖,執行後回傳結果
*
* GET /webhooks/named
* Header: X-Arcrun-API-Key
* → 列出當前 api_key 下所有 named webhook
*
* DELETE /webhooks/named/:name
* Header: X-Arcrun-API-Key
* → 刪除指定 workflow
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { executeWebhookGraph } from '../actions/webhook-handlers';
import { writeExecutionVerdict } from '../actions/execution-logger';
import type { GraphNode } from '../types';
import { extractCronExpr } from '../lib/cron-match';
import { recordTelemetry } from '../lib/telemetry';
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
import type { ExposureConsent } from '../lib/exposure-consent';
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
type NamedWorkflowRecord = {
name: string;
graph: Record<string, unknown>;
config?: Record<string, unknown>;
description: string;
created_at: string;
// 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對
// 對應 SDD: arcrun.md 三-A P1 #3
cron_expr?: string;
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
// 存人類明示同意憑證(法律憑證,可審)。SDD: data-exfil-warning §7
exposure_consent?: ExposureConsent;
};
function kvKey(apiKey: string, name: string): string {
return `${apiKey}:wf:${name}`;
}
/** 輕量 cron index entry — scheduled() 只列這個 prefix(每分鐘 tick 不掃全量 KV*/
function cronIndexKey(apiKey: string, name: string): string {
return `cron-idx:${apiKey}:${name}`;
}
// POST /webhooks/named — 部署(acr push 呼叫)
webhooksNamedRouter.post('/webhooks/named', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const body = await c.req.json().catch(() => null) as {
name?: string;
graph?: Record<string, unknown>;
config?: Record<string, unknown>;
description?: string;
exposure_consent?: ExposureConsent;
} | null;
if (!body?.name || !body.graph) {
return c.json({ error: '缺少必要欄位:name, graph' }, 400);
}
const name = body.name.trim();
if (!/^[\w-]+$/.test(name)) {
return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400);
}
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
// 首次部署某 workflow 需人類明示同意;已同意(含 suppress_future)則放行(§3 首次問記住)。
const priorRaw = await c.env.WEBHOOKS.get(kvKey(apiKey, name));
const priorRecord = priorRaw ? (JSON.parse(priorRaw) as NamedWorkflowRecord) : null;
const consentError = checkExposureConsent(body.exposure_consent, priorRecord?.exposure_consent);
if (consentError !== null) {
return c.json({ error: consentError, requires: 'exposure_consent' }, 403);
}
// 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled()
const cronExpr = extractCronExpr(body.graph);
const record: NamedWorkflowRecord = {
name,
graph: body.graph,
config: body.config,
description: typeof body.description === 'string' ? body.description : '',
created_at: new Date().toISOString(),
cron_expr: cronExpr ?? undefined,
// 法律憑證:存人類明示同意(本次新同意或沿用既有)
exposure_consent: resolveConsentForRecord(body.exposure_consent, priorRecord?.exposure_consent),
};
const start = Date.now();
await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record));
// 維護 cron index:有 cron_expr 就寫 / 沒有就刪除(避免 push 改 yaml 拿掉 cron 後殘留)
if (cronExpr) {
await c.env.WEBHOOKS.put(cronIndexKey(apiKey, name), JSON.stringify({ cron_expr: cronExpr }));
} else {
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
}
// Implicit telemetry (LI M1.2)
recordTelemetry(c.env, apiKey, {
event_type: 'deploy_success',
workflow_name: name,
duration_ms: Date.now() - start,
agent_user_agent: c.req.header('User-Agent') ?? undefined,
}, c.executionCtx);
const baseUrl = new URL(c.req.url).origin;
return c.json({
name,
webhook_url: `${baseUrl}/webhooks/named/${name}/trigger`,
description: record.description,
created_at: record.created_at,
}, 201);
});
// POST /webhooks/named/:name/trigger — 觸發執行
webhooksNamedRouter.post('/webhooks/named/:name/trigger', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const name = c.req.param('name');
const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
if (!raw) {
return c.json({ error: `找不到 workflow "${name}",請先執行 acr push` }, 404);
}
let record: NamedWorkflowRecord;
try {
record = JSON.parse(raw) as NamedWorkflowRecord;
} catch {
return c.json({ error: 'workflow 定義損毀' }, 500);
}
let triggerContext: Record<string, unknown> = {};
try {
const body = await c.req.json().catch(() => null);
if (body && typeof body === 'object') {
triggerContext = body as Record<string, unknown>;
}
} catch {
// 無 body 時使用空 context
}
const graph = record.graph as { id?: string; nodes?: unknown[] };
const workflowId = graph.id ?? name;
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as GraphNode[]) : [];
const userAgent = c.req.header('User-Agent') ?? undefined;
// resumable-workflow SDD §5?async=1 → 背景執行(waitUntil)+ 立回 202,不依賴呼叫端連線。
// 不帶 ?async=1 維持原同步行為(向後相容)。
if (c.req.query('async') === '1') {
c.executionCtx.waitUntil(
executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent)
.then(result =>
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
),
);
return c.json({ accepted: true }, 202);
}
const result = await executeWebhookGraph(
c.env,
record.graph,
triggerContext,
name,
apiKey,
c.executionCtx,
userAgent,
);
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
);
return c.json(result, result.success ? 200 : 500);
});
// GET /webhooks/named — 列出當前 api_key 下所有 workflow
webhooksNamedRouter.get('/webhooks/named', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const prefix = `${apiKey}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = list.keys.map(k => {
const name = k.name.slice(prefix.length);
return { name };
});
const baseUrl = new URL(c.req.url).origin;
const result = workflows.map(w => ({
name: w.name,
webhook_url: `${baseUrl}/webhooks/named/${w.name}/trigger`,
}));
return c.json({ workflows: result, total: result.length });
});
// DELETE /webhooks/named/:name — 刪除 workflow
webhooksNamedRouter.delete('/webhooks/named/:name', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const name = c.req.param('name');
const existing = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
if (!existing) {
return c.json({ error: `找不到 workflow "${name}"` }, 404);
}
await c.env.WEBHOOKS.delete(kvKey(apiKey, name));
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
return c.json({ deleted: true, name });
});
+80
View File
@@ -0,0 +1,80 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { generateToken, validateAndParseWebhook, executeWebhookGraph } from '../actions/webhook-handlers';
import { resolveWebhookGraph } from '../actions/webhook-graph-resolver';
import { writeExecutionVerdict } from '../actions/execution-logger';
export const webhooksRouter = new Hono<{ Bindings: Bindings }>();
type WebhookRecord = {
graph: Record<string, unknown>;
description: string;
created_at: string;
};
// POST /webhooks — 接受 graph、triplets 或直接 nodes/edges
webhooksRouter.post('/webhooks', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body) return c.json({ error: 'invalid json' }, 400);
const description = typeof body.description === 'string' ? body.description : '';
const resolved = await resolveWebhookGraph(body as Record<string, unknown>, description, c.env);
if (resolved.error) {
return c.json({ error: resolved.error }, 400);
}
const token = generateToken();
const record: WebhookRecord = {
graph: resolved.resolvedGraph,
description,
created_at: new Date().toISOString(),
};
await c.env.WEBHOOKS.put(token, JSON.stringify(record));
const baseUrl = new URL(c.req.url).origin;
return c.json({
token,
webhook_url: `${baseUrl}/webhooks/${token}/trigger`,
description: record.description,
created_at: record.created_at,
}, 201);
});
// POST /webhooks/:token/trigger — 觸發執行
webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
const token = c.req.param('token');
if (!token || token.length < 16) {
return c.json({ error: 'invalid token' }, 400);
}
const raw = await c.env.WEBHOOKS.get(token, 'text');
if (!raw) return c.json({ error: 'webhook not found' }, 404);
const record = await validateAndParseWebhook(raw);
if (!record) return c.json({ error: 'webhook 定義損毀' }, 500);
let triggerContext: Record<string, unknown> = {};
try {
const body = await c.req.json().catch(() => null);
if (body && typeof body === 'object') {
triggerContext = body as Record<string, unknown>;
}
} catch {
// 無 body 時使用空 context
}
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token, apiKey);
// fire-and-forget analytics(不阻擋回應)
const graph = record.graph as { id?: string; nodes?: unknown[] };
const workflowId = graph.id ?? token;
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
);
return c.json(result, result.success ? 200 : 500);
});