diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c4652d6 --- /dev/null +++ b/.env.example @@ -0,0 +1,53 @@ +# ─────────────────────────────────────────────────────────────────────────── +# arcrun self-hosted .env 範本 +# +# 用法(AI 操盤手會幫你做):把這個檔複製成 .env,然後照下面說明, +# 一格一格把「=」右邊填上。左邊的名稱(KEY)不要改。 +# cp .env.example .env +# +# 這個 .env 只放在你自己電腦/專案,已被 .gitignore 排除,不會上傳。 +# ─────────────────────────────────────────────────────────────────────────── + + +# ── ① Cloudflare(最基礎,這兩格沒填,下面什麼都跑不了)──────────────────────── +# +# arcrun 跑在「你自己的 Cloudflare」上(免費額度即可,不必綁信用卡)。 +# 你要先有一個 Cloudflare 帳號,然後拿兩串東西貼回來: +# +# 1) 帳號代碼(Account ID): +# 登入 https://dash.cloudflare.com → 右側欄就有「Account ID」→ 複製貼到下面。 +# +# 2) 金鑰(API Token): +# https://dash.cloudflare.com/profile/api-tokens → Create Custom Token → +# 勾兩組權限:Account / Workers Scripts / Edit 和 Account / Workers KV Storage / Edit +# → 建立後複製那串 token 貼到下面。(不需要 R2、不需要綁卡。) +# +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= + + +# ── ② 身份與加密(自架單人用,這兩格你自己決定/保管)────────────────────────── +# +# NAMESPACE:你的資料分區標籤。隨便取個英數小名即可(例:leo、myteam)。 +# 這不是密碼,只是用來分隔你的資料。 +# +# ENCRYPTION_KEY:你的 credential 加密金鑰,64 個以上的 hex 字元。你自己保管。 +# 不會的話,AI 可以幫你產一串: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# ⚠️ 這串忘了 = 你之前上傳加密的 credential 就解不開了,請留底。 +# (安裝完還要把「同一串」設進你的 worker,acr init 會印確切指令給你跟著做。) +# +NAMESPACE= +ENCRYPTION_KEY= + + +# ── ③ 各服務的 token(要連哪個服務才填哪個;可之後再加)──────────────────────── +# +# 連外部服務(Notion、Gmail、Telegram…)的 token 放這裡,給 AI 幫你 +# 透過 `acr creds push` 加密上傳(不會明文留在雲端)。要連什麼就加一行。 +# +# 例:連 Notion → 去 https://www.notion.com/my-integrations 建一個 integration、 +# 拿它的 token(ntn_… 開頭),把你要讀的 database 分享給這個 integration, +# 然後填在下面: +# +# NOTION_INTEGRATION_TOKEN= diff --git a/.gitignore b/.gitignore index 4962961..c8283c4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ credentials.yaml ~/.arcrun/ .env .env.* +# 範本(無值,需進 repo 給 self-host 用戶 cp 成 .env)——必須放行 +!.env.example # 任何測試/真實憑證一律不進 repo(2026-06-03:曾誤 commit GCP SA 金鑰被 GitHub push protection 擋) docs/test_credentials/ *.sa.json diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 23306a6..f44717f 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -12,12 +12,12 @@ import { CfAccountClient } from '../lib/cf-api.js'; import { REQUIRED_KV_NAMESPACES, SECRET_TARGET_WORKERS, - wranglerAvailable, downloadAndDeploy, type DeployContext, } from '../lib/deploy.js'; import { cmdInstallHarness } from './install-harness.js'; import { cmdMcpSetup } from './mcp-setup.js'; +import { detectEnvironment, printPreflight, verifyInstall } from '../lib/preflight.js'; const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register'; @@ -164,10 +164,13 @@ async function initSelfHosted( console.log(chalk.gray(' Self-hosted 模式:自動部署整套 arcrun 到你的 Cloudflare 帳號\n')); console.log(chalk.gray(' 你只需提供 CF Account ID + API Token,其餘 CLI 自動完成。\n')); - // 前置:wrangler(CF CLI) - if (!wranglerAvailable()) { - console.log(chalk.yellow(' ✗ 找不到 wrangler(Cloudflare CLI)。')); - console.log(chalk.yellow(' 請先安裝:npm i -g wrangler,然後重新執行 acr init --self-hosted\n')); + // §7.8 P0:偵測先於動作(pip 式)——先看環境有什麼,缺前置就停下給補救指令, + // 不假設齊備直接動手(test_arcrun/4:D1 缺了 AI 跑去讀原始碼自己想辦法的災難)。 + const pre = detectEnvironment(); + printPreflight('環境偵測(安裝前)', pre.items); + if (pre.fatal) { + console.log(chalk.yellow('\n ✗ 缺少必要前置(見上方 →)。補齊後重新執行 acr init --self-hosted。')); + console.log(chalk.gray(' init 冪等:補好重跑,已就緒的會自動跳過。\n')); process.exit(1); } @@ -279,6 +282,20 @@ async function initSelfHosted( await callSeedEndpoint(cypherUrl); } + // §7.8 P0 裝完驗收:實查 CF(KV/D1)+ 打 cypher /health 確認真就緒,缺哪項明確報哪項 + // + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。 + const verify = await verifyInstall({ + cf, + requiredKv: REQUIRED_KV_NAMESPACES, + expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined, + cypherUrl, + }); + printPreflight('安裝驗收(裝完檢查)', verify.items); + if (!verify.allOk) { + console.log(chalk.yellow('\n ⚠ 部分項目未就緒(見上方 →)。多數可跑 acr update 冪等補裝。')); + console.log(chalk.gray(' (worker 剛部署可能需數十秒生效,可稍候再跑 acr update 重驗。)')); + } + // 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7) console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`)); console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml')); diff --git a/cli/src/commands/mcp-setup.ts b/cli/src/commands/mcp-setup.ts index ee2e835..d611834 100644 --- a/cli/src/commands/mcp-setup.ts +++ b/cli/src/commands/mcp-setup.ts @@ -53,5 +53,9 @@ export function cmdMcpSetup(): void { if (mcpUrl === DEFAULT_MCP_URL && (!config.mcp_url || config.mcp_url.trim() === '')) { console.log(chalk.gray(` (用平台預設;要連自己/客戶的 MCP,在 config 設 mcp_url 或 ARCRUN_MCP_URL env,再重跑)`)); } - console.log(chalk.gray(` Claude Code 進此資料夾會自動連這台 MCP。切帳號 = 在對應資料夾重跑 acr mcp-setup。\n`)); + console.log(chalk.gray(` Claude Code 進此資料夾會自動連這台 MCP。切帳號 = 在對應資料夾重跑 acr mcp-setup。`)); + // §7.8 P2 D3:project scope MCP 寫進 .mcp.json 後**不會即時生效**,要重啟 IDE/client 才載入。 + // 不提示 → 用戶開了 MCP 工具卻發現用不了,以為壞了(D3 撞牆)。明說「請重啟」引導,不讓人誤判。 + console.log(chalk.yellow(`\n ⚠ 請重啟 IDE / Claude Code client,project scope MCP 才會載入生效。`)); + console.log(chalk.gray(` (重啟後若仍未出現 arcrun 工具,確認該 client 已「信任此工作區」。)\n`)); } diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index c1f7c83..2c65ea6 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -84,6 +84,20 @@ export async function cmdUpdate(): Promise { } catch (e) { console.log(chalk.yellow(` ⚠ seed 失敗(${e instanceof Error ? e.message : e})`)); } + + // kbdb-base 8.P0:一次性把舊的 per-key cron-idx:{apiKey}:{name} 折進單一 cron-idx:_all。 + // 部署 8.P0 後既有 cron workflow 若不重 push 會停擺(scheduled 只讀新集中 key)→ 這裡冪等補上。 + // 冪等、不刪舊 key、失敗不致命(重跑 acr update 會再試)。 + process.stdout.write(chalk.gray(' → 遷移 cron index(舊 per-key → 集中 key,冪等)...')); + try { + const res = await fetch(`${cypherUrl}/webhooks/named/migrate-cron-index`, { method: 'POST' }); + const body = await res.json().catch(() => null) as { success?: boolean; migrated?: number; skipped?: number } | null; + console.log(res.ok && body?.success + ? chalk.green(` ✓ migrated ${body.migrated ?? 0}, skipped ${body.skipped ?? 0}`) + : chalk.yellow(` ⚠ HTTP ${res.status}(可重跑 acr update)`)); + } catch (e) { + console.log(chalk.yellow(` ⚠ cron index 遷移失敗(${e instanceof Error ? e.message : e})`)); + } } console.log(''); } else { diff --git a/cli/src/commands/whoami.ts b/cli/src/commands/whoami.ts new file mode 100644 index 0000000..ffb1789 --- /dev/null +++ b/cli/src/commands/whoami.ts @@ -0,0 +1,77 @@ +/** + * acr whoami — 一眼看「我現在是誰、連哪台、帳號從哪層來」(§7.8 P1,D2 修法)。 + * + * D2 根因(self-hosted-init.md §7.8):config 分層**已實作**(config.ts),但 AI 不用 CLI 讀帳號、 + * 自己 curl 猜全域帳號 URL → 打到錯帳號。治本不是再改 config,是給 AI 一個無腦入口: + * 「問 CLI 拿正確身份,別自己 curl 猜」。與 MCP arcrun_whoami 對齊(薄殼一致,rule 07 §5)。 + * + * 與 acr config 區別:config 印完整欄位表(人用、含敏感欄位遮罩);whoami 印精煉摘要 + * (mode / 連哪台 cypher / 帳號來源層),AI 讀完就知道該用哪個帳號、打哪個 URL,不必再猜。 + * + * --json:給 AI/MCP 結構化讀取(薄殼一致)。 + */ +import chalk from 'chalk'; +import { + loadConfig, + resolveConfigSources, + getCypherExecutorUrl, + getMcpUrl, + activeProjectConfigPath, + type ConfigSource, +} from '../lib/config.js'; + +const SOURCE_LABEL: Record = { + env: 'env 變數', + project: '專案層 .arcrun.yaml', + global: '全域 ~/.arcrun/config.yaml', + default: '預設值', +}; + +/** 帳號身份欄位(self-hosted=api_key 即 NAMESPACE 分區標籤;standard=平台 api_key)。*/ +function identitySource(): ConfigSource { + const row = resolveConfigSources().find((r) => r.field === 'api_key'); + return row?.source ?? 'default'; +} + +function maskKey(v?: string): string { + if (!v) return '(未設)'; + return v.length > 8 ? `${v.slice(0, 8)}…` : v; +} + +export async function cmdWhoami(options: { json?: boolean }): Promise { + const config = loadConfig(); + const cypherUrl = getCypherExecutorUrl(config); + const mcpUrl = getMcpUrl(config); + const accountSource = identitySource(); + const projectPath = activeProjectConfigPath(); + + if (options.json) { + // 結構化輸出(AI/MCP 對齊用)。敏感值不全印。 + console.log(JSON.stringify({ + mode: config.mode, + account: maskKey(config.api_key), + account_source: accountSource, + cypher_executor_url: cypherUrl, + mcp_url: mcpUrl, + cloudflare_account_id: config.cloudflare_account_id ?? null, + project_config: projectPath ?? null, + }, null, 2)); + return; + } + + console.log(chalk.bold('\n arcrun — 目前身份\n')); + console.log(` ${chalk.cyan('模式')} ${config.mode}`); + console.log(` ${chalk.cyan('帳號')} ${maskKey(config.api_key)} ${chalk.gray(`← ${SOURCE_LABEL[accountSource]}`)}`); + console.log(` ${chalk.cyan('連哪台')} ${cypherUrl}`); + console.log(` ${chalk.cyan('MCP')} ${mcpUrl}`); + if (config.mode === 'self-hosted' && config.cloudflare_account_id) { + console.log(` ${chalk.cyan('CF 帳號')} ${config.cloudflare_account_id}`); + } + console.log(''); + if (projectPath) { + console.log(chalk.gray(` 此資料夾用專案層設定:${projectPath}`)); + } else { + console.log(chalk.gray(' 此資料夾用全域設定(無 .arcrun.yaml)')); + } + console.log(chalk.gray(' → 部署/觸發前用這個帳號,別自己 curl 全域 URL 猜帳號。\n')); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index e2ad091..5d5bc6f 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { cmdInit } from './commands/init.js'; import { cmdConfig } from './commands/config.js'; +import { cmdWhoami } from './commands/whoami.js'; import { cmdCredsPush } from './commands/creds.js'; import { cmdPush } from './commands/push.js'; import { cmdRun } from './commands/run.js'; @@ -62,6 +63,13 @@ program .option('--where', '顯示每個設定值來自哪一層(env > 專案層 .arcrun.yaml > 全域)') .action((options: { where?: boolean }) => cmdConfig(options)); +// acr whoami [--json]:一眼看當前身份(mode / 連哪台 cypher / 帳號來源層)。§7.8 P1 D2 修法。 +program + .command('whoami') + .description('顯示目前生效的身份(帳號、連哪台 cypher、來源層)——AI 別自己 curl 猜帳號') + .option('--json', '結構化輸出(給 AI / 腳本讀取)') + .action((options: { json?: boolean }) => cmdWhoami(options)); + // acr creds push [credentials.yaml] const credsCmd = program.command('creds').description('Credential 管理'); credsCmd diff --git a/cli/src/lib/preflight.ts b/cli/src/lib/preflight.ts new file mode 100644 index 0000000..b45b0ad --- /dev/null +++ b/cli/src/lib/preflight.ts @@ -0,0 +1,133 @@ +/** + * preflight.ts — self-hosted 安裝的「偵測先於動作 + 裝完驗收」(§7.8 P0,pip 式)。 + * + * 核心判準(self-hosted-init.md §7.8): + * - **偵測先於動作**:init 先檢查各前置(node / wrangler / CF 可達),缺的才裝、有的跳過。 + * 不是假設齊備直接動手 → 缺一個就卡(test_arcrun/4 的 D1 大跑去讀原始碼自己想辦法)。 + * - **裝完驗收**:部署後逐項確認(KV / D1 / migration / cypher 可達),缺哪項明確報哪項 + * + 給一鍵補裝指令。不是靜默印灰字(原本 harness/MCP 失敗只 console.log 灰字,用戶不知道)。 + * - **冪等**:重跑檢查後「什麼也沒動」(ensureKvNamespace / ensureD1Database 本就冪等)。 + * + * 本檔只做「偵測 + 報告」,不自己建資源(建資源仍走 cf-api 的 ensure*,由 init 編排)。 + */ + +import { execFileSync } from 'node:child_process'; +import chalk from 'chalk'; +import type { CfAccountClient } from './cf-api.js'; + +export interface PreflightItem { + name: string; + ok: boolean; + detail?: string; + /** 缺漏時給用戶的一鍵補救指令(沒有則留空)。*/ + fix?: string; +} + +/** node 是否可用 + 版本(init 本身是 node 跑的,能跑到這裡 node 必在,但仍印版本供診斷)。*/ +function detectNode(): PreflightItem { + try { + const v = execFileSync('node', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString().trim(); + return { name: 'node', ok: true, detail: v }; + } catch { + return { name: 'node', ok: false, fix: '安裝 Node.js 18+:https://nodejs.org' }; + } +} + +/** wrangler(CF CLI)是否可用 + 版本。self-hosted 部署的硬前置。*/ +function detectWrangler(): PreflightItem { + try { + const v = execFileSync('wrangler', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString().trim(); + return { name: 'wrangler', ok: true, detail: v }; + } catch { + return { name: 'wrangler', ok: false, fix: 'npm i -g wrangler' }; + } +} + +/** + * 安裝前偵測(pip 式:先看環境有什麼)。 + * CF 憑證可達由呼叫端用 CfAccountClient.verifyAccess 接著驗(需要 token,不在這層)。 + * 回傳所有項目 + 是否有 fatal 缺漏(node/wrangler 缺 = 無法繼續)。 + */ +export function detectEnvironment(): { items: PreflightItem[]; fatal: boolean } { + const items = [detectNode(), detectWrangler()]; + const fatal = items.some((i) => !i.ok); + return { items, fatal }; +} + +/** 印一組偵測結果(✓/✗ + 版本 + 補救指令)。*/ +export function printPreflight(title: string, items: PreflightItem[]): void { + console.log(chalk.bold(`\n ${title}`)); + for (const it of items) { + if (it.ok) { + console.log(chalk.green(` ✓ ${it.name}`) + (it.detail ? chalk.gray(` ${it.detail}`) : '')); + } else { + console.log(chalk.yellow(` ✗ ${it.name}`) + (it.detail ? chalk.gray(` ${it.detail}`) : '')); + if (it.fix) console.log(chalk.gray(` → ${it.fix}`)); + } + } +} + +/** + * 裝完驗收:逐項確認 self-hosted 環境真的就緒(§7.8 D1 根因:安裝不偵測,缺了不報)。 + * 各項以「實際查 CF / 打 cypher」確認,非看 config 有沒有寫——避免假綠(mindset §7)。 + * + * @returns items(每項 ok + detail/fix)。呼叫端依 allOk 決定是否 exit 非零 / 印補裝指引。 + */ +export async function verifyInstall(opts: { + cf: CfAccountClient; + requiredKv: readonly string[]; + expectD1Name?: string; + cypherUrl?: string; +}): Promise<{ items: PreflightItem[]; allOk: boolean }> { + const items: PreflightItem[] = []; + + // KV:實查 CF 上現有 namespace,比對必需清單 + try { + const existing = await opts.cf.listKvNamespaces(); + const have = new Set(existing.keys()); + const missing = opts.requiredKv.filter((t) => !have.has(t)); + items.push( + missing.length === 0 + ? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true } + : { name: 'KV namespaces', ok: false, detail: `缺 ${missing.join(', ')}`, fix: 'acr update(冪等重建)' }, + ); + } catch (e) { + items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' }); + } + + // D1:實查 CF 上是否有該庫 + if (opts.expectD1Name) { + try { + const dbs = await opts.cf.listD1Databases(); + items.push( + dbs.has(opts.expectD1Name) + ? { name: `D1 ${opts.expectD1Name}`, ok: true } + : { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'acr update(冪等重建 + 套 migration)' }, + ); + } catch (e) { + items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: msg(e), fix: 'acr update' }); + } + } + + // cypher-executor 可達(打 /health,不只看 config 有 URL) + if (opts.cypherUrl) { + try { + const res = await fetch(`${opts.cypherUrl}/health`, { method: 'GET' }); + items.push( + res.ok + ? { name: 'cypher-executor 可達', ok: true, detail: opts.cypherUrl } + : { name: 'cypher-executor 可達', ok: false, detail: `HTTP ${res.status} @ ${opts.cypherUrl}`, fix: 'acr update(重部署)' }, + ); + } catch (e) { + items.push({ name: 'cypher-executor 可達', ok: false, detail: msg(e), fix: 'acr update(重部署);或 worker 剛部署稍候再試' }); + } + } + + return { items, allOk: items.every((i) => i.ok) }; +} + +function msg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/cypher-executor/src/lib/cron-index.ts b/cypher-executor/src/lib/cron-index.ts new file mode 100644 index 0000000..928df58 --- /dev/null +++ b/cypher-executor/src/lib/cron-index.ts @@ -0,0 +1,70 @@ +/** + * Cron index — 單一固定 key 模型(kbdb-base 8.P0 止血)。 + * + * 背景:原本每個 cron workflow 寫一筆 `cron-idx:{apiKey}:{name}`,scheduled() 每分鐘 + * `WEBHOOKS.list({prefix:'cron-idx:'})` 一次 = 1440 list/日,單獨就爆 CF KV 免費 list 上限(1000/日)。 + * + * 解法(SDD §8.2):所有 cron workflow 的 cron_expr 集中存進**單一固定 key** `cron-idx:_all`。 + * scheduled() 每分鐘只 `get` 一次(KV get 免費額度 100K/日,遠夠)→ list 次數歸零。 + * acr push(webhooks-named POST)/ delete 時對這個 key 做 read-modify-write 維護。 + * + * 結構:{ [ "{apiKey}:{name}" ]: cron_expr } + * key 用 `{apiKey}:{name}` 維持多租戶隔離(scheduled 觸發時拆回 apiKey/name 去讀完整 record)。 + */ + +// KVNamespace 用全域 ambient 型別(與 types.ts 一致,不從 @cloudflare/workers-types import +// 以免產生第二個不相容的 KVNamespace 型別)。 + +/** 單一固定索引 key — 全租戶共用一筆,scheduled() 只 get 這個 */ +export const CRON_INDEX_KEY = 'cron-idx:_all'; + +/** 索引內容:entryKey("{apiKey}:{name}")→ cron_expr */ +export type CronIndex = Record; + +/** 組出索引 entry 的 key(apiKey + name),含 ':' 也安全:split 時 name 用 slice 還原 */ +export function cronEntryKey(apiKey: string, name: string): string { + return `${apiKey}:${name}`; +} + +/** 從 entryKey 拆回 { apiKey, name }(name 可能含 ':',取第一個 ':' 後全部為 name) */ +export function parseCronEntryKey(entryKey: string): { apiKey: string; name: string } | null { + const idx = entryKey.indexOf(':'); + if (idx <= 0) return null; + return { apiKey: entryKey.slice(0, idx), name: entryKey.slice(idx + 1) }; +} + +/** 讀整個 cron index(單次 get,不 list) */ +export async function readCronIndex(kv: KVNamespace): Promise { + const raw = await kv.get(CRON_INDEX_KEY, 'text'); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as CronIndex) : {}; + } catch { + return {}; + } +} + +/** + * upsert / 移除單筆 cron entry(read-modify-write 單一 key)。 + * @param cronExpr - 有值=upsert;null/undefined=移除(push 改掉 cron 後清乾淨) + */ +export async function updateCronIndexEntry( + kv: KVNamespace, + apiKey: string, + name: string, + cronExpr: string | null | undefined, +): Promise { + const index = await readCronIndex(kv); + const entryKey = cronEntryKey(apiKey, name); + + if (cronExpr) { + if (index[entryKey] === cronExpr) return; // 無變化,不浪費一次 put + index[entryKey] = cronExpr; + } else { + if (!(entryKey in index)) return; // 本來就沒有,不浪費一次 put + delete index[entryKey]; + } + + await kv.put(CRON_INDEX_KEY, JSON.stringify(index)); +} diff --git a/cypher-executor/src/routes/webhooks-named.ts b/cypher-executor/src/routes/webhooks-named.ts index fcc1438..5b9e4ce 100644 --- a/cypher-executor/src/routes/webhooks-named.ts +++ b/cypher-executor/src/routes/webhooks-named.ts @@ -28,6 +28,7 @@ import { executeWebhookGraph } from '../actions/webhook-handlers'; import { writeExecutionVerdict } from '../actions/execution-logger'; import type { GraphNode } from '../types'; import { extractCronExpr } from '../lib/cron-match'; +import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index'; import { recordTelemetry } from '../lib/telemetry'; import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent'; import type { ExposureConsent } from '../lib/exposure-consent'; @@ -52,11 +53,6 @@ function kvKey(apiKey: string, name: string): string { return `${apiKey}:wf:${name}`; } -/** 輕量 cron index entry — scheduled() 只列這個 prefix(每分鐘 tick 不掃全量 KV)*/ -function cronIndexKey(apiKey: string, name: string): string { - return `cron-idx:${apiKey}:${name}`; -} - // POST /webhooks/named — 部署(acr push 呼叫) webhooksNamedRouter.post('/webhooks/named', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); @@ -107,12 +103,9 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => { const start = Date.now(); await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record)); - // 維護 cron index:有 cron_expr 就寫 / 沒有就刪除(避免 push 改 yaml 拿掉 cron 後殘留) - if (cronExpr) { - await c.env.WEBHOOKS.put(cronIndexKey(apiKey, name), JSON.stringify({ cron_expr: cronExpr })); - } else { - await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name)); - } + // 維護單一 cron index key(8.P0):有 cron_expr 就 upsert / 沒有就移除 + // (避免 push 改 yaml 拿掉 cron 後殘留)。scheduled() 每分鐘只 get 這一個 key。 + await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, cronExpr); // Implicit telemetry (LI M1.2) recordTelemetry(c.env, apiKey, { @@ -131,6 +124,34 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => { }, 201); }); +// POST /webhooks/named/migrate-cron-index — 一次性 migration(8.P0):把舊的 per-key +// cron-idx:{apiKey}:{name} 折進單一 cron-idx:_all(這裡才 list 一次,非每分鐘 tick)。 +// 增量寫、不刪舊 key(重跑安全、冪等)。部署 8.P0 後跑一次,讓既有 cron workflow 不漏掉。 +// 必須在 /:name/trigger 之前註冊,否則 :name 會攔截 "migrate-cron-index"。 +webhooksNamedRouter.post('/webhooks/named/migrate-cron-index', async (c) => { + const list = await c.env.WEBHOOKS.list({ prefix: 'cron-idx:' }); + let migrated = 0, skipped = 0; + const errors: string[] = []; + for (const k of list.keys) { + if (k.name === CRON_INDEX_KEY) { skipped++; continue; } // 跳過新的集中 key 自己 + const parts = k.name.split(':'); // cron-idx:{apiKey}:{name} + if (parts.length < 3) { skipped++; continue; } + const apiKey = parts[1]; + const name = parts.slice(2).join(':'); + try { + const raw = await c.env.WEBHOOKS.get(k.name, 'text'); + if (!raw) { skipped++; continue; } + const idx = JSON.parse(raw) as { cron_expr?: string }; + if (!idx.cron_expr) { skipped++; continue; } + await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, idx.cron_expr); + migrated++; + } catch (e) { + errors.push(`${k.name}: ${e instanceof Error ? e.message : String(e)}`); + } + } + return c.json({ success: errors.length === 0, migrated, skipped, errors }); +}); + // POST /webhooks/named/:name/trigger — 觸發執行(api_key 走 header;標準/向後相容) webhooksNamedRouter.post('/webhooks/named/:name/trigger', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); @@ -248,6 +269,6 @@ webhooksNamedRouter.delete('/webhooks/named/:name', async (c) => { } await c.env.WEBHOOKS.delete(kvKey(apiKey, name)); - await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name)); + await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, null); return c.json({ deleted: true, name }); }); diff --git a/cypher-executor/src/scheduled.ts b/cypher-executor/src/scheduled.ts index 2e36295..ffc5c2b 100644 --- a/cypher-executor/src/scheduled.ts +++ b/cypher-executor/src/scheduled.ts @@ -2,17 +2,21 @@ * scheduled() handler — 對應 wrangler.toml [triggers].crons 觸發。 * * 流程: - * 1. 列出 WEBHOOKS KV 所有 webhook:{api_key}:{name} key - * 2. 對每個 workflow 解析 cron_expr(acr push 時若首節點是 cron 零件會存進 record.cron_expr) - * 3. 用 cronMatch() 比對 event.scheduledTime(UTC 分鐘精度) + * 1. 單次 get cron index(cron-idx:_all,集中存所有 cron workflow 的 cron_expr) + * 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTime(UTC 分鐘精度) + * 3. 匹配才去讀完整 workflow record({apiKey}:wf:{name}) * 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋) * - * SDD: arcrun.md 三-A P1 #3 + * 8.P0 止血(SDD §8.2):原本每分鐘 WEBHOOKS.list('cron-idx:') = 1440 list/日 爆 KV 上限, + * 改成單一固定 key 只 get 一次 → list 歸零。 + * + * SDD: arcrun.md 三-A P1 #3 / kbdb-base §8.2 */ import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types'; import type { Bindings } from './types'; import { cronMatch } from './lib/cron-match'; +import { readCronIndex, parseCronEntryKey } from './lib/cron-index'; import { executeWebhookGraph } from './actions/webhook-handlers'; type StoredWorkflowRecord = { @@ -29,25 +33,18 @@ export async function handleScheduled( const now = new Date(controller.scheduledTime); console.log('[scheduled] tick', now.toISOString(), 'controller.cron=', controller.cron); - // 只列 cron-idx: prefix,輕量 — acr push 時為 cron-tagged workflow 額外寫一筆 index - // 主 workflow record 仍在 {apiKey}:wf:{name},需要時再 get - const list = await env.WEBHOOKS.list({ prefix: 'cron-idx:' }); + // 8.P0:單次 get 集中索引(取代每分鐘 list),主 workflow record 仍在 {apiKey}:wf:{name} + const index = await readCronIndex(env.WEBHOOKS); + const entries = Object.entries(index); let triggered = 0; - for (const entry of list.keys) { - // key = cron-idx:{api_key}:{name} - const parts = entry.name.split(':'); - if (parts.length < 3) continue; - const apiKey = parts[1]; - const name = parts.slice(2).join(':'); // name 可能含 ':'(雖然 push handler 已用 /^[\w-]+$/ 擋) + for (const [entryKey, cronExpr] of entries) { + const parsed = parseCronEntryKey(entryKey); + if (!parsed) continue; + const { apiKey, name } = parsed; - // 從 cron-idx 拿 cron_expr(輕量) - const idxRaw = await env.WEBHOOKS.get(entry.name, 'text'); - if (!idxRaw) continue; - let idx: { cron_expr?: string }; - try { idx = JSON.parse(idxRaw); } catch { continue; } - if (!idx.cron_expr) continue; - if (!cronMatch(idx.cron_expr, now)) continue; + if (!cronExpr) continue; + if (!cronMatch(cronExpr, now)) continue; // 匹配才去讀完整 workflow record const wfKey = `${apiKey}:wf:${name}`; @@ -60,7 +57,7 @@ export async function handleScheduled( try { record = JSON.parse(wfRaw) as StoredWorkflowRecord; } catch { continue; } triggered++; - console.log('[scheduled] trigger', name, 'apiKey=', apiKey.slice(0, 12) + '...', 'cron=', idx.cron_expr); + console.log('[scheduled] trigger', name, 'apiKey=', apiKey.slice(0, 12) + '...', 'cron=', cronExpr); // 把 apiKey 也放進 triggerContext,讓 workflow 內節點能用 {{api_key}}(跟 webhook trigger 慣例一致) const triggerContext = { api_key: apiKey, @@ -75,5 +72,5 @@ export async function handleScheduled( ), ); } - console.log(`[scheduled] scanned ${list.keys.length} cron-idx entries, ${triggered} triggered`); + console.log(`[scheduled] scanned ${entries.length} cron-idx entries, ${triggered} triggered`); } diff --git a/llms.txt b/llms.txt index e74a4d0..af4945a 100644 --- a/llms.txt +++ b/llms.txt @@ -30,14 +30,20 @@ npm i -g arcrun && acr install-harness 1. `npm i -g arcrun && acr install-harness`(裝 CLI + 防護) 2. `npm i -g wrangler`(Cloudflare CLI,部署用) -3. **帶使用者拿 Cloudflare 的兩串憑證**(唯一只能他做的事)—— - 用白話照抄式引導,**不要對使用者講 KV / Worker / R2 / zone 等術語**(他多半聽不懂,也不需要懂): - - 帳號代碼(Account ID):登入 https://dash.cloudflare.com 右側欄複製。 - - 金鑰(API Token):https://dash.cloudflare.com/profile/api-tokens → Create Custom Token → - 照抄勾**兩組**權限(Account/Workers Scripts/Edit、Account/Workers KV Storage/Edit) - → 複製產生的 token。(不需要 R2、不需要綁信用卡——只用 Workers + KV 免費額度。) - - 叫使用者把這兩串貼給你。 -4. `acr init --self-hosted`(你幫跑,貼使用者的兩串)—— 自動建資源、部署、seed、寫 .mcp.json(MCP 連線)。 +3. **建 .env 並帶使用者填憑證**(憑證是唯一只有他能拿的東西,但「建檔」由你做): + - **你做**:`cp .env.example .env`(repo 根有 `.env.example` 範本,左邊 KEY 名都寫好了、 + 每格上面有白話說明)。使用者只需要找到值、填到「=」右邊。 + - **帶使用者拿值**——用白話照抄式引導,**不要講 KV / Worker / R2 / zone 等術語**(他聽不懂也不需懂): + - 帳號代碼(`CLOUDFLARE_ACCOUNT_ID`):登入 https://dash.cloudflare.com 右側欄複製。 + - 金鑰(`CLOUDFLARE_API_TOKEN`):https://dash.cloudflare.com/profile/api-tokens → Create Custom Token → + 照抄勾**兩組**權限(Account/Workers Scripts/Edit、Account/Workers KV Storage/Edit) + → 複製產生的 token。(不需要 R2、不需要綁信用卡——只用 Workers + KV 免費額度。) + - `NAMESPACE`:隨便取個英數小名(非密碼)。`ENCRYPTION_KEY`:你可幫他產 + (`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`)。 + - 使用者把值貼進 .env(或貼給你、你幫他填進對應格)。**CLOUDFLARE 兩格沒填,後面什麼都跑不了。** + - 連外部服務(如 Notion)的 token 也填進 .env 的 ③ 區,之後 `acr creds push` 加密上傳。 +4. `acr init --self-hosted`(你幫跑,讀 .env 的 CF 憑證)—— 自動建資源、部署、seed、寫 .mcp.json(MCP 連線)。 + 跑完會印「安裝驗收」逐項 ✓/✗;有 ✗ 照它給的指令補(多數 `acr update` 冪等重試)。 5. 跑完照提示 `wrangler secret put ENCRYPTION_KEY`(CLI 會印確切指令)。 6. 把使用者需求拆成 workflow → `acr push`。完成給客觀證據(HTTP 2xx / trace)。 diff --git a/mcp/src/tools/arcrun_whoami.ts b/mcp/src/tools/arcrun_whoami.ts new file mode 100644 index 0000000..eba73c1 --- /dev/null +++ b/mcp/src/tools/arcrun_whoami.ts @@ -0,0 +1,34 @@ +/** + * arcrun_whoami — MCP 端的「我現在是誰、連哪台」(§7.8 P1 D2,與 CLI acr whoami 對齊)。 + * + * D2 根因(self-hosted-init.md §7.8):AI 不用工具讀帳號、自己 curl 猜全域 → 打錯帳號。 + * 治本是給 AI 無腦入口:問工具拿身份。CLI 有 acr whoami,MCP 必須對齊(薄殼一致,rule 07 §5)—— + * 否則「AI 偏好 MCP」時又得繞回 curl。 + * + * 薄殼:只回報 MCP 已解析的 orgNamespace(綁哪個帳號)+ cypher binding 連向,無業務邏輯。 + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Env } from "../types.js"; + +export function registerWhoami(server: McpServer, env: Env, orgNamespace: string) { + server.tool( + "arcrun_whoami", + "回報這個 MCP 連線目前生效的身份:綁哪個帳號 / namespace、cypher 連向哪。" + + "部署 / 觸發 / 查 workflow 前先 call 此 tool 確認帳號,**不要自己 curl 猜帳號 URL**(會打到錯帳號)。", + {}, + async () => { + // 薄殼:MCP 透過 service binding(CYPHER_EXECUTOR)連 cypher,binding 本身決定連哪台; + // 身份來自啟動時解析的 orgNamespace(綁哪個帳號的資料分區)。這裡只如實回報,不做推斷。 + const identity = { + account_namespace: orgNamespace || "(未設)", + cypher: "service-binding:CYPHER_EXECUTOR", + kbdb: "service-binding:KBDB", + note: + "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。", + }; + return { + content: [{ type: "text" as const, text: JSON.stringify(identity, null, 2) }], + }; + }, + ); +} diff --git a/mcp/src/tools/registry.ts b/mcp/src/tools/registry.ts index 35e53ba..2c3e2e1 100644 --- a/mcp/src/tools/registry.ts +++ b/mcp/src/tools/registry.ts @@ -20,6 +20,7 @@ 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"; +import { registerWhoami } from "./arcrun_whoami.js"; export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) { registerSearchComponents(server, env, orgNamespace); @@ -49,4 +50,6 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri registerAllSkillExampleTools(server, env); // kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後) registerAllRecipeTools(server, env); + // §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號) + registerWhoami(server, env, orgNamespace); }