feat(kbdb): recipe 公庫/私庫雙向機制 + UUID 身份 + KBDB Base + 市場數據

kbdb-base SDD §7.5(公庫/私庫雙向機制,richblack 2026-06-07 拍板)。

## KBDB Base worker(新)
- kbdb/:D1-only 核心三表(entries/templates/entry_values)+ CRUD + LIKE search
  + recipe-stats 端點(市場數據)+ 0001_base.sql migration(含 recipe_stat seed)

## Phase 2.3:init 建 D1 + 套 migration
- cli cf-api.ts 加 listD1Databases/ensureD1Database;init 建 arcrun-kbdb D1
- deploy.ts 部署後對 D1 套 0001_base.sql(CF /d1/query API,idempotent)+ 注入 database_id

## Phase 5.1:recipe 成功記錄(市場數據來源)
- GraphExecutor 收集本次用到的 recipe uuid(usedRecipeKeys)
- executeWebhookGraph 執行結束一次性記 per-uuid 成功/失敗到 KBDB(fire-and-forget)

## Phase 7.5:recipe UUID 身份 + app-store 模型
- recipe 領 uuid=唯一身份;canonical_id/author/公私=屬性(§7.5.5)
- recipe:{uuid} + idx:canonical/installed/hash;resolveRecipe 向後相容不破執行鏈
- POST /recipes/submit=領新 uuid 新增作者版本(非覆蓋,app-store)
- GET /public-recipes 搜尋(多作者+per-uuid 市場星數)/ :id pull(選市場最佳)
- 落空→found:false 創作引導(§7.5.6 閉環)
- POST /recipes/migrate-uuid 一次性轉舊 key(增量寫不刪舊、冪等)
- init-seed 用 UUID(author=system)

## 薄殼(rule 07 §5:CLI + MCP 覆蓋同組能力)
- CLI: acr recipe search/pull/submit-p(config 加 DEFAULT_PUBLIC_LIBRARY_URL)
- MCP: arcrun_recipe_search/pull/submit_p/push/list/delete(補齊漂移)

## 壓測修正
- api-recipe-seeds: google_sheets_append PUT→POST(:append 正確動詞,階段12)

四 worker tsc 全綠(cypher/cli/kbdb/mcp)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-07 16:18:10 +08:00
parent 95a1462b65
commit 6a75117ba3
28 changed files with 3438 additions and 37 deletions
@@ -5,6 +5,41 @@ import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
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 = {
graph: Record<string, unknown>;
description: string;
@@ -58,6 +93,9 @@ export async function executeWebhookGraph(
agent_user_agent: userAgent,
}, ctx);
// kbdb-base §7.1:整體成功 → 用到的 recipe 各記成功一次。
recordRecipeStats(env, executor.usedRecipeKeys, true, Date.now(), ctx);
return { success: true, data: result.data, duration_ms };
} catch (err) {
const duration_ms = Date.now() - start;
@@ -73,6 +111,12 @@ export async function executeWebhookGraph(
agent_user_agent: userAgent,
}, ctx);
// kbdb-base §7.1:真錯(非 paused)→ 用到的 recipe 各記失敗一次。
// paused 是「執行中暫停等 callback」非失敗,不記(resume 後成功才會在那條路徑記成功)。
if (!isPaused) {
recordRecipeStats(env, executor.usedRecipeKeys, false, Date.now(), ctx);
}
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
+19
View File
@@ -4,6 +4,7 @@ import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from
import { injectCredentials } from './actions/credential-injector';
import { tryAuthDispatch } from './actions/auth-dispatcher';
import { expandPromptRecipe } from './lib/recipe-expander';
import { resolveRecipe } from './routes/recipes';
import { persistPausedRun, isResumablePending, parseRecipeOutput } from './lib/paused-runs';
import { buildMagicVars } from './lib/magic-vars';
import { recordTelemetry } from './lib/telemetry';
@@ -21,6 +22,11 @@ export class GraphExecutor {
private apiKey?: string;
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
// 暫停時持久化 state 用,需在 execute 進入時設定
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;
result = await runner(mergedContext);
+5 -2
View File
@@ -86,9 +86,12 @@ export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
{
canonical_id: '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}}',
method: 'PUT',
method: 'POST',
auth_service: 'google_sheets_sa',
},
{
+6 -5
View File
@@ -20,6 +20,7 @@ import { Hono } from 'hono';
import type { Bindings } from '../types';
import { deriveRecipeHash } from '../lib/hash';
import type { RecipeDefinition, AuthRecipeDefinition } from './recipes';
import { installRecipeRecord, resolveRecipe } from './recipes';
import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds';
import { AUTH_RECIPE_SEEDS } from '../lib/auth-recipe-seeds';
@@ -41,8 +42,11 @@ initSeedRouter.post('/init/seed', async (c) => {
try {
const canonicalId = seed.canonical_id.trim().toLowerCase();
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 = {
uuid: existing?.uuid ?? crypto.randomUUID(),
author: existing?.author ?? 'system',
canonical_id: canonicalId,
hash_id: hashId,
display_name: seed.display_name,
@@ -54,10 +58,7 @@ initSeedRouter.post('/init/seed', async (c) => {
created_at: existing?.created_at ?? now,
updated_at: now,
};
await Promise.all([
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
]);
await installRecipeRecord(c.env.RECIPES, recipe);
apiOk++;
} catch (e) {
apiFail++;
+327 -27
View File
@@ -22,6 +22,12 @@ import type { ExposureConsent } from '../lib/exposure-consent';
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
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;
hash_id: string; // rec_xxxxxxxx
display_name?: string;
@@ -47,6 +53,36 @@ export interface RecipeDefinition {
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
recipesRouter.post('/recipes', async (c) => {
let body: Partial<RecipeDefinition>;
@@ -63,8 +99,10 @@ recipesRouter.post('/recipes', async (c) => {
const hashId = await deriveRecipeHash(canonicalId);
const now = Date.now();
// 讀取現有版本(保留 created_at + 既有同意憑證)
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
// 私庫(POST /recipes= 自己地盤,同 canonical 就地更新自己安裝的那份(沿用既有 uuid)。
// 既有 installed → 沿用其 uuid + created_at;無 → 新領 uuid(首次裝這個 canonical)。
// 讀取順序:先 UUID 模型(installed→uuid),fallback 舊 keymigration 前的種子)。
const existing = await resolveRecipe(canonicalId, c.env.RECIPES);
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
@@ -73,6 +111,9 @@ recipesRouter.post('/recipes', async (c) => {
}
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,
hash_id: hashId,
display_name: body.display_name,
@@ -88,16 +129,124 @@ recipesRouter.post('/recipes', async (c) => {
updated_at: now,
};
// 寫入兩個 KV key
await Promise.all([
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
]);
await installRecipeRecord(c.env.RECIPES, 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) => {
const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES);
@@ -105,42 +254,193 @@ recipesRouter.get('/recipes/:id', async (c) => {
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) => {
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 });
const all = (await Promise.all(
list.keys.map(k => c.env.RECIPES.get(k.name, 'json') as Promise<RecipeDefinition | null>)
)).filter(Boolean) as RecipeDefinition[];
// 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) => {
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}`),
const canonicalId = recipe.canonical_id;
const ops: Promise<unknown>[] = [
c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
]);
return c.json({ success: true, deleted: recipe.canonical_id });
c.env.RECIPES.delete(`recipe:${canonicalId}`), // 舊 key(若存在)
];
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(
id: string,
kv: KVNamespace,
): 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_')) {
const canonicalId = await kv.get(`idx:${id}`);
if (!canonicalId) return null;
return kv.get(`recipe:${canonicalId}`, 'json');
const looked = await kv.get(`idx:${id}`);
if (!looked) return direct; // hash 查不到,回 step1 結果(通常 null
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 ────────────────────────────────────────────────────────────────
+3
View File
@@ -49,6 +49,9 @@ export type Bindings = {
SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID)
// KBDB 整合
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
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain