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,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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user