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
+33 -12
View File
@@ -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 key8.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 — 一次性 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;標準/向後相容)
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 });
});