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
+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));
}