Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a75117ba3 | |||
| 95a1462b65 | |||
| 62f1d1d390 |
@@ -201,6 +201,17 @@ async function initSelfHosted(
|
||||
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 subdomain(cypher-executor WORKER_SUBDOMAIN 用)
|
||||
let workerSubdomain = '';
|
||||
try {
|
||||
@@ -212,7 +223,7 @@ async function initSelfHosted(
|
||||
|
||||
// 4. 下載 repo 部署物(含預編譯 wasm)+ 注入 KV id + wrangler deploy 全部 Worker
|
||||
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 cypherUrl = deploy.cypherExecutorUrl
|
||||
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
|
||||
|
||||
+152
-1
@@ -6,7 +6,7 @@
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
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 yaml from 'js-yaml';
|
||||
|
||||
@@ -22,6 +22,9 @@ interface RecipeYaml {
|
||||
}
|
||||
|
||||
interface RecipeDefinition {
|
||||
uuid?: string; // UUID 身份模型(kbdb-base §7.5.5)
|
||||
author?: string;
|
||||
derived_from?: string;
|
||||
canonical_id: string;
|
||||
hash_id: string;
|
||||
display_name?: string;
|
||||
@@ -237,3 +240,151 @@ export async function cmdRecipeDelete(id: string): Promise<void> {
|
||||
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
@@ -17,7 +17,7 @@ import { cmdPush } from './commands/push.js';
|
||||
import { cmdRun } from './commands/run.js';
|
||||
import { cmdValidate } from './commands/validate.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 { cmdLogs } from './commands/logs.js';
|
||||
import { cmdUpdate } from './commands/update.js';
|
||||
@@ -121,6 +121,21 @@ recipeCmd
|
||||
.command('delete <id>')
|
||||
.description('刪除 recipe(canonical_id 或 rec_hash)')
|
||||
.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
|
||||
const authRecipeCmd = program.command('auth-recipe').description('第三方服務認證 Recipe(新增服務整合)');
|
||||
|
||||
@@ -144,6 +144,25 @@ export class CfAccountClient {
|
||||
const result = await this.cf<{ subdomain: string }>('/workers/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 解密邏輯對應)*/
|
||||
|
||||
@@ -67,6 +67,15 @@ const ENV_MAP: Record<string, keyof ArcrunConfig> = {
|
||||
*/
|
||||
export const DEFAULT_MCP_URL = 'https://mcp.arcrun.dev';
|
||||
|
||||
/**
|
||||
* 公庫 URL(recipe 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 {
|
||||
return existsSync(CONFIG_PATH) || findProjectConfig() !== undefined;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface DeployContext {
|
||||
apiToken: string;
|
||||
workerSubdomain: string;
|
||||
kvNamespaceIds: Record<string, string>; // title → id
|
||||
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
|
||||
}
|
||||
|
||||
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)。
|
||||
// 建 D1(cf-api ensureD1Database)只產生空資料庫,schema 要靠這步套。
|
||||
// migration 檔來自同一份 tarball(root/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
|
||||
? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev`
|
||||
: 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 + accountId;query 端點接受多語句檔,一次送整份 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 路徑。*/
|
||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
||||
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);
|
||||
|
||||
writeFileSync(tomlPath, toml, 'utf8');
|
||||
|
||||
@@ -20,23 +20,10 @@
|
||||
import type { Bindings } from '../types';
|
||||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
import type { ServiceBinding } from '../types';
|
||||
|
||||
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
|
||||
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
|
||||
|
||||
/**
|
||||
* primitive 名 → service binding key(Phase 7,2026-06-06)。
|
||||
* 比照 component-loader 的邏輯零件:有 binding 走 CF 內部 RPC(繞開同 zone 522 + 同帳號 workers.dev 1042),
|
||||
* 無 binding(如 self-hosted 未綁、或 mtls 未部署)fallback 到 fetch(workers.dev)。
|
||||
*/
|
||||
const AUTH_BINDING_MAP: Record<string, keyof import('../types').Bindings> = {
|
||||
static_key: 'SVC_AUTH_STATIC_KEY',
|
||||
service_account: 'SVC_AUTH_SERVICE_ACCOUNT',
|
||||
oauth2: 'SVC_AUTH_OAUTH2',
|
||||
mtls: 'SVC_AUTH_MTLS',
|
||||
};
|
||||
|
||||
/** auth primitive 本身的 componentId(避免自引用) */
|
||||
const AUTH_PRIMITIVE_IDS = new Set([
|
||||
'auth_static_key',
|
||||
@@ -75,27 +62,18 @@ export async function tryAuthDispatch(
|
||||
if (!recipe) return null;
|
||||
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
|
||||
|
||||
// 呼叫對應 auth primitive Worker(Phase 7,2026-06-06):
|
||||
// binding 優先(CF 內部 RPC,繞開同 zone 522 + 同帳號 workers.dev 子請求 1042,壓測階段 11),
|
||||
// 無 binding(self-hosted 未綁 / mtls 未部署)fallback 到 fetch(workers.dev)。比照 component-loader makeLogicRunner。
|
||||
const reqInit = {
|
||||
// 走新路徑:HTTP POST 到對應 auth primitive Worker
|
||||
// 走 workers.dev 避開同 zone 死鎖(P0 #9)
|
||||
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(primitiveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'authenticate', api_key: apiKey, service }),
|
||||
};
|
||||
|
||||
const bindingKey = AUTH_BINDING_MAP[recipe.primitive];
|
||||
const svc = bindingKey ? (env[bindingKey] as ServiceBinding | undefined) : undefined;
|
||||
|
||||
let res: Response;
|
||||
if (svc) {
|
||||
// service binding:用任意 URL,CF 內部 RPC 直送目標 Worker(不經公網)
|
||||
res = await svc.fetch(new Request('https://auth-primitive/', reqInit));
|
||||
} else {
|
||||
// fallback:公網 workers.dev(自架未綁 binding / 開發環境 / mtls)
|
||||
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
|
||||
res = await fetch(primitiveUrl, reqInit);
|
||||
}
|
||||
body: JSON.stringify({
|
||||
action: 'authenticate',
|
||||
api_key: apiKey,
|
||||
service,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
|
||||
@@ -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 key(uuid),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,
|
||||
|
||||
@@ -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 workflow(SDD: 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);
|
||||
|
||||
|
||||
@@ -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=RAW,body 帶 values。auth: google service_account。',
|
||||
// 壓測階段 12 修正:append 官方 API 是 POST .../values/{range}:append(PUT 是 values.update 覆寫的動詞),
|
||||
// 種子寫死 PUT 導致每個 self-host 用戶 seed 到壞 recipe(PUT :append → Google 400)。
|
||||
// body 形狀屬工作流,泛用種子不寫死欄位 → 由工作流的 _path + body 處理(body_from 機制待 §13.4 補)。
|
||||
description: '追加一列到 Sheets。POST .../values/{range}:append?valueInputOption=RAW,body 帶 {values:[[...]]}。auth: google service_account。',
|
||||
endpoint: 'https://sheets.googleapis.com{{_path}}',
|
||||
method: 'PUT',
|
||||
method: 'POST',
|
||||
auth_service: 'google_sheets_sa',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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 自哪個 uuid(Leo 改 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 key(kbdb-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}`;
|
||||
|
||||
/**
|
||||
* 寫一份 recipe(UUID 身份模型):給定 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 舊 key(migration 前的種子)。
|
||||
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_consent(mindset §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-uuid(5.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 是 uuid(recipe:{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. 直接 uuid(pull / 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,12 +21,6 @@ export type Bindings = {
|
||||
SVC_DATE_OPS: ServiceBinding;
|
||||
SVC_VALIDATE_JSON: ServiceBinding;
|
||||
// SVC_AI_TRANSFORM_* 已移除(Phase 2 刪 ai_transform 零件 + wrangler.toml service binding)
|
||||
// Auth primitive Service Bindings(Phase 7,2026-06-06):繞開 self-hosted 同帳號 workers.dev 子請求 1042。
|
||||
// optional:auth_mtls 尚未部署(無 binding);無 binding 時 auth-dispatcher fallback 到 fetch(workers.dev)。
|
||||
SVC_AUTH_STATIC_KEY?: ServiceBinding;
|
||||
SVC_AUTH_SERVICE_ACCOUNT?: ServiceBinding;
|
||||
SVC_AUTH_OAUTH2?: ServiceBinding;
|
||||
SVC_AUTH_MTLS?: ServiceBinding;
|
||||
// KV Context Store:節點 output 透過 KV 傳遞,解決同名欄位衝突
|
||||
EXEC_CONTEXT: KVNamespace;
|
||||
// Recipe Store:API recipe 定義(key: recipe:{canonical_id} 或 idx:{hash_id})
|
||||
@@ -55,6 +49,9 @@ export type Bindings = {
|
||||
SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID)
|
||||
// KBDB 整合
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
// KBDB Base worker URL(recipe 成功記錄 /recipe-stats/record、fragment 抓取)。
|
||||
// 未設 fallback 見各使用點(recipe-expander 預設 kbdb.finally.click)。kbdb-base SDD §7.1。
|
||||
KBDB_BASE_URL?: string;
|
||||
// Component Worker subdomain(workers.dev 帳號 subdomain)
|
||||
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
|
||||
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
|
||||
|
||||
@@ -2,7 +2,14 @@ name = "arcrun-cypher-executor"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
workers_dev = true
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
# global_fetch_strictly_public:讓 fetch() 走公網「前門」,解 self-hosted 的 same-zone 1042
|
||||
# (credential-primitives-wasm Phase 7,2026-06-06)。
|
||||
# 病因(官方 docs):self-hosted 的 cypher 與 auth worker 同在 {sub}.workers.dev zone,
|
||||
# cypher fetch auth 屬 same-zone fetch,CF 預設擋(1042)。官方 cypher 在 cypher.arcrun.dev、
|
||||
# 打 auth 的 *.workers.dev 屬跨 zone 故不踩。此 flag 讓 same-zone fetch 改走公網前門 → 同 zone 也通。
|
||||
# 安全(已查證官方 docs):唯一副作用是「Worker fetch 自己 hostname 會 self-loop」,
|
||||
# 但 cypher 只打外部 API + sibling auth worker(皆非自己 hostname)→ 不 self-loop。
|
||||
compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "EXEC_CONTEXT"
|
||||
@@ -91,24 +98,6 @@ service = "arcrun-date-ops"
|
||||
binding = "SVC_VALIDATE_JSON"
|
||||
service = "arcrun-validate-json"
|
||||
|
||||
# Auth primitive service bindings(credential-primitives-wasm Phase 7,2026-06-06)
|
||||
# 為何:auth-dispatcher 原用 fetch(workers.dev) 打同帳號 auth worker,self-hosted 帳號踩 CF 1042
|
||||
# (壓測階段 11)。service binding 是 CF 內部 RPC,繞開同 zone 522 + 同帳號 1042。
|
||||
# 範圍:只綁「已部署」的 auth worker。auth_mtls 尚未部署(.component-builds 無、官方 404),
|
||||
# 綁不存在的 worker 會讓 deploy 報 "referenced Worker not found"(見上 ai_transform 教訓),
|
||||
# 故 mtls 待它部署後再加。auth-dispatcher 對無 binding 的 primitive 自動 fallback fetch。
|
||||
[[services]]
|
||||
binding = "SVC_AUTH_STATIC_KEY"
|
||||
service = "arcrun-auth-static-key"
|
||||
|
||||
[[services]]
|
||||
binding = "SVC_AUTH_SERVICE_ACCOUNT"
|
||||
service = "arcrun-auth-service-account"
|
||||
|
||||
[[services]]
|
||||
binding = "SVC_AUTH_OAUTH2"
|
||||
service = "arcrun-auth-oauth2"
|
||||
|
||||
# ai_transform_compile / ai_transform_run 已於 Phase 2(2026-05-29)刪除
|
||||
# (Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點)。對應 worker 已 wrangler delete,
|
||||
# service binding 一併移除(否則 deploy 報 referenced Worker not found)。
|
||||
|
||||
@@ -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');
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1870
File diff suppressed because it is too large
Load Diff
@@ -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 ?? [];
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Recipe tools(kbdb-base §7.5.i)— MCP 薄殼補齊 recipe 能力。
|
||||
*
|
||||
* rule 07 §5:CLI + MCP 覆蓋同一組 API 能力,MCP 不可長期落後。
|
||||
* CLI 已有 recipe push/list/delete/search/pull/submit-p(cli/src/commands/recipe.ts);
|
||||
* 此檔把同六能力暴露為 MCP 工具,**薄殼**:只 cypherFetch + 格式化,無業務邏輯。
|
||||
*
|
||||
* 私庫操作(push/list/delete/pull-install)→ cypherFetch 打用戶 cypher(= 私庫)。
|
||||
* 公庫操作(search/pull-fetch/submit-p)→ 同樣經 cypher(MCP 連平台 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",
|
||||
"搜尋公庫 recipe(API 整合配方)。同 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",
|
||||
"刪除自己私庫某 recipe(canonical_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), []);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { registerReportFeedback } from "./arcrun_report_feedback.js";
|
||||
import { registerAllIntrospectionTools } from "./arcrun_introspection.js";
|
||||
import { registerAllWorkflowCrudTools } from "./arcrun_workflow_crud.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) {
|
||||
registerSearchComponents(server, env, orgNamespace);
|
||||
@@ -46,4 +47,6 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
|
||||
// LI SDD M3.2: skills + examples lookup(KBDB-backed)
|
||||
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
|
||||
registerAllSkillExampleTools(server, env);
|
||||
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
|
||||
registerAllRecipeTools(server, env);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user