feat(onboarding+kbdb): 8.P0 cron 止血 + §7.8 onboarding + .env.example 範本
kbdb-base 8.P0:scheduled.ts cron 每分鐘 KV list → 單一 key get(lib/cron-index.ts); webhooks-named 維護單 key + 一次性 migrate-cron-index;acr update 自動遷移。1440 list/日 → 0。 self-hosted-init §7.8 onboarding: P0 init 偵測+裝完驗收(lib/preflight.ts,pip 式,冪等) P1 acr whoami(+--json)+ MCP arcrun_whoami(AI 不繞 CLI 猜帳號) P2 mcp-setup 寫完印「請重啟 client」 P3(部分)repo .env.example 範本(每格白話說明、值留空)+ llms.txt 教 AI 幫用戶 cp 建 .env Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
|
||||
@@ -84,6 +84,20 @@ export async function cmdUpdate(): Promise<void> {
|
||||
} 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 {
|
||||
|
||||
@@ -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<ConfigSource, string> = {
|
||||
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<void> {
|
||||
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'));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
|
||||
/** 組出索引 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<CronIndex> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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 →
|
||||
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 免費額度。)
|
||||
- 叫使用者把這兩串貼給你。
|
||||
4. `acr init --self-hosted`(你幫跑,貼使用者的兩串)—— 自動建資源、部署、seed、寫 .mcp.json(MCP 連線)。
|
||||
- `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)。
|
||||
|
||||
|
||||
@@ -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) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user