53b05c6d3d
leo 08-12 實撞:更新完 leo21c,Portal 設定頁的「版本」變成 「無法讀取目前版本(知識庫服務可能正在啟動)」。版本號是 leo 唯一的驗收介面, 看不到就等於他無法自己確認任何一次更新有沒有生效。 根因(Arcrun#106):`bundle_version` 來自部署時注入的 plain_text var `ARCRUN_BUNDLE_VERSION`,而**只有安裝器會注入**。wrangler deploy 是整份覆蓋, toml 沒寫的 var 直接消失 ⇒ CLI 更新那條路每跑一次就把標籤洗掉一次。 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),沒修「櫃子上的標籤」。 修法(兩種 var 走相反的規則,這是本次的判斷): · 設定類 var = 使用者實例的事實 → **沿用**(讀綁定時同一份回應就帶回來,不多打 API) ——把 #97「已部署的 worker 上綁著什麼就是事實」原封不動套用到 plain_text var。 · 版本標籤 = 這份成品的屬性 → **每趟重烙,絕不沿用舊值**。 沿用舊值會得到一個永遠停在安裝當天的假標籤——比沒有標籤更糟, 因為它會讓人以為驗收過了。 版號取部署當下發行頻道公告的 release(Portal/daemon 就是拿它當「最新版」比), 另外把**真正部署的 commit** 一起烙上去(/health 多吐 `bundle_commit`)→ 漂掉查得出來。 查不到 release 就誠實退成 `YYYY-MM-DD+<commit7>`,不掰一個 semver 假裝已是最新。 順帶(都是同一條路上的東西): · ref 先解析成 commit sha 再用 sha 下載 archive——不可變,順手解掉 branch tarball 被快取的老病 · Portal 版本行接受帶 build metadata 的 semver(`1.4.41+d61` 這種先前一律被當成「較舊版本」) · cli 測試在 node 22 上本來一支都跑不起來(.js→.ts 解析 + parameter property),補上 resolve hook ——#97 那份「使用者的東西還在不在」的迴歸守衛也在其中,跑不起來的守衛等於沒有守衛 · types.ts 的 ARCRUN_BUNDLE_VERSION 重複宣告(TS2300)併回一處 驗證見 PR:cli 49/49 綠、cypher health 4/4 綠、Portal 版本行原始碼實跑五種情境、 對真實已部署 worker 的唯讀 dry-run。**未做**:真實實例上的 acr update 端到端 (本機唯一有憑證的帳號是 leo21c=紅線禁碰,youlin 無憑證)。 Refs: Leo/Arcrun#106, #97, #95 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
/**
|
||
* Cloudflare KV REST API wrapper
|
||
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
|
||
*/
|
||
|
||
import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js';
|
||
|
||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||
|
||
export interface CfKvClientOptions {
|
||
accountId: string;
|
||
namespaceId: string;
|
||
apiToken: string;
|
||
}
|
||
|
||
export class CfKvClient {
|
||
private base: string;
|
||
private headers: Record<string, string>;
|
||
|
||
constructor({ accountId, namespaceId, apiToken }: CfKvClientOptions) {
|
||
this.base = `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`;
|
||
this.headers = {
|
||
'Authorization': `Bearer ${apiToken}`,
|
||
'Content-Type': 'application/json',
|
||
};
|
||
}
|
||
|
||
async put(key: string, value: string): Promise<void> {
|
||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||
method: 'PUT',
|
||
headers: { ...this.headers, 'Content-Type': 'text/plain' },
|
||
body: value,
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.text();
|
||
throw new Error(`KV PUT 失敗(${res.status}):${err.slice(0, 200)}`);
|
||
}
|
||
}
|
||
|
||
async get(key: string): Promise<string | null> {
|
||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||
headers: this.headers,
|
||
});
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) {
|
||
const err = await res.text();
|
||
throw new Error(`KV GET 失敗(${res.status}):${err.slice(0, 200)}`);
|
||
}
|
||
return res.text();
|
||
}
|
||
|
||
async list(prefix?: string): Promise<Array<{ name: string; expiration?: number; metadata?: unknown }>> {
|
||
const url = new URL(`${this.base}/keys`);
|
||
if (prefix) url.searchParams.set('prefix', prefix);
|
||
url.searchParams.set('limit', '1000');
|
||
|
||
const res = await fetch(url.toString(), { headers: this.headers });
|
||
if (!res.ok) {
|
||
const err = await res.text();
|
||
throw new Error(`KV LIST 失敗(${res.status}):${err.slice(0, 200)}`);
|
||
}
|
||
const data = await res.json() as {
|
||
result: Array<{ name: string; expiration?: number; metadata?: unknown }>;
|
||
};
|
||
return data.result ?? [];
|
||
}
|
||
|
||
async delete(key: string): Promise<void> {
|
||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||
method: 'DELETE',
|
||
headers: this.headers,
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.text();
|
||
throw new Error(`KV DELETE 失敗(${res.status}):${err.slice(0, 200)}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cloudflare Account-level API wrapper(self-hosted installer 用)。
|
||
*
|
||
* 負責 acr init --self-hosted 的資源建立:驗 token、建/列 KV namespace、查 workers.dev subdomain。
|
||
* (不建 R2:R2 是 dead storage 且綁卡違背開源免費;見 init.ts step 2 + registry-canon Phase 1.5。)
|
||
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
|
||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
|
||
*/
|
||
export class CfAccountClient implements ResourceApi {
|
||
private accountBase: string;
|
||
private headers: Record<string, string>;
|
||
|
||
constructor(accountId: string, apiToken: string) {
|
||
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`;
|
||
this.headers = {
|
||
'Authorization': `Bearer ${apiToken}`,
|
||
'Content-Type': 'application/json',
|
||
};
|
||
}
|
||
|
||
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const { ok, status, result, error } = await this.cfRaw<T>(path, init);
|
||
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
|
||
return result as T;
|
||
}
|
||
|
||
/** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */
|
||
private async cfRaw<T>(
|
||
path: string,
|
||
init?: RequestInit,
|
||
): Promise<{ ok: boolean; status: number; result?: T; error?: string }> {
|
||
const res = await fetch(`${this.accountBase}${path}`, {
|
||
...init,
|
||
headers: { ...this.headers, ...(init?.headers ?? {}) },
|
||
});
|
||
const data = await res.json().catch(() => null) as
|
||
| { success: boolean; result: T; errors?: Array<{ message: string }> }
|
||
| null;
|
||
if (!res.ok || !data?.success) {
|
||
return {
|
||
ok: false,
|
||
status: res.status,
|
||
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
|
||
};
|
||
}
|
||
return { ok: true, status: res.status, result: data.result };
|
||
}
|
||
|
||
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
|
||
async verifyAccess(): Promise<void> {
|
||
// GET /accounts/{id} 能通 = token 有此 account 的基本讀權限
|
||
await this.cf<{ id: string; name: string }>('');
|
||
}
|
||
|
||
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
|
||
async listKvNamespaces(): Promise<Map<string, string>> {
|
||
const result = await this.cf<Array<{ id: string; title: string }>>(
|
||
'/storage/kv/namespaces?per_page=100',
|
||
);
|
||
const map = new Map<string, string>();
|
||
for (const ns of result) map.set(ns.title, ns.id);
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* 無條件新建一顆 KV namespace。
|
||
*
|
||
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
|
||
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
|
||
* (安裝器取的名字跟我們的 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
|
||
* 要不要建,一律先經過 resource-resolver 的 planResources 判斷;那裡只有在
|
||
* 「確定沒有任何已部署的 worker 綁過這個 binding」時才會排進 create。
|
||
*/
|
||
async createKvNamespace(title: string): Promise<string> {
|
||
const result = await this.cf<{ id: string; title: string }>(
|
||
'/storage/kv/namespaces',
|
||
{ method: 'POST', body: JSON.stringify({ title }) },
|
||
);
|
||
return result.id;
|
||
}
|
||
|
||
/**
|
||
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
|
||
* CF:`GET /accounts/{id}/workers/scripts/{script}/settings` → `result.bindings[]`。
|
||
*
|
||
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
|
||
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
|
||
* 把查不到當成不存在,就是 #97 的根因。
|
||
*/
|
||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
|
||
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
|
||
if (!res.ok) {
|
||
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
|
||
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
||
}
|
||
const raw = res.result?.bindings ?? [];
|
||
// #106:同一份回應裡也帶著 plain_text var(實測 CF `/settings` 會回 `text` 值)。
|
||
// 舊版只挑資源類、把 var 整批丟掉 → 重部署等於把它們洗掉。
|
||
return { deployed: true, bindings: normalizeBindings(raw), vars: normalizeVars(raw) };
|
||
}
|
||
|
||
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
||
async getWorkersSubdomain(): Promise<string> {
|
||
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
|
||
return result.subdomain;
|
||
}
|
||
|
||
// D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified).
|
||
async listD1Databases(): Promise<Map<string, string>> {
|
||
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
|
||
const map = new Map<string, string>();
|
||
for (const db of result) map.set(db.name, db.uuid);
|
||
return map;
|
||
}
|
||
|
||
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 */
|
||
async createD1Database(name: string): Promise<string> {
|
||
const result = await this.cf<{ uuid: string; name: string }>(
|
||
'/d1/database',
|
||
{ method: 'POST', body: JSON.stringify({ name }) },
|
||
);
|
||
return result.uuid;
|
||
}
|
||
|
||
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
|
||
async listVectorizeIndexes(): Promise<string[]> {
|
||
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
|
||
return (result ?? []).map(i => i.name);
|
||
}
|
||
|
||
/**
|
||
* 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**,見 deploy.ts 常數說明)。
|
||
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。沒有 ensure 版本:
|
||
* 「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
|
||
*/
|
||
async createVectorizeIndex(name: string): Promise<string> {
|
||
const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name,
|
||
config: { dimensions: 1024, metric: 'cosine' },
|
||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||
}),
|
||
});
|
||
if (res.ok) return name;
|
||
const detail = (res.error ?? '').toLowerCase();
|
||
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
|
||
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
|
||
}
|
||
}
|
||
|
||
/** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */
|
||
interface RawWorkerBinding {
|
||
type?: string;
|
||
name?: string;
|
||
namespace_id?: string;
|
||
id?: string;
|
||
database_id?: string;
|
||
index_name?: string;
|
||
/** `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。 */
|
||
text?: string;
|
||
}
|
||
|
||
/**
|
||
* 抽出已部署 worker 上的 `plain_text` var(#106)。
|
||
*
|
||
* 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被 CLI 搬來搬去;
|
||
* wrangler deploy 不會動 secret,它們自己會留著)。
|
||
*/
|
||
function normalizeVars(raw: RawWorkerBinding[]): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
for (const b of raw) {
|
||
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
|
||
function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] {
|
||
const out: LiveBinding[] = [];
|
||
for (const b of raw) {
|
||
if (!b?.name) continue;
|
||
if (b.type === 'kv_namespace') {
|
||
const value = b.namespace_id ?? b.id;
|
||
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
|
||
} else if (b.type === 'd1' || b.type === 'd1_database') {
|
||
const value = b.id ?? b.database_id;
|
||
if (value) out.push({ kind: 'd1', binding: b.name, value });
|
||
} else if (b.type === 'vectorize') {
|
||
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
|
||
}
|
||
}
|
||
return out;
|
||
}
|