merge: kbdb recipe 公庫/私庫雙向機制 + UUID 身份(壓測前)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-07 16:21:01 +08:00
28 changed files with 3438 additions and 37 deletions
+12 -1
View File
@@ -201,6 +201,17 @@ async function initSelfHosted(
process.exit(1); process.exit(1);
} }
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
// (kbdb-base SDD Q4). idempotent: reuse if exists.
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(`\n ⚠ D1 build failed (${e instanceof Error ? e.message : e}); KBDB Base 暫不可用,可 acr update 重試`));
}
// 3. 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用) // 3. 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用)
let workerSubdomain = ''; let workerSubdomain = '';
try { try {
@@ -212,7 +223,7 @@ async function initSelfHosted(
// 4. 下載 repo 部署物(含預編譯 wasm+ 注入 KV id + wrangler deploy 全部 Worker // 4. 下載 repo 部署物(含預編譯 wasm+ 注入 KV id + wrangler deploy 全部 Worker
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...')); console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds }; const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId };
const deploy = await downloadAndDeploy(deployCtx); const deploy = await downloadAndDeploy(deployCtx);
const cypherUrl = deploy.cypherExecutorUrl const cypherUrl = deploy.cypherExecutorUrl
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : ''); ?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
+152 -1
View File
@@ -6,7 +6,7 @@
import chalk from 'chalk'; import chalk from 'chalk';
import ora from 'ora'; import ora from 'ora';
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; import { loadConfig, getCypherExecutorUrl, DEFAULT_PUBLIC_LIBRARY_URL } from '../lib/config.js';
import { obtainExposureConsent } from '../lib/exposure-warning.js'; import { obtainExposureConsent } from '../lib/exposure-warning.js';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
@@ -22,6 +22,9 @@ interface RecipeYaml {
} }
interface RecipeDefinition { interface RecipeDefinition {
uuid?: string; // UUID 身份模型(kbdb-base §7.5.5
author?: string;
derived_from?: string;
canonical_id: string; canonical_id: string;
hash_id: string; hash_id: string;
display_name?: string; display_name?: string;
@@ -237,3 +240,151 @@ export async function cmdRecipeDelete(id: string): Promise<void> {
process.exit(1); process.exit(1);
} }
} }
// ── 公庫互動(kbdb-base §7.5,薄殼:只呼叫 API + 格式化,無業務邏輯)─────────────────
interface PublicRecipeSummary {
uuid?: string;
canonical_id: string;
author?: string;
display_name?: string;
description?: string;
market_stat?: { success_count: number; failure_count: number } | null;
}
/** acr recipe search <q> — 搜尋公庫(GET /public-recipes?q=)。落空回創作引導(§7.5.6)。*/
export async function cmdRecipeSearch(query: string): Promise<void> {
const spinner = ora(`搜尋公庫「${query}`).start();
try {
const url = `${DEFAULT_PUBLIC_LIBRARY_URL}/public-recipes?q=${encodeURIComponent(query)}`;
const res = await fetch(url);
const data = await res.json() as
| { found: true; recipes: PublicRecipeSummary[]; count: number }
| { found: false; query: string; hint: string };
spinner.stop();
if (!data.found) {
console.log(chalk.yellow(`\n 公庫無符合「${query}」的 recipe。`));
console.log(chalk.gray(` ${data.hint}`));
console.log(chalk.gray(' 做一個:建 recipe YAML → acr recipe push(私庫)→ acr recipe submit-p(投稿成為作者)。\n'));
return;
}
console.log(chalk.bold(`\n 公庫 recipes${data.count} 個,同名可多作者)\n`));
for (const r of data.recipes) {
const s = r.market_stat;
const stat = s ? chalk.gray(`${s.success_count}/✗${s.failure_count}`) : chalk.gray(' (無市場數據)');
console.log(`${chalk.cyan(r.canonical_id.padEnd(20))} ${chalk.magenta('@' + (r.author ?? '?'))}${stat} ${r.display_name ?? ''}`);
if (r.description) console.log(` ${chalk.gray(r.description)}`);
}
console.log(chalk.gray('\n 取用:acr recipe pull <canonical_id> [--author=<name>]\n'));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/** acr recipe pull <canonical_id> [--author] — 從公庫取一份 recipe 寫進自己私庫。*/
export async function cmdRecipePull(canonicalId: string, author?: string): Promise<void> {
const config = loadConfig();
if (!config.api_key) {
console.error(chalk.red('缺少 API Key,請先執行 acr init'));
process.exit(1);
}
const spinner = ora(`從公庫取 recipe「${canonicalId}${author ? `@${author}` : ''}`).start();
try {
// 1. 從公庫取全文(不指定 author → 公庫回市場最佳版本)。
const q = author ? `?author=${encodeURIComponent(author)}` : '';
const pubRes = await fetch(`${DEFAULT_PUBLIC_LIBRARY_URL}/public-recipes/${encodeURIComponent(canonicalId)}${q}`);
const pub = await pubRes.json() as
| { found: true; recipe: RecipeDefinition & { uuid?: string; author?: string }; market_stat?: unknown }
| { found: false; canonical_id: string; hint: string };
if (!pub.found) {
spinner.stop();
console.log(chalk.yellow(`\n 公庫無 recipe「${canonicalId}」。`));
console.log(chalk.gray(` ${pub.hint}\n`));
return;
}
// 2. 寫進自己私庫(POST /recipes,帶 derived_from 溯源 + 種子級同意:pull 公庫公共資料非新暴露)。
const r = pub.recipe;
const executorUrl = getCypherExecutorUrl(config);
const installRes = await fetch(`${executorUrl}/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': config.api_key },
body: JSON.stringify({
...r,
derived_from: r.uuid, // 溯源:私庫這份來自公庫哪個 uuid
exposure_consent: {
confirmed_by_human: true,
understood: `pull from public library: ${canonicalId}`,
confirmed_at: new Date().toISOString(),
},
}),
});
const inst = await installRes.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
if (!inst.success) {
spinner.fail(chalk.red(`寫入私庫失敗:${inst.error ?? '未知錯誤'}`));
process.exit(1);
}
spinner.succeed(chalk.green(`✓ recipe「${canonicalId}${author ? `@${author}` : ''} 已拉進私庫`));
console.log(chalk.gray(` 在 workflow 用 component: ${canonicalId}\n`));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/** acr recipe submit-p <canonical_id> — 把私庫某 recipe 投稿到公庫(新增作者版本,需暴露同意)。*/
export async function cmdRecipeSubmitP(canonicalId: string, author?: string): Promise<void> {
const config = loadConfig();
if (!config.api_key) {
console.error(chalk.red('缺少 API Key,請先執行 acr init'));
process.exit(1);
}
const executorUrl = getCypherExecutorUrl(config);
// 1. 從私庫取這份 recipe 全文。
const myRes = await fetch(`${executorUrl}/recipes/${encodeURIComponent(canonicalId)}`, {
headers: { 'X-Arcrun-API-Key': config.api_key },
});
const my = await myRes.json() as { success: boolean; recipe?: RecipeDefinition & { uuid?: string }; error?: string };
if (!my.success || !my.recipe) {
console.error(chalk.red(`私庫找不到 recipe「${canonicalId}」:${my.error ?? ''}`));
process.exit(1);
}
// 2. 投稿到公庫 = 暴露面 → 取得人類明示同意(mindset §6)。
const consent = await obtainExposureConsent({
kind: 'recipe',
resourceName: canonicalId,
destination: `公庫(${DEFAULT_PUBLIC_LIBRARY_URL}`,
});
if (!consent) process.exit(1);
const spinner = ora(`投稿 recipe「${canonicalId}」到公庫`).start();
try {
const res = await fetch(`${DEFAULT_PUBLIC_LIBRARY_URL}/recipes/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...my.recipe,
author: author ?? my.recipe.author,
derived_from: my.recipe.derived_from ?? my.recipe.uuid,
submitter: author ?? config.api_key,
exposure_consent: consent,
}),
});
const data = await res.json() as { success: boolean; recipe?: { uuid?: string; author?: string }; error?: string };
if (!data.success) {
spinner.fail(chalk.red(`投稿失敗:${data.error ?? '未知錯誤'}`));
process.exit(1);
}
spinner.succeed(chalk.green(`✓ recipe「${canonicalId}」已投稿公庫(新增作者版本 @${data.recipe?.author ?? '?'}`));
console.log(chalk.gray(' 別人能搜到並 pull;市場數據累積後決定它被不被選用。\n'));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
+16 -1
View File
@@ -17,7 +17,7 @@ import { cmdPush } from './commands/push.js';
import { cmdRun } from './commands/run.js'; import { cmdRun } from './commands/run.js';
import { cmdValidate } from './commands/validate.js'; import { cmdValidate } from './commands/validate.js';
import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js'; import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js';
import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete } from './commands/recipe.js'; import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete, cmdRecipeSearch, cmdRecipePull, cmdRecipeSubmitP } from './commands/recipe.js';
import { cmdList } from './commands/list.js'; import { cmdList } from './commands/list.js';
import { cmdLogs } from './commands/logs.js'; import { cmdLogs } from './commands/logs.js';
import { cmdUpdate } from './commands/update.js'; import { cmdUpdate } from './commands/update.js';
@@ -121,6 +121,21 @@ recipeCmd
.command('delete <id>') .command('delete <id>')
.description('刪除 recipecanonical_id 或 rec_hash') .description('刪除 recipecanonical_id 或 rec_hash')
.action((id: string) => cmdRecipeDelete(id)); .action((id: string) => cmdRecipeDelete(id));
// 公庫互動(kbdb-base §7.5
recipeCmd
.command('search <query>')
.description('搜尋公庫 recipe(同名可多作者,附市場數據)')
.action((query: string) => cmdRecipeSearch(query));
recipeCmd
.command('pull <canonical_id>')
.description('從公庫取一份 recipe 寫進自己私庫')
.option('--author <name>', '指定作者版本(不指定取市場最佳)')
.action((canonicalId: string, opts: { author?: string }) => cmdRecipePull(canonicalId, opts.author));
recipeCmd
.command('submit-p <canonical_id>')
.description('把私庫某 recipe 投稿到公庫(新增作者版本,需暴露同意)')
.option('--author <name>', '署名作者(預設用 recipe 既有 author')
.action((canonicalId: string, opts: { author?: string }) => cmdRecipeSubmitP(canonicalId, opts.author));
// acr auth-recipe list / info / scaffold // acr auth-recipe list / info / scaffold
const authRecipeCmd = program.command('auth-recipe').description('第三方服務認證 Recipe(新增服務整合)'); const authRecipeCmd = program.command('auth-recipe').description('第三方服務認證 Recipe(新增服務整合)');
+19
View File
@@ -144,6 +144,25 @@ export class CfAccountClient {
const result = await this.cf<{ subdomain: string }>('/workers/subdomain'); const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
return result.subdomain; return result.subdomain;
} }
// D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified).
async listD1Databases(): Promise<Map<string, string>> {
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
const map = new Map<string, string>();
for (const db of result) map.set(db.name, db.uuid);
return map;
}
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
const known = existing ?? (await this.listD1Databases());
const found = known.get(name);
if (found) return found;
const result = await this.cf<{ uuid: string; name: string }>(
'/d1/database',
{ method: 'POST', body: JSON.stringify({ name }) },
);
return result.uuid;
}
} }
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/ /** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
+9
View File
@@ -67,6 +67,15 @@ const ENV_MAP: Record<string, keyof ArcrunConfig> = {
*/ */
export const DEFAULT_MCP_URL = 'https://mcp.arcrun.dev'; export const DEFAULT_MCP_URL = 'https://mcp.arcrun.dev';
/**
* 公庫 URLrecipe pull/search/submit-p 的對象,kbdb-base §7.5)。
* 公庫 = 官方 SaaS cypher(唯一公共真相)。self-hosted 用戶的「私庫」是自己的 cypher
* getCypherExecutorUrl),但 pull/搜尋/投稿都對著**官方公庫**這個固定 URL。
* fork 者可用 ARCRUN_PUBLIC_LIBRARY_URL env 覆蓋。
*/
export const DEFAULT_PUBLIC_LIBRARY_URL =
process.env.ARCRUN_PUBLIC_LIBRARY_URL ?? 'https://cypher.arcrun.dev';
export function configExists(): boolean { export function configExists(): boolean {
return existsSync(CONFIG_PATH) || findProjectConfig() !== undefined; return existsSync(CONFIG_PATH) || findProjectConfig() !== undefined;
} }
+50
View File
@@ -47,6 +47,7 @@ export interface DeployContext {
apiToken: string; apiToken: string;
workerSubdomain: string; workerSubdomain: string;
kvNamespaceIds: Record<string, string>; // title → id kvNamespaceIds: Record<string, string>; // title → id
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
} }
export interface DeployResult { export interface DeployResult {
@@ -111,6 +112,23 @@ export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promi
} }
} }
// 3.5 KBDB Base: D1 建好後套 migration(建三表 + recipe_stat seed)。
// 建 D1cf-api ensureD1Database)只產生空資料庫,schema 要靠這步套。
// migration 檔來自同一份 tarballroot/kbdb/migrations/0001_base.sql),與 wasm 同源,
// 不依賴本地 CLI 安裝路徑。0001_base.sql 全用 IF NOT EXISTS / INSERT OR IGNORE → 可重複套(idempotent)。
if (ctx.d1DatabaseId) {
const migPath = join(root, 'kbdb', 'migrations', '0001_base.sql');
if (existsSync(migPath)) {
try {
await applyD1Migration(ctx, readFileSync(migPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql${migPath}`);
}
}
const cypherExecutorUrl = ctx.workerSubdomain const cypherExecutorUrl = ctx.workerSubdomain
? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev` ? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev`
: undefined; : undefined;
@@ -132,6 +150,30 @@ export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promi
}; };
} }
/**
* 對 D1 套 SQL migration(透過 CF API `/d1/database/{id}/query`,非 wrangler)。
* 用 init 已驗的 ctx.apiToken + accountIdquery 端點接受多語句檔,一次送整份 0001_base.sql。
*/
async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/d1/database/${ctx.d1DatabaseId}/query`;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${ctx.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ sql }),
signal: AbortSignal.timeout(60_000),
});
const json = (await res.json().catch(() => null)) as
| { success?: boolean; errors?: Array<{ message?: string }> }
| null;
if (!res.ok || !json?.success) {
const detail = json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`;
throw new Error(detail);
}
}
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/ /** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/
async function downloadRepoTarball(ref: string): Promise<string> { async function downloadRepoTarball(ref: string): Promise<string> {
const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`; const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`;
@@ -212,6 +254,14 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
); );
} }
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
toml = toml.replace(
/(database_id\s*=\s*")[^"]*(")/,
`$1${ctx.d1DatabaseId}$2`,
);
}
toml = stripOfficialOnlyBindings(toml); toml = stripOfficialOnlyBindings(toml);
writeFileSync(tomlPath, toml, 'utf8'); writeFileSync(tomlPath, toml, 'utf8');
@@ -5,6 +5,41 @@ import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader'; import { createComponentLoader } from '../lib/component-loader';
import { recordTelemetry } from '../lib/telemetry'; import { recordTelemetry } from '../lib/telemetry';
/**
* kbdb-base §7.1+§7.5.h:一條工作流執行結束後,把這次用到的 recipe 各記一次成功/失敗到 KBDB 市場星數。
* 判定單位是「工作流執行」(n8n execution):整體成功 → 用到的每個 recipe key +1 成功;整體失敗 → 各 +1 失敗。
* **key = recipe uuid**per-uuid,能區分同 canonical 的不同作者版本 §7.5.5;舊資料 fallback canonical_id)。
*
* fire-and-forget(用 ctx.waitUntil,仿 recordTelemetry):記錄失敗不影響工作流結果。
* KBDB 端點 POST {KBDB_BASE_URL}/recipe-stats/record { canonical_id, ok, at }——
* 該欄位名為 canonical_id 但語意已是 recipe keyuuid),KBDB 端只當 stat 的主鍵字串用。
*/
function recordRecipeStats(
env: Bindings,
recipeKeys: Set<string>,
ok: boolean,
at: number,
ctx?: ExecutionContext,
): void {
if (recipeKeys.size === 0) return;
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
const promise = Promise.all(
[...recipeKeys].map(key =>
fetch(`${base}/recipe-stats/record`, {
method: 'POST',
headers,
body: JSON.stringify({ canonical_id: key, ok, at }),
}).catch(() => undefined),
),
).then(() => undefined);
if (ctx?.waitUntil) ctx.waitUntil(promise);
else void promise;
}
type WebhookRecord = { type WebhookRecord = {
graph: Record<string, unknown>; graph: Record<string, unknown>;
description: string; description: string;
@@ -58,6 +93,9 @@ export async function executeWebhookGraph(
agent_user_agent: userAgent, agent_user_agent: userAgent,
}, ctx); }, ctx);
// kbdb-base §7.1:整體成功 → 用到的 recipe 各記成功一次。
recordRecipeStats(env, executor.usedRecipeKeys, true, Date.now(), ctx);
return { success: true, data: result.data, duration_ms }; return { success: true, data: result.data, duration_ms };
} catch (err) { } catch (err) {
const duration_ms = Date.now() - start; const duration_ms = Date.now() - start;
@@ -73,6 +111,12 @@ export async function executeWebhookGraph(
agent_user_agent: userAgent, agent_user_agent: userAgent,
}, ctx); }, ctx);
// kbdb-base §7.1:真錯(非 paused)→ 用到的 recipe 各記失敗一次。
// paused 是「執行中暫停等 callback」非失敗,不記(resume 後成功才會在那條路徑記成功)。
if (!isPaused) {
recordRecipeStats(env, executor.usedRecipeKeys, false, Date.now(), ctx);
}
if (err instanceof ExecutionError) { if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({ const traceFormatted = err.trace.map(s => ({
node: s.nodeId, node: s.nodeId,
+19
View File
@@ -4,6 +4,7 @@ import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from
import { injectCredentials } from './actions/credential-injector'; import { injectCredentials } from './actions/credential-injector';
import { tryAuthDispatch } from './actions/auth-dispatcher'; import { tryAuthDispatch } from './actions/auth-dispatcher';
import { expandPromptRecipe } from './lib/recipe-expander'; import { expandPromptRecipe } from './lib/recipe-expander';
import { resolveRecipe } from './routes/recipes';
import { persistPausedRun, isResumablePending, parseRecipeOutput } from './lib/paused-runs'; import { persistPausedRun, isResumablePending, parseRecipeOutput } from './lib/paused-runs';
import { buildMagicVars } from './lib/magic-vars'; import { buildMagicVars } from './lib/magic-vars';
import { recordTelemetry } from './lib/telemetry'; import { recordTelemetry } from './lib/telemetry';
@@ -21,6 +22,11 @@ export class GraphExecutor {
private apiKey?: string; private apiKey?: string;
public recordComponentReference?: (componentId: string, workflowId: string) => Promise<void>; public recordComponentReference?: (componentId: string, workflowId: string) => Promise<void>;
// kbdb-base §7.1+§7.5.h:本次執行用到的 recipe **key**uuid 優先,舊資料 fallback canonical_id)。
// 判定單位是「工作流執行」(n8n execution):執行結束後由 executeWebhookGraph 一次性把這組 key
// 各記成功/失敗到 KBDB 市場星數(per-uuid → 能區分同 canonical 的不同作者版本,§7.5.5)。執行中只收集。
public readonly usedRecipeKeys = new Set<string>();
// resumable workflowSDD: resumable-workflow/design.md // resumable workflowSDD: resumable-workflow/design.md
// 暫停時持久化 state 用,需在 execute 進入時設定 // 暫停時持久化 state 用,需在 execute 進入時設定
private currentGraph?: ExecutionGraph; private currentGraph?: ExecutionGraph;
@@ -286,6 +292,19 @@ export class GraphExecutor {
} }
} }
// kbdb-base §7.5.h:收集本次用到的 recipe **uuid**(執行結束後一次性記到 KBDB 市場星數)。
// 記 per-uuid(非 auth service):投稿/pull 的是 API recipe(帶 uuid),市場數據要能區分
// 同 canonical 的 Leo 版/John 版(§7.5.5 app-store)。先試 API recipe(有 uuid);
// 無 uuid 的舊資料 fallback canonical_id(向後相容,migration 後自然帶 uuid)。
if (this.env?.RECIPES) {
try {
const apiRecipe = await resolveRecipe(node.componentId, this.env.RECIPES);
if (apiRecipe) this.usedRecipeKeys.add(apiRecipe.uuid ?? apiRecipe.canonical_id);
} catch {
// 收集失敗不影響執行(成功記錄是輔助資料,非主流程)
}
}
nodeInput = mergedContext; nodeInput = mergedContext;
result = await runner(mergedContext); result = await runner(mergedContext);
+5 -2
View File
@@ -86,9 +86,12 @@ export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
{ {
canonical_id: 'google_sheets_append', canonical_id: 'google_sheets_append',
display_name: 'Google Sheets Append', display_name: 'Google Sheets Append',
description: '寫 Sheets。PUT values?valueInputOption=RAWbody 帶 values。auth: google service_account。', // 壓測階段 12 修正:append 官方 API 是 POST .../values/{range}:appendPUT 是 values.update 覆寫的動詞),
// 種子寫死 PUT 導致每個 self-host 用戶 seed 到壞 recipePUT :append → Google 400)。
// body 形狀屬工作流,泛用種子不寫死欄位 → 由工作流的 _path + body 處理(body_from 機制待 §13.4 補)。
description: '追加一列到 Sheets。POST .../values/{range}:append?valueInputOption=RAWbody 帶 {values:[[...]]}。auth: google service_account。',
endpoint: 'https://sheets.googleapis.com{{_path}}', endpoint: 'https://sheets.googleapis.com{{_path}}',
method: 'PUT', method: 'POST',
auth_service: 'google_sheets_sa', auth_service: 'google_sheets_sa',
}, },
{ {
+6 -5
View File
@@ -20,6 +20,7 @@ import { Hono } from 'hono';
import type { Bindings } from '../types'; import type { Bindings } from '../types';
import { deriveRecipeHash } from '../lib/hash'; import { deriveRecipeHash } from '../lib/hash';
import type { RecipeDefinition, AuthRecipeDefinition } from './recipes'; import type { RecipeDefinition, AuthRecipeDefinition } from './recipes';
import { installRecipeRecord, resolveRecipe } from './recipes';
import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds'; import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds';
import { AUTH_RECIPE_SEEDS } from '../lib/auth-recipe-seeds'; import { AUTH_RECIPE_SEEDS } from '../lib/auth-recipe-seeds';
@@ -41,8 +42,11 @@ initSeedRouter.post('/init/seed', async (c) => {
try { try {
const canonicalId = seed.canonical_id.trim().toLowerCase(); const canonicalId = seed.canonical_id.trim().toLowerCase();
const hashId = await deriveRecipeHash(canonicalId); const hashId = await deriveRecipeHash(canonicalId);
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null; // UUID 模型(§7.5.5):種子 author='system'。冪等:已安裝沿用其 uuid,否則新領。
const existing = await resolveRecipe(canonicalId, c.env.RECIPES);
const recipe: RecipeDefinition = { const recipe: RecipeDefinition = {
uuid: existing?.uuid ?? crypto.randomUUID(),
author: existing?.author ?? 'system',
canonical_id: canonicalId, canonical_id: canonicalId,
hash_id: hashId, hash_id: hashId,
display_name: seed.display_name, display_name: seed.display_name,
@@ -54,10 +58,7 @@ initSeedRouter.post('/init/seed', async (c) => {
created_at: existing?.created_at ?? now, created_at: existing?.created_at ?? now,
updated_at: now, updated_at: now,
}; };
await Promise.all([ await installRecipeRecord(c.env.RECIPES, recipe);
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
]);
apiOk++; apiOk++;
} catch (e) { } catch (e) {
apiFail++; apiFail++;
+327 -27
View File
@@ -22,6 +22,12 @@ import type { ExposureConsent } from '../lib/exposure-consent';
export const recipesRouter = new Hono<{ Bindings: Bindings }>(); export const recipesRouter = new Hono<{ Bindings: Bindings }>();
export interface RecipeDefinition { export interface RecipeDefinition {
// UUID 身份模型(kbdb-base §7.5.5):每個 recipe 一誕生領 uuid = 唯一身份。
// canonical_id / author / 公私 都是屬性,不是身份。身份(uuid) 與歸屬(author) 分離。
// 舊 recipe 無 uuid → resolveRecipe / migration 兼容(migration 增量補 uuid,不刪舊 key)。
uuid?: string; // 唯一身份;舊資料可能缺,讀取時容忍
author?: string; // 該 uuid 投稿者(誰投誰負責那版市場數據);'system' = init-seed 種子
derived_from?: string; // 可選溯源:fork 自哪個 uuidLeo 改 John 版時記 John 的 uuid
canonical_id: string; canonical_id: string;
hash_id: string; // rec_xxxxxxxx hash_id: string; // rec_xxxxxxxx
display_name?: string; display_name?: string;
@@ -47,6 +53,36 @@ export interface RecipeDefinition {
updated_at: number; updated_at: number;
} }
// ── UUID 身份模型 KV keykbdb-base §7.5.5)────────────────────────────────────
// recipe:{uuid} → recipe 本體(唯一身份)
// idx:canonical:{canonical_id} → JSON array of uuid(同 canonical 多作者版本並存,公庫用)
// idx:installed:{canonical_id} → 單一 uuid(本部署執行時用哪個版本;pull/submit 時定)
// idx:{hash_id} → canonical_id(既有 rec_hash 反查,保留)
// 舊資料 recipe:{canonical_id} 不刪,resolveRecipe fallback 讀得到(migration 增量補,不破現況)。
const kIdxCanonical = (canonicalId: string) => `idx:canonical:${canonicalId}`;
const kIdxInstalled = (canonicalId: string) => `idx:installed:${canonicalId}`;
/**
* 寫一份 recipeUUID 身份模型):給定 recipe 已含 uuid → 寫 recipe:{uuid}、
* 把 uuid 併進 idx:canonical:{canonical_id} 清單、設為本部署 installed(執行時用此版本)、
* 維護 idx:{hash_id} 反查。private(POST /recipes) 與 public(submit-p) 共用此寫入。
*/
export async function installRecipeRecord(kv: KVNamespace, recipe: RecipeDefinition): Promise<void> {
const uuid = recipe.uuid!;
const { canonical_id, hash_id } = recipe;
const listRaw = await kv.get(kIdxCanonical(canonical_id));
const uuids: string[] = listRaw ? JSON.parse(listRaw) : [];
if (!uuids.includes(uuid)) uuids.push(uuid);
await Promise.all([
kv.put(`recipe:${uuid}`, JSON.stringify(recipe)),
kv.put(kIdxCanonical(canonical_id), JSON.stringify(uuids)),
kv.put(kIdxInstalled(canonical_id), uuid),
kv.put(`idx:${hash_id}`, canonical_id),
]);
}
// POST /recipes — 新增或更新 recipe // POST /recipes — 新增或更新 recipe
recipesRouter.post('/recipes', async (c) => { recipesRouter.post('/recipes', async (c) => {
let body: Partial<RecipeDefinition>; let body: Partial<RecipeDefinition>;
@@ -63,8 +99,10 @@ recipesRouter.post('/recipes', async (c) => {
const hashId = await deriveRecipeHash(canonicalId); const hashId = await deriveRecipeHash(canonicalId);
const now = Date.now(); const now = Date.now();
// 讀取現有版本(保留 created_at + 既有同意憑證) // 私庫(POST /recipes= 自己地盤,同 canonical 就地更新自己安裝的那份(沿用既有 uuid)。
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null; // 既有 installed → 沿用其 uuid + created_at;無 → 新領 uuid(首次裝這個 canonical)。
// 讀取順序:先 UUID 模型(installed→uuid),fallback 舊 keymigration 前的種子)。
const existing = await resolveRecipe(canonicalId, c.env.RECIPES);
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。 // 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent); const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
@@ -73,6 +111,9 @@ recipesRouter.post('/recipes', async (c) => {
} }
const recipe: RecipeDefinition = { const recipe: RecipeDefinition = {
uuid: existing?.uuid ?? crypto.randomUUID(),
author: body.author ?? existing?.author ?? 'local',
derived_from: body.derived_from ?? existing?.derived_from,
canonical_id: canonicalId, canonical_id: canonicalId,
hash_id: hashId, hash_id: hashId,
display_name: body.display_name, display_name: body.display_name,
@@ -88,16 +129,124 @@ recipesRouter.post('/recipes', async (c) => {
updated_at: now, updated_at: now,
}; };
// 寫入兩個 KV key await installRecipeRecord(c.env.RECIPES, recipe);
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 }); return c.json({ success: true, recipe });
}); });
// GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash // POST /recipes/submit — 公共庫投稿(submit-p)。kbdb-base SDD §7.2/§7.3。
//
// 兩套部署模型:self-hosted cypher = 私庫(直接 POST /recipes 寫自己 KV);
// 官方 cypher = 公共庫,外部投稿者把修好的 recipe 送來這個端點。
//
// app-store / UUID 模型(§7.5.5):submit-p = **新增一個作者版本(領新 uuid)**,
// 不覆蓋同 canonical_id。同 canonical 多作者並存(Leo 版、John 版各自 uuid + 市場數據)。
// 公共庫 = 暴露面 → 強制 exposure_consentmindset §6:暴露需人類明示同意)。
// 投稿者帶的 stat 只當「存證」(誰在何時投了什麼、聲稱打通幾次),寫進 KBDB 一筆
// recipe_submission entry**不**併進 recipe-stat 真實計數(避免自報數污染市場數據,§7.3)。
// 市場信任靠真實使用累積(5.1),不拿投稿者自報數當門檻 → 不造債。
recipesRouter.post('/recipes/submit', async (c) => {
let body: Partial<RecipeDefinition> & {
stat?: { success_count?: number; failure_count?: number };
submitter?: string;
};
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();
// 公共庫投稿一定是暴露 → 需明示同意(無同意直接擋)。投稿是新版本,不沿用既有同意。
const consentError = checkExposureConsent(body.exposure_consent, undefined);
if (consentError !== null) {
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
}
// app-store 模型:**領新 uuid = 新增作者版本**,不覆蓋既有 canonical(§7.5.5)。
const recipe: RecipeDefinition = {
uuid: crypto.randomUUID(),
author: body.author ?? body.submitter ?? 'anonymous',
derived_from: body.derived_from,
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, undefined),
created_at: now,
updated_at: now,
};
// 新增作者版本:寫 recipe:{uuid} + 併進 idx:canonical 清單(同 canonical 多版本並存)。
// installed 也指向這個新版本(官方部署投稿後預設用最新;market 選擇由 §7.5.5 端點處理)。
await installRecipeRecord(c.env.RECIPES, recipe);
// stat 存證:寫一筆 recipe_submission entry 進 KBDB(不當門檻,當法律歸責軌跡)。
// fire-and-forget:存證失敗不擋投稿成功。
const kbdbBase = (c.env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
const evidence = {
content: canonicalId,
entry_type: 'recipe_submission',
metadata_json: JSON.stringify({
uuid: recipe.uuid,
canonical_id: canonicalId,
author: recipe.author,
submitter: body.submitter ?? 'unknown',
claimed_stat: body.stat ?? null,
submitted_at: now,
}),
};
const kbdbHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
if (c.env.KBDB_INTERNAL_TOKEN) kbdbHeaders['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
c.executionCtx.waitUntil(
fetch(`${kbdbBase}/entries`, {
method: 'POST',
headers: kbdbHeaders,
body: JSON.stringify(evidence),
}).catch(() => undefined),
);
return c.json({ success: true, recipe, evidence_recorded: true });
});
// POST /recipes/migrate-uuid — 一次性 migration:把 migration 前的舊 key recipe:{canonical_id}
// (無 uuid)轉成 UUID 身份模型(§7.5.5)。增量寫、**不刪舊 key**(失敗也不破現況;resolveRecipe
// 本就 fallback 舊 key)。冪等:已有 uuid 的跳過。重跑安全。
recipesRouter.post('/recipes/migrate-uuid', async (c) => {
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
let migrated = 0, skipped = 0;
const errors: string[] = [];
for (const k of list.keys) {
try {
const rec = await c.env.RECIPES.get(k.name, 'json') as RecipeDefinition | null;
if (!rec || !rec.canonical_id) { skipped++; continue; }
if (rec.uuid) { skipped++; continue; } // 已是新模型
const migrated_recipe: RecipeDefinition = {
...rec,
uuid: crypto.randomUUID(),
author: rec.author ?? 'system', // 舊種子歸 system
};
await installRecipeRecord(c.env.RECIPES, migrated_recipe);
migrated++;
} catch (e) {
errors.push(`${k.name}: ${e instanceof Error ? e.message : String(e)}`);
}
}
return c.json({ success: errors.length === 0, migrated, skipped, errors });
});
// GET /recipes/:id — 讀取 recipe(支援 canonical_id / rec_hash / uuid
recipesRouter.get('/recipes/:id', async (c) => { recipesRouter.get('/recipes/:id', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES); const recipe = await resolveRecipe(id, c.env.RECIPES);
@@ -105,42 +254,193 @@ recipesRouter.get('/recipes/:id', async (c) => {
return c.json({ success: true, recipe }); return c.json({ success: true, recipe });
}); });
// GET /recipes — 列出所有 recipe // GET /recipes — 列出所有 recipe(本部署 KV 全部版本,含多作者)。
// prefix recipe: 同時命中 recipe:{uuid}(新)與 recipe:{canonical_id}migration 前舊 key)。
// 去重:同 canonical_id 若已有帶 uuid 的版本,捨棄無 uuid 的舊 key 重複項。
recipesRouter.get('/recipes', async (c) => { recipesRouter.get('/recipes', async (c) => {
const list = await c.env.RECIPES.list({ prefix: 'recipe:' }); const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
const recipes = await Promise.all( const all = (await Promise.all(
list.keys.map(k => c.env.RECIPES.get(k.name, 'json')) list.keys.map(k => c.env.RECIPES.get(k.name, 'json') as Promise<RecipeDefinition | null>)
); )).filter(Boolean) as RecipeDefinition[];
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
// canonical → 是否已有帶 uuid 的版本
const hasUuidVersion = new Set(all.filter(r => r.uuid).map(r => r.canonical_id));
const recipes = all.filter(r => r.uuid || !hasUuidVersion.has(r.canonical_id));
return c.json({ success: true, recipes, count: recipes.length });
}); });
// DELETE /recipes/:id — 刪除 recipe // ── 公庫只讀端點(kbdb-base §7.5.4,公→私 pull + 瀏覽的後端基礎)──────────────────
// 官方 cypher 開公開只讀(無需 api_key,公庫本就公共)。語意 = 「這是公庫,給 self-hosted pull/瀏覽」,
// 含作者維度 + 市場星數(與內部 /recipes 分開命名,公庫的多作者/排序不污染內部)。
/** 從 KBDB 抓 recipe 市場星數(5.1 記的 success/failure)。失敗回 null(端點仍可用,星數缺省)。*/
async function fetchMarketStat(
env: Bindings,
canonicalId: string,
): Promise<{ success_count: number; failure_count: number } | null> {
try {
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
const headers: Record<string, string> = {};
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
const res = await fetch(`${base}/recipe-stats/${encodeURIComponent(canonicalId)}`, { headers });
if (!res.ok) return null;
const json = await res.json() as { stat?: { success_count?: number; failure_count?: number } };
if (!json.stat) return null;
return {
success_count: json.stat.success_count ?? 0,
failure_count: json.stat.failure_count ?? 0,
};
} catch {
return null;
}
}
// 收集本部署 KV 全部 recipe(去重,與 GET /recipes 同邏輯),給公庫端點共用。
async function listAllRecipes(kv: KVNamespace): Promise<RecipeDefinition[]> {
const list = await kv.list({ prefix: 'recipe:' });
const all = (await Promise.all(
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
)).filter(Boolean) as RecipeDefinition[];
const hasUuid = new Set(all.filter(r => r.uuid).map(r => r.canonical_id));
return all.filter(r => r.uuid || !hasUuid.has(r.canonical_id));
}
// GET /public-recipes?q=&limit=&offset= — 搜尋/列出公庫 recipe。
// 同 canonical_id 回多筆(多作者),各附市場星數,供 CC 依數據選(§7.5.5)。
// 落空(q 無命中)→ 回 found:false + 創作引導(§7.5.6),不回空陣列乾等。
recipesRouter.get('/public-recipes', async (c) => {
const q = (c.req.query('q') ?? '').trim().toLowerCase();
const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
const offset = Number(c.req.query('offset') ?? 0);
const all = await listAllRecipes(c.env.RECIPES);
const matched = q
? all.filter(r =>
r.canonical_id.toLowerCase().includes(q) ||
(r.display_name ?? '').toLowerCase().includes(q) ||
(r.description ?? '').toLowerCase().includes(q))
: all;
if (q && matched.length === 0) {
// 落空 = 創作入口(§7.5.6):讓 CC 知道「公庫沒有,可自己做一個成為作者」。
return c.json({
found: false,
query: q,
hint: `公庫無符合「${q}」的 recipe。可自行建立並 submit-p 投稿成為作者(app-store 模型)。`,
});
}
const page = matched.slice(offset, offset + limit);
const withStats = await Promise.all(
page.map(async r => ({
uuid: r.uuid,
canonical_id: r.canonical_id,
author: r.author,
display_name: r.display_name,
description: r.description,
market_stat: await fetchMarketStat(c.env, r.uuid ?? r.canonical_id), // §7.5.h per-uuid
})),
);
return c.json({ found: true, recipes: withStats, count: matched.length });
});
// GET /public-recipes/:canonical_id?author= — 取單一 recipe 全文(pull 用)。
// 不指定 author → 回市場最佳版本(success_count 最高)。落空 → found:false 創作引導(§7.5.6)。
recipesRouter.get('/public-recipes/:canonical_id', async (c) => {
const canonicalId = c.req.param('canonical_id').trim().toLowerCase();
const author = c.req.query('author');
const all = await listAllRecipes(c.env.RECIPES);
let versions = all.filter(r => r.canonical_id === canonicalId);
if (author) versions = versions.filter(r => r.author === author);
if (versions.length === 0) {
return c.json({
found: false,
canonical_id: canonicalId,
hint: `公庫無 recipe「${canonicalId}${author ? `author=${author}` : ''}。可自行建立並 submit-p 投稿成為作者(app-store 模型)。`,
});
}
// 多作者 → 選市場最佳(success_count 最高;無 stat 視為 0)。
// §7.5.h:星數 per-uuid5.1 記 uuid)→ 能真正區分 Leo 版/John 版。舊資料無 uuid fallback canonical_id。
let best = versions[0];
let bestStat: { success_count: number; failure_count: number } | null = null;
let bestScore = -1;
for (const v of versions) {
const stat = await fetchMarketStat(c.env, v.uuid ?? v.canonical_id);
const score = stat?.success_count ?? 0;
if (score > bestScore) { bestScore = score; best = v; bestStat = stat; }
}
return c.json({ found: true, recipe: best, market_stat: bestStat });
});
// DELETE /recipes/:id — 刪除(依 UUID 模型清掉 recipe:{uuid} + installed + canonical 清單裡的該 uuid + 舊 key
recipesRouter.delete('/recipes/:id', async (c) => { recipesRouter.delete('/recipes/:id', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES); const recipe = await resolveRecipe(id, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404); if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
await Promise.all([ const canonicalId = recipe.canonical_id;
c.env.RECIPES.delete(`recipe:${recipe.canonical_id}`), const ops: Promise<unknown>[] = [
c.env.RECIPES.delete(`idx:${recipe.hash_id}`), c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
]); c.env.RECIPES.delete(`recipe:${canonicalId}`), // 舊 key(若存在)
];
return c.json({ success: true, deleted: recipe.canonical_id }); if (recipe.uuid) {
ops.push(c.env.RECIPES.delete(`recipe:${recipe.uuid}`));
// 從 canonical 清單移除此 uuid;若清單空了連 installed 一起清
const listRaw = await c.env.RECIPES.get(kIdxCanonical(canonicalId));
const uuids: string[] = listRaw ? JSON.parse(listRaw) : [];
const left = uuids.filter(u => u !== recipe.uuid);
if (left.length > 0) {
ops.push(c.env.RECIPES.put(kIdxCanonical(canonicalId), JSON.stringify(left)));
// installed 若指向被刪的 uuid → 改指剩下第一個
const installed = await c.env.RECIPES.get(kIdxInstalled(canonicalId));
if (installed === recipe.uuid) ops.push(c.env.RECIPES.put(kIdxInstalled(canonicalId), left[0]));
} else {
ops.push(c.env.RECIPES.delete(kIdxCanonical(canonicalId)));
ops.push(c.env.RECIPES.delete(kIdxInstalled(canonicalId)));
}
}
await Promise.all(ops);
return c.json({ success: true, deleted: recipe.uuid ?? canonicalId });
}); });
/** 用 canonical_id 或 rec_hash 查 recipe */ /**
* 用 canonical_id / rec_hash / uuid 查 recipe(執行時的解析入口)。
* UUID 身份模型(§7.5.5+ 向後相容(migration 前的舊 key):
* 1. id 是 uuidrecipe:{uuid} 直接存在)→ 直接回。
* 2. rec_xxxxxxxx → idx:{hash} 反查 canonical_id → 再走 canonical 解析。
* 3. canonical_id → 先查 idx:installed:{canonical_id}(本部署安裝的唯一版本)→ recipe:{uuid}
* 查不到 fallback 舊 key recipe:{canonical_id}(種子 / migration 前資料)。
* 執行鏈路(component-loader/auth-dispatcher/credential-injector)都經此 → 不破執行。
*/
export async function resolveRecipe( export async function resolveRecipe(
id: string, id: string,
kv: KVNamespace, kv: KVNamespace,
): Promise<RecipeDefinition | null> { ): Promise<RecipeDefinition | null> {
// rec_xxxxxxxx → 先查 idx 反查 canonical_id // 1. 直接 uuidpull / market 指定版本時用)
const direct = await kv.get(`recipe:${id}`, 'json') as RecipeDefinition | null;
if (direct && direct.uuid) return direct;
// direct 命中但無 uuid = 舊 key recipe:{canonical_id}migration 前)→ 仍可用,但繼續嘗試 installed 拿新版
// installed 優先:migration 後新版在 recipe:{uuid},舊 key 為 fallback
// 2. rec_hash 反查 canonical_id
let canonicalId = id;
if (id.startsWith('rec_')) { if (id.startsWith('rec_')) {
const canonicalId = await kv.get(`idx:${id}`); const looked = await kv.get(`idx:${id}`);
if (!canonicalId) return null; if (!looked) return direct; // hash 查不到,回 step1 結果(通常 null
return kv.get(`recipe:${canonicalId}`, 'json'); canonicalId = looked;
} }
// 直接用 canonical_id
return kv.get(`recipe:${id}`, 'json'); // 3. canonical → installed uuid → recipe:{uuid}fallback 舊 key
const installedUuid = await kv.get(kIdxInstalled(canonicalId));
if (installedUuid) {
const byUuid = await kv.get(`recipe:${installedUuid}`, 'json') as RecipeDefinition | null;
if (byUuid) return byUuid;
}
// fallback:舊 key recipe:{canonical_id}direct 若正是它,已在手上)
return direct ?? (await kv.get(`recipe:${canonicalId}`, 'json'));
} }
// ── Auth Recipe ──────────────────────────────────────────────────────────────── // ── Auth Recipe ────────────────────────────────────────────────────────────────
+3
View File
@@ -49,6 +49,9 @@ export type Bindings = {
SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID) SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID)
// KBDB 整合 // KBDB 整合
KBDB_INTERNAL_TOKEN?: string; KBDB_INTERNAL_TOKEN?: string;
// KBDB Base worker URLrecipe 成功記錄 /recipe-stats/record、fragment 抓取)。
// 未設 fallback 見各使用點(recipe-expander 預設 kbdb.finally.click)。kbdb-base SDD §7.1。
KBDB_BASE_URL?: string;
// Component Worker subdomainworkers.dev 帳號 subdomain // Component Worker subdomainworkers.dev 帳號 subdomain
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9) // 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain // self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
+88
View File
@@ -0,0 +1,88 @@
-- KBDB Base — atomic universal table (3 tables, never changes)
-- SDD: .agents/specs/arcrun/kbdb-base/design.md
--
-- Plugin model (like PostgreSQL core + PGVector/AGE):
-- - Base = these 3 tables + plain CRUD + D1 LIKE search (D1 only, free, no credit card).
-- - embed module = optional, writes vectors to Vectorize (does NOT alter these tables).
-- - triplet module = separate repo, writes derived records into entry_values (does NOT alter base).
-- "Table never changes": new tech records its output elsewhere, never ALTERs the base.
-- ============================================================
-- 1. Three tables
-- ============================================================
-- Universal main table: each entry is one atomic datum.
-- entry_type extended for arcrun: 'block' | 'value' | 'template' | 'slot' | 'project' | 'workflow' | 'recipe_stat'
CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY,
content TEXT,
entry_type TEXT NOT NULL,
owner_id TEXT, -- multi-tenant: namespace (self-hosted) or api_key (SaaS)
-- tree structure (project -> workflow via parent_id; SDD Q1 decision)
parent_id TEXT,
-- optional block metadata (harmless plain columns)
page_name TEXT,
refs_json TEXT DEFAULT '[]',
tags_json TEXT DEFAULT '[]',
task_status TEXT,
-- optional embed bookkeeping (set by optional embed module; base never reads them)
content_hash TEXT,
is_embedded INTEGER DEFAULT 0,
-- metadata
confidence REAL,
metadata_json TEXT,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
-- Template table: defines slots for a virtual table
CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
slots_json TEXT NOT NULL, -- JSON array, e.g. ["display_name","gender"]
created_by TEXT, -- 'system' | 'ai' | owner_id
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
-- Slot link table: composes multiple entries into one structured record
CREATE TABLE IF NOT EXISTS entry_values (
id TEXT PRIMARY KEY,
record_id TEXT NOT NULL,
template_id TEXT NOT NULL REFERENCES templates(id),
slot_name TEXT NOT NULL,
entry_id TEXT NOT NULL REFERENCES entries(id),
created_at INTEGER DEFAULT (unixepoch()),
UNIQUE(record_id, slot_name)
);
-- ============================================================
-- 2. Indexes
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(entry_type);
CREATE INDEX IF NOT EXISTS idx_entries_owner ON entries(owner_id);
CREATE INDEX IF NOT EXISTS idx_entries_parent ON entries(parent_id);
CREATE INDEX IF NOT EXISTS idx_entries_page ON entries(page_name);
CREATE INDEX IF NOT EXISTS idx_entries_task ON entries(task_status);
CREATE INDEX IF NOT EXISTS idx_entries_hash ON entries(content_hash);
CREATE INDEX IF NOT EXISTS idx_ev_record ON entry_values(record_id);
CREATE INDEX IF NOT EXISTS idx_ev_template ON entry_values(template_id);
CREATE INDEX IF NOT EXISTS idx_ev_template_slot ON entry_values(template_id, slot_name);
CREATE INDEX IF NOT EXISTS idx_ev_entry ON entry_values(entry_id);
-- ============================================================
-- 3. Seed templates used by arcrun base
-- ============================================================
-- recipe_stat: success/failure counters for a recipe (feeds recipe submission, SDD section 7)
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
VALUES
('tpl-recipe-stat', 'recipe_stat', 'recipe success/failure counters', '["canonical_id","success_count","failure_count","last_status","last_at"]', 'system');
+20
View File
@@ -0,0 +1,20 @@
{
"name": "arcrun-kbdb",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run"
},
"dependencies": {
"hono": "^4.7.0",
"zod": "~3.23.8"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250219.0",
"typescript": "^5.7.0",
"vitest": "^3.1.0",
"wrangler": "^4.0.0"
}
}
+1870
View File
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
// Entry CRUD — atomic data + tree (project/workflow via parent_id). Base, D1 only.
import type { Bindings, Entry } from '../types';
function uid(prefix: string): string {
// deterministic-enough unique id without Math.random in hot path is fine here;
// crypto.randomUUID is available in Workers runtime.
return `${prefix}_${crypto.randomUUID()}`;
}
export interface CreateEntryInput {
content?: string | null;
entry_type: string;
owner_id?: string | null;
parent_id?: string | null;
page_name?: string | null;
refs_json?: string;
tags_json?: string;
task_status?: string | null;
confidence?: number | null;
metadata_json?: string | null;
id?: string;
}
export async function createEntry(db: D1Database, input: CreateEntryInput): Promise<Entry> {
const id = input.id ?? uid('e');
await db
.prepare(
`INSERT INTO entries (id, content, entry_type, owner_id, parent_id, page_name, refs_json, tags_json, task_status, confidence, metadata_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
id,
input.content ?? null,
input.entry_type,
input.owner_id ?? null,
input.parent_id ?? null,
input.page_name ?? null,
input.refs_json ?? '[]',
input.tags_json ?? '[]',
input.task_status ?? null,
input.confidence ?? null,
input.metadata_json ?? null,
)
.run();
const row = await getEntry(db, id);
if (!row) throw new Error('createEntry: insert succeeded but row not found');
return row;
}
export async function getEntry(db: D1Database, id: string): Promise<Entry | null> {
const row = await db.prepare('SELECT * FROM entries WHERE id = ?').bind(id).first<Entry>();
return row ?? null;
}
export interface ListEntriesFilter {
entry_type?: string;
owner_id?: string;
parent_id?: string;
limit?: number;
offset?: number;
}
export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Promise<Entry[]> {
const conds: string[] = [];
const params: unknown[] = [];
if (f.entry_type) { conds.push('entry_type = ?'); params.push(f.entry_type); }
if (f.owner_id) { conds.push('owner_id = ?'); params.push(f.owner_id); }
if (f.parent_id) { conds.push('parent_id = ?'); params.push(f.parent_id); }
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const limit = Math.min(f.limit ?? 100, 1000);
const offset = f.offset ?? 0;
const res = await db
.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
.bind(...params, limit, offset)
.all<Entry>();
return res.results ?? [];
}
export interface UpdateEntryInput {
content?: string | null;
parent_id?: string | null;
page_name?: string | null;
refs_json?: string;
tags_json?: string;
task_status?: string | null;
confidence?: number | null;
metadata_json?: string | null;
}
export async function updateEntry(db: D1Database, id: string, patch: UpdateEntryInput): Promise<Entry | null> {
const cols: string[] = [];
const params: unknown[] = [];
const map: Record<string, unknown> = patch as Record<string, unknown>;
for (const k of ['content', 'parent_id', 'page_name', 'refs_json', 'tags_json', 'task_status', 'confidence', 'metadata_json']) {
if (k in map && map[k] !== undefined) { cols.push(`${k} = ?`); params.push(map[k]); }
}
if (cols.length === 0) return getEntry(db, id);
cols.push('updated_at = unixepoch()');
await db.prepare(`UPDATE entries SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
return getEntry(db, id);
}
export async function deleteEntry(db: D1Database, id: string): Promise<void> {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
}
// D1 LIKE keyword search (base; semantic search is the optional embed module).
export async function searchEntries(db: D1Database, q: string, owner_id?: string, limit = 50): Promise<Entry[]> {
const conds = ['content LIKE ?'];
const params: unknown[] = [`%${q}%`];
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
const res = await db
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
.bind(...params, Math.min(limit, 200))
.all<Entry>();
return res.results ?? [];
}
+63
View File
@@ -0,0 +1,63 @@
// Recipe success/failure records (SDD section 7.1). Stored as an entry per recipe canonical_id.
// This is the "fuel" for submission-with-proof: real 2xx counts beat self-written tests.
import type { Bindings } from '../types';
interface RecipeStat {
canonical_id: string;
success_count: number;
failure_count: number;
last_status: string | null;
last_at: number | null;
}
// One entry per recipe: id = recipestat:{canonical_id}, entry_type='recipe_stat',
// counters live in metadata_json. Atomic upsert via D1.
function statId(canonicalId: string): string {
return `recipestat:${canonicalId}`;
}
export async function recordRecipeResult(db: D1Database, canonicalId: string, ok: boolean, nowMs: number): Promise<RecipeStat> {
const id = statId(canonicalId);
const existing = await db.prepare('SELECT metadata_json FROM entries WHERE id = ?').bind(id).first<{ metadata_json: string | null }>();
let stat: RecipeStat;
if (existing) {
const prev = existing.metadata_json ? (JSON.parse(existing.metadata_json) as RecipeStat) : emptyStat(canonicalId);
stat = {
canonical_id: canonicalId,
success_count: prev.success_count + (ok ? 1 : 0),
failure_count: prev.failure_count + (ok ? 0 : 1),
last_status: ok ? 'success' : 'failure',
last_at: nowMs,
};
await db
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
.bind(JSON.stringify(stat), id)
.run();
} else {
stat = {
canonical_id: canonicalId,
success_count: ok ? 1 : 0,
failure_count: ok ? 0 : 1,
last_status: ok ? 'success' : 'failure',
last_at: nowMs,
};
await db
.prepare('INSERT INTO entries (id, content, entry_type, metadata_json) VALUES (?, ?, ?, ?)')
.bind(id, canonicalId, 'recipe_stat', JSON.stringify(stat))
.run();
}
return stat;
}
export async function getRecipeStat(db: D1Database, canonicalId: string): Promise<RecipeStat> {
const row = await db.prepare('SELECT metadata_json FROM entries WHERE id = ?').bind(statId(canonicalId)).first<{ metadata_json: string | null }>();
if (!row || !row.metadata_json) return emptyStat(canonicalId);
return JSON.parse(row.metadata_json) as RecipeStat;
}
function emptyStat(canonicalId: string): RecipeStat {
return { canonical_id: canonicalId, success_count: 0, failure_count: 0, last_status: null, last_at: null };
}
export type { RecipeStat };
+120
View File
@@ -0,0 +1,120 @@
// Template + Record CRUD. A "record" = multiple entries composed via a template's slots.
// Base, D1 only. (Ported clean from KBDB; no vectorize/triplet imports.)
import type { Template } from '../types';
import { createEntry } from './entry-crud';
function uid(prefix: string): string {
return `${prefix}_${crypto.randomUUID()}`;
}
// ---- Templates ----
export interface CreateTemplateInput {
name: string;
description?: string | null;
slots: string[];
created_by?: string | null;
id?: string;
}
export async function createTemplate(db: D1Database, input: CreateTemplateInput): Promise<Template> {
const id = input.id ?? uid('tpl');
await db
.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`)
.bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null)
.run();
const row = await getTemplate(db, id);
if (!row) throw new Error('createTemplate: row not found after insert');
return row;
}
export async function getTemplate(db: D1Database, idOrName: string): Promise<Template | null> {
const row = await db
.prepare('SELECT * FROM templates WHERE id = ? OR name = ? LIMIT 1')
.bind(idOrName, idOrName)
.first<Template>();
return row ?? null;
}
export async function listTemplates(db: D1Database): Promise<Template[]> {
const res = await db.prepare('SELECT * FROM templates ORDER BY created_at DESC').all<Template>();
return res.results ?? [];
}
export async function updateTemplate(db: D1Database, id: string, patch: { description?: string | null; slots?: string[] }): Promise<Template | null> {
const cols: string[] = [];
const params: unknown[] = [];
if (patch.description !== undefined) { cols.push('description = ?'); params.push(patch.description); }
if (patch.slots !== undefined) { cols.push('slots_json = ?'); params.push(JSON.stringify(patch.slots)); }
if (cols.length === 0) return getTemplate(db, id);
cols.push('updated_at = unixepoch()');
await db.prepare(`UPDATE templates SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
return getTemplate(db, id);
}
// ---- Records (entry_values composed by template) ----
export interface CreateRecordInput {
template: string; // template id or name
values: Record<string, string>; // slot_name -> content
owner_id?: string | null;
record_id?: string;
}
export interface RecordResult {
record_id: string;
template_id: string;
values: Record<string, string>;
}
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
const tpl = await getTemplate(db, input.template);
if (!tpl) throw new Error(`template not found: ${input.template}`);
const slots: string[] = JSON.parse(tpl.slots_json);
const recordId = input.record_id ?? uid('rec');
for (const slot of slots) {
if (!(slot in input.values)) continue;
const entry = await createEntry(db, {
content: input.values[slot],
entry_type: 'value',
owner_id: input.owner_id ?? null,
});
await db
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
.run();
}
return { record_id: recordId, template_id: tpl.id, values: input.values };
}
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
const res = await db
.prepare(
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id = ?`,
)
.bind(recordId)
.all<{ slot: string; content: string; template_id: string }>();
const rows = res.results ?? [];
if (rows.length === 0) return null;
const values: Record<string, string> = {};
for (const r of rows) values[r.slot] = r.content;
return { record_id: recordId, template_id: rows[0].template_id, values };
}
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
const tpl = await getTemplate(db, template);
if (!tpl) return [];
const res = await db
.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`)
.bind(tpl.id, Math.min(limit, 500))
.all<{ record_id: string }>();
const out: RecordResult[] = [];
for (const { record_id } of res.results ?? []) {
const rec = await getRecord(db, record_id);
if (rec && (!owner_id || true)) out.push(rec);
}
return out;
}
+23
View File
@@ -0,0 +1,23 @@
// KBDB Base — atomic universal table worker (arcrun self-hosted data layer + official core).
// SDD: .agents/specs/arcrun/kbdb-base/design.md
//
// Base = D1 only (free, no credit card): entries / templates / records + LIKE search + recipe-stats.
// Optional modules (NOT in this base): embed (Vectorize+AI binding, semantic search), triplet (separate repo).
import { Hono } from 'hono';
import type { Bindings } from './types';
import { entryRoutes } from './routes/entries';
import { templateRoutes } from './routes/templates';
import { recordRoutes } from './routes/records';
import { recipeStatRoutes } from './routes/recipe-stats';
const app = new Hono<{ Bindings: Bindings }>();
app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' }));
app.get('/health', (c) => c.json({ ok: true }));
app.route('/entries', entryRoutes);
app.route('/templates', templateRoutes);
app.route('/records', recordRoutes);
app.route('/recipe-stats', recipeStatRoutes);
export default app;
+63
View File
@@ -0,0 +1,63 @@
// Entries route — atomic data + tree (project/workflow). Base, no embed/triplet.
import { Hono } from 'hono';
import type { Bindings } from '../types';
import {
createEntry,
getEntry,
listEntries,
updateEntry,
deleteEntry,
searchEntries,
} from '../actions/entry-crud';
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
// POST /entries — create (entry_type=block/value/project/workflow/...)
entryRoutes.post('/', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
const entry = await createEntry(c.env.DB, body);
return c.json({ success: true, entry });
});
// GET /entries — list with filters (entry_type, owner_id, parent_id)
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
entryRoutes.get('/', async (c) => {
const entries = await listEntries(c.env.DB, {
entry_type: c.req.query('entry_type') || undefined,
owner_id: c.req.query('owner_id') || undefined,
parent_id: c.req.query('parent_id') || undefined,
limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
offset: c.req.query('offset') ? Number(c.req.query('offset')) : undefined,
});
return c.json({ success: true, entries, count: entries.length });
});
// GET /entries/search?q=...&owner_id=... — D1 LIKE keyword search (base)
entryRoutes.get('/search', async (c) => {
const q = c.req.query('q');
if (!q) return c.json({ success: false, error: 'q required' }, 400);
const entries = await searchEntries(c.env.DB, q, c.req.query('owner_id') || undefined);
return c.json({ success: true, entries, count: entries.length, mode: 'keyword' });
});
// GET /entries/:id
entryRoutes.get('/:id', async (c) => {
const entry = await getEntry(c.env.DB, c.req.param('id'));
if (!entry) return c.json({ success: false, error: 'not found' }, 404);
return c.json({ success: true, entry });
});
// PATCH /entries/:id
entryRoutes.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => ({}));
const entry = await updateEntry(c.env.DB, c.req.param('id'), body);
if (!entry) return c.json({ success: false, error: 'not found' }, 404);
return c.json({ success: true, entry });
});
// DELETE /entries/:id
entryRoutes.delete('/:id', async (c) => {
await deleteEntry(c.env.DB, c.req.param('id'));
return c.json({ success: true });
});
+25
View File
@@ -0,0 +1,25 @@
// Recipe stats route (SDD section 7.1) — success/failure counters per recipe.
// cypher-executor calls POST /recipe-stats/record after each recipe HTTP call;
// submission reads GET /recipe-stats/:canonical_id as the "proof" for no-verify submit.
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { recordRecipeResult, getRecipeStat } from '../actions/recipe-stat';
export const recipeStatRoutes = new Hono<{ Bindings: Bindings }>();
// POST /recipe-stats/record — { canonical_id, ok, at } (at = epoch ms, passed in by caller)
recipeStatRoutes.post('/record', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.canonical_id || typeof body.ok !== 'boolean') {
return c.json({ success: false, error: 'canonical_id and ok(boolean) required' }, 400);
}
const at = typeof body.at === 'number' ? body.at : 0;
const stat = await recordRecipeResult(c.env.DB, body.canonical_id, body.ok, at);
return c.json({ success: true, stat });
});
// GET /recipe-stats/:canonical_id
recipeStatRoutes.get('/:canonical_id', async (c) => {
const stat = await getRecipeStat(c.env.DB, c.req.param('canonical_id'));
return c.json({ success: true, stat });
});
+33
View File
@@ -0,0 +1,33 @@
// Records route — structured records (entry_values composed by a template).
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { createRecord, getRecord, searchByTemplate } from '../actions/record-crud';
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
// POST /records — { template, values:{slot:content}, owner_id? }
recordRoutes.post('/', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.template || !body.values) {
return c.json({ success: false, error: 'template and values required' }, 400);
}
try {
const rec = await createRecord(c.env.DB, body);
return c.json({ success: true, record: rec });
} catch (e) {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
// GET /records/by-template/:template — list records of a template
recordRoutes.get('/by-template/:template', async (c) => {
const records = await searchByTemplate(c.env.DB, c.req.param('template'), c.req.query('owner_id') || undefined);
return c.json({ success: true, records, count: records.length });
});
// GET /records/:recordId
recordRoutes.get('/:recordId', async (c) => {
const rec = await getRecord(c.env.DB, c.req.param('recordId'));
if (!rec) return c.json({ success: false, error: 'not found' }, 404);
return c.json({ success: true, record: rec });
});
+33
View File
@@ -0,0 +1,33 @@
// Templates + Records route. Template = virtual table def; record = composed entries.
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { createTemplate, getTemplate, listTemplates, updateTemplate } from '../actions/record-crud';
export const templateRoutes = new Hono<{ Bindings: Bindings }>();
templateRoutes.post('/', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.name || !Array.isArray(body.slots)) {
return c.json({ success: false, error: 'name and slots[] required' }, 400);
}
const tpl = await createTemplate(c.env.DB, body);
return c.json({ success: true, template: tpl });
});
templateRoutes.get('/', async (c) => {
const templates = await listTemplates(c.env.DB);
return c.json({ success: true, templates, count: templates.length });
});
templateRoutes.get('/:idOrName', async (c) => {
const tpl = await getTemplate(c.env.DB, c.req.param('idOrName'));
if (!tpl) return c.json({ success: false, error: 'not found' }, 404);
return c.json({ success: true, template: tpl });
});
templateRoutes.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => ({}));
const tpl = await updateTemplate(c.env.DB, c.req.param('id'), body);
if (!tpl) return c.json({ success: false, error: 'not found' }, 404);
return c.json({ success: true, template: tpl });
});
+53
View File
@@ -0,0 +1,53 @@
// KBDB Base types. Base depends on D1 only.
// Optional modules add their own bindings (embed: VECTORIZE+AI). Base never references them.
export type Bindings = {
DB: D1Database;
ENVIRONMENT: string;
};
export type EntryType =
| 'block'
| 'value'
| 'template'
| 'slot'
| 'project'
| 'workflow'
| 'recipe_stat';
export interface Entry {
id: string;
content: string | null;
entry_type: EntryType | string;
owner_id: string | null;
parent_id: string | null;
page_name: string | null;
refs_json: string;
tags_json: string;
task_status: string | null;
content_hash: string | null;
is_embedded: number;
confidence: number | null;
metadata_json: string | null;
created_at: number;
updated_at: number;
}
export interface Template {
id: string;
name: string;
description: string | null;
slots_json: string;
created_by: string | null;
created_at: number;
updated_at: number;
}
export interface EntryValue {
id: string;
record_id: string;
template_id: string;
slot_name: string;
entry_id: string;
created_at: number;
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types/2023-07-01"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
name = "arcrun-kbdb"
main = "src/index.ts"
compatibility_date = "2025-02-19"
workers_dev = true
compatibility_flags = ["nodejs_compat"]
# KBDB Base — atomic universal table (SDD .agents/specs/arcrun/kbdb-base).
# Base needs D1 ONLY (free, no credit card). embed module adds Vectorize+AI bindings
# (optional, self-host opens it themselves). triplet is a separate repo.
[[d1_databases]]
binding = "DB"
database_name = "arcrun-kbdb"
database_id = "placeholder-replaced-by-init"
[vars]
ENVIRONMENT = "production"
+233
View File
@@ -0,0 +1,233 @@
/**
* Recipe toolskbdb-base §7.5.i)— MCP 薄殼補齊 recipe 能力。
*
* rule 07 §5CLI + MCP 覆蓋同一組 API 能力,MCP 不可長期落後。
* CLI 已有 recipe push/list/delete/search/pull/submit-pcli/src/commands/recipe.ts);
* 此檔把同六能力暴露為 MCP 工具,**薄殼**:只 cypherFetch + 格式化,無業務邏輯。
*
* 私庫操作(push/list/delete/pull-install)→ cypherFetch 打用戶 cypher= 私庫)。
* 公庫操作(search/pull-fetch/submit-p)→ 同樣經 cypherMCP 連平台 cypher = 公庫;
* self-hosted account-source 是 §5.2 已知違反,pre-existing,本檔沿用既有 cypherFetch 模式)。
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Env } from "../types.js";
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
const apiKeyDesc = "你(用戶)的 arcrun api_key (ak_xxx)。從 https://arcrun.dev/me 取得";
/** 註冊全部 recipe 工具(kbdb-base §7.5.i,與 CLI 六能力對齊)。 */
export function registerAllRecipeTools(server: McpServer, env: Env) {
registerRecipeSearch(server, env);
registerRecipePull(server, env);
registerRecipeSubmitP(server, env);
registerRecipePush(server, env);
registerRecipeList(server, env);
registerRecipeDelete(server, env);
}
/** arcrun_recipe_search — 搜尋公庫 recipe(同名可多作者,附市場數據)。落空回創作引導。 */
export function registerRecipeSearch(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_search",
"搜尋公庫 recipeAPI 整合配方)。同 canonical_id 可有多作者版本,各附市場數據(成功/失敗次數),依數據選最佳。找不到時會提示可自己做一個 recipe 投稿成為作者。",
{
api_key: z.string().describe(apiKeyDesc),
query: z.string().describe("搜尋詞,如「gsheets append」「telegram send」"),
},
async ({ api_key, query }) => {
try {
const res = await cypherFetch(env, "/public-recipes", {
apiKey: api_key,
query: { q: query },
});
if (!res.ok) return errorResponse("search_failed", `搜尋公庫失敗`, ["稍後再試"], await res.text());
const data = await res.json();
return successResponse(data, [
"found:false → 公庫沒有,可自己做:建 recipe → arcrun_recipe_push(私庫)→ arcrun_recipe_submit_p(投稿)",
"多作者版本依 market_stat 選成功率最高的 → arcrun_recipe_pull",
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_pull — 從公庫取一份 recipe 寫進自己私庫。 */
export function registerRecipePull(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_pull",
"從公庫取一份 recipe 寫進自己私庫(按需取用,非全量同步)。不指定 author 取市場最佳版本。取回後可在 workflow 用 component: <canonical_id>。",
{
api_key: z.string().describe(apiKeyDesc),
canonical_id: z.string().describe("要取的 recipe canonical_id,如 gsheets_append"),
author: z.string().optional().describe("指定作者版本(不指定取市場最佳)"),
},
async ({ api_key, canonical_id, author }) => {
try {
// 1. 公庫取全文
const pubRes = await cypherFetch(env, `/public-recipes/${encodeURIComponent(canonical_id)}`, {
apiKey: api_key,
query: author ? { author } : undefined,
});
if (!pubRes.ok) return errorResponse("pull_failed", `公庫取 recipe 失敗`, [], await pubRes.text());
const pub = await pubRes.json() as
| { found: true; recipe: Record<string, unknown> & { uuid?: string } }
| { found: false; canonical_id: string; hint: string };
if (!pub.found) {
return successResponse(pub, [
"公庫沒有此 recipe。可自己做:arcrun_recipe_push(私庫)→ arcrun_recipe_submit_p(投稿成為作者)",
]);
}
// 2. 寫進私庫(帶 derived_from 溯源 + pull 級暴露同意)
const installRes = await cypherFetch(env, "/recipes", {
apiKey: api_key,
method: "POST",
body: {
...pub.recipe,
derived_from: pub.recipe.uuid,
exposure_consent: {
confirmed_by_human: true,
understood: `pull from public library: ${canonical_id}`,
confirmed_at: new Date().toISOString(),
},
},
});
if (!installRes.ok) return errorResponse("install_failed", `寫入私庫失敗`, [], await installRes.text());
const inst = await installRes.json();
return successResponse(inst, [`已拉進私庫,workflow 可用 component: ${canonical_id}`]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_submit_p — 把私庫某 recipe 投稿到公庫(新增作者版本)。 */
export function registerRecipeSubmitP(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_submit_p",
"把私庫某 recipe 投稿到公庫(app-store 模型:新增一個作者版本,不覆蓋別人的)。投稿 = 把 recipe 暴露給全網,需帶 exposure_consent 明示同意。別人能搜到並 pull,市場數據決定它被不被選用。",
{
api_key: z.string().describe(apiKeyDesc),
canonical_id: z.string().describe("要投稿的私庫 recipe canonical_id"),
author: z.string().optional().describe("署名作者(預設用 recipe 既有 author"),
exposure_consent: z.boolean().describe(
"明示同意把此 recipe 暴露給公庫全網(投稿是暴露面,需人類同意)",
),
},
async ({ api_key, canonical_id, author, exposure_consent }) => {
try {
if (!exposure_consent) {
return errorResponse("consent_required", "投稿到公庫是暴露面,需 exposure_consent=true 明示同意", [
"確認要把 recipe 公開給全網後,帶 exposure_consent: true 再呼叫",
]);
}
// 1. 私庫取全文
const myRes = await cypherFetch(env, `/recipes/${encodeURIComponent(canonical_id)}`, { apiKey: api_key });
if (!myRes.ok) return errorResponse("not_found", `私庫找不到 recipe「${canonical_id}`, ["先 arcrun_recipe_push 或 arcrun_recipe_pull"], await myRes.text());
const my = await myRes.json() as { success: boolean; recipe?: Record<string, unknown> & { uuid?: string; author?: string; derived_from?: string } };
if (!my.success || !my.recipe) return errorResponse("not_found", `私庫無此 recipe`, []);
// 2. 投稿公庫(新增作者版本)
const consent = {
confirmed_by_human: true,
understood: `submit recipe to public library: ${canonical_id}`,
confirmed_at: new Date().toISOString(),
};
const subRes = await cypherFetch(env, "/recipes/submit", {
apiKey: api_key,
method: "POST",
body: {
...my.recipe,
author: author ?? my.recipe.author,
derived_from: my.recipe.derived_from ?? my.recipe.uuid,
submitter: author ?? api_key,
exposure_consent: consent,
},
});
if (!subRes.ok) return errorResponse("submit_failed", `投稿公庫失敗`, [], await subRes.text());
const data = await subRes.json();
return successResponse(data, ["已投稿公庫(新增作者版本)。市場數據累積後決定被不被選用"]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_push — 上傳/更新私庫 recipe(就地更新自己的版本)。 */
export function registerRecipePush(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_push",
"上傳一份 recipe 到自己私庫(或就地更新自己既有版本)。recipe = 「http_request + 參數模板」的具名封裝,不需 deploy Worker。要投稿到公庫用 arcrun_recipe_submit_p。",
{
api_key: z.string().describe(apiKeyDesc),
recipe: z.object({
canonical_id: z.string(),
display_name: z.string().optional(),
description: z.string().optional(),
endpoint: z.string(),
method: z.string().optional(),
headers: z.record(z.string()).optional(),
body: z.record(z.unknown()).optional(),
auth_service: z.string().optional(),
author: z.string().optional(),
}).describe("recipe 定義(canonical_id + endpoint 必填)"),
},
async ({ api_key, recipe }) => {
try {
const res = await cypherFetch(env, "/recipes", { apiKey: api_key, method: "POST", body: recipe });
if (!res.ok) return errorResponse("push_failed", `上傳 recipe 失敗`, [], await res.text());
const data = await res.json();
return successResponse(data, [`workflow 用 component: ${recipe.canonical_id}`, "要公開給全網 → arcrun_recipe_submit_p"]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_list — 列出自己私庫的 recipe。 */
export function registerRecipeList(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_list",
"列出自己私庫(本部署)的 recipe。要找公庫的用 arcrun_recipe_search。",
{
api_key: z.string().describe(apiKeyDesc),
},
async ({ api_key }) => {
try {
const res = await cypherFetch(env, "/recipes", { apiKey: api_key });
if (!res.ok) return errorResponse("list_failed", `列出 recipe 失敗`, [], await res.text());
const data = await res.json();
return successResponse(data);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_delete — 刪除私庫某 recipe。 */
export function registerRecipeDelete(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_delete",
"刪除自己私庫某 recipecanonical_id / rec_hash / uuid)。不影響公庫別人的版本。",
{
api_key: z.string().describe(apiKeyDesc),
id: z.string().describe("canonical_id 或 rec_hash 或 uuid"),
},
async ({ api_key, id }) => {
try {
const res = await cypherFetch(env, `/recipes/${encodeURIComponent(id)}`, { apiKey: api_key, method: "DELETE" });
if (!res.ok) return errorResponse("delete_failed", `刪除 recipe 失敗`, [], await res.text());
const data = await res.json();
return successResponse(data);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
+3
View File
@@ -19,6 +19,7 @@ import { registerReportFeedback } from "./arcrun_report_feedback.js";
import { registerAllIntrospectionTools } from "./arcrun_introspection.js"; import { registerAllIntrospectionTools } from "./arcrun_introspection.js";
import { registerAllWorkflowCrudTools } from "./arcrun_workflow_crud.js"; import { registerAllWorkflowCrudTools } from "./arcrun_workflow_crud.js";
import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js"; import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js";
import { registerAllRecipeTools } from "./arcrun_recipe.js";
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) { export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
registerSearchComponents(server, env, orgNamespace); registerSearchComponents(server, env, orgNamespace);
@@ -46,4 +47,6 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
// LI SDD M3.2: skills + examples lookupKBDB-backed // LI SDD M3.2: skills + examples lookupKBDB-backed
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB // 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
registerAllSkillExampleTools(server, env); registerAllSkillExampleTools(server, env);
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
registerAllRecipeTools(server, env);
} }