Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d3973ecbc |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -24,6 +24,8 @@ import { consoleAuthRouter } from './routes/console-auth';
|
||||
import { consoleDashboardRouter } from './routes/console-dashboard';
|
||||
import { portalRouter } from './routes/portal';
|
||||
import { portalDataRouter } from './routes/portal-data';
|
||||
import { storageRouter } from './routes/storage';
|
||||
import { withDurableStores } from './lib/durable-store';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -97,11 +99,19 @@ app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單
|
||||
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
|
||||
app.route('/', portalRouter); // portal-auth P2(#24/#25):RAG Portal 多人授權——用戶模型+認證 API
|
||||
app.route('/', portalDataRouter); // portal-auth P3:/portal/data/* server-side enforce(owner_id+library 注入,安全核心)
|
||||
app.route('/', storageRouter); // KV 退休(#16/#17):資產遷移/盤點端點
|
||||
|
||||
// Worker 導出(fetch + scheduled)
|
||||
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick;
|
||||
// 邏輯在 src/scheduled.ts。對應 SDD: arcrun.md 三-A P1 #3。
|
||||
//
|
||||
// 🔴 KV 退休(Leo/Arcrun#16 + #17):WEBHOOKS / RECIPES 在這裡被換成 KBDB 撐腰的版本
|
||||
//(lib/durable-store.ts)。**這是唯一的接線點**——換在入口,四十幾處呼叫端一行不動,
|
||||
// 也就沒有「某一處忘了改」這種漏洞(那正是資產會不見的入口)。
|
||||
// 使用者的工作流與 recipe 從此住在 KBDB(D1,一份資產一列 entry),KV 只是快取:
|
||||
// KV 被換掉/重建之後,資料仍在,且會在下一次讀取時自己長回快取。
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
scheduled: handleScheduled,
|
||||
fetch: (req: Request, env: Bindings, ctx: ExecutionContext) => app.fetch(req, withDurableStores(env), ctx),
|
||||
scheduled: (event: ScheduledController, env: Bindings, ctx: ExecutionContext) =>
|
||||
handleScheduled(event, withDurableStores(env), ctx),
|
||||
} satisfies ExportedHandler<Bindings>;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* asset-keys — 哪些 KV key 是「使用者的資產」,以及它在 KBDB 裡對應哪一列
|
||||
*
|
||||
* KV 退休(Leo/Arcrun#16 + #17)。leo 2026-08-12:
|
||||
* 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
|
||||
* 「如果零件和工作流的 recipe 不見了,是很可怕的事情」
|
||||
*
|
||||
* ── 這支檔在整件事裡的位置 ────────────────────────────────────────────────
|
||||
* 真正的修法只有一句:**資產的家在 KBDB,KV 降級成可丟棄的快取**。
|
||||
* 但 KV 的呼叫端有四十幾處(webhooks-named / portal / executions / component-loader /
|
||||
* auth-dispatcher / wasi-shim …),逐處改寫既冗長又容易漏一處——漏掉的那處就是下一次
|
||||
* 「東西不見了」的入口。所以改法是換掉 binding 本身(見 durable-store.ts),
|
||||
* 而這支檔就是那層唯一需要人看懂的東西:**一張表,說清楚哪些 key 是資產、對應哪一列。**
|
||||
*
|
||||
* ── 判準:資產 vs 衍生 ────────────────────────────────────────────────────
|
||||
* 資產 = 弄丟了就再也回不來的東西(使用者/AI 寫出來的工作流、recipe)。→ 進 KBDB。
|
||||
* 衍生 = 從資產算得出來的東西(反查索引 idx:*、cron 索引、快取、session、
|
||||
* 帶 TTL 的暫存)。→ 留在 KV,弄丟了自己重建(durable-store 負責重建)。
|
||||
*
|
||||
* 判斷不確定時一律歸「衍生」——把衍生誤存進 KBDB 只是多幾列垃圾,
|
||||
* 把資產誤判成衍生才是把人家的東西弄丟。
|
||||
*
|
||||
* ── 為什麼不做成「KV key 原樣鏡射進 KBDB」 ────────────────────────────────
|
||||
* 那樣 KBDB 會變成第二顆 KV,leo 要的「一個 entry」就不成立:搜不到、看不懂、
|
||||
* 對 portal/console 也沒有意義。所以這裡把每個 key 解析成**有語意的一列**
|
||||
* (entry_type + owner_id + page_name + 一句描述),KBDB 端才真的是「他的資產」。
|
||||
*/
|
||||
|
||||
/** KBDB 裡 arcrun 資產的 entry_type(每個都有一列 template 定義,見 kbdb/migrations/0005)。 */
|
||||
export type AssetEntryType = 'workflow_def' | 'api_recipe' | 'auth_recipe' | 'prompt_recipe';
|
||||
|
||||
export interface AssetRef {
|
||||
entry_type: AssetEntryType;
|
||||
/** KBDB entries.id——由 key 決定,同一份資產永遠同一列(冪等,重跑遷移不長重複)。 */
|
||||
entry_id: string;
|
||||
/** 租戶。recipe 是整台實例共用的庫,故為 null(與現行 RECIPES KV 無租戶前綴一致)。 */
|
||||
owner_id: string | null;
|
||||
/** 該型別的自然鍵(workflow 名 / recipe uuid / service 名),對應 entries.page_name。 */
|
||||
page_name: string;
|
||||
/** 原本的 KV key——反向重建快取時要用(KBDB → KV 回填)。 */
|
||||
kv_key: string;
|
||||
}
|
||||
|
||||
/** entries.id 的前綴,跟別人的資料分得開,也讓 `arcrun:` 一眼看得出是誰的列。 */
|
||||
const ID_PREFIX = 'arcrun';
|
||||
|
||||
/**
|
||||
* 把一個 KV key 解析成 KBDB 的一列;不是資產就回 null(呼叫端原樣走 KV)。
|
||||
*
|
||||
* ⚠️ 這是**唯一**決定「什麼進 KBDB」的地方。要新增一類資產就加在這裡,
|
||||
* 不要在別處另開一條偷偷寫 KBDB 的路——兩套並存必然漂移(2026-08-08 credential 的教訓)。
|
||||
*/
|
||||
export function classifyAssetKey(key: string): AssetRef | null {
|
||||
// ── 明確排除的衍生資料(放最前面,免得被下面的樣式誤收)────────────────
|
||||
// idx:* recipe/component 反查索引(canonical→uuid、hash→canonical)
|
||||
// cron-idx:* cron 排程索引(8.P0 的單一 key)
|
||||
// 兩者都能從資產重算,見 durable-store.ts 的 rehydrate*。
|
||||
if (key.startsWith('idx:') || key.startsWith('cron-idx:')) return null;
|
||||
|
||||
// auth_recipe:{service} — 「怎麼認證」的定義。注意只有定義,沒有任何密文
|
||||
//(憑證明文在 CF Workers Secrets,見 .claude/rules/01-tech-stack.md「Credential 儲存規範」)。
|
||||
if (key.startsWith('auth_recipe:')) {
|
||||
const service = key.slice('auth_recipe:'.length);
|
||||
if (!service) return null;
|
||||
return {
|
||||
entry_type: 'auth_recipe',
|
||||
entry_id: `${ID_PREFIX}:auth_recipe:${service}`,
|
||||
owner_id: null,
|
||||
page_name: service,
|
||||
kv_key: key,
|
||||
};
|
||||
}
|
||||
|
||||
// prompt_recipe:{name}
|
||||
if (key.startsWith('prompt_recipe:')) {
|
||||
const name = key.slice('prompt_recipe:'.length);
|
||||
if (!name) return null;
|
||||
return {
|
||||
entry_type: 'prompt_recipe',
|
||||
entry_id: `${ID_PREFIX}:prompt_recipe:${name}`,
|
||||
owner_id: null,
|
||||
page_name: name,
|
||||
kv_key: key,
|
||||
};
|
||||
}
|
||||
|
||||
// recipe:{uuid}|recipe:{canonical_id}(migration 前的舊 key,仍是資產,一樣要保住)
|
||||
if (key.startsWith('recipe:')) {
|
||||
const id = key.slice('recipe:'.length);
|
||||
if (!id) return null;
|
||||
return {
|
||||
entry_type: 'api_recipe',
|
||||
entry_id: `${ID_PREFIX}:recipe:${id}`,
|
||||
owner_id: null,
|
||||
page_name: id,
|
||||
kv_key: key,
|
||||
};
|
||||
}
|
||||
|
||||
// {api_key}:wf:{name} — 具名工作流(acr push / portal 安裝器寫的那把)。
|
||||
// 用**第一個** ':wf:' 切:api_key 不含冒號,而 workflow 名允許的字元集
|
||||
//(webhooks-named.ts 驗 /^[\w-]+$/)本來就不含冒號,所以切點唯一。
|
||||
const wfAt = key.indexOf(':wf:');
|
||||
if (wfAt > 0) {
|
||||
const owner = key.slice(0, wfAt);
|
||||
const name = key.slice(wfAt + ':wf:'.length);
|
||||
if (!owner || !name) return null;
|
||||
return {
|
||||
entry_type: 'workflow_def',
|
||||
entry_id: `${ID_PREFIX}:wf:${owner}:${name}`,
|
||||
owner_id: owner,
|
||||
page_name: name,
|
||||
kv_key: key,
|
||||
};
|
||||
}
|
||||
|
||||
// 其餘一律衍生/暫存:匿名 webhook token、daemon-active、session、stats… 留在 KV。
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 從 KBDB 的一列反推回原本的 KV key(快取回填、list 都要用)。 */
|
||||
export function assetKvKey(entryType: AssetEntryType, ownerId: string | null, pageName: string): string {
|
||||
switch (entryType) {
|
||||
case 'workflow_def':
|
||||
return `${ownerId ?? ''}:wf:${pageName}`;
|
||||
case 'api_recipe':
|
||||
return `recipe:${pageName}`;
|
||||
case 'auth_recipe':
|
||||
return `auth_recipe:${pageName}`;
|
||||
case 'prompt_recipe':
|
||||
return `prompt_recipe:${pageName}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一個 KV list 的 prefix 該去 KBDB 撈哪一類資產。
|
||||
*
|
||||
* 為什麼 list 一定要走 KBDB(不能像 get 那樣先問快取):被換掉的那顆 KV 是**空的**,
|
||||
* 空 KV list 出來是「零筆」而不是「查不到」——這正是 2026-08-12 那天畫面上
|
||||
* 「九支工作流全部消失」的形狀。get 可以 KV 先行(miss 再回源),list 不行。
|
||||
*
|
||||
* 回 null = 這個 prefix 不是資產類(例如 cron-idx:),照舊走 KV。
|
||||
*/
|
||||
export function classifyListPrefix(prefix: string | undefined): { entry_type: AssetEntryType; owner_id?: string } | null {
|
||||
if (!prefix) return null; // 無 prefix 的全域 list(webhooks-list)維持原行為
|
||||
if (prefix.startsWith('idx:') || prefix.startsWith('cron-idx:')) return null;
|
||||
if (prefix === 'auth_recipe:') return { entry_type: 'auth_recipe' };
|
||||
if (prefix === 'prompt_recipe:') return { entry_type: 'prompt_recipe' };
|
||||
if (prefix === 'recipe:') return { entry_type: 'api_recipe' };
|
||||
// `{api_key}:wf:` — 列出某租戶的所有工作流(webhooks-named / portal-data 都用這個)
|
||||
if (prefix.endsWith(':wf:')) {
|
||||
const owner = prefix.slice(0, -':wf:'.length);
|
||||
if (owner) return { entry_type: 'workflow_def', owner_id: owner };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -273,13 +273,8 @@ function makeHttpRunner(url: string): ComponentRunner {
|
||||
const text = await res.text();
|
||||
return { success: false, status: res.status, error: text.slice(0, 200) };
|
||||
}
|
||||
// 只讀一次 body(同檔 readBodyOnce 的註解已寫明這個坑,這裡以前卻正好踩到):
|
||||
// 舊寫法 `try { res.json() } catch { res.text() }` 在零件回非 JSON 時,
|
||||
// res.json() 失敗當下 body 已被消費 → 第二次讀丟 "Body has already been used",
|
||||
// 使用者看到的是這句跟真因(零件回了非 JSON)完全無關的訊息(Arcrun#92 同類)。
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); }
|
||||
catch { return { success: true, data: text }; }
|
||||
try { return await res.json(); }
|
||||
catch { return { success: true, data: await res.text() }; }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* durable-store — 讓 KV 變成快取,資產的家搬到 KBDB
|
||||
*
|
||||
* KV 退休(Leo/Arcrun#16 + #17)。leo 2026-08-12:
|
||||
* 「因為對 KV 的使用有禁令,但卻會把資產放在這裡,這不是違法嗎?」
|
||||
* 「現在是我幫他寫工作流,未來是他的 AI 自己寫工作流,
|
||||
* 如果零件和工作流的 recipe 不見了,是很可怕的事情」
|
||||
* 同一天實害:一次例行更新讓使用者的九支工作流在畫面上全部消失
|
||||
*(根因見 cli/src/lib/resource-resolver.ts 檔頭 Arcrun#97)。
|
||||
*
|
||||
* ── 一句話 ────────────────────────────────────────────────────────────────
|
||||
* **資產寫進 KBDB(D1,一份資產一列 entry);KV 退成可丟棄的快取。**
|
||||
* 換掉/重建 KV 之後,資料仍在 KBDB,而且會在下一次讀取時自己長回快取裡。
|
||||
*
|
||||
* ── 為什麼是換掉 binding,不是改四十幾處呼叫端 ────────────────────────────
|
||||
* WEBHOOKS / RECIPES 的呼叫端散在 webhooks-named、portal、executions、component-loader、
|
||||
* auth-dispatcher、wasi-shim…… 逐處改寫既冗長又一定會漏,而**漏掉的那一處就是下一次
|
||||
* 「東西不見了」的入口**。所以在 worker 入口把 binding 換成本檔的包裝,呼叫端一行不動——
|
||||
* 「哪些 key 是資產」則集中在 asset-keys.ts 那一張表裡,是這件事唯一需要人看懂的東西。
|
||||
*
|
||||
* ── 三條行為規則 ──────────────────────────────────────────────────────────
|
||||
* 1. **讀**:先問 KV(快)→ 沒有就回源 KBDB → 順手把快取補回去(空 KV 自己痊癒)。
|
||||
* 2. **寫**:先寫 KBDB(真相),成功了才寫 KV 快取。
|
||||
* KBDB 寫失敗 → **拋錯**,不假裝部署成功——「看起來成功、其實沒存到」正是這張票的病。
|
||||
* 3. **列舉**:一律走 KBDB,**不准問 KV**。被換掉的那顆 KV 是空的,
|
||||
* 空 KV 列出來是「零筆」而不是「查不到」——那正是「九支工作流全部消失」的形狀。
|
||||
*
|
||||
* ── 衍生資料(idx:* / cron-idx:*)怎麼辦 ──────────────────────────────────
|
||||
* 它們算得出來,所以不進 KBDB(KBDB 不該長出垃圾列),改成**讀不到就重算**。
|
||||
* 見 rehydrateRecipeIndices / rehydrateCronIndex。
|
||||
*/
|
||||
|
||||
import { classifyAssetKey, classifyListPrefix, assetKvKey, type AssetRef, type AssetEntryType } from './asset-keys';
|
||||
import { CRON_INDEX_KEY, cronEntryKey, type CronIndex } from './cron-index';
|
||||
|
||||
export interface KbdbEnv {
|
||||
KBDB_BASE_URL?: string;
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
}
|
||||
|
||||
/** KBDB 位址與 token 的取法沿用既有慣例(lib/workflow-search.ts、routes/webhooks-named.ts 同款)。 */
|
||||
function kbdbBase(env: KbdbEnv): string {
|
||||
return (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function kbdbHeaders(env: KbdbEnv): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.KBDB_INTERNAL_TOKEN) h['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
/** KBDB 一列 entry 的回應形狀(只取本檔用得到的欄位)。 */
|
||||
interface KbdbEntry {
|
||||
id: string;
|
||||
content?: string | null;
|
||||
entry_type?: string | null;
|
||||
owner_id?: string | null;
|
||||
page_name?: string | null;
|
||||
metadata_json?: string | null;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
/** 資產寫進 metadata_json 的信封。definition = 原值 parse 過的物件;非 JSON 的原字串走 definition_raw。 */
|
||||
interface AssetEnvelope {
|
||||
arcrun_asset: true;
|
||||
kv_key: string;
|
||||
definition?: unknown;
|
||||
definition_raw?: string;
|
||||
/** api_recipe 專用:本部署目前安裝的是不是這一版(重建 idx:installed:* 用,見 rehydrateRecipeIndices)。 */
|
||||
installed?: boolean;
|
||||
}
|
||||
|
||||
export class KbdbUnavailableError extends Error {
|
||||
constructor(op: string, detail: string) {
|
||||
super(
|
||||
`資產無法寫入 KBDB(${op}):${detail}。` +
|
||||
'本次操作已中止且未寫入任何一邊——這是刻意的:寧可讓你現在看到失敗,' +
|
||||
'也不要寫進只會被下次更新換掉的 KV、事後才發現東西不見了(Leo/Arcrun#16、#17)。',
|
||||
);
|
||||
this.name = 'KbdbUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 從資產定義裡挑一句「給人看也給搜尋看」的描述,當 entries.content。 */
|
||||
function assetContent(type: AssetEntryType, def: unknown, pageName: string): string {
|
||||
const d = (def ?? {}) as Record<string, unknown>;
|
||||
const pick = (...keys: string[]): string => {
|
||||
for (const k of keys) {
|
||||
const v = d[k];
|
||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
switch (type) {
|
||||
case 'workflow_def':
|
||||
return pick('description') || pageName;
|
||||
case 'api_recipe':
|
||||
return pick('description', 'display_name', 'canonical_id') || pageName;
|
||||
case 'auth_recipe':
|
||||
return pick('description', 'display_name', 'service') || pageName;
|
||||
case 'prompt_recipe':
|
||||
return pick('description', 'name') || pageName;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 KBDB 一列還原成原本的 KV 值(字串)。不是資產信封(或壞掉)→ null,誠實當作沒有。 */
|
||||
function entryToKvValue(entry: KbdbEntry | null | undefined): string | null {
|
||||
if (!entry?.metadata_json) return null;
|
||||
try {
|
||||
const env = JSON.parse(entry.metadata_json) as AssetEnvelope;
|
||||
if (!env || env.arcrun_asset !== true) return null;
|
||||
if (typeof env.definition_raw === 'string') return env.definition_raw;
|
||||
if (env.definition === undefined) return null;
|
||||
return JSON.stringify(env.definition);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KV binding 的替身:介面與 KVNamespace 同形,行為見檔頭三條規則。
|
||||
* 一個 request 建一個實例(rehydrate 的去重旗標是 per-instance 的)。
|
||||
*/
|
||||
export class DurableKv {
|
||||
private rehydratedRecipeIdx = false;
|
||||
private rehydratedCronIdx = false;
|
||||
|
||||
constructor(
|
||||
private readonly kv: KVNamespace,
|
||||
private readonly env: KbdbEnv,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 拿回底層那顆真正的 KV。**只有遷移/盤點會用到**(routes/storage.ts):
|
||||
* 那兩支的工作正是「比較 KV 那邊有什麼、KBDB 這邊有什麼」,
|
||||
* 若透過包裝去問,list 會被導去 KBDB,就永遠比不出差異、也搬不動舊資料。
|
||||
* 一般業務程式碼不該碰這支——碰了就等於繞過本卷的全部保護。
|
||||
*/
|
||||
get rawKv(): KVNamespace {
|
||||
return this.kv;
|
||||
}
|
||||
|
||||
// ── KBDB 存取原語 ──────────────────────────────────────────────────────
|
||||
|
||||
private async kbdbGetEntry(entryId: string): Promise<KbdbEntry | null> {
|
||||
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(entryId)}`, {
|
||||
headers: kbdbHeaders(this.env),
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null; // 讀不到就當沒有;快取仍可能有值,不炸讀取路徑
|
||||
const json = (await res.json().catch(() => null)) as { entry?: KbdbEntry } | null;
|
||||
return json?.entry ?? null;
|
||||
}
|
||||
|
||||
private async kbdbListEntries(entryType: AssetEntryType, ownerId?: string): Promise<KbdbEntry[]> {
|
||||
const params = new URLSearchParams({ entry_type: entryType, limit: '1000' });
|
||||
if (ownerId) params.set('owner_id', ownerId);
|
||||
const res = await fetch(`${kbdbBase(this.env)}/entries?${params.toString()}`, {
|
||||
headers: kbdbHeaders(this.env),
|
||||
});
|
||||
if (!res.ok) throw new KbdbUnavailableError('list', `HTTP ${res.status}`);
|
||||
const json = (await res.json().catch(() => null)) as { entries?: KbdbEntry[] } | null;
|
||||
return json?.entries ?? [];
|
||||
}
|
||||
|
||||
private async kbdbPutEntry(ref: AssetRef, envelope: AssetEnvelope): Promise<void> {
|
||||
const content = assetContent(ref.entry_type, envelope.definition, ref.page_name);
|
||||
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(ref.entry_id)}`, {
|
||||
method: 'PUT',
|
||||
headers: kbdbHeaders(this.env),
|
||||
body: JSON.stringify({
|
||||
entry_type: ref.entry_type,
|
||||
owner_id: ref.owner_id,
|
||||
page_name: ref.page_name,
|
||||
content,
|
||||
// 刻意**不**標 embed:true:工作流的語意搜尋走既有的 entry_type='workflow' 那一列
|
||||
//(workflow-discovery 方案 C 的雙寫),這裡標了會變成同一支工作流嵌兩份向量。
|
||||
metadata_json: JSON.stringify(envelope),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new KbdbUnavailableError('put', `HTTP ${res.status} @ ${ref.entry_id}`);
|
||||
}
|
||||
|
||||
private async kbdbDeleteEntry(entryId: string): Promise<void> {
|
||||
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(entryId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: kbdbHeaders(this.env),
|
||||
});
|
||||
// 404 = 本來就沒有,對刪除而言是成功(冪等)。
|
||||
if (!res.ok && res.status !== 404) throw new KbdbUnavailableError('delete', `HTTP ${res.status} @ ${entryId}`);
|
||||
}
|
||||
|
||||
// ── 衍生索引重建(讀不到就重算,不進 KBDB)────────────────────────────
|
||||
|
||||
/**
|
||||
* 從 KBDB 的 api_recipe 列重建 recipe 反查索引:
|
||||
* idx:{hash_id} → canonical_id
|
||||
* idx:canonical:{canonical} → [uuid, ...]
|
||||
* idx:installed:{canonical} → uuid
|
||||
*
|
||||
* installed 的還原順序:先看資產自己標的 installed 旗標(正常路徑,寫入時就記下了,
|
||||
* 見 put() 對 `idx:installed:` 的處理);同一個 canonical 沒有任何一版標記時
|
||||
*(=遷移之前就存在的舊資料),退而取 updated_at 最新的那一版——因為
|
||||
* installRecipeRecord 的語意本來就是「最後寫入的那版即為安裝版」。
|
||||
* 這是還原不是猜測,但仍是**退路**,故在此寫明白。
|
||||
*/
|
||||
private async rehydrateRecipeIndices(): Promise<void> {
|
||||
if (this.rehydratedRecipeIdx) return;
|
||||
this.rehydratedRecipeIdx = true;
|
||||
|
||||
const entries = await this.kbdbListEntries('api_recipe');
|
||||
const byCanonical = new Map<string, Array<{ uuid: string; installed: boolean; updated_at: number }>>();
|
||||
const writes: Array<Promise<unknown>> = [];
|
||||
|
||||
for (const e of entries) {
|
||||
const raw = entryToKvValue(e);
|
||||
if (!raw) continue;
|
||||
let def: { uuid?: string; canonical_id?: string; hash_id?: string };
|
||||
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
|
||||
if (!def.canonical_id) continue;
|
||||
|
||||
if (def.hash_id) writes.push(this.kv.put(`idx:${def.hash_id}`, def.canonical_id));
|
||||
if (!def.uuid) continue;
|
||||
|
||||
let installed = false;
|
||||
try {
|
||||
installed = (JSON.parse(e.metadata_json ?? '{}') as AssetEnvelope).installed === true;
|
||||
} catch { /* 壞信封 → 當作沒標記,走 updated_at 退路 */ }
|
||||
|
||||
const list = byCanonical.get(def.canonical_id) ?? [];
|
||||
list.push({ uuid: def.uuid, installed, updated_at: e.updated_at ?? 0 });
|
||||
byCanonical.set(def.canonical_id, list);
|
||||
}
|
||||
|
||||
for (const [canonical, versions] of byCanonical) {
|
||||
writes.push(this.kv.put(`idx:canonical:${canonical}`, JSON.stringify(versions.map((v) => v.uuid))));
|
||||
const chosen =
|
||||
versions.find((v) => v.installed) ??
|
||||
versions.reduce((a, b) => (b.updated_at > a.updated_at ? b : a));
|
||||
writes.push(this.kv.put(`idx:installed:${canonical}`, chosen.uuid));
|
||||
}
|
||||
await Promise.all(writes);
|
||||
}
|
||||
|
||||
/** 從 KBDB 的 workflow_def 列重建 cron 索引(單一 key,見 lib/cron-index.ts)。 */
|
||||
private async rehydrateCronIndex(): Promise<void> {
|
||||
if (this.rehydratedCronIdx) return;
|
||||
this.rehydratedCronIdx = true;
|
||||
|
||||
const entries = await this.kbdbListEntries('workflow_def');
|
||||
const index: CronIndex = {};
|
||||
for (const e of entries) {
|
||||
const raw = entryToKvValue(e);
|
||||
if (!raw) continue;
|
||||
let def: { cron_expr?: string };
|
||||
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
|
||||
if (!def.cron_expr || !e.owner_id || !e.page_name) continue;
|
||||
index[cronEntryKey(e.owner_id, e.page_name)] = def.cron_expr;
|
||||
}
|
||||
// 即使是空的也要寫回去:寫了之後 get 就命中,下一分鐘的 tick 不會再重算一次
|
||||
//(不寫的話 scheduled() 每分鐘都會回源 KBDB 一趟,白花錢)。
|
||||
await this.kv.put(CRON_INDEX_KEY, JSON.stringify(index));
|
||||
}
|
||||
|
||||
// ── KVNamespace 介面 ───────────────────────────────────────────────────
|
||||
|
||||
async get(key: string, type?: 'text' | 'json' | 'arrayBuffer' | 'stream' | { type: string }): Promise<any> {
|
||||
// 二進位/串流形態本 worker 沒有呼叫端在用(資產都是 JSON 文字)。原樣轉發,
|
||||
// 不假裝支援——真有人開始用而拿不到 KBDB 回源,會在這裡被看見,不是靜默降級。
|
||||
const t0 = typeof type === 'string' ? type : type?.type;
|
||||
if (t0 === 'arrayBuffer' || t0 === 'stream') return this.kv.get(key, t0 as 'arrayBuffer');
|
||||
|
||||
const asText = (raw: string | null): unknown => {
|
||||
if (raw === null) return null;
|
||||
const t = typeof type === 'string' ? type : type?.type;
|
||||
if (t === 'json') {
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
const ref = classifyAssetKey(key);
|
||||
if (!ref) {
|
||||
// 衍生/暫存:原樣走 KV。讀不到而且是「算得出來」的索引 → 重算一次再讀。
|
||||
const raw = await this.kv.get(key, 'text');
|
||||
if (raw !== null) return asText(raw);
|
||||
if (key.startsWith('idx:')) {
|
||||
await this.rehydrateRecipeIndices().catch(() => {});
|
||||
return asText(await this.kv.get(key, 'text'));
|
||||
}
|
||||
if (key === CRON_INDEX_KEY) {
|
||||
await this.rehydrateCronIndex().catch(() => {});
|
||||
return asText(await this.kv.get(key, 'text'));
|
||||
}
|
||||
return asText(raw);
|
||||
}
|
||||
|
||||
// 資產:快取優先,miss 回源 KBDB 並補快取(被換掉的空 KV 就是這樣自己痊癒的)。
|
||||
const cached = await this.kv.get(key, 'text');
|
||||
if (cached !== null) return asText(cached);
|
||||
|
||||
const fromKbdb = entryToKvValue(await this.kbdbGetEntry(ref.entry_id));
|
||||
if (fromKbdb === null) return asText(null);
|
||||
await this.kv.put(key, fromKbdb).catch(() => {}); // 補快取失敗不影響這次讀取
|
||||
return asText(fromKbdb);
|
||||
}
|
||||
|
||||
async put(key: string, value: string | ArrayBuffer | ReadableStream, options?: KVNamespacePutOptions): Promise<void> {
|
||||
// 帶 TTL=定義上就是暫存(daemon 回報、session…),不是資產,不進 KBDB。
|
||||
if (options?.expirationTtl || options?.expiration || typeof value !== 'string') {
|
||||
return this.kv.put(key, value as string, options);
|
||||
}
|
||||
|
||||
// idx:installed:{canonical} 不是資產,但它記的是**使用者的選擇**(這個 canonical 目前
|
||||
// 裝的是哪一版),純算不回來。所以把它記進對應那一版 recipe 資產的信封裡,
|
||||
// KBDB 端不會多出一列「指標 entry」,重建時又還原得精確(見 rehydrateRecipeIndices)。
|
||||
if (key.startsWith('idx:installed:')) {
|
||||
await this.kv.put(key, value, options);
|
||||
await this.markInstalledVersion(key.slice('idx:installed:'.length), value).catch(() => {
|
||||
// 標記失敗不擋主流程:recipe 本體已經在 KBDB,最壞情況是重建時退回 updated_at 那條路。
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = classifyAssetKey(key);
|
||||
if (!ref) return this.kv.put(key, value, options);
|
||||
|
||||
let definition: unknown;
|
||||
let definitionRaw: string | undefined;
|
||||
try { definition = JSON.parse(value); } catch { definitionRaw = value; }
|
||||
|
||||
// 覆寫定義不該把「這版是目前安裝的那版」洗掉 → 只有 api_recipe 需要先讀回舊信封。
|
||||
// 其他三型沒有這個欄位,省下這一次往返(每次 acr push 都會走到這裡)。
|
||||
const previous = ref.entry_type === 'api_recipe' ? await this.readEnvelope(ref) : null;
|
||||
|
||||
// 先真相、後快取。KBDB 失敗就拋——不寫 KV、不回報成功(禁假綠,mindset §7)。
|
||||
await this.kbdbPutEntry(ref, {
|
||||
arcrun_asset: true,
|
||||
kv_key: key,
|
||||
...(definitionRaw !== undefined ? { definition_raw: definitionRaw } : { definition }),
|
||||
...(previous?.installed ? { installed: true } : {}),
|
||||
});
|
||||
await this.kv.put(key, value, options);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const ref = classifyAssetKey(key);
|
||||
if (ref) await this.kbdbDeleteEntry(ref.entry_id);
|
||||
await this.kv.delete(key);
|
||||
}
|
||||
|
||||
async list(options?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> {
|
||||
const target = classifyListPrefix(options?.prefix ?? undefined);
|
||||
if (!target) return this.kv.list(options) as Promise<KVNamespaceListResult<unknown, string>>;
|
||||
|
||||
// 資產列舉一律回源(見檔頭規則 3)。順手把每一筆補進快取——列表回應本來就帶了完整內容,
|
||||
// 呼叫端接著一筆筆 get 時就會全部命中,一次回源換掉 N 次往返。
|
||||
const entries = await this.kbdbListEntries(target.entry_type, target.owner_id);
|
||||
const keys: Array<{ name: string }> = [];
|
||||
const warm: Array<Promise<unknown>> = [];
|
||||
for (const e of entries) {
|
||||
if (!e.page_name) continue;
|
||||
// workflow_def 的 KV key 由租戶+名字組成,缺租戶就組不出正確的 key——
|
||||
// 與其回一個 `:wf:x` 這種對不到任何東西的名字,不如跳過(列不出來看得見,
|
||||
// 組錯名字則會安靜地讀到 null,那更難查)。
|
||||
if (target.entry_type === 'workflow_def' && !e.owner_id) continue;
|
||||
const name = assetKvKey(target.entry_type, e.owner_id ?? null, e.page_name);
|
||||
keys.push({ name });
|
||||
const raw = entryToKvValue(e);
|
||||
if (raw !== null) warm.push(this.kv.put(name, raw).catch(() => {}));
|
||||
}
|
||||
await Promise.all(warm);
|
||||
return { keys, list_complete: true, cacheStatus: null } as unknown as KVNamespaceListResult<unknown, string>;
|
||||
}
|
||||
|
||||
/** KVNamespace 介面補齊(本 worker 沒有呼叫端在用,原樣轉發,不做資產處理)。 */
|
||||
getWithMetadata(key: string, type?: any): Promise<any> {
|
||||
return (this.kv as unknown as { getWithMetadata: (k: string, t?: any) => Promise<any> }).getWithMetadata(key, type);
|
||||
}
|
||||
|
||||
// ── 內部小工具 ─────────────────────────────────────────────────────────
|
||||
|
||||
private async readEnvelope(ref: AssetRef): Promise<AssetEnvelope | null> {
|
||||
const entry = await this.kbdbGetEntry(ref.entry_id);
|
||||
if (!entry?.metadata_json) return null;
|
||||
try {
|
||||
const env = JSON.parse(entry.metadata_json) as AssetEnvelope;
|
||||
return env?.arcrun_asset === true ? env : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把「這個 canonical 目前裝的是哪一版」記進該版 recipe 的信封(同 canonical 的其他版清掉旗標)。 */
|
||||
private async markInstalledVersion(canonicalId: string, uuid: string): Promise<void> {
|
||||
const entries = await this.kbdbListEntries('api_recipe');
|
||||
const jobs: Array<Promise<unknown>> = [];
|
||||
for (const e of entries) {
|
||||
const raw = entryToKvValue(e);
|
||||
if (!raw) continue;
|
||||
let def: { uuid?: string; canonical_id?: string };
|
||||
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
|
||||
if (def.canonical_id !== canonicalId || !def.uuid) continue;
|
||||
|
||||
const shouldBeInstalled = def.uuid === uuid;
|
||||
let envelope: AssetEnvelope;
|
||||
try { envelope = JSON.parse(e.metadata_json ?? '{}') as AssetEnvelope; } catch { continue; }
|
||||
if ((envelope.installed === true) === shouldBeInstalled) continue; // 已經是對的,不白寫
|
||||
|
||||
envelope.installed = shouldBeInstalled;
|
||||
jobs.push(
|
||||
fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(e.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: kbdbHeaders(this.env),
|
||||
body: JSON.stringify({ metadata_json: JSON.stringify(envelope) }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 worker 入口把 WEBHOOKS / RECIPES 換成 KBDB 撐腰的版本。
|
||||
*
|
||||
* 只換這兩個:它們裝的是**使用者寫出來的東西**(工作流、recipe)。
|
||||
* 其餘 KV(EXEC_CONTEXT 執行中暫存、SESSIONS_KV、ANALYTICS_KV、CREDENTIALS_KV)
|
||||
* 要嘛是暫存、要嘛另有搬遷路徑(credential 走 CF Workers Secrets + D1 目錄,
|
||||
* 見 .claude/rules/01-tech-stack.md),不在本卷範圍——**不順手一起動**。
|
||||
*
|
||||
* KBDB_BASE_URL 沒設也照樣運作:kbdbBase() 有預設值;真的連不上時
|
||||
* 讀取路徑退回純 KV(維持現況、不比以前糟),寫入路徑誠實拋錯(不假裝存好了)。
|
||||
*/
|
||||
export function withDurableStores<T extends { WEBHOOKS?: KVNamespace; RECIPES?: KVNamespace } & KbdbEnv>(env: T): T {
|
||||
const wrapped = { ...env } as T;
|
||||
if (env.WEBHOOKS) wrapped.WEBHOOKS = new DurableKv(env.WEBHOOKS, env) as unknown as KVNamespace;
|
||||
if (env.RECIPES) wrapped.RECIPES = new DurableKv(env.RECIPES, env) as unknown as KVNamespace;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得底層真 KV(沒被包裝就是它自己)。遷移/盤點專用,理由見 DurableKv.rawKv。
|
||||
* 寫成獨立函式是為了讓「誰在繞過包裝」grep 得出來——目前只有 routes/storage.ts。
|
||||
*/
|
||||
export function unwrapKv(binding: KVNamespace): KVNamespace {
|
||||
const maybe = binding as unknown as { rawKv?: KVNamespace };
|
||||
return maybe.rawKv ?? binding;
|
||||
}
|
||||
@@ -27,21 +27,6 @@ export interface ArcrunHostEnv {
|
||||
const WASI_ESUCCESS = 0;
|
||||
const WASI_ENOSYS = 76;
|
||||
|
||||
// ── host function 回傳碼(u6u.*)─────────────────────────────────────────────
|
||||
// 零件(main.go)用同一組數字判斷,改這裡要同步改 registry/components/*/main.go。
|
||||
export const HOST_OK = 0;
|
||||
/** host 端出錯(memory 不可用 / 例外)— 零件無從得知細節 */
|
||||
export const HOST_ERROR = 1;
|
||||
/** 查無此 key / ref(kv_get、secret_get 用) */
|
||||
export const HOST_NOT_FOUND = 2;
|
||||
/**
|
||||
* Arcrun#92:資料塞不進零件宣告的接收緩衝區(**不是**連線失敗、**不是**對方報錯)。
|
||||
* 舊行為是「照寫下去」——data 比零件的 outBuf 大時會覆寫零件堆積體,零件接著用
|
||||
* `outBuf[:outLen]` 切片會 panic,或 writeOut 撞到 memory 邊界丟例外 → 回 1 →
|
||||
* 零件印一句與真因無關的 "HTTP request failed"。使用者照那句去查連線,方向全錯。
|
||||
*/
|
||||
export const HOST_TOO_LARGE = 3;
|
||||
|
||||
// fd 常數
|
||||
const FD_STDIN = 0;
|
||||
const FD_STDOUT = 1;
|
||||
@@ -90,51 +75,6 @@ export interface WasiHostFunctions {
|
||||
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 容量握手的判定規則(Arcrun#92):資料塞不塞得進零件宣告的緩衝區?
|
||||
* declaredCapacity === 0 ⇒ 舊零件沒宣告容量,host 無從得知上限 → 維持舊行為照寫,
|
||||
* 不可自作聰明套一個預設值(各零件緩衝區大小不同:http_request 64KB、claude_api 1MB,
|
||||
* 硬套會把原本正常的大回應誤判成「太大」——那是換一種說謊)。
|
||||
*/
|
||||
export function outFitsCapacity(declaredCapacity: number, dataLength: number): boolean {
|
||||
return declaredCapacity === 0 || dataLength <= declaredCapacity;
|
||||
}
|
||||
|
||||
/** 位元組數轉人看得懂的單位(訊息裡要出現真實數字,不能只說「太大」) */
|
||||
export function formatBytes(n: number): string {
|
||||
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${n} bytes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arcrun#92:「回應太大」的 error envelope。
|
||||
*
|
||||
* 寫法上的三個要求(票上的紅線:不准換一句含糊的萬用句):
|
||||
* 1. 講**發生什麼**:多大、上限多少(真實數字,不是「太大」兩個字)
|
||||
* 2. 講**不是什麼**:不是連線失敗、資料也沒被偷偷截半——避免使用者往錯方向查
|
||||
* 3. 講**怎麼辦**:縮小回應的具體手段
|
||||
* 另附機器可讀欄位(code / actual_bytes / limit_bytes),讓上層能判斷而不必比對字串。
|
||||
*
|
||||
* status 用 0 而不是 413:對方伺服器並沒有回 413,寫 413 等於偽造一個上游狀態碼
|
||||
* (與 fetch 失敗的 envelope 同慣例,0 = 根本沒拿到 HTTP 狀態)。
|
||||
*/
|
||||
export function oversizeResponseEnvelope(actualBytes: number, limitBytes: number) {
|
||||
return {
|
||||
error:
|
||||
`回應太大,裝不下:對方回了 ${formatBytes(actualBytes)},` +
|
||||
`超過這個零件單次能接收的 ${formatBytes(limitBytes)} 上限。` +
|
||||
`這不是連線失敗,資料也沒有被截掉一半——是整包放不進零件。` +
|
||||
`做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試;` +
|
||||
`真的需要整包資料時,改成分頁多抓幾次、每次處理一批。`,
|
||||
code: 'response_too_large',
|
||||
actual_bytes: actualBytes,
|
||||
limit_bytes: limitBytes,
|
||||
status: 0,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WASI shim 實例
|
||||
* @param stdinData - 要寫入 stdin 的 UTF-8 字串(通常是 JSON.stringify(input))
|
||||
@@ -155,34 +95,14 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
}
|
||||
|
||||
// 寫入結果到 WASM 的 outPtr buffer(host function 共用)
|
||||
// 回傳 HOST_OK / HOST_ERROR / HOST_TOO_LARGE
|
||||
//
|
||||
// 容量握手(Arcrun#92):零件在呼叫 host function 前,把自己 outBuf 的長度預先寫進
|
||||
// *outLenPtr;host 在寫回前讀這個值當容量上限。塞不下就**不寫**(避免覆寫零件記憶體)
|
||||
// 並回 HOST_TOO_LARGE,讓上層改寫一段講真話的訊息。
|
||||
//
|
||||
// 舊零件(沒做握手)讀到 0 = 「未宣告容量」→ 維持舊行為。這裡不能自作聰明假設 64KB:
|
||||
// 各零件緩衝區大小不同(http_request 64KB、claude_api 1MB),統一硬套會把原本
|
||||
// 跑得好好的大回應誤判成太大。
|
||||
// 回傳 0 = 成功,1 = memory 不可用
|
||||
function writeOut(buf: ArrayBuffer, outPtr: number, outLenPtr: number, data: Uint8Array): number {
|
||||
try {
|
||||
const view = new DataView(buf);
|
||||
const declaredCapacity = view.getUint32(outLenPtr, true);
|
||||
if (!outFitsCapacity(declaredCapacity, data.length)) return HOST_TOO_LARGE;
|
||||
new Uint8Array(buf, outPtr, data.length).set(data);
|
||||
view.setUint32(outLenPtr, data.length, true);
|
||||
return HOST_OK;
|
||||
} catch {
|
||||
return HOST_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/** 讀零件宣告的緩衝區容量(0 = 舊零件沒宣告) */
|
||||
function declaredCapacityOf(buf: ArrayBuffer, outLenPtr: number): number {
|
||||
try {
|
||||
return new DataView(buf).getUint32(outLenPtr, true);
|
||||
} catch {
|
||||
new DataView(buf).setUint32(outLenPtr, data.length, true);
|
||||
return 0;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,28 +352,12 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
try {
|
||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
|
||||
const encoded = new TextEncoder().encode(result);
|
||||
const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded);
|
||||
if (status !== HOST_TOO_LARGE) return status;
|
||||
|
||||
// Arcrun#92:回應塞不進零件緩衝區。以前這裡會硬寫(覆寫零件記憶體)或回 1,
|
||||
// 零件對外只講得出 "HTTP request failed"——訊息與真因脫節。
|
||||
// 現在改寫一個講真話的 error envelope(零件既有的 parsed["error"] 判定鏈
|
||||
// 會原樣帶到使用者面前,不必改零件也能講對原因)。
|
||||
const capacity = declaredCapacityOf(memory.buffer, outLenPtr);
|
||||
const envelope = new TextEncoder().encode(
|
||||
JSON.stringify(oversizeResponseEnvelope(encoded.length, capacity)),
|
||||
);
|
||||
const envStatus = writeOut(memory.buffer, outPtr, outLenPtr, envelope);
|
||||
// 連這段說明都塞不下(緩衝區極小)→ 回 3,由零件自己講「回應太大」
|
||||
return envStatus === HOST_OK ? HOST_OK : HOST_TOO_LARGE;
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
||||
} catch (e) {
|
||||
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
|
||||
// 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。
|
||||
// writeOut 失敗(memory 壞)才 fallback return 1。
|
||||
// 訊息截到 200 字:這段本身若超過零件緩衝區會被判成 HOST_TOO_LARGE,
|
||||
// 零件就會把「連不上」說成「回應太大」——又一次訊息與真因脫節(Arcrun#92)。
|
||||
const errDetail = (e instanceof Error ? e.message : String(e)).slice(0, 200);
|
||||
const errDetail = e instanceof Error ? e.message : String(e);
|
||||
const errEnv = new TextEncoder().encode(
|
||||
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
|
||||
);
|
||||
@@ -462,8 +366,7 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// kv_get(keyPtr, keyLen, outPtr, outLenPtr)
|
||||
// → 0 成功;1 錯誤;2 找不到 key;3 值太大塞不進零件緩衝區(Arcrun#92)
|
||||
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
|
||||
kv_get: hostFunctions?.kv_get
|
||||
? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) { console.error('[kv_get] memory null'); return 1; }
|
||||
@@ -484,8 +387,7 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// secret_get(refPtr, refLen, outPtr, outLenPtr)
|
||||
// → 0 成功;1 錯誤;2 找不到 ref;3 值太大塞不進零件緩衝區(Arcrun#92)
|
||||
// secret_get(refPtr, refLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 ref
|
||||
// 與 kv_get 同款 pointer/memory-write 機制;差別只在 host 端實作來源(env[ref] 而非 KV.get)。
|
||||
secret_get: hostFunctions?.secret_get
|
||||
? hostWrap(async (refPtr: number, refLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
|
||||
@@ -132,7 +132,13 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
|
||||
// execution_log/execution_log_usage(KV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
|
||||
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
|
||||
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
|
||||
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);
|
||||
// KV 退休(#16/#17)新增四型:資產定義本體住進 KBDB 之後,它們是「系統的東西」而不是
|
||||
// 使用者的知識卡——知識瀏覽/搜尋要跟 execution_log 一樣排除,否則 portal 會冒出
|
||||
// 一堆 workflow_def / api_recipe 汙染結果。
|
||||
const INTERNAL_ENTRY_TYPES = new Set([
|
||||
'value', 'workflow', 'execution_log', 'execution_log_usage',
|
||||
'workflow_def', 'api_recipe', 'auth_recipe', 'prompt_recipe',
|
||||
]);
|
||||
|
||||
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
|
||||
entries: T[],
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* /storage — 資產盤點與搬遷(KV → KBDB)
|
||||
*
|
||||
* KV 退休(Leo/Arcrun#16 + #17)。交辦的驗收條件之一逐字是:
|
||||
* 「既有的東西要能搬過去,而且**搬的過程不能弄丟任何一筆**(搬之前先數,搬之後再數)」
|
||||
* 所以這兩支端點的重點不是「搬」,是**數得出來**:
|
||||
* GET /storage/audit 兩邊各有幾筆、差在哪幾筆(唯讀,先看再決定要不要搬)
|
||||
* POST /storage/migrate-to-kbdb 搬,回傳搬之前的數、逐筆結果、搬之後的數
|
||||
*
|
||||
* 三個刻意的設計:
|
||||
* 1. **只增不刪**:搬完不動 KV 的原始資料。搬錯了、想反悔,原地還在;
|
||||
* KV 本來就要退成快取,留著它一份沒有壞處(真的要清是另一個決定,不在這支裡順手做)。
|
||||
* 2. **冪等**:KBDB 端用固定的 entry id(asset-keys.ts),同一筆搬幾次都只有一列。
|
||||
* 可以放心重跑到 missing 歸零為止。
|
||||
* 3. **誠實**:每一筆的成敗逐筆列出來,失敗有原因;不吞錯、不四捨五入回一句「成功」
|
||||
* (禁假綠,mindset §7)。搬完 after 對不上 before 就是沒搬乾淨,數字自己會講。
|
||||
*
|
||||
* flag 安全:兩支都是人/AI 主動呼叫一次的操作,**不掛 cron、不輪詢**
|
||||
*(KV list 免費額度 1000/日,這裡會 list,所以更不能自動化重複打)。
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { unwrapKv } from '../lib/durable-store';
|
||||
import { classifyAssetKey, type AssetEntryType } from '../lib/asset-keys';
|
||||
|
||||
export const storageRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/** 本卷管的四類資產,以及它們住在哪顆 KV。 */
|
||||
const ASSET_SOURCES: Array<{ binding: 'WEBHOOKS' | 'RECIPES' }> = [
|
||||
{ binding: 'WEBHOOKS' },
|
||||
{ binding: 'RECIPES' },
|
||||
];
|
||||
|
||||
interface ScannedKey {
|
||||
key: string;
|
||||
binding: 'WEBHOOKS' | 'RECIPES';
|
||||
entry_type: AssetEntryType;
|
||||
entry_id: string;
|
||||
}
|
||||
|
||||
/** 掃一顆 KV 的全部 key(跟著 cursor 走完,不只第一頁),挑出屬於資產的。 */
|
||||
async function scanAssetKeys(kv: KVNamespace, binding: 'WEBHOOKS' | 'RECIPES'): Promise<ScannedKey[]> {
|
||||
const found: ScannedKey[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await kv.list(cursor ? { cursor } : {});
|
||||
for (const k of page.keys) {
|
||||
const ref = classifyAssetKey(k.name);
|
||||
if (ref) found.push({ key: k.name, binding, entry_type: ref.entry_type, entry_id: ref.entry_id });
|
||||
}
|
||||
cursor = page.list_complete ? undefined : page.cursor;
|
||||
} while (cursor);
|
||||
return found;
|
||||
}
|
||||
|
||||
function kbdb(env: Bindings): { base: string; headers: Record<string, string> } {
|
||||
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
return { base, headers };
|
||||
}
|
||||
|
||||
/** KBDB 端某型別現有的 entry id 集合(用來算「哪幾筆還沒搬過去」)。 */
|
||||
async function kbdbExistingIds(env: Bindings, entryType: AssetEntryType): Promise<Set<string>> {
|
||||
const { base, headers } = kbdb(env);
|
||||
const res = await fetch(`${base}/entries?entry_type=${encodeURIComponent(entryType)}&limit=1000`, { headers });
|
||||
if (!res.ok) throw new Error(`KBDB 讀取失敗(${entryType}):HTTP ${res.status}`);
|
||||
const json = (await res.json().catch(() => null)) as { entries?: Array<{ id: string }> } | null;
|
||||
return new Set((json?.entries ?? []).map((e) => e.id));
|
||||
}
|
||||
|
||||
const ASSET_TYPES: AssetEntryType[] = ['workflow_def', 'api_recipe', 'auth_recipe', 'prompt_recipe'];
|
||||
|
||||
interface Tally {
|
||||
kv: Record<string, number>;
|
||||
kbdb: Record<string, number>;
|
||||
missing_in_kbdb: string[];
|
||||
}
|
||||
|
||||
/** 兩邊各數一次,並列出「KV 有、KBDB 沒有」的那幾筆。 */
|
||||
async function tally(env: Bindings): Promise<Tally> {
|
||||
const scanned: ScannedKey[] = [];
|
||||
for (const src of ASSET_SOURCES) {
|
||||
const binding = env[src.binding];
|
||||
if (!binding) continue;
|
||||
scanned.push(...(await scanAssetKeys(unwrapKv(binding), src.binding)));
|
||||
}
|
||||
|
||||
const kvCounts: Record<string, number> = {};
|
||||
for (const t of ASSET_TYPES) kvCounts[t] = 0;
|
||||
for (const s of scanned) kvCounts[s.entry_type] += 1;
|
||||
|
||||
const kbdbCounts: Record<string, number> = {};
|
||||
const existing = new Map<AssetEntryType, Set<string>>();
|
||||
for (const t of ASSET_TYPES) {
|
||||
const ids = await kbdbExistingIds(env, t);
|
||||
existing.set(t, ids);
|
||||
kbdbCounts[t] = ids.size;
|
||||
}
|
||||
|
||||
const missing = scanned
|
||||
.filter((s) => !existing.get(s.entry_type)!.has(s.entry_id))
|
||||
.map((s) => s.key);
|
||||
|
||||
return { kv: kvCounts, kbdb: kbdbCounts, missing_in_kbdb: missing };
|
||||
}
|
||||
|
||||
// GET /storage/audit — 唯讀盤點。搬之前先看、搬之後再看,兩次數字自己會說話。
|
||||
storageRouter.get('/storage/audit', async (c) => {
|
||||
try {
|
||||
const t = await tally(c.env);
|
||||
return c.json({
|
||||
success: true,
|
||||
kv_asset_counts: t.kv,
|
||||
kbdb_asset_counts: t.kbdb,
|
||||
missing_in_kbdb: t.missing_in_kbdb,
|
||||
missing_count: t.missing_in_kbdb.length,
|
||||
verdict:
|
||||
t.missing_in_kbdb.length === 0
|
||||
? 'KV 裡的資產在 KBDB 都有一份——換掉 KV 不會弄丟東西。'
|
||||
: `還有 ${t.missing_in_kbdb.length} 筆只存在於 KV。跑 POST /storage/migrate-to-kbdb 把它們搬過去。`,
|
||||
});
|
||||
} catch (e) {
|
||||
// 誠實:盤點本身失敗就說失敗,不回一個看起來很乾淨的 0(那會被讀成「沒東西要搬」)。
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /storage/migrate-to-kbdb — 把 KV 裡的資產補進 KBDB。只增不刪、冪等、可重跑。
|
||||
// body(都可省略):{ dry_run?: boolean }
|
||||
storageRouter.post('/storage/migrate-to-kbdb', async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as { dry_run?: boolean };
|
||||
const dryRun = body.dry_run === true;
|
||||
|
||||
let before: Tally;
|
||||
try {
|
||||
before = await tally(c.env);
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: `搬遷前盤點失敗,未動任何資料:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return c.json({
|
||||
success: true,
|
||||
dry_run: true,
|
||||
before: { kv: before.kv, kbdb: before.kbdb },
|
||||
would_migrate: before.missing_in_kbdb,
|
||||
would_migrate_count: before.missing_in_kbdb.length,
|
||||
});
|
||||
}
|
||||
|
||||
const migrated: string[] = [];
|
||||
const errors: Array<{ key: string; error: string }> = [];
|
||||
|
||||
for (const src of ASSET_SOURCES) {
|
||||
const wrapped = c.env[src.binding];
|
||||
if (!wrapped) continue;
|
||||
const raw = unwrapKv(wrapped);
|
||||
for (const s of await scanAssetKeys(raw, src.binding)) {
|
||||
try {
|
||||
// 從**真** KV 讀原值,再用包裝過的 binding 寫回去——寫入路徑就是平常那條
|
||||
//(先 KBDB 後快取),所以搬遷用的是跟日常寫入完全同一段程式碼,不另開一條會漂移的路。
|
||||
const value = await raw.get(s.key, 'text');
|
||||
if (value === null) continue; // 掃到當下剛好被刪:不是錯,跳過
|
||||
await wrapped.put(s.key, value);
|
||||
migrated.push(s.key);
|
||||
} catch (e) {
|
||||
errors.push({ key: s.key, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let after: Tally | null = null;
|
||||
let afterError: string | null = null;
|
||||
try {
|
||||
after = await tally(c.env);
|
||||
} catch (e) {
|
||||
afterError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const clean = errors.length === 0 && after !== null && after.missing_in_kbdb.length === 0;
|
||||
return c.json(
|
||||
{
|
||||
success: clean,
|
||||
before: { kv: before.kv, kbdb: before.kbdb, missing_in_kbdb: before.missing_in_kbdb.length },
|
||||
migrated,
|
||||
migrated_count: migrated.length,
|
||||
errors,
|
||||
after: after
|
||||
? { kv: after.kv, kbdb: after.kbdb, missing_in_kbdb: after.missing_in_kbdb }
|
||||
: { error: afterError },
|
||||
verdict: clean
|
||||
? '搬完了:KV 裡的每一筆資產在 KBDB 都有對應的一列(missing 歸零)。KV 原始資料原封不動保留。'
|
||||
: '**沒有搬乾淨**——看 errors 與 after.missing_in_kbdb。修掉原因後可以直接重跑(冪等,不會產生重複)。',
|
||||
},
|
||||
clean ? 200 : 500,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* asset-keys 單元測試 — 「哪些 KV key 是使用者的資產」這張表本身
|
||||
*
|
||||
* KV 退休(Leo/Arcrun#16 + #17)。這支測的是純函式,沒有 KV / KBDB / 網路,
|
||||
* 因為它要守的東西也很單純:**分類錯了,資產就會被留在會被換掉的那一層。**
|
||||
*
|
||||
* 兩個方向都要測,而且分量一樣重:
|
||||
* - 資產不可以被誤判成衍生(漏收=下次更新就不見了,這是 2026-08-12 的病)
|
||||
* - 衍生不可以被誤判成資產(多收=KBDB 長出算得出來的垃圾列)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyAssetKey, classifyListPrefix, assetKvKey } from '../src/lib/asset-keys';
|
||||
import { CRON_INDEX_KEY } from '../src/lib/cron-index';
|
||||
|
||||
describe('classifyAssetKey — 資產', () => {
|
||||
it('具名工作流 {api_key}:wf:{name} → workflow_def,租戶與名字都拆得出來', () => {
|
||||
const ref = classifyAssetKey('leo:wf:rag_chat');
|
||||
expect(ref).not.toBeNull();
|
||||
expect(ref!.entry_type).toBe('workflow_def');
|
||||
expect(ref!.owner_id).toBe('leo');
|
||||
expect(ref!.page_name).toBe('rag_chat');
|
||||
expect(ref!.kv_key).toBe('leo:wf:rag_chat');
|
||||
});
|
||||
|
||||
it('api recipe(uuid key 與 migration 前的 canonical key 都算資產)', () => {
|
||||
expect(classifyAssetKey('recipe:8f3b-uuid')!.entry_type).toBe('api_recipe');
|
||||
expect(classifyAssetKey('recipe:telegram_send')!.entry_type).toBe('api_recipe');
|
||||
});
|
||||
|
||||
it('auth recipe / prompt recipe', () => {
|
||||
expect(classifyAssetKey('auth_recipe:notion')!.entry_type).toBe('auth_recipe');
|
||||
expect(classifyAssetKey('auth_recipe:notion')!.page_name).toBe('notion');
|
||||
expect(classifyAssetKey('prompt_recipe:wiki_synthesis')!.entry_type).toBe('prompt_recipe');
|
||||
});
|
||||
|
||||
it('同一個 key 永遠對到同一個 entry_id(冪等的根據——重跑遷移不會長出重複列)', () => {
|
||||
expect(classifyAssetKey('leo:wf:a')!.entry_id).toBe(classifyAssetKey('leo:wf:a')!.entry_id);
|
||||
expect(classifyAssetKey('leo:wf:a')!.entry_id).not.toBe(classifyAssetKey('evan:wf:a')!.entry_id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyAssetKey — 衍生資料不可誤收', () => {
|
||||
it('recipe 反查索引 idx:* 全部不是資產(算得回來,見 rehydrateRecipeIndices)', () => {
|
||||
expect(classifyAssetKey('idx:rec_f7e2a1b3')).toBeNull();
|
||||
expect(classifyAssetKey('idx:canonical:telegram_send')).toBeNull();
|
||||
expect(classifyAssetKey('idx:installed:telegram_send')).toBeNull();
|
||||
});
|
||||
|
||||
it('cron 索引不是資產', () => {
|
||||
expect(classifyAssetKey(CRON_INDEX_KEY)).toBeNull();
|
||||
expect(classifyAssetKey('cron-idx:leo:daily')).toBeNull();
|
||||
});
|
||||
|
||||
it('匿名 webhook token / 其他暫存 key 不是資產(本卷不碰,非漏收)', () => {
|
||||
expect(classifyAssetKey('a1b2c3d4e5f6')).toBeNull();
|
||||
expect(classifyAssetKey('daemon-active:leo')).toBeNull();
|
||||
});
|
||||
|
||||
it('殘缺的 key 不當資產(寧可退回原本的 KV 行為,也不要建出半截的列)', () => {
|
||||
expect(classifyAssetKey('recipe:')).toBeNull();
|
||||
expect(classifyAssetKey('auth_recipe:')).toBeNull();
|
||||
expect(classifyAssetKey(':wf:orphan')).toBeNull();
|
||||
expect(classifyAssetKey('leo:wf:')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetKvKey — 從 KBDB 反推回 KV key(回填快取與 list 都靠它)', () => {
|
||||
it('四型都能原路折返', () => {
|
||||
for (const key of ['leo:wf:rag_chat', 'recipe:telegram_send', 'auth_recipe:notion', 'prompt_recipe:x']) {
|
||||
const ref = classifyAssetKey(key)!;
|
||||
expect(assetKvKey(ref.entry_type, ref.owner_id, ref.page_name)).toBe(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyListPrefix — 列舉走哪一邊', () => {
|
||||
it('租戶工作流列舉 → 走 KBDB(帶 owner)', () => {
|
||||
expect(classifyListPrefix('leo:wf:')).toEqual({ entry_type: 'workflow_def', owner_id: 'leo' });
|
||||
});
|
||||
|
||||
it('recipe / auth_recipe 列舉 → 走 KBDB', () => {
|
||||
expect(classifyListPrefix('recipe:')).toEqual({ entry_type: 'api_recipe' });
|
||||
expect(classifyListPrefix('auth_recipe:')).toEqual({ entry_type: 'auth_recipe' });
|
||||
});
|
||||
|
||||
it('索引與無 prefix 的全域列舉 → 維持原本的 KV 行為', () => {
|
||||
expect(classifyListPrefix('idx:')).toBeNull();
|
||||
expect(classifyListPrefix('cron-idx:')).toBeNull();
|
||||
expect(classifyListPrefix(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Arcrun#92 — 「回應太大」不准再被說成「請求失敗」
|
||||
*
|
||||
* 背景:零件(main.go)給 host function 的接收緩衝區是固定大小(http_request 64KB、
|
||||
* claude_api 1MB)。回應超過這個大小時,舊 host 會照寫不誤 → 覆寫零件記憶體 → 零件
|
||||
* 切片 panic,或 writeOut 撞 memory 邊界丟例外 → 回 1 → 零件印一句
|
||||
* "HTTP request failed"。使用者拿到那句會去查連線/URL/防火牆,全部查錯方向。
|
||||
*
|
||||
* 這一支測的是**訊息有沒有講真話**,不是「有沒有回錯誤」:
|
||||
* 1. 判定規則本身(沒宣告容量的舊零件不可被誤判成太大)
|
||||
* 2. 訊息內容:真實數字 + 撇清錯誤方向 + 具體該怎麼辦 + 機器可讀欄位
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
outFitsCapacity,
|
||||
formatBytes,
|
||||
oversizeResponseEnvelope,
|
||||
HOST_TOO_LARGE,
|
||||
} from '../src/lib/wasi-shim';
|
||||
|
||||
describe('容量握手的判定規則', () => {
|
||||
it('宣告 64KB、資料 200KB → 塞不下', () => {
|
||||
expect(outFitsCapacity(65536, 200_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('剛好等於容量 → 塞得下(不可 off-by-one 誤殺)', () => {
|
||||
expect(outFitsCapacity(65536, 65536)).toBe(true);
|
||||
});
|
||||
|
||||
it('舊零件沒宣告容量(0)→ 一律視為塞得下,維持舊行為', () => {
|
||||
// 這條是防「換一種說謊」:不能因為新規則就把 claude_api 那種 1MB 緩衝區的
|
||||
// 大回應統統誤判成「太大」。沒宣告 = host 不知道上限 = 不准亂猜。
|
||||
expect(outFitsCapacity(0, 900_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('HOST_TOO_LARGE 與零件端的 hostTooLarge 常數同值(registry/components/*/main.go)', () => {
|
||||
expect(HOST_TOO_LARGE).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('分別用 bytes / KB / MB', () => {
|
||||
expect(formatBytes(512)).toBe('512 bytes');
|
||||
expect(formatBytes(65536)).toBe('64 KB');
|
||||
expect(formatBytes(3_355_443)).toBe('3.2 MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('「回應太大」的訊息本身', () => {
|
||||
const env = oversizeResponseEnvelope(3_355_443, 65536);
|
||||
|
||||
it('講出實際大小與上限(不是只說「太大」)', () => {
|
||||
expect(env.error).toContain('3.2 MB');
|
||||
expect(env.error).toContain('64 KB');
|
||||
expect(env.actual_bytes).toBe(3_355_443);
|
||||
expect(env.limit_bytes).toBe(65536);
|
||||
});
|
||||
|
||||
it('明講「不是連線失敗」,把使用者從錯誤方向拉回來', () => {
|
||||
expect(env.error).toContain('不是連線失敗');
|
||||
});
|
||||
|
||||
it('給得出下一步(分頁/篩選),不是叫人「稍後再試」', () => {
|
||||
expect(env.error).toMatch(/分頁|篩選/);
|
||||
expect(env.error).not.toMatch(/稍後再試|請重新操作/);
|
||||
});
|
||||
|
||||
it('不准退回萬用句', () => {
|
||||
expect(env.error).not.toMatch(/請求失敗|HTTP request failed|未知錯誤/);
|
||||
});
|
||||
|
||||
it('帶機器可讀欄位,上層不必比對字串', () => {
|
||||
expect(env.code).toBe('response_too_large');
|
||||
});
|
||||
|
||||
it('status 不偽造上游狀態碼(對方沒回 413)', () => {
|
||||
expect(env.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
-- arcrun 資產型別 template seed — KV 退休(Leo/Arcrun#16 + #17)
|
||||
--
|
||||
-- 為什麼有這一檔(leo 2026-08-12 原話):
|
||||
-- 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
|
||||
-- 「如果零件和工作流的 recipe 不見了,是很可怕的事情」
|
||||
-- 同一天(2026-08-12)真的發作過:一次例行更新讓使用者的九支工作流在畫面上全部消失
|
||||
-- (根因見 cli/src/lib/resource-resolver.ts 檔頭 Arcrun#97——舊 deploy 會照名字新建一顆空 KV
|
||||
-- 再綁上去)。#97 修的是「不要再把 worker 綁到空的資源上」;本卷修的是更根本的一句:
|
||||
-- **使用者的資產本來就不該只存在於一個會被換掉的暫存層裡。**
|
||||
--
|
||||
-- KBDB 鐵律(leo 2026-06-14,D38):三張表打天下,永遠不加新 table,新資料類型一律用 template。
|
||||
-- 本檔**零 schema 異動**——只 INSERT OR IGNORE 四列 template 定義,手法與同目錄
|
||||
-- 0003_library_map.sql / 0004_execution_log_template.sql 完全相同。
|
||||
--
|
||||
-- 儲存精神比照 0004(execution_log)與 recipe-stat:template 只負責「schema 文件化 +
|
||||
-- GET /templates 可發現」,實際一筆資產是 entries 表的**一列**——
|
||||
-- entry_type = 'workflow_def' | 'api_recipe' | 'auth_recipe' | 'prompt_recipe'
|
||||
-- owner_id = 租戶(workflow 才有;recipe 是整台實例共用的庫,故為 NULL)
|
||||
-- page_name = 該型別的自然鍵(workflow 名 / recipe uuid / service 名)
|
||||
-- content = 給人看也給語意搜尋看的一句描述
|
||||
-- metadata_json = 定義本體(graph / endpoint / inject … 原樣 JSON)
|
||||
-- ——不走 entry_values 全展開的多列 record:一支 workflow 的 graph 是一整包巢狀 JSON,
|
||||
-- 拆成 slot 多列既不會變得比較好查,反而讓「一筆資產=一列」這件事不再成立
|
||||
-- (recipe_stat 與 execution_log 早已示範「template 存在 + entries 直接存」這個模式合法)。
|
||||
--
|
||||
-- 讀寫一律走 HTTP API(/entries、/entries/:id),呼叫端是 cypher-executor 的
|
||||
-- src/lib/durable-store.ts。牆外沒有任何一行 SQL。
|
||||
|
||||
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
|
||||
VALUES
|
||||
('tpl-workflow-def', 'workflow_def',
|
||||
'工作流定義本體(KV 退休 #17)。一支工作流=entries 一列;graph/config/cron_expr 打包進 metadata_json,WEBHOOKS KV 降為可丟棄的快取',
|
||||
'["name","description","graph","config","cron_expr","created_at"]', 'system'),
|
||||
|
||||
('tpl-api-recipe', 'api_recipe',
|
||||
'API recipe 定義本體(KV 退休 #16)。一份 recipe=entries 一列;endpoint/headers/body/auth 等打包進 metadata_json,RECIPES KV 降為快取。idx:* 反查索引屬衍生資料,不進 KBDB,由 durable-store 從本型別重建',
|
||||
'["uuid","canonical_id","hash_id","author","endpoint","method","auth_service","installed"]', 'system'),
|
||||
|
||||
('tpl-auth-recipe', 'auth_recipe',
|
||||
'Auth recipe 定義本體(KV 退休 #16)。一個服務一列;primitive/base_url/required_secrets/inject 打包進 metadata_json。只存「怎麼認證」,不存任何密文(憑證明文在 CF Workers Secrets,見 .claude/rules/01-tech-stack.md)',
|
||||
'["service","primitive","base_url","version","required_secrets","inject"]', 'system'),
|
||||
|
||||
('tpl-prompt-recipe', 'prompt_recipe',
|
||||
'Prompt recipe 定義本體(KV 退休 #16)。一份 prompt recipe=entries 一列,定義打包進 metadata_json',
|
||||
'["name","definition"]', 'system');
|
||||
@@ -134,6 +134,51 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* 以「呼叫端指定的 id」寫一列——已存在就整列覆蓋,不存在就新建(KV 退休 #16/#17)。
|
||||
*
|
||||
* 為什麼 base 需要這一支:`createEntry` 的 id 預設是隨機的,同一份資產每存一次就多一列;
|
||||
* 呼叫端若想要「同一份資產永遠是同一列」,只能自己先 GET 再決定 POST 還是 PATCH——
|
||||
* 兩趟往返、而且中間有競態。把它收成一個原語,語意才單一(冪等:同樣的輸入跑幾次結果都一樣)。
|
||||
*
|
||||
* 這是 base 的通用能力,不是替某個 entry_type 開的特例——任何有天然鍵的資料型別
|
||||
* (arcrun 的 workflow_def / api_recipe / auth_recipe,或未來別的)都用得上。
|
||||
* **零 schema 異動**:仍然只寫 entries 這一張既有的表。
|
||||
*
|
||||
* 覆蓋語意刻意是「整列取代」而非 PATCH 式合併:呼叫端手上是一份完整的資產定義,
|
||||
* 合併語意會讓「刪掉某個欄位」變成做不到的事(舊值會留下來)。
|
||||
* created_at 保留原值(資產的誕生時間不因為改一次內容就被重寫),updated_at 更新。
|
||||
*/
|
||||
export async function upsertEntry(db: D1Database, id: string, input: Omit<CreateEntryInput, 'id'>): Promise<Entry> {
|
||||
const existing = await getEntry(db, id);
|
||||
if (!existing) return createEntry(db, { ...input, id });
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE entries
|
||||
SET content = ?, entry_type = ?, owner_id = ?, parent_id = ?, page_name = ?,
|
||||
refs_json = ?, tags_json = ?, task_status = ?, confidence = ?, metadata_json = ?,
|
||||
updated_at = unixepoch()
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
input.content ?? null,
|
||||
input.entry_type,
|
||||
input.owner_id ?? null,
|
||||
input.parent_id ?? null,
|
||||
input.page_name ?? null,
|
||||
input.refs_json ?? '[]',
|
||||
input.tags_json ?? '[]',
|
||||
input.task_status ?? null,
|
||||
input.confidence ?? null,
|
||||
input.metadata_json ?? null,
|
||||
id,
|
||||
)
|
||||
.run();
|
||||
const row = await getEntry(db, id);
|
||||
if (!row) throw new Error('upsertEntry: update succeeded but row not found');
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。
|
||||
* 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getEntry,
|
||||
listEntries,
|
||||
updateEntry,
|
||||
upsertEntry,
|
||||
deleteEntry,
|
||||
searchEntries,
|
||||
isDeprecatedEntry,
|
||||
@@ -426,6 +427,21 @@ entryRoutes.get('/backfill-library/status', async (c) => {
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// PUT /entries/:id — 以呼叫端指定的 id 整列覆寫(不存在就新建)。KV 退休 #16/#17。
|
||||
//
|
||||
// 與 POST / 的差別:POST 的 id 是隨機的,同一份資產每存一次多一列;PUT 讓「同一份資產永遠
|
||||
// 是同一列」,所以重跑遷移、重複部署同一支工作流都不會長出重複資料(冪等)。
|
||||
// 與 PATCH /:id 的差別:PATCH 是部分更新(沒帶的欄位留著),PUT 是整列取代
|
||||
// ——呼叫端手上是完整定義時要的是後者,否則「刪掉一個欄位」永遠做不到。
|
||||
entryRoutes.put('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
|
||||
const entry = await upsertEntry(c.env.DB, c.req.param('id'), body);
|
||||
// 與 POST / 同款:標了 embed:true 的才進 Vectorize,fire-and-forget、失敗不致命。
|
||||
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||||
return c.json({ success: true, entry });
|
||||
});
|
||||
|
||||
// PATCH /entries/:id
|
||||
entryRoutes.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
@@ -30,13 +30,6 @@ import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。
|
||||
const (
|
||||
hostOK uint32 = 0
|
||||
hostError uint32 = 1
|
||||
hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
//go:wasmimport u6u kv_get
|
||||
@@ -327,17 +320,9 @@ func doRefresh(input Input, recipe AuthRecipe) (string, int64, bool) {
|
||||
formBody := form.Encode()
|
||||
|
||||
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
|
||||
respStr, code := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
|
||||
if code == hostTooLarge {
|
||||
writeError("token endpoint 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" +
|
||||
"這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" +
|
||||
"多半表示 " + cfg.TokenEndpoint + " 回的不是正常的 token JSON(例如回了一整頁 HTML 錯誤頁);" +
|
||||
"請確認 auth recipe 的 token_endpoint 指向正確的 token 端點。")
|
||||
return "", 0, false
|
||||
}
|
||||
if code != hostOK {
|
||||
writeError("token endpoint 沒有拿到回應:引擎的 host function 回傳錯誤碼 " +
|
||||
strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。")
|
||||
respStr, ok2 := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
|
||||
if !ok2 {
|
||||
writeError("token endpoint HTTP 請求失敗")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
@@ -402,7 +387,7 @@ func writeError(msg string) {
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut
|
||||
var outLen uint32
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
@@ -434,7 +419,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
outBuf := make([]byte, 65536)
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
var outLen uint32
|
||||
|
||||
status := hostCryptoDecrypt(
|
||||
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
|
||||
@@ -447,22 +432,18 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
// httpRequest 回傳 (回應原文, host function 回傳碼)。
|
||||
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。
|
||||
// 之所以不再只回 bool:bool 把「連不上」和「回應太大」混成同一句話,
|
||||
// 使用者拿到 "token endpoint HTTP 請求失敗" 會往連線方向查,方向全錯(Arcrun#92)。
|
||||
func httpRequest(reqURL, method, headersJSON, body string) (string, uint32) {
|
||||
func httpRequest(reqURL, method, headersJSON, body string) (string, bool) {
|
||||
urlBytes := []byte(reqURL)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
if len(urlBytes) == 0 {
|
||||
return "", hostError
|
||||
return "", false
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
var outLen uint32
|
||||
|
||||
var bodyPtr uintptr
|
||||
if len(bodyBytes) > 0 {
|
||||
@@ -481,9 +462,9 @@ func httpRequest(reqURL, method, headersJSON, body string) (string, uint32) {
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", status
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), hostOK
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
|
||||
|
||||
@@ -19,19 +19,11 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。
|
||||
const (
|
||||
hostOK uint32 = 0
|
||||
hostError uint32 = 1
|
||||
hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
//go:wasmimport u6u kv_get
|
||||
@@ -275,17 +267,9 @@ func main() {
|
||||
|
||||
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
|
||||
|
||||
respStr, code := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
|
||||
if code == hostTooLarge {
|
||||
writeError("token exchange 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" +
|
||||
"這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" +
|
||||
"多半表示 " + recipe.TokenExchange.Endpoint + " 回的不是正常的 token JSON" +
|
||||
"(例如回了一整頁 HTML 錯誤頁);請確認 auth recipe 的 token_exchange.endpoint 正確。")
|
||||
return
|
||||
}
|
||||
if code != hostOK {
|
||||
writeError("token exchange 沒有拿到回應:引擎的 host function 回傳錯誤碼 " +
|
||||
strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。")
|
||||
respStr, ok := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
|
||||
if !ok {
|
||||
writeError("token exchange HTTP 失敗")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -360,12 +344,11 @@ func pemToPkcs8(pem string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(cleaned)
|
||||
}
|
||||
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。
|
||||
// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBuf(Arcrun#92)
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut
|
||||
var outLen uint32
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
@@ -381,7 +364,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
encBytes := []byte(encB64)
|
||||
ivBytes := []byte(ivB64)
|
||||
outBuf := make([]byte, 65536)
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
var outLen uint32
|
||||
|
||||
if len(encBytes) == 0 || len(ivBytes) == 0 {
|
||||
return "", false
|
||||
@@ -404,7 +387,7 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
|
||||
return nil, false
|
||||
}
|
||||
outBuf := make([]byte, 1024) // RSA-2048 簽章 = 256 bytes,1KB 綽綽有餘
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
var outLen uint32
|
||||
|
||||
status := hostCryptoSignRS256(
|
||||
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
|
||||
@@ -417,21 +400,19 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
|
||||
return outBuf[:outLen], true
|
||||
}
|
||||
|
||||
// httpRequest 呼叫 host,回傳 (response body 字串, host function 回傳碼)。
|
||||
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。
|
||||
// 不再只回 bool 的理由(Arcrun#92):bool 把「連不上」與「回應太大」講成同一句話。
|
||||
func httpRequest(url, method, headersJSON, body string) (string, uint32) {
|
||||
// httpRequest 呼叫 host,回傳 response body 字串(host 側把 status + body 串好)
|
||||
func httpRequest(url, method, headersJSON, body string) (string, bool) {
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
if len(urlBytes) == 0 {
|
||||
return "", hostError
|
||||
return "", false
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
var outLen uint32
|
||||
|
||||
// bodyBytes 可能為空(GET),host function 允許 len=0
|
||||
var bodyPtr uintptr
|
||||
@@ -451,9 +432,9 @@ func httpRequest(url, method, headersJSON, body string) (string, uint32) {
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", status
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), hostOK
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
// interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串。
|
||||
|
||||
@@ -287,14 +287,11 @@ func writeError(msg string) {
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。
|
||||
// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBuf(Arcrun#92)
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
// 容量握手(Arcrun#92):先把緩衝區大小告訴 host,host 才能在值塞不下時
|
||||
// 回 status=3(值太大)而不是硬寫爆這塊記憶體。見 wasi-shim.ts writeOut。
|
||||
outLen := uint32(len(outBuf))
|
||||
var outLen uint32
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
@@ -312,7 +309,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
encBytes := []byte(encB64)
|
||||
ivBytes := []byte(ivB64)
|
||||
outBuf := make([]byte, 65536)
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
var outLen uint32
|
||||
|
||||
// 處理空字串的防呆(TinyGo 取 &[]byte{}[0] 會 panic)
|
||||
if len(encBytes) == 0 || len(ivBytes) == 0 {
|
||||
|
||||
@@ -15,14 +15,9 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// 與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組回傳碼。
|
||||
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
|
||||
const hostTooLarge uint32 = 3
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
@@ -107,9 +102,7 @@ func main() {
|
||||
methodBytes := []byte("POST")
|
||||
|
||||
outBuf := make([]byte, 1024*1024) // 1MB
|
||||
// 容量握手(Arcrun#92):先把緩衝區大小告訴 host,塞不下時 host 會回一段
|
||||
// 講明「回應太大 + 實際/上限大小 + 該怎麼辦」的 envelope,而不是硬寫爆記憶體。
|
||||
outLen := uint32(len(outBuf))
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
@@ -124,16 +117,8 @@ func main() {
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區
|
||||
if result == hostTooLarge {
|
||||
writeError("Mira 的回應太大,裝不下:超過這個零件單次能接收的 1 MB 上限。" +
|
||||
"這不是連線失敗,也不是 Mira 沒回應。" +
|
||||
"做法:把 prompt 改成請 Mira 回短一點(或分段回),或改用 callback_url 走非同步取回。")
|
||||
return
|
||||
}
|
||||
if result != 0 {
|
||||
writeError("沒有拿到 Mira 的回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
|
||||
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是 prompt 寫錯。")
|
||||
writeError("Mira daemon request failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,9 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組數字。
|
||||
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92:以前這種情況會被說成 "HTTP request failed")
|
||||
const hostTooLarge uint32 = 3
|
||||
|
||||
// host function 宣告(由 WASI shim 注入)
|
||||
//
|
||||
//go:wasmimport u6u http_request
|
||||
@@ -91,11 +86,7 @@ func main() {
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(bodyStr)
|
||||
outBuf := make([]byte, 65536) // 64KB output buffer
|
||||
// 容量握手(Arcrun#92):呼叫前先把緩衝區大小告訴 host。
|
||||
// host(cypher-executor/src/lib/wasi-shim.ts 的 writeOut)拿這個值當上限——
|
||||
// 塞不下時不會硬寫爆這塊記憶體,而是改寫一段「回應太大 + 實際/上限大小 + 該怎麼辦」
|
||||
// 的 error envelope 回來,由下面既有的 parsed["error"] 判定鏈原樣交給使用者。
|
||||
outLen := uint32(len(outBuf))
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
@@ -110,18 +101,8 @@ func main() {
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
// host function 回傳碼(定義在 wasi-shim.ts):0=成功 1=host 端錯誤 3=回應塞不下緩衝區
|
||||
if result == hostTooLarge {
|
||||
// 走到這裡=連「回應太大」的說明本身都塞不進緩衝區(極端情況),
|
||||
// 所以零件自己講。訊息一樣要講清楚真因,不能退回 "HTTP request failed"。
|
||||
writeError("回應太大,裝不下:對方的回應超過這個零件單次能接收的 64 KB 上限。" +
|
||||
"這不是連線失敗,資料也沒有被截掉一半。" +
|
||||
"做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試。")
|
||||
return
|
||||
}
|
||||
if result != 0 {
|
||||
writeError("沒有拿到回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
|
||||
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是你的 workflow 參數寫錯。")
|
||||
writeError("HTTP request failed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Arcrun#92 重現腳本 — 「回應太大」到底會讓使用者看到什麼訊息
|
||||
*
|
||||
* 為什麼要有這支:http_request 零件的接收緩衝區是 64 KB。回應超過這個大小時,
|
||||
* 舊版會硬把資料寫進零件記憶體(寫爆)或讓 host 丟例外回 1,零件對外只講得出一句
|
||||
* "HTTP request failed"。使用者照那句去查連線/防火牆/URL,方向全錯。
|
||||
* 這支腳本把那個情境真的做出來,讓「修之前 / 修之後」的訊息可以並排比。
|
||||
*
|
||||
* 用法(本機,不碰任何線上實例):
|
||||
*
|
||||
* # 修之後(工作區現在的 wasm)
|
||||
* node scripts/repro-oversize-response.mjs 200000
|
||||
*
|
||||
* # 修之前(把 main 上的舊 wasm 取出來當對照組;舊 wasm 不做容量握手 → 走舊路徑)
|
||||
* git show origin/main:.component-builds/http_request/component.wasm > /tmp/old-http_request.wasm
|
||||
* node scripts/repro-oversize-response.mjs 200000 /tmp/old-http_request.wasm
|
||||
*
|
||||
* # 對照:沒超過上限時兩者都應該正常
|
||||
* node scripts/repro-oversize-response.mjs 1024
|
||||
*
|
||||
* Node < 22.18 請加 --experimental-strip-types(本檔會 import 一支 .ts)。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, '..');
|
||||
|
||||
const { createWasiShim } = await import(
|
||||
resolve(repoRoot, 'cypher-executor/src/lib/wasi-shim.ts')
|
||||
);
|
||||
|
||||
const responseBytes = Number(process.argv[2] ?? 200_000);
|
||||
const wasmPath = process.argv[3]
|
||||
? resolve(process.argv[3])
|
||||
: resolve(repoRoot, '.component-builds/http_request/component.wasm');
|
||||
|
||||
// 假裝遠端回了一包很大的 JSON(2xx,host function 照原樣把 body 交給零件)
|
||||
const filler = 'x'.repeat(Math.max(0, responseBytes - 14));
|
||||
const remoteBody = JSON.stringify({ items: filler });
|
||||
|
||||
const shim = createWasiShim(
|
||||
JSON.stringify({ url: 'https://example.com/big-list', method: 'GET' }),
|
||||
{ http_request: async () => remoteBody },
|
||||
);
|
||||
|
||||
const instance = await WebAssembly.instantiate(
|
||||
await WebAssembly.compile(readFileSync(wasmPath)),
|
||||
shim.imports,
|
||||
);
|
||||
shim.setMemory(instance.exports.memory);
|
||||
|
||||
let crashed = null;
|
||||
try {
|
||||
await shim.run(instance);
|
||||
} catch (e) {
|
||||
crashed = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const stdout = shim.getStdout().trim();
|
||||
const stderr = shim.getStderr().trim();
|
||||
|
||||
console.log(`wasm : ${wasmPath}`);
|
||||
console.log(`模擬回應大小 : ${remoteBody.length} bytes(零件緩衝區上限 65536 bytes)`);
|
||||
console.log('');
|
||||
|
||||
// 以下複刻 .component-builds/http_request/src/index.ts 的收尾,
|
||||
// 印出「使用者真的會拿到的那一包」
|
||||
if (stderr) console.log(`零件 stderr : ${stderr.slice(0, 300)}`);
|
||||
if (crashed) console.log(`WASM 執行中止 : ${crashed}`);
|
||||
|
||||
if (!stdout) {
|
||||
console.log('使用者看到 : HTTP 500 {"success":false,"error":"WASM component produced no output"}');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch (e) {
|
||||
console.log(`使用者看到 : HTTP 500 {"success":false,"error":"${e.message}"}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`success : ${parsed.success}`);
|
||||
console.log(`error : ${parsed.error ?? '(無)'}`);
|
||||
if (parsed.success) {
|
||||
const body = parsed.data?.body ?? '';
|
||||
console.log(`data.body 長度 : ${String(body).length} bytes`);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# verify-kv-retirement.sh — 把「換掉 KV,資產還在」真的做一次
|
||||
#
|
||||
# KV 退休(Leo/Arcrun#16 + #17)。交辦的驗收條件逐字是:
|
||||
# 「證明『換掉/重建那個暫存層,資產還在』——不是說明它會在,是**真的弄一次給我看**」
|
||||
# 「既有的東西要能搬過去,而且搬的過程不能弄丟任何一筆(搬之前先數,搬之後再數)」
|
||||
# 這支腳本就是那一次。它做的事,照順序:
|
||||
#
|
||||
# 1. 開一台**全新的空**本機實例(local D1 + local KV,跑真的 migrations)
|
||||
# 2. 用平常那條路(POST /webhooks/named、POST /recipes、POST /auth-recipes)
|
||||
# 放進 9 支工作流 + 3 份 recipe——9 是照 2026-08-12 那天真的消失的數量
|
||||
# 3. 數一次(KV 幾筆、KBDB 幾筆)
|
||||
# 4. **把整個 KV 層砍掉重建**(rm -rf 那顆 KV 的本機儲存 → 重開 worker)
|
||||
# =模擬 Arcrun#97 那天發生的事:worker 被綁到一顆全新的空 KV
|
||||
# 5. 再數一次,並且**真的觸發一支工作流**確認它還跑得動
|
||||
#
|
||||
# 通過的定義(不通就 exit 1,不留模稜兩可):
|
||||
# 砍掉 KV 之後,列出來仍然是 9 支、recipe 仍在、工作流仍然跑得出結果。
|
||||
#
|
||||
# ⚠️ 全程只碰本機(--local + --persist-to 到暫存目錄),**不碰任何線上實例**。
|
||||
# 腳本裡沒有任何 --remote、沒有任何真實帳號憑證。
|
||||
#
|
||||
# 用法: bash scripts/verify-kv-retirement.sh
|
||||
# 需要: node 22+、pnpm、可執行 npx wrangler / curl 的 shell
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
KBDB_PORT=8801
|
||||
CYPHER_PORT=8802
|
||||
TOKEN="e2e-local-token"
|
||||
TENANT="leo-e2e"
|
||||
WF_COUNT=9
|
||||
|
||||
KBDB="http://127.0.0.1:${KBDB_PORT}"
|
||||
CYPHER="http://127.0.0.1:${CYPHER_PORT}"
|
||||
|
||||
kbdb_pid=""; cypher_pid=""
|
||||
cleanup() {
|
||||
[ -n "$kbdb_pid" ] && kill "$kbdb_pid" 2>/dev/null || true
|
||||
[ -n "$cypher_pid" ] && kill "$cypher_pid" 2>/dev/null || true
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
|
||||
fail() { printf '\n\033[31m❌ %s\033[0m\n' "$*"; exit 1; }
|
||||
|
||||
wait_for() { # wait_for <url> <label>
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -sf -m 2 "$1" >/dev/null 2>&1; then return 0; fi
|
||||
sleep 1
|
||||
done
|
||||
fail "$2 沒有起來($1)"
|
||||
}
|
||||
|
||||
start_cypher() {
|
||||
( cd "$REPO_ROOT/cypher-executor" && \
|
||||
npx wrangler dev --local --port "$CYPHER_PORT" \
|
||||
--config wrangler.test.toml \
|
||||
--persist-to "$WORK/cypher-state" \
|
||||
--var "KBDB_BASE_URL:$KBDB" \
|
||||
--var "KBDB_INTERNAL_TOKEN:$TOKEN" \
|
||||
--show-interactive-dev-session=false >"$WORK/cypher.log" 2>&1 ) &
|
||||
cypher_pid=$!
|
||||
wait_for "$CYPHER/health" "cypher-executor"
|
||||
}
|
||||
|
||||
# ── 1. 空實例:真的跑 migrations ───────────────────────────────────────────────
|
||||
say "1. 開一台全新的空實例(local D1 + local KV,跑真的 migrations)"
|
||||
( cd "$REPO_ROOT/kbdb" && npx wrangler d1 migrations apply DB --local --persist-to "$WORK/kbdb-state" )
|
||||
|
||||
( cd "$REPO_ROOT/kbdb" && \
|
||||
npx wrangler dev --local --port "$KBDB_PORT" \
|
||||
--persist-to "$WORK/kbdb-state" \
|
||||
--var "KBDB_INTERNAL_TOKEN:$TOKEN" \
|
||||
--show-interactive-dev-session=false >"$WORK/kbdb.log" 2>&1 ) &
|
||||
kbdb_pid=$!
|
||||
wait_for "$KBDB/health" "kbdb"
|
||||
start_cypher
|
||||
|
||||
# ── 2. 用平常那條路放資產進去 ─────────────────────────────────────────────────
|
||||
say "2. 放進 $WF_COUNT 支工作流 + 3 份 recipe(走的是 acr push 用的同一組端點)"
|
||||
for i in $(seq 1 "$WF_COUNT"); do
|
||||
curl -sf -X POST "$CYPHER/webhooks/named" \
|
||||
-H 'Content-Type: application/json' -H "X-Arcrun-API-Key: $TENANT" \
|
||||
-d "{\"name\":\"wf_$i\",\"description\":\"驗證用工作流 $i:把 text 轉大寫\",
|
||||
\"graph\":{\"id\":\"wf_$i\",\"nodes\":[{\"id\":\"upper\",\"type\":\"Component\",\"componentId\":\"comp_uppercase\"}],\"edges\":[]}}" \
|
||||
>/dev/null || fail "部署 wf_$i 失敗"
|
||||
done
|
||||
|
||||
for svc in alpha beta; do
|
||||
curl -sf -X POST "$CYPHER/recipes" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"canonical_id\":\"${svc}_send\",\"endpoint\":\"https://example.invalid/$svc\",\"description\":\"驗證用 recipe $svc\"}" \
|
||||
>/dev/null || fail "建立 recipe $svc 失敗"
|
||||
done
|
||||
curl -sf -X POST "$CYPHER/auth-recipes" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"service":"alpha","primitive":"static_key","base_url":"https://example.invalid",
|
||||
"required_secrets":[{"key":"alpha_token","label":"Token","help_url":"https://example.invalid/docs"}],
|
||||
"inject":{"header":{"Authorization":"Bearer {{secret.alpha_token}}"}}}' \
|
||||
>/dev/null || fail "建立 auth recipe 失敗"
|
||||
|
||||
# ── 3. 搬之前先數 ─────────────────────────────────────────────────────────────
|
||||
say "3. 數一次(/storage/audit:KV 幾筆、KBDB 幾筆、差幾筆)"
|
||||
curl -s "$CYPHER/storage/audit" | tee "$WORK/audit-before.json"; echo
|
||||
before_wf="$(curl -s "$CYPHER/webhooks/named" -H "X-Arcrun-API-Key: $TENANT" | grep -o '"total":[0-9]*' | cut -d: -f2)"
|
||||
echo "部署後列出來的工作流數:$before_wf"
|
||||
[ "$before_wf" = "$WF_COUNT" ] || fail "還沒開始拆就對不上:期望 $WF_COUNT,實得 $before_wf"
|
||||
|
||||
# ── 4. 把 KV 層砍掉重建(模擬 2026-08-12 那天) ────────────────────────────────
|
||||
say "4. 砍掉整個 KV 層並重建 —— 模擬 Arcrun#97 那天『worker 被綁到一顆全新的空 KV』"
|
||||
kill "$cypher_pid" 2>/dev/null || true; wait "$cypher_pid" 2>/dev/null || true; cypher_pid=""
|
||||
rm -rf "$WORK/cypher-state" # ← 這一行就是「那個暫存層被換掉」
|
||||
echo "已刪除:$WORK/cypher-state(cypher 的整顆本機 KV)"
|
||||
start_cypher
|
||||
|
||||
# ── 5. 再數一次,而且真的跑一支 ───────────────────────────────────────────────
|
||||
say "5. KV 全空之後,再數一次"
|
||||
after_json="$(curl -s "$CYPHER/webhooks/named" -H "X-Arcrun-API-Key: $TENANT")"
|
||||
echo "$after_json" | head -c 400; echo
|
||||
after_wf="$(echo "$after_json" | grep -o '"total":[0-9]*' | cut -d: -f2)"
|
||||
echo "KV 砍掉重建後列出來的工作流數:$after_wf"
|
||||
[ "$after_wf" = "$WF_COUNT" ] || fail "工作流少了:期望 $WF_COUNT,實得 $after_wf —— 資產沒有被保住"
|
||||
|
||||
recipes_after="$(curl -s "$CYPHER/recipes" | grep -o '"count":[0-9]*' | cut -d: -f2)"
|
||||
echo "KV 砍掉重建後的 recipe 數:$recipes_after"
|
||||
[ "${recipes_after:-0}" -ge 2 ] || fail "recipe 少了:期望 >=2,實得 ${recipes_after:-0}"
|
||||
|
||||
auth_after="$(curl -s "$CYPHER/auth-recipes/alpha" | grep -c '"success":true' || true)"
|
||||
[ "$auth_after" = "1" ] || fail "auth recipe 不見了"
|
||||
echo "auth recipe alpha:仍在"
|
||||
|
||||
say "5b. 不只是列得出來——真的觸發一支工作流"
|
||||
run="$(curl -s -X POST "$CYPHER/webhooks/named/wf_3/trigger" \
|
||||
-H 'Content-Type: application/json' -H "X-Arcrun-API-Key: $TENANT" \
|
||||
-d '{"text":"still here"}')"
|
||||
echo "$run" | head -c 400; echo
|
||||
echo "$run" | grep -q 'STILL HERE' || fail "工作流列得出來卻跑不動——那不算資產還在"
|
||||
|
||||
say "結論"
|
||||
printf '\033[32m✅ 通:整個 KV 層被砍掉重建之後,%s 支工作流、recipe、auth recipe 全部還在,且工作流真的跑得出結果。\033[0m\n' "$WF_COUNT"
|
||||
echo " 資產的家=KBDB(D1,一份資產一列 entry);KV 只是快取,砍掉會自己長回來。"
|
||||
@@ -8,6 +8,75 @@
|
||||
|
||||
## 待裁決
|
||||
|
||||
### P-KV|KV 退休:工作流與 recipe 的家搬到 KBDB(Leo/Arcrun#16 + #17)— 2026-08-12
|
||||
|
||||
**觸發**:leo 2026-08-12 原話——
|
||||
> 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
|
||||
> 「因為對 KV 的使用有禁令,但卻會把資產放在這裡,這不是違法嗎?」
|
||||
> 「現在是我幫他寫工作流,未來是他的 AI 自己寫工作流,
|
||||
> **如果零件和工作流的 recipe 不見了,是很可怕的事情**」
|
||||
|
||||
同日實害:一次例行更新讓使用者的九支工作流在畫面上全部消失(Arcrun#97)。
|
||||
#97 已修掉直接原因(`cli/src/lib/resource-resolver.ts`:不再照名字猜使用者的資源、
|
||||
不再擅自新建一顆空的綁上去)。**本案修的是更下面那一句**:使用者的資產本來就不該
|
||||
只存在於一個會被換掉的暫存層裡——#97 修的是「別再換錯」,這裡修的是「換了也不會怎樣」。
|
||||
|
||||
**為什麼要走規格層(D35)**:`.claude/rules/01-tech-stack.md`「資料儲存」那張表把
|
||||
workflow 定義寫在 `WEBHOOKS` KV、recipe 寫在 `RECIPES` KV。改掉真相來源=改規格。
|
||||
現行 active SDD 是 `workflow-discovery`,本案不在它的 tasks 內,故依 D35 第 3 條
|
||||
寫 proposal 停下等 leo confirm。**#16 已被多份 SDD 引用為前提**
|
||||
(`arcrun/artifact-sharing/` design K2「KBDB 是唯一公庫後端」、requirements Out of Scope、
|
||||
tasks 1.5/5.2 都寫明「遷移本體由 #16 負責」),所以這不是新方向,是那些卷等的那一塊落地。
|
||||
|
||||
**提議的規格(三句)**
|
||||
1. **資產的真相來源=KBDB**(D1 `entries` 一列一份資產)。KV 降級為可丟棄的快取。
|
||||
2. **零 SQL、永不加表**(D38):新增四個 `entry_type`
|
||||
(`workflow_def` / `api_recipe` / `auth_recipe` / `prompt_recipe`),
|
||||
各在 `templates` 表 seed 一列定義(`kbdb/migrations/0005_arcrun_asset_templates.sql`,
|
||||
手法同 0003/0004)。定義本體打包進 `metadata_json`,比照 `execution_log`/`recipe_stat` 既有先例。
|
||||
3. **衍生資料不進 KBDB**:`idx:*`(recipe 反查)、`cron-idx:_all` 算得回來,
|
||||
留在 KV,讀不到就從 KBDB 重算(不讓 KBDB 長出垃圾列)。
|
||||
|
||||
**實作形狀(已寫在 `feat/kv-retire-recipes-16-17` 分支,未合併)**
|
||||
- 換 binding 而不是改呼叫端:`src/index.ts` 入口把 `WEBHOOKS`/`RECIPES`
|
||||
換成 KBDB 撐腰的包裝(`src/lib/durable-store.ts`),四十幾處呼叫端一行不動。
|
||||
理由:逐處改寫一定會漏,**漏掉的那一處就是下一次「東西不見了」的入口**。
|
||||
- 「哪些 key 是資產」集中成一張表(`src/lib/asset-keys.ts`),是唯一需要人看懂的東西。
|
||||
- 讀=KV 先行、miss 回源 KBDB 並補快取;寫=先 KBDB 再 KV,KBDB 失敗就拋錯(禁假綠);
|
||||
**列舉一律走 KBDB**——空 KV 列出來是「零筆」而不是「查不到」,那正是消失的形狀。
|
||||
- 搬遷與盤點:`GET /storage/audit`、`POST /storage/migrate-to-kbdb`(只增不刪、冪等、逐筆回報)。
|
||||
- KBDB 端只加一個通用原語:`PUT /entries/:id`(指定 id 的整列 upsert),零 schema 異動。
|
||||
|
||||
**影響分析**
|
||||
- 現行 active SDD `workflow-discovery`:**不受影響**。它的 `entry_type='workflow'`
|
||||
搜尋 entry 照舊雙寫,本案刻意用另一個型別 `workflow_def` 存定義本體、且不標 `embed`,
|
||||
以免同一支工作流嵌兩份向量。search/backfill 兩支端點一行未動。
|
||||
- `artifact-sharing`:本案就是它 K2 等的 #16。落地後可拆 tasks 1.5 的 KV 過渡轉接(5.2)。
|
||||
- credential:**不碰**。憑證走 CF Workers Secrets + D1 目錄(rule 01),不在本案範圍。
|
||||
- 匿名 webhook(`webhooks.ts` 的 `put(token, record)`):**目前沒搬**,仍是 KV-only。
|
||||
它也是使用者建出來的東西,但不在 #16/#17 的字面範圍內——在此列出,請 leo 裁要不要納入。
|
||||
- 效能:資產讀取多一層快取判斷;快取命中時與現況相同,miss 時多一次 KBDB 往返。
|
||||
`list` 一律回源,但會順手把整批補進快取,所以「列出來再逐筆讀」總共只多一次往返。
|
||||
|
||||
**尚未完成/誠實限制(決定要不要 confirm 前請先看這段)**
|
||||
- **端到端證據沒跑**。實作環境(雲端工人沙箱)不放行執行測試與 HTTP
|
||||
(`vitest`/`node`/`curl` 皆被權限閘擋下),所以「砍掉 KV、資產還在」這一次
|
||||
**我沒有真的做出來給你看**。已跑到的只有:5 份 migration 在本機 D1 全部套用成功、
|
||||
兩顆 worker 都能以改動後的程式碼在本機開起來、`tsc` 錯誤數與改動前一致(7 個既有錯,未新增)。
|
||||
- 那一次驗證已經寫成可執行的腳本 `scripts/verify-kv-retirement.sh`
|
||||
(開空實例 → 放 9 支工作流+3 份 recipe → 數一次 → **砍掉整個 KV 層重建** → 再數一次
|
||||
→ 真的觸發一支確認跑得動),**在能執行的機器上跑一次就是那個證據**。
|
||||
- 因此本案的狀態是 **◐ 半通**:程式碼與遷移路徑齊備,證據缺一份。
|
||||
**建議 confirm 的順序是「先跑那支腳本、綠了再合併」**,不要因為程式碼看起來完整就先併——
|
||||
這件事的整個重點就是不要再有「看起來好好的,其實東西不見了」。
|
||||
|
||||
**⏸ 停在這裡等 leo 裁**:
|
||||
① 方向 confirm 嗎(資產真相來源改 KBDB、KV 降快取)?
|
||||
② 匿名 webhook 要不要一起納入?
|
||||
③ KV 舊資料要不要清(本案只增不刪,清是另一個決定)?
|
||||
|
||||
---
|
||||
|
||||
### P2|fan-out 並行執行(一個節點的多條出邊目前是循序跑)— 2026-08-03
|
||||
|
||||
**觸發**:leo 08-03 原話——「這是在測試中的計畫,**希望體驗很好**,我發現用 gemma4 的反應非常慢。」
|
||||
|
||||
@@ -341,7 +341,6 @@ arcrun 不自管加密金鑰,`crypto_decrypt` host function 已成永遠回失
|
||||
|------|--------|------|------|
|
||||
| ~~**credential 注入 401**~~ | ✅ 已解 | **8.1-8.5 全完成(2026-06-25 確認)** | 機制(auth_static_key `resolve_credentials` + graph-executor `resolveCredentialRefs`)已端到端實證:2026-06-13 Notion `{{credential.notion_token}}` 真讀到資料(同等於 8.5 OpenAI 驗收,機制與服務無關)。tasks.md 8.5 已補 `[x]` |
|
||||
| §8 P1/P2 recipe/workflow list 遷 D1 | 🔴 高 | 架構已拍板未動 code | 走 kbdb /entries HTTP 雙寫不加 binding;依賴 D1(現已可建)。另開 session 做 |
|
||||
| 零件接收緩衝區有硬上限(http_request 64KB/claude_api 1MB) | 🟡 中 | 訊息已誠實(Arcrun#92),**上限本身還在** | 回應超過上限=真的抓不回來。修的是「以前說成 HTTP request failed」,現在改說「回應太大+實際/上限大小+改用分頁」。host↔零件走**容量握手**(零件先把 outBuf 長度寫進 `*outLenPtr`,host 塞不下回 `HOST_TOO_LARGE=3`,見 `wasi-shim.ts`)。要真的支援大回應得另外設計(分頁/串流),不是調大 buffer 就好 |
|
||||
| 4 份 inline http_request host fn 抽共用 helper | 🟡 中 | 待 dedup | http_request/claude_api/kbdb_upsert_block/km_writer 各自複製貼上同段(這次假綠修也是逐份改) |
|
||||
| `arcrun.dev/llms.txt` 404 | 🟡 中 | 未 serve | landing/public 缺檔;GitHub repo 內正常(test/5 走 GitHub 不阻擋) |
|
||||
| MCP account-source | 🟡 中 | 記錄中 | self-hosted MCP 指官方不指自己(§5.2 已知) |
|
||||
|
||||
Reference in New Issue
Block a user