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:
uncle6me-web
2026-06-09 19:15:51 +08:00
parent c5d8924fb2
commit c152f5fc1d
14 changed files with 487 additions and 48 deletions
+53
View File
@@ -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=
+2
View File
@@ -16,6 +16,8 @@ credentials.yaml
~/.arcrun/ ~/.arcrun/
.env .env
.env.* .env.*
# 範本(無值,需進 repo 給 self-host 用戶 cp 成 .env)——必須放行
!.env.example
# 任何測試/真實憑證一律不進 repo2026-06-03:曾誤 commit GCP SA 金鑰被 GitHub push protection 擋) # 任何測試/真實憑證一律不進 repo2026-06-03:曾誤 commit GCP SA 金鑰被 GitHub push protection 擋)
docs/test_credentials/ docs/test_credentials/
*.sa.json *.sa.json
+22 -5
View File
@@ -12,12 +12,12 @@ import { CfAccountClient } from '../lib/cf-api.js';
import { import {
REQUIRED_KV_NAMESPACES, REQUIRED_KV_NAMESPACES,
SECRET_TARGET_WORKERS, SECRET_TARGET_WORKERS,
wranglerAvailable,
downloadAndDeploy, downloadAndDeploy,
type DeployContext, type DeployContext,
} from '../lib/deploy.js'; } from '../lib/deploy.js';
import { cmdInstallHarness } from './install-harness.js'; import { cmdInstallHarness } from './install-harness.js';
import { cmdMcpSetup } from './mcp-setup.js'; import { cmdMcpSetup } from './mcp-setup.js';
import { detectEnvironment, printPreflight, verifyInstall } from '../lib/preflight.js';
const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register'; 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(' Self-hosted 模式:自動部署整套 arcrun 到你的 Cloudflare 帳號\n'));
console.log(chalk.gray(' 你只需提供 CF Account ID + API Token,其餘 CLI 自動完成。\n')); console.log(chalk.gray(' 你只需提供 CF Account ID + API Token,其餘 CLI 自動完成。\n'));
// 前置:wranglerCF CLI // §7.8 P0:偵測先於動作(pip 式)——先看環境有什麼,缺前置就停下給補救指令,
if (!wranglerAvailable()) { // 不假設齊備直接動手(test_arcrun/4:D1 缺了 AI 跑去讀原始碼自己想辦法的災難)。
console.log(chalk.yellow(' ✗ 找不到 wranglerCloudflare CLI)。')); const pre = detectEnvironment();
console.log(chalk.yellow(' 請先安裝:npm i -g wrangler,然後重新執行 acr init --self-hosted\n')); printPreflight('環境偵測(安裝前)', pre.items);
if (pre.fatal) {
console.log(chalk.yellow('\n ✗ 缺少必要前置(見上方 →)。補齊後重新執行 acr init --self-hosted。'));
console.log(chalk.gray(' init 冪等:補好重跑,已就緒的會自動跳過。\n'));
process.exit(1); process.exit(1);
} }
@@ -279,6 +282,20 @@ async function initSelfHosted(
await callSeedEndpoint(cypherUrl); await callSeedEndpoint(cypherUrl);
} }
// §7.8 P0 裝完驗收:實查 CFKV/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) // 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`)); console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml')); console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
+5 -1
View File
@@ -53,5 +53,9 @@ export function cmdMcpSetup(): void {
if (mcpUrl === DEFAULT_MCP_URL && (!config.mcp_url || config.mcp_url.trim() === '')) { 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(` (用平台預設;要連自己/客戶的 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 D3project scope MCP 寫進 .mcp.json 後**不會即時生效**,要重啟 IDE/client 才載入。
// 不提示 → 用戶開了 MCP 工具卻發現用不了,以為壞了(D3 撞牆)。明說「請重啟」引導,不讓人誤判。
console.log(chalk.yellow(`\n ⚠ 請重啟 IDE / Claude Code clientproject scope MCP 才會載入生效。`));
console.log(chalk.gray(` (重啟後若仍未出現 arcrun 工具,確認該 client 已「信任此工作區」。)\n`));
} }
+14
View File
@@ -84,6 +84,20 @@ export async function cmdUpdate(): Promise<void> {
} catch (e) { } catch (e) {
console.log(chalk.yellow(` ⚠ seed 失敗(${e instanceof Error ? e.message : 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(''); console.log('');
} else { } else {
+77
View File
@@ -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'));
}
+8
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { cmdInit } from './commands/init.js'; import { cmdInit } from './commands/init.js';
import { cmdConfig } from './commands/config.js'; import { cmdConfig } from './commands/config.js';
import { cmdWhoami } from './commands/whoami.js';
import { cmdCredsPush } from './commands/creds.js'; import { cmdCredsPush } from './commands/creds.js';
import { cmdPush } from './commands/push.js'; import { cmdPush } from './commands/push.js';
import { cmdRun } from './commands/run.js'; import { cmdRun } from './commands/run.js';
@@ -62,6 +63,13 @@ program
.option('--where', '顯示每個設定值來自哪一層(env > 專案層 .arcrun.yaml > 全域)') .option('--where', '顯示每個設定值來自哪一層(env > 專案層 .arcrun.yaml > 全域)')
.action((options: { where?: boolean }) => cmdConfig(options)); .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] // acr creds push [credentials.yaml]
const credsCmd = program.command('creds').description('Credential 管理'); const credsCmd = program.command('creds').description('Credential 管理');
credsCmd credsCmd
+133
View File
@@ -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' };
}
}
/** wranglerCF 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);
}
+70
View File
@@ -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 pushwebhooks-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 的 keyapiKey + 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 entryread-modify-write 單一 key)。
* @param cronExpr - 有值=upsertnull/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));
}
+33 -12
View File
@@ -28,6 +28,7 @@ import { executeWebhookGraph } from '../actions/webhook-handlers';
import { writeExecutionVerdict } from '../actions/execution-logger'; import { writeExecutionVerdict } from '../actions/execution-logger';
import type { GraphNode } from '../types'; import type { GraphNode } from '../types';
import { extractCronExpr } from '../lib/cron-match'; import { extractCronExpr } from '../lib/cron-match';
import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index';
import { recordTelemetry } from '../lib/telemetry'; import { recordTelemetry } from '../lib/telemetry';
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent'; import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
import type { ExposureConsent } 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}`; 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 呼叫) // POST /webhooks/named — 部署(acr push 呼叫)
webhooksNamedRouter.post('/webhooks/named', async (c) => { webhooksNamedRouter.post('/webhooks/named', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key'); const apiKey = c.req.header('X-Arcrun-API-Key');
@@ -107,12 +103,9 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
const start = Date.now(); const start = Date.now();
await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record)); await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record));
// 維護 cron index:有 cron_expr 就寫 / 沒有就刪除(避免 push 改 yaml 拿掉 cron 後殘留) // 維護單一 cron index key8.P0:有 cron_expr 就 upsert / 沒有就移除
if (cronExpr) { // (避免 push 改 yaml 拿掉 cron 後殘留)。scheduled() 每分鐘只 get 這一個 key。
await c.env.WEBHOOKS.put(cronIndexKey(apiKey, name), JSON.stringify({ cron_expr: cronExpr })); await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, cronExpr);
} else {
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
}
// Implicit telemetry (LI M1.2) // Implicit telemetry (LI M1.2)
recordTelemetry(c.env, apiKey, { recordTelemetry(c.env, apiKey, {
@@ -131,6 +124,34 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
}, 201); }, 201);
}); });
// POST /webhooks/named/migrate-cron-index — 一次性 migration8.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;標準/向後相容) // POST /webhooks/named/:name/trigger — 觸發執行(api_key 走 header;標準/向後相容)
webhooksNamedRouter.post('/webhooks/named/:name/trigger', async (c) => { webhooksNamedRouter.post('/webhooks/named/:name/trigger', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key'); 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(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 }); return c.json({ deleted: true, name });
}); });
+19 -22
View File
@@ -2,17 +2,21 @@
* scheduled() handler wrangler.toml [triggers].crons * scheduled() handler wrangler.toml [triggers].crons
* *
* *
* 1. WEBHOOKS KV webhook:{api_key}:{name} key * 1. get cron indexcron-idx:_all cron workflow cron_expr
* 2. workflow cron_expracr push cron record.cron_expr * 2. cron_expr event.scheduledTimeUTC
* 3. cronMatch() event.scheduledTimeUTC * 3. workflow record{apiKey}:wf:{name}
* 4. executeWebhookGraph waitUntil * 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 { ExecutionContext, ScheduledController } from '@cloudflare/workers-types';
import type { Bindings } from './types'; import type { Bindings } from './types';
import { cronMatch } from './lib/cron-match'; import { cronMatch } from './lib/cron-match';
import { readCronIndex, parseCronEntryKey } from './lib/cron-index';
import { executeWebhookGraph } from './actions/webhook-handlers'; import { executeWebhookGraph } from './actions/webhook-handlers';
type StoredWorkflowRecord = { type StoredWorkflowRecord = {
@@ -29,25 +33,18 @@ export async function handleScheduled(
const now = new Date(controller.scheduledTime); const now = new Date(controller.scheduledTime);
console.log('[scheduled] tick', now.toISOString(), 'controller.cron=', controller.cron); console.log('[scheduled] tick', now.toISOString(), 'controller.cron=', controller.cron);
// 只列 cron-idx: prefix,輕量 — acr push 時為 cron-tagged workflow 額外寫一筆 index // 8.P0:單次 get 集中索引(取代每分鐘 list),主 workflow record 仍在 {apiKey}:wf:{name}
// 主 workflow record 仍在 {apiKey}:wf:{name},需要時再 get const index = await readCronIndex(env.WEBHOOKS);
const list = await env.WEBHOOKS.list({ prefix: 'cron-idx:' }); const entries = Object.entries(index);
let triggered = 0; let triggered = 0;
for (const entry of list.keys) { for (const [entryKey, cronExpr] of entries) {
// key = cron-idx:{api_key}:{name} const parsed = parseCronEntryKey(entryKey);
const parts = entry.name.split(':'); if (!parsed) continue;
if (parts.length < 3) continue; const { apiKey, name } = parsed;
const apiKey = parts[1];
const name = parts.slice(2).join(':'); // name 可能含 ':'(雖然 push handler 已用 /^[\w-]+$/ 擋)
// 從 cron-idx 拿 cron_expr(輕量) if (!cronExpr) continue;
const idxRaw = await env.WEBHOOKS.get(entry.name, 'text'); if (!cronMatch(cronExpr, now)) continue;
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;
// 匹配才去讀完整 workflow record // 匹配才去讀完整 workflow record
const wfKey = `${apiKey}:wf:${name}`; const wfKey = `${apiKey}:wf:${name}`;
@@ -60,7 +57,7 @@ export async function handleScheduled(
try { record = JSON.parse(wfRaw) as StoredWorkflowRecord; } catch { continue; } try { record = JSON.parse(wfRaw) as StoredWorkflowRecord; } catch { continue; }
triggered++; 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 慣例一致) // 把 apiKey 也放進 triggerContext,讓 workflow 內節點能用 {{api_key}}(跟 webhook trigger 慣例一致)
const triggerContext = { const triggerContext = {
api_key: apiKey, 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`);
} }
+12 -6
View File
@@ -30,14 +30,20 @@ npm i -g arcrun && acr install-harness
1. `npm i -g arcrun && acr install-harness`(裝 CLI + 防護) 1. `npm i -g arcrun && acr install-harness`(裝 CLI + 防護)
2. `npm i -g wrangler`Cloudflare CLI,部署用) 2. `npm i -g wrangler`Cloudflare CLI,部署用)
3. **帶使用者拿 Cloudflare 的兩串憑證**(唯一只能他做的事)—— 3. **建 .env 並帶使用者填憑證**(憑證是唯一只有他能拿的東西,但「建檔」由你做):
用白話照抄式引導,**不要對使用者講 KV / Worker / R2 / zone 等術語**(他多半聽不懂,也不需要懂): - **你做**`cp .env.example .env`repo 根有 `.env.example` 範本,左邊 KEY 名都寫好了、
- 帳號代碼(Account ID):登入 https://dash.cloudflare.com 右側欄複製 每格上面有白話說明)。使用者只需要找到值、填到「=」右邊
- 金鑰(API Token):https://dash.cloudflare.com/profile/api-tokens → Create Custom Token → - **帶使用者拿值**——用白話照抄式引導,**不要講 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 照抄勾**兩組**權限(Account/Workers Scripts/Edit、Account/Workers KV Storage/Edit
→ 複製產生的 token。(不需要 R2、不需要綁信用卡——只用 Workers + KV 免費額度。) → 複製產生的 token。(不需要 R2、不需要綁信用卡——只用 Workers + KV 免費額度。)
- 叫使用者把這兩串貼給你。 - `NAMESPACE`:隨便取個英數小名(非密碼)。`ENCRYPTION_KEY`:你可幫他產
4. `acr init --self-hosted`(你幫跑,貼使用者的兩串)—— 自動建資源、部署、seed、寫 .mcp.jsonMCP 連線)。 `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.jsonMCP 連線)。
跑完會印「安裝驗收」逐項 ✓/✗;有 ✗ 照它給的指令補(多數 `acr update` 冪等重試)。
5. 跑完照提示 `wrangler secret put ENCRYPTION_KEY`CLI 會印確切指令)。 5. 跑完照提示 `wrangler secret put ENCRYPTION_KEY`CLI 會印確切指令)。
6. 把使用者需求拆成 workflow → `acr push`。完成給客觀證據(HTTP 2xx / trace)。 6. 把使用者需求拆成 workflow → `acr push`。完成給客觀證據(HTTP 2xx / trace)。
+34
View File
@@ -0,0 +1,34 @@
/**
* arcrun_whoami MCP §7.8 P1 D2 CLI acr whoami
*
* D2 self-hosted-init.md §7.8AI curl
* AI CLI acr whoamiMCP 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 bindingCYPHER_EXECUTOR)連 cypherbinding 本身決定連哪台;
// 身份來自啟動時解析的 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) }],
};
},
);
}
+3
View File
@@ -20,6 +20,7 @@ import { registerAllIntrospectionTools } from "./arcrun_introspection.js";
import { registerAllWorkflowCrudTools } from "./arcrun_workflow_crud.js"; import { registerAllWorkflowCrudTools } from "./arcrun_workflow_crud.js";
import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js"; import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js";
import { registerAllRecipeTools } from "./arcrun_recipe.js"; import { registerAllRecipeTools } from "./arcrun_recipe.js";
import { registerWhoami } from "./arcrun_whoami.js";
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) { export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
registerSearchComponents(server, env, orgNamespace); registerSearchComponents(server, env, orgNamespace);
@@ -49,4 +50,6 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
registerAllSkillExampleTools(server, env); registerAllSkillExampleTools(server, env);
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後) // kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
registerAllRecipeTools(server, env); registerAllRecipeTools(server, env);
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
registerWhoami(server, env, orgNamespace);
} }