fix(cli): 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。 使用者的資源是安裝器建的(`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
This commit is contained in:
+103
-14
@@ -3,6 +3,8 @@
|
||||
* 使用 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 {
|
||||
@@ -83,7 +85,7 @@ export class CfKvClient {
|
||||
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
|
||||
*/
|
||||
export class CfAccountClient {
|
||||
export class CfAccountClient implements ResourceApi {
|
||||
private accountBase: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
@@ -96,6 +98,16 @@ export class CfAccountClient {
|
||||
}
|
||||
|
||||
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 ?? {}) },
|
||||
@@ -104,10 +116,13 @@ export class CfAccountClient {
|
||||
| { success: boolean; result: T; errors?: Array<{ message: string }> }
|
||||
| null;
|
||||
if (!res.ok || !data?.success) {
|
||||
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
|
||||
throw new Error(`CF API ${path} 失敗:${msg}`);
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
return data.result;
|
||||
return { ok: true, status: res.status, result: data.result };
|
||||
}
|
||||
|
||||
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
|
||||
@@ -126,12 +141,16 @@ export class CfAccountClient {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
|
||||
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listKvNamespaces());
|
||||
const found = known.get(title);
|
||||
if (found) return found;
|
||||
|
||||
/**
|
||||
* 無條件新建一顆 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 }) },
|
||||
@@ -139,6 +158,24 @@ export class CfAccountClient {
|
||||
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');
|
||||
@@ -153,14 +190,66 @@ export class CfAccountClient {
|
||||
return map;
|
||||
}
|
||||
|
||||
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listD1Databases());
|
||||
const found = known.get(name);
|
||||
if (found) return found;
|
||||
/** 無條件新建 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user