45a546a686
病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。 使用者的資源是安裝器建的(`arcrun-rag-<x>-kv-webhooks`),更新找的是 `WEBHOOKS` ⇒ 找不到 ⇒ 新建一顆空的並綁上去。 2026-08-12 實撞(leo21c):一次例行更新後 KV 9 顆 → 18 顆、D1 1 顆 → 2 顆,worker 全綁到新建的空的 ⇒ 工作流一支都看不到、portal 登出、總圖空的、80 把 recipe 解不出來 leo 原話:「leo21c 是掛掉的」。資料沒掉,但從他的角度就是東西全不見了。 修法方向:**已經部署上去的 worker 綁著什麼,那就是事實**—— 名字是使用者那側的事,不是更新指令可以決定的。 📍 repo:matrix/arcrun(cli/)|📍 票:Leo/Arcrun#97
256 lines
10 KiB
TypeScript
256 lines
10 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: [] };
|
||
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
||
}
|
||
return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) };
|
||
}
|
||
|
||
/** 查 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;
|
||
}
|
||
|
||
/** 把 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;
|
||
}
|