diff --git a/README.md b/README.md index 2bef3ae..6ab204d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Served via jsDelivr; fetched automatically during install — you never need to - `arcrun-wait/` — **arcrun-wait**(首裝) - `daemon/` — 桌面 App(Mac/Windows)安裝檔 -Built from `Arcrun@db33cbe907dd` by `installer/scripts/ship.mjs`(arcrun-rag repo,release 1.4.53,built 2026-08-24)。 +Built from `Arcrun@d3019a52cb18` by `installer/scripts/ship.mjs`(arcrun-rag repo,release 1.4.54,built 2026-08-26)。 ⚠️ 這份檔案由出貨管線每次自動重寫(`installer/scripts/render-bundles-readme.mjs`)—— 不要手動改這裡列的零件清單——它是算出來的:公庫=Arcrun 這一版編了什麼, diff --git a/arcrun-cypher-executor/worker.mjs b/arcrun-cypher-executor/worker.mjs index ab3ba98..d2b3b9e 100644 --- a/arcrun-cypher-executor/worker.mjs +++ b/arcrun-cypher-executor/worker.mjs @@ -2436,7 +2436,7 @@ var init_recipes = __esm({ updated_at: now2 }; await installRecipeRecord(c.env.RECIPES, recipe); - const kbdbBase2 = (c.env.KBDB_BASE_URL ?? "https://kbdb.finally.click").replace(/\/$/, ""); + const kbdbBase3 = (c.env.KBDB_BASE_URL ?? "https://kbdb.finally.click").replace(/\/$/, ""); const evidence = { content: canonicalId, entry_type: "recipe_submission", @@ -2449,12 +2449,12 @@ var init_recipes = __esm({ submitted_at: now2 }) }; - const kbdbHeaders = { "Content-Type": "application/json" }; - if (c.env.KBDB_INTERNAL_TOKEN) kbdbHeaders["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; + const kbdbHeaders2 = { "Content-Type": "application/json" }; + if (c.env.KBDB_INTERNAL_TOKEN) kbdbHeaders2["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; c.executionCtx.waitUntil( - fetch(`${kbdbBase2}/entries`, { + fetch(`${kbdbBase3}/entries`, { method: "POST", - headers: kbdbHeaders, + headers: kbdbHeaders2, body: JSON.stringify(evidence) }).catch(() => void 0) ); @@ -9372,7 +9372,35 @@ async function mutateAuthStore(env, fn, tokenOverride) { } // cypher-executor/src/routes/health.ts +init_kbdb_proxy(); var healthRouter = new Hono2(); +var DATA_LAYER_CACHE_MS = 1e4; +var dataLayerCache = null; +async function dataLayerStatus(env) { + const now2 = Date.now(); + if (dataLayerCache && now2 - dataLayerCache.at < DATA_LAYER_CACHE_MS) return dataLayerCache.block; + let block; + try { + const { base, headers } = kbdbBase(env); + const res = await fetch(`${base}/health`, { headers, signal: AbortSignal.timeout(5e3) }); + const body = await res.json().catch(() => null); + block = body?.data_layer ?? { + ok: false, + reason: "kbdb_health_unreadable", + detail: `HTTP ${res.status}`, + summary: "\u9019\u53F0\u7684 kbdb \u9084\u6C92\u6709\u8CC7\u6599\u5C64\u4E16\u4EE3\u63A2\u91DD\uFF08\u6BD4 Arcrun#159 \u7684\u4FEE\u6CD5\u820A\uFF09\u2014\u2014\u72C0\u614B\u672A\u77E5\u3002" + }; + } catch (e) { + block = { + ok: false, + reason: "kbdb_unreachable", + detail: e instanceof Error ? e.message : String(e), + summary: "\u6253\u4E0D\u5230 kbdb\u2014\u2014\u8CC7\u6599\u5C64\u72C0\u614B\u672A\u77E5\uFF08\u672A\u77E5\u4E0D\u7B49\u65BC\u5065\u5EB7\uFF09\u3002" + }; + } + dataLayerCache = { at: now2, block }; + return block; +} function authStoreStatus(env) { const legacy = readAuthStore(env); return { @@ -9380,11 +9408,14 @@ function authStoreStatus(env) { portal_users: { home: "kbdb", writable: true, legacy_secrets_present: legacy.users.length > 0 } }; } -healthRouter.get("/health", (c) => { +healthRouter.get("/health", async (c) => { const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION; const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT; + const dataLayer = await dataLayerStatus(c.env); return c.json({ ok: true, + status: dataLayer.ok ? "ok" : "degraded", + data_layer: dataLayer, ...bundleVersion ? { bundle_version: bundleVersion } : {}, ...bundleCommit ? { bundle_commit: bundleCommit } : {}, auth_store: authStoreStatus(c.env), @@ -14818,7 +14849,7 @@ async function cachedGiteaSprint(env, nowMs, waitUntil, fetcher = fetchGiteaSpri consoleDashboardRouter.get("/console/dashboard-data", async (c) => { const tenant2 = knowledgeOwner(c.env); const now2 = Date.now(); - const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env); + const { base: kbdbUrl, headers: kbdbHeaders2 } = kbdbBase(c.env); const graphUrl = graphBase(c.env); const [ beatEntries, @@ -14839,8 +14870,8 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => { fetchEntries(c.env, tenant2, "dash_wait", 100), fetchEntries(c.env, tenant2, "inbox", 200), cachedGiteaSprint(c.env, now2, (p) => c.executionCtx.waitUntil(p)), - fetchJson(`${kbdbUrl}/health`, kbdbHeaders), - fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders), + fetchJson(`${kbdbUrl}/health`, kbdbHeaders2), + fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders2), // graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。 // headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。 fetchJson( @@ -15048,6 +15079,464 @@ init_webhook_handlers(); // cypher-executor/src/lib/app-system.ts init_kbdb_proxy(); init_webhook_handlers(); + +// cypher-executor/src/lib/asset-keys.ts +var ID_PREFIX = "arcrun"; +function classifyAssetKey(key) { + if (key.startsWith("idx:") || key.startsWith("cron-idx:")) return null; + 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 + }; + } + 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 + }; + } + 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 + }; + } + const appAt = key.indexOf(":app:"); + if (appAt > 0) { + const owner = key.slice(0, appAt); + const id = key.slice(appAt + ":app:".length); + if (!owner || !id) return null; + return { + entry_type: "app_install", + entry_id: `${ID_PREFIX}:app:${owner}:${id}`, + owner_id: owner, + page_name: id, + kv_key: key + }; + } + 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 + }; + } + return null; +} +function assetKvKey(entryType, ownerId, pageName) { + switch (entryType) { + case "workflow_def": + return `${ownerId ?? ""}:wf:${pageName}`; + case "app_install": + return `${ownerId ?? ""}:app:${pageName}`; + case "api_recipe": + return `recipe:${pageName}`; + case "auth_recipe": + return `auth_recipe:${pageName}`; + case "prompt_recipe": + return `prompt_recipe:${pageName}`; + } +} +function classifyListPrefix(prefix) { + if (!prefix) return null; + 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" }; + if (prefix.endsWith(":wf:")) { + const owner = prefix.slice(0, -":wf:".length); + if (owner) return { entry_type: "workflow_def", owner_id: owner }; + } + if (prefix.endsWith(":app:")) { + const owner = prefix.slice(0, -":app:".length); + if (owner) return { entry_type: "app_install", owner_id: owner }; + } + return null; +} + +// cypher-executor/src/lib/durable-store.ts +function kbdbBase2(env) { + return (env.KBDB_BASE_URL ?? "https://arcrun-kbdb.uncle6-me.workers.dev").replace(/\/$/, ""); +} +function kbdbHeaders(env) { + const h = { "Content-Type": "application/json" }; + if (env.KBDB_INTERNAL_TOKEN) h["Authorization"] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`; + return h; +} +var KbdbUnavailableError = class extends Error { + constructor(op, detail) { + super( + `\u8CC7\u7522\u7121\u6CD5\u5BEB\u5165 KBDB\uFF08${op}\uFF09\uFF1A${detail}\u3002\u672C\u6B21\u64CD\u4F5C\u5DF2\u4E2D\u6B62\u4E14\u672A\u5BEB\u5165\u4EFB\u4F55\u4E00\u908A\u2014\u2014\u9019\u662F\u523B\u610F\u7684\uFF1A\u5BE7\u53EF\u8B93\u4F60\u73FE\u5728\u770B\u5230\u5931\u6557\uFF0C\u4E5F\u4E0D\u8981\u5BEB\u9032\u53EA\u6703\u88AB\u4E0B\u6B21\u66F4\u65B0\u63DB\u6389\u7684 KV\u3001\u4E8B\u5F8C\u624D\u767C\u73FE\u6771\u897F\u4E0D\u898B\u4E86\uFF08Leo/Arcrun#16\u3001#17\uFF09\u3002` + ); + this.name = "KbdbUnavailableError"; + } +}; +function assetContent(type, def, pageName) { + const d = def ?? {}; + const pick = (...keys) => { + 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; + case "app_install": + return pick("name") || pageName; + } +} +function entryToKvValue(entry) { + if (!entry?.metadata_json) return null; + try { + const env = JSON.parse(entry.metadata_json); + if (!env || env.arcrun_asset !== true) return null; + if (typeof env.definition_raw === "string") return env.definition_raw; + if (env.definition === void 0) return null; + return JSON.stringify(env.definition); + } catch { + return null; + } +} +var DurableKv = class { + constructor(kv, env) { + this.kv = kv; + this.env = env; + } + kv; + env; + rehydratedRecipeIdx = false; + rehydratedCronIdx = false; + /** + * 拿回底層那顆真正的 KV。**只有遷移/盤點會用到**(routes/storage.ts): + * 那兩支的工作正是「比較 KV 那邊有什麼、KBDB 這邊有什麼」, + * 若透過包裝去問,list 會被導去 KBDB,就永遠比不出差異、也搬不動舊資料。 + * 一般業務程式碼不該碰這支——碰了就等於繞過本卷的全部保護。 + */ + get rawKv() { + return this.kv; + } + // ── KBDB 存取原語 ────────────────────────────────────────────────────── + async kbdbGetEntry(entryId) { + const res = await fetch(`${kbdbBase2(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); + return json?.entry ?? null; + } + async kbdbListEntries(entryType, ownerId) { + const params = new URLSearchParams({ entry_type: entryType, limit: "1000" }); + if (ownerId) params.set("owner_id", ownerId); + const res = await fetch(`${kbdbBase2(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); + return json?.entries ?? []; + } + async kbdbPutEntry(ref, envelope) { + const content = assetContent(ref.entry_type, envelope.definition, ref.page_name); + const res = await fetch(`${kbdbBase2(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}`); + } + async kbdbDeleteEntry(entryId) { + const res = await fetch(`${kbdbBase2(this.env)}/entries/${encodeURIComponent(entryId)}`, { + method: "DELETE", + headers: kbdbHeaders(this.env) + }); + 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 的語意本來就是「最後寫入的那版即為安裝版」。 + * 這是還原不是猜測,但仍是**退路**,故在此寫明白。 + */ + async rehydrateRecipeIndices() { + if (this.rehydratedRecipeIdx) return; + this.rehydratedRecipeIdx = true; + const entries = await this.kbdbListEntries("api_recipe"); + const byCanonical = /* @__PURE__ */ new Map(); + const writes = []; + for (const e of entries) { + const raw2 = entryToKvValue(e); + if (!raw2) continue; + let def; + try { + def = JSON.parse(raw2); + } 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 ?? "{}").installed === true; + } catch { + } + 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)。 */ + async rehydrateCronIndex() { + if (this.rehydratedCronIdx) return; + this.rehydratedCronIdx = true; + const entries = await this.kbdbListEntries("workflow_def"); + const index = {}; + for (const e of entries) { + const raw2 = entryToKvValue(e); + if (!raw2) continue; + let def; + try { + def = JSON.parse(raw2); + } catch { + continue; + } + if (!def.cron_expr || !e.owner_id || !e.page_name) continue; + index[cronEntryKey(e.owner_id, e.page_name)] = def.cron_expr; + } + await this.kv.put(CRON_INDEX_KEY, JSON.stringify(index)); + } + // ── KVNamespace 介面 ─────────────────────────────────────────────────── + async get(key, type) { + const t0 = typeof type === "string" ? type : type?.type; + if (t0 === "arrayBuffer" || t0 === "stream") return this.kv.get(key, t0); + const asText = (raw2) => { + if (raw2 === null) return null; + const t = typeof type === "string" ? type : type?.type; + if (t === "json") { + try { + return JSON.parse(raw2); + } catch { + return null; + } + } + return raw2; + }; + const ref = classifyAssetKey(key); + if (!ref) { + const raw2 = await this.kv.get(key, "text"); + if (raw2 !== null) return asText(raw2); + 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(raw2); + } + 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, value, options) { + if (options?.expirationTtl || options?.expiration || typeof value !== "string") { + return this.kv.put(key, value, options); + } + if (key.startsWith("idx:installed:")) { + await this.kv.put(key, value, options); + await this.markInstalledVersion(key.slice("idx:installed:".length), value).catch(() => { + }); + return; + } + const ref = classifyAssetKey(key); + if (!ref) return this.kv.put(key, value, options); + let definition; + let definitionRaw; + try { + definition = JSON.parse(value); + } catch { + definitionRaw = value; + } + const previous = ref.entry_type === "api_recipe" ? await this.readEnvelope(ref) : null; + await this.kbdbPutEntry(ref, { + arcrun_asset: true, + kv_key: key, + ...definitionRaw !== void 0 ? { definition_raw: definitionRaw } : { definition }, + ...previous?.installed ? { installed: true } : {} + }); + await this.kv.put(key, value, options); + } + async delete(key) { + const ref = classifyAssetKey(key); + if (ref) await this.kbdbDeleteEntry(ref.entry_id); + await this.kv.delete(key); + } + async list(options) { + const target = classifyListPrefix(options?.prefix ?? void 0); + if (!target) return this.kv.list(options); + const entries = await this.kbdbListEntries(target.entry_type, target.owner_id); + const keys = []; + const warm = []; + for (const e of entries) { + if (!e.page_name) continue; + 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 raw2 = entryToKvValue(e); + if (raw2 !== null) warm.push(this.kv.put(name, raw2).catch(() => { + })); + } + await Promise.all(warm); + if (target.entry_type === "app_install") { + const seen = new Set(keys.map((k) => k.name)); + const fromKv = await this.kv.list({ prefix: options?.prefix ?? void 0 }); + const heal = []; + for (const k of fromKv.keys) { + if (seen.has(k.name)) continue; + keys.push({ name: k.name }); + heal.push( + (async () => { + const raw2 = await this.kv.get(k.name, "text"); + if (raw2 === null) return; + const ref = classifyAssetKey(k.name); + if (!ref) return; + let definition; + let definitionRaw; + try { + definition = JSON.parse(raw2); + } catch { + definitionRaw = raw2; + } + await this.kbdbPutEntry(ref, { + arcrun_asset: true, + kv_key: k.name, + ...definitionRaw !== void 0 ? { definition_raw: definitionRaw } : { definition } + }); + })().catch(() => { + }) + ); + } + await Promise.all(heal); + } + return { keys, list_complete: true, cacheStatus: null }; + } + /** KVNamespace 介面補齊(本 worker 沒有呼叫端在用,原樣轉發,不做資產處理)。 */ + getWithMetadata(key, type) { + return this.kv.getWithMetadata(key, type); + } + // ── 內部小工具 ───────────────────────────────────────────────────────── + async readEnvelope(ref) { + const entry = await this.kbdbGetEntry(ref.entry_id); + if (!entry?.metadata_json) return null; + try { + const env = JSON.parse(entry.metadata_json); + return env?.arcrun_asset === true ? env : null; + } catch { + return null; + } + } + /** 把「這個 canonical 目前裝的是哪一版」記進該版 recipe 的信封(同 canonical 的其他版清掉旗標)。 */ + async markInstalledVersion(canonicalId, uuid) { + const entries = await this.kbdbListEntries("api_recipe"); + const jobs = []; + for (const e of entries) { + const raw2 = entryToKvValue(e); + if (!raw2) continue; + let def; + try { + def = JSON.parse(raw2); + } catch { + continue; + } + if (def.canonical_id !== canonicalId || !def.uuid) continue; + const shouldBeInstalled = def.uuid === uuid; + let envelope; + try { + envelope = JSON.parse(e.metadata_json ?? "{}"); + } catch { + continue; + } + if (envelope.installed === true === shouldBeInstalled) continue; + envelope.installed = shouldBeInstalled; + jobs.push( + fetch(`${kbdbBase2(this.env)}/entries/${encodeURIComponent(e.id)}`, { + method: "PATCH", + headers: kbdbHeaders(this.env), + body: JSON.stringify({ metadata_json: JSON.stringify(envelope) }) + }) + ); + } + await Promise.all(jobs); + } +}; + +// cypher-executor/src/lib/app-system.ts +function appStore(env) { + return new DurableKv(env.WEBHOOKS, env); +} +function kbdbBehindHint(e) { + const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e); + if (!msg.includes("KbdbUnavailable")) return null; + return "\u9019\u53F0\u5BE6\u4F8B\u7684\u77E5\u8B58\u5EAB\uFF08KBDB\uFF09\u6C92\u6709\u56DE\u61C9\uFF0C\u6216\u5B83\u7684\u7248\u672C\u6BD4\u76EE\u524D\u7684\u7A0B\u5F0F\u78BC\u820A\u2014\u2014\u5B89\u88DD\u9700\u8981\u77E5\u8B58\u5EAB\u63D0\u4F9B\u300C\u540C\u4E00\u4EFD\u8CC7\u7522\u6C38\u9060\u540C\u4E00\u5217\u300D\u7684\u5BEB\u5165\u80FD\u529B\u3002\u8ACB\u7BA1\u7406\u8005\u628A\u77E5\u8B58\u5EAB\u4E00\u8D77\u66F4\u65B0\u5230\u540C\u4E00\u7248\uFF1B\u5728\u90A3\u4E4B\u524D**\u5DF2\u7D93\u88DD\u597D\u7684 App \u4E0D\u53D7\u5F71\u97FF**\uFF0C\u7167\u5E38\u53EF\u7528\u3002"; +} var APP_UI_STYLES = ["inherit", "own"]; var DEFAULT_APP_UI_STYLE = "inherit"; var ID_RE = /^[a-z][a-z0-9_-]{0,63}$/; @@ -15119,6 +15608,17 @@ function validateAppDeclaration(raw2) { } if (d.actions !== void 0 && (!Array.isArray(d.actions) || d.actions.some((a) => typeof a !== "string"))) { errors.push("actions \u5FC5\u9808\u662F\u5B57\u4E32\u9663\u5217"); + } else if (Array.isArray(d.actions) && Array.isArray(d.workflows)) { + const wfNames = new Set( + d.workflows.map((w) => w && typeof w === "object" ? w.name : void 0).filter((n) => typeof n === "string") + ); + for (const a of d.actions) { + if (!wfNames.has(a)) { + errors.push( + `actions \u88E1\u7684\u300C${a}\u300D\u627E\u4E0D\u5230\u540C\u540D\u7684 workflow\uFF08\u52D5\u4F5C\u540D\u5C31\u662F\u5DE5\u4F5C\u6D41\u672C\u5730\u540D\uFF09\u3002\u9019\u4EFD\u5BA3\u544A\u7684\u5DE5\u4F5C\u6D41\u662F\uFF1A${[...wfNames].join("\uFF0F") || "\uFF08\u4E00\u500B\u90FD\u6C92\u6709\uFF09"}` + ); + } + } } return { ok: errors.length === 0, errors }; } @@ -15143,6 +15643,26 @@ function applyDeclarationDefaults(raw2) { keeps_data: raw2.remove?.keeps_data ?? true }; } +var APP_PLACEHOLDERS = ["__NAMESPACE__", "__CYPHER_BASE__"]; +function resolveDeclarationPlaceholders(value, coords) { + const base = coords.cypherBase.replace(/\/+$/, ""); + const swap = (s) => s.split("__NAMESPACE__").join(coords.namespace).split("__CYPHER_BASE__").join(base); + const walk = (v) => { + if (typeof v === "string") return swap(v); + if (Array.isArray(v)) return v.map(walk); + if (v && typeof v === "object") { + const out = {}; + for (const [k, val] of Object.entries(v)) out[swap(k)] = walk(val); + return out; + } + return v; + }; + return walk(value); +} +function remainingPlaceholders(value) { + const text = JSON.stringify(value) ?? ""; + return APP_PLACEHOLDERS.filter((p) => text.includes(p)); +} async function computeContentHash(raw2) { const canonical = canonicalize(raw2); const data = new TextEncoder().encode(canonical); @@ -15164,7 +15684,7 @@ function workflowKvKey(tenant2, wfKey) { return `${tenant2}:wf:${wfKey}`; } async function getInstalledApp(env, tenant2, id) { - const raw2 = await env.WEBHOOKS.get(appKey(tenant2, id), "text"); + const raw2 = await appStore(env).get(appKey(tenant2, id), "text"); if (!raw2) return null; try { return JSON.parse(raw2); @@ -15174,10 +15694,10 @@ async function getInstalledApp(env, tenant2, id) { } async function listInstalledApps(env, tenant2) { const prefix = `${tenant2}:app:`; - const list = await env.WEBHOOKS.list({ prefix }); + const list = await appStore(env).list({ prefix }); const apps = await Promise.all( list.keys.map(async (k) => { - const raw2 = await env.WEBHOOKS.get(k.name, "text"); + const raw2 = await appStore(env).get(k.name, "text"); if (!raw2) return null; try { return JSON.parse(raw2); @@ -15201,36 +15721,83 @@ async function ensureTemplate(env, name, slots, description) { body: JSON.stringify({ name, slots, description }) }); } -async function installApp(env, tenant2, rawDecl) { - const validation = validateAppDeclaration(rawDecl); - if (!validation.ok) return { ok: false, errors: validation.errors }; - const decl = applyDeclarationDefaults(rawDecl); - const contentHash = await computeContentHash(rawDecl); +async function installApp(env, tenant2, rawDecl, opts = {}) { + let decl0 = rawDecl; + if (opts.coords) { + try { + decl0 = resolveDeclarationPlaceholders(rawDecl, opts.coords); + } catch (e) { + return { + ok: false, + step: "\u88DC\u4E0A\u9019\u53F0\u5BE6\u4F8B\u7684\u4F4D\u5740", + errors: [e instanceof Error ? e.message : String(e)], + hint: "\u9019\u662F\u7CFB\u7D71\u5167\u90E8\u7684\u554F\u984C\uFF0C\u4E0D\u662F\u4F60\u505A\u932F\u4E86\u3002\u628A\u9019\u53E5\u8A71\u56DE\u5831\u7D66\u7BA1\u7406\u8005\u5373\u53EF\u3002" + }; + } + } + const left = remainingPlaceholders(decl0); + if (left.length > 0) { + return { + ok: false, + step: "\u88DC\u4E0A\u9019\u53F0\u5BE6\u4F8B\u7684\u4F4D\u5740", + errors: [`\u9019\u4EFD\u5BA3\u544A\u88E1\u9084\u6709\u6C92\u586B\u7684\u4F4D\u7F6E\uFF1A${left.join("\u3001")}`], + hint: "\u9019\u500B App \u7684\u4F5C\u8005\u7559\u4E86\u8981\u7531\u5BE6\u4F8B\u586B\u7684\u6B04\u4F4D\uFF0C\u4F46\u9019\u53F0\u5BE6\u4F8B\u6C92\u586B\u5F97\u8D77\u4F86\u3002\u8ACB\u56DE\u5831\u7D66\u7BA1\u7406\u8005\u3002" + }; + } + const validation = validateAppDeclaration(decl0); + if (!validation.ok) { + return { + ok: false, + step: "\u6AA2\u67E5\u9019\u500B App \u7684\u5BA3\u544A", + errors: validation.errors, + hint: "\u9019\u662F App \u4F5C\u8005\u90A3\u908A\u8981\u4FEE\u7684\uFF0C\u91CD\u8A66\u4E0D\u6703\u8B8A\u597D\u3002\u628A\u4E0A\u9762\u9019\u5E7E\u884C\u539F\u6587\u56DE\u5831\u7D66\u9019\u500B App \u7684\u4F5C\u8005\u3002" + }; + } + const decl = applyDeclarationDefaults(decl0); + const contentHash = await computeContentHash(decl0); const existing = await getInstalledApp(env, tenant2, decl.id); if (existing && existing.content_hash === contentHash && existing.status === "active") { return { ok: true, changed: false, app: existing }; } - for (const dt of decl.data) { - await ensureTemplate(env, dt.name, dt.slots, dt.description); + try { + for (const dt of decl.data) { + await ensureTemplate(env, dt.name, dt.slots, dt.description); + } + } catch (e) { + return { + ok: false, + step: "\u6E96\u5099\u8CC7\u6599\u578B\u5225", + errors: [e instanceof Error ? e.message : String(e)], + hint: "\u9019\u53F0\u5BE6\u4F8B\u7684\u77E5\u8B58\u5EAB\u670D\u52D9\u6C92\u6709\u56DE\u61C9\u3002\u7A0D\u7B49\u4E00\u5206\u9418\u518D\u6309\u4E00\u6B21\u5B89\u88DD\uFF1B\u4E00\u76F4\u5931\u6557\u5C31\u628A\u9019\u53E5\u8A71\u56DE\u5831\u7D66\u7BA1\u7406\u8005\u3002" + }; } const workflowRefs = []; - for (const wf of decl.workflows) { - const wfKey = `${decl.id}__${wf.name}`; - await env.WEBHOOKS.put( - workflowKvKey(tenant2, wfKey), - JSON.stringify({ - graph: wf.graph, - description: wf.description || `${decl.name}: ${wf.name}`, - created_at: existing?.installed_at ?? (/* @__PURE__ */ new Date()).toISOString() - }) - ); - workflowRefs.push({ name: wf.name, wf_key: wfKey, description: wf.description || "" }); + try { + for (const wf of decl.workflows) { + const wfKey = `${decl.id}__${wf.name}`; + await appStore(env).put( + workflowKvKey(tenant2, wfKey), + JSON.stringify({ + graph: wf.graph, + description: wf.description || `${decl.name}: ${wf.name}`, + created_at: existing?.installed_at ?? (/* @__PURE__ */ new Date()).toISOString() + }) + ); + workflowRefs.push({ name: wf.name, wf_key: wfKey, description: wf.description || "" }); + } + } catch (e) { + return { + ok: false, + step: "\u5B89\u88DD\u5DE5\u4F5C\u6D41", + errors: [e instanceof Error ? e.message : String(e)], + hint: kbdbBehindHint(e) ?? `\u5DF2\u7D93\u88DD\u4E0A ${workflowRefs.length}\uFF0F${decl.workflows.length} \u689D\u3002\u518D\u6309\u4E00\u6B21\u5B89\u88DD\u6703\u5F9E\u982D\u8986\u5BEB\u4E00\u904D\uFF1B\u4E00\u76F4\u5931\u6557\u5C31\u628A\u9019\u53E5\u8A71\u56DE\u5831\u7D66\u7BA1\u7406\u8005\u3002` + }; } if (existing) { const keepKeys = new Set(workflowRefs.map((w) => w.wf_key)); for (const prev of existing.workflows) { if (!keepKeys.has(prev.wf_key)) { - await env.WEBHOOKS.delete(workflowKvKey(tenant2, prev.wf_key)); + await appStore(env).delete(workflowKvKey(tenant2, prev.wf_key)); } } } @@ -15252,16 +15819,32 @@ async function installApp(env, tenant2, rawDecl) { updated_at: (/* @__PURE__ */ new Date()).toISOString(), status: "active" }; - await env.WEBHOOKS.put(appKey(tenant2, decl.id), JSON.stringify(record)); + try { + await appStore(env).put(appKey(tenant2, decl.id), JSON.stringify(record)); + } catch (e) { + return { + ok: false, + step: "\u5BEB\u5165\u5B89\u88DD\u7D00\u9304", + errors: [e instanceof Error ? e.message : String(e)], + hint: kbdbBehindHint(e) ?? "\u5DE5\u4F5C\u6D41\u5DF2\u7D93\u88DD\u597D\u4E86\uFF0C\u53EA\u5DEE\u6700\u5F8C\u9019\u4E00\u7B46\u7D00\u9304\u2014\u2014\u518D\u6309\u4E00\u6B21\u5B89\u88DD\u5C31\u6703\u88DC\u4E0A\u3002" + }; + } return { ok: true, changed: true, app: record }; } async function uninstallApp(env, tenant2, id) { const existing = await getInstalledApp(env, tenant2, id); if (!existing) return { ok: false, error: "\u9019\u500B App \u6C92\u6709\u5B89\u88DD\u7D00\u9304" }; - for (const wf of existing.workflows) { - await env.WEBHOOKS.delete(workflowKvKey(tenant2, wf.wf_key)); + try { + for (const wf of existing.workflows) { + await appStore(env).delete(workflowKvKey(tenant2, wf.wf_key)); + } + await appStore(env).delete(appKey(tenant2, id)); + } catch (e) { + return { + ok: false, + error: kbdbBehindHint(e) ?? `\u79FB\u9664\u5931\u6557\uFF1A${e instanceof Error ? e.message : String(e)}` + }; } - await env.WEBHOOKS.delete(appKey(tenant2, id)); return { ok: true }; } async function runAppAction(env, tenant2, appId, action, payload, ctx) { @@ -15272,7 +15855,7 @@ async function runAppAction(env, tenant2, appId, action, payload, ctx) { } const wfRef = app2.workflows.find((w) => w.name === action); if (!wfRef) return { ok: false, status: 500, error: "\u52D5\u4F5C\u5C0D\u61C9\u7684\u5DE5\u4F5C\u6D41\u907A\u5931\uFF08\u5B89\u88DD\u614B\u640D\u6BC0\uFF09" }; - const raw2 = await env.WEBHOOKS.get(workflowKvKey(tenant2, wfRef.wf_key), "text"); + const raw2 = await appStore(env).get(workflowKvKey(tenant2, wfRef.wf_key), "text"); if (!raw2) return { ok: false, status: 500, error: "\u5DE5\u4F5C\u6D41\u8CC7\u6599\u907A\u5931\uFF08\u5B89\u88DD\u614B\u640D\u6BC0\uFF09" }; let graph; try { @@ -15307,6 +15890,899 @@ function detailApp(app2) { }; } +// cypher-executor/src/lib/app-catalog/notes.json +var notes_default = { + id: "notes", + name: "\u7B46\u8A18", + icon: "\u{1F4DD}", + workflows: [ + { + name: "create_note", + description: "\u6536\u4E00\u6BB5 markdown \u5167\u5BB9\uFF08\u53EF\u9078\u65E5\u671F\uFF09\uFF0C\u5BEB\u5165\u4E00\u7B46 note record\uFF0C\u56DE\u50B3\u6574\u7406\u904E\u7684\u4E7E\u6DE8\u6B04\u4F4D \uFF08\u4E0D\u662F KBDB \u539F\u59CB\u56DE\u61C9\u2014\u2014\u90A3\u500B\u8981\u547C\u53EB\u7AEF\u81EA\u5DF1\u525D body \u5B57\u4E32\uFF0C\u4E0D\u8A72\u8B93\u524D\u7AEF\u505A\u9019\u4EF6\u4E8B\uFF09\u3002\n", + graph: { + id: "notes_create", + name: "notes_create", + nodes: [ + { + id: "input", + type: "Input", + label: "input" + }, + { + id: "prep", + type: "Component", + label: "prep", + componentId: "code", + data: { + code: `// \u{1F534} \u771F\u5BE6\u8E29\u904E\u7684\u5751\uFF08\u672C\u6A5F\u7528 matrix/arcrun \u7684 GraphExecutor \u771F\u8DD1\u904E\u624D\u767C\u73FE\uFF0C\u4E0D\u662F\u731C\u7684\uFF09\uFF1A +// {{input.xxx}} \u5728 xxx \u9019\u500B key \u771F\u7684\u4E0D\u5B58\u5728\u6642\uFF0CGraphExecutor \u7684 interpolateString +// \u6703\u56DE\u300C\u539F\u6A23\u4FDD\u7559\u9019\u500B {{...}} \u5B57\u9762\u5B57\u4E32\u300D\uFF0C\u4E0D\u662F undefined\u3001\u4E5F\u4E0D\u662F\u7A7A\u5B57\u4E32 +// \uFF08matrix/arcrun/cypher-executor/src/graph-executor.ts interpolateString \u7684\u65E2\u6709\u8A2D\u8A08\uFF1A +// \u300C\u627E\u4E0D\u5230\u5C31\u4FDD\u7559\u5B57\u9762\u5B57\u4E32\u300D\uFF0C\u662F\u5F15\u64CE\u6545\u610F\u7684\u884C\u70BA\uFF0C\u4E0D\u662F bug\uFF09\u3002 +// \u21D2 \u547C\u53EB\u7AEF\u6F0F\u5E36 date \u6642\uFF0C\u9019\u88E1\u6536\u5230\u7684 input.date \u4E0D\u662F undefined\uFF0C\u662F\u5B57\u4E32 "{{input.date}}" +// \u2014\u2014\u82E5\u53EA\u5224\u65B7 == null || === ''\uFF0C\u9019\u500B\u5B57\u9762\u5B57\u4E32\u6703\u88AB\u8AA4\u5224\u6210\u300C\u6709\u7D66\u503C\u300D\uFF0C +// \u7136\u5F8C\u5728 YYYY-MM-DD \u683C\u5F0F\u6AA2\u67E5\u90A3\u95DC\u5931\u6557\uFF0C\u8B93\u300C\u4E0D\u5E36 date \u5C31\u7528\u4ECA\u5929\u300D\u6574\u500B\u5931\u6548\u3002 +// \u4FEE\u6CD5\uFF1A\u591A\u5224\u4E00\u7A2E\u300C\u770B\u8D77\u4F86\u50CF\u6C92\u89E3\u958B\u7684\u6A21\u677F\u300D\u7684\u5F62\u72C0\uFF0C\u8996\u540C\u6C92\u7D66\u3002 +function provided(v) { + if (typeof v !== 'string') return false; + var t = v.trim(); + return t !== '' && !/^\\{\\{[\\s\\S]*\\}\\}$/.test(t); +} +const content = provided(input.content) ? String(input.content).trim() : ''; +if (!content) return { success: false, error: 'content \u4E0D\u53EF\u70BA\u7A7A\uFF08\u7B46\u8A18\u81F3\u5C11\u8981\u6709\u5167\u5BB9\uFF09' }; +const now = new Date(); +const rawDate = provided(input.date) ? String(input.date).trim() : now.toISOString().slice(0, 10); +if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(rawDate)) { + return { success: false, error: 'date \u5FC5\u9808\u662F YYYY-MM-DD \u683C\u5F0F\uFF08\u6536\u5230\uFF1A' + rawDate + '\uFF09' }; +} +return { success: true, content: content, date: rawDate, created_at: now.toISOString() }; +`, + input: { + content: "{{input.content}}", + date: "{{input.date}}" + }, + limits: { + timeout_ms: 1e3, + max_output_bytes: 8192 + } + } + }, + { + id: "write_note", + type: "Component", + label: "write_note", + componentId: "http_request", + data: { + method: "POST", + url: "__CYPHER_BASE__/kbdb/records", + headers: { + "Content-Type": "application/json", + "X-Arcrun-API-Key": "__NAMESPACE__" + }, + body_json: { + template: "note", + values: { + date: "{{prep.data.date}}", + content: "{{prep.data.content}}", + created_at: "{{prep.data.created_at}}" + } + } + } + }, + { + id: "extract_note", + type: "Component", + label: "extract_note", + componentId: "code", + data: { + code: "function parse(b) { if (typeof b === 'string') { try { return JSON.parse(b); } catch (e) { return null; } } return b; }\nconst body = parse(input.body);\nif (!body || body.success !== true || !body.record) {\n return { success: false, error: (body && body.error) || 'KBDB \u5BEB\u5165\u6C92\u6709\u56DE\u53EF\u7528\u7684 record' };\n}\nconst v = body.record.values || {};\nreturn {\n success: true,\n record_id: body.record.record_id,\n date: v.date || '',\n content: v.content || '',\n created_at: v.created_at || '',\n};\n", + input: { + body: "{{write_note.data.body}}" + }, + limits: { + timeout_ms: 1e3, + max_output_bytes: 65536 + } + } + } + ], + edges: [ + { + from: "input", + to: "prep", + type: "ON_SUCCESS" + }, + { + from: "prep", + to: "write_note", + type: "ON_SUCCESS" + }, + { + from: "write_note", + to: "extract_note", + type: "ON_SUCCESS" + } + ] + } + }, + { + name: "list_notes", + description: "\u53D6\u56DE\u6CB3\u9053\uFF1A\u9810\u8A2D\u5168\u90E8\uFF08\u4F9D created_at \u65B0\u5230\u820A\uFF09\uFF0C\u5E36 date \u53EA\u56DE\u90A3\u4E00\u5929\u7684\u7B46\u8A18\u3002\n", + graph: { + id: "notes_list", + name: "notes_list", + nodes: [ + { + id: "input", + type: "Input", + label: "input" + }, + { + id: "fetch_notes", + type: "Component", + label: "fetch_notes", + componentId: "http_request", + data: { + method: "GET", + url: "__CYPHER_BASE__/kbdb/records/by-template/note?limit=500", + headers: { + Accept: "application/json", + "X-Arcrun-API-Key": "__NAMESPACE__" + } + } + }, + { + id: "filter_sort", + type: "Component", + label: "filter_sort", + componentId: "code", + data: { + code: "// \u{1F534} \u771F\u5BE6\u8E29\u904E\u7684\u5751\uFF08\u672C\u6A5F\u7528 matrix/arcrun \u7684 GraphExecutor \u771F\u8DD1\u904E\u624D\u767C\u73FE\u2014\u2014\n// \u4E0D\u52A0\u9019\u6BB5\u9632\u8B77\u6642\uFF0C\u300C\u4E0D\u5E36 date\uFF1D\u67E5\u5168\u90E8\u300D\u9019\u500B\u6700\u5E38\u7528\u7684\u9810\u8A2D\u8DEF\u5F91\u6703\u975C\u9ED8\u56DE\u50B3 0 \u7B46\uFF0C\n// \u8A73\u7D30\u6210\u56E0\u898B notes-create.yaml \u7684 prep \u7BC0\u9EDE\u540C\u6B3E\u8A3B\u89E3\uFF0C\u9019\u88E1\u4E0D\u91CD\u8907\u8CBC\u4E00\u6B21\uFF09\u3002\nfunction provided(v) {\n if (typeof v !== 'string') return false;\n var t = v.trim();\n return t !== '' && !/^\\{\\{[\\s\\S]*\\}\\}$/.test(t);\n}\nfunction parse(b) { if (typeof b === 'string') { try { return JSON.parse(b); } catch (e) { return null; } } return b; }\nconst body = parse(input.body) || {};\nconst records = Array.isArray(body.records) ? body.records : [];\nconst wantDate = provided(input.date) ? String(input.date).trim() : '';\nlet notes = records.map(function (r) {\n const v = r.values || {};\n return {\n record_id: r.record_id,\n date: v.date || '',\n content: v.content || '',\n created_at: v.created_at || ''\n };\n});\nif (wantDate) notes = notes.filter(function (n) { return n.date === wantDate; });\nnotes.sort(function (a, b) { return (b.created_at || '').localeCompare(a.created_at || ''); });\nreturn { success: true, notes: notes, count: notes.length, date: wantDate || null };\n", + input: { + body: "{{fetch_notes.data.body}}", + date: "{{input.date}}" + }, + limits: { + timeout_ms: 3e3, + max_output_bytes: 4194304 + } + } + } + ], + edges: [ + { + from: "input", + to: "fetch_notes", + type: "ON_SUCCESS" + }, + { + from: "fetch_notes", + to: "filter_sort", + type: "ON_SUCCESS" + } + ] + } + } + ], + ui: { + html: ` + +
+ + +MCP_TOKEN_TTL(mcp worker)設定。\n 正在前往搜尋頁… 沒有自動跳轉請點這裡
\n\n\n"},"/portal/app-mount.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// App 掛載協定 v0.2 的結構自測(Arcrun#152/#82,leo 2026-08-24 拍板)\n// 跑法:node console-ui/public/portal/app-mount.test.mjs\n//\n// 這支守的是**協定的形狀**,不是長相——長相要用瀏覽器量(見 #152 的驗收留言)。\n// 之所以要有它:這一輪的病根是「出口與樣式被做成個案」——筆記 App 有返回鍵、總圖沒有;\n// CIS 表複製進 shadow 了、但 App 根本沒接上。個案會悄悄再長出來,所以把\n// 「不准再有個案」這件事寫成可執行的檢查。\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// ── ① 出口:每一個非首頁的 view 都必須有 .pagehead ────────────────────────────\n// ensureBackControls() 是往 `#v-\n \n —\n \n \n \n MCP_TOKEN_TTL(mcp worker)設定。\n 正在前往搜尋頁… 沒有自動跳轉請點這裡
\n\n\n"},"/portal/app-mount.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// App 掛載協定 v0.2 的結構自測(Arcrun#152/#82,leo 2026-08-24 拍板)\n// 跑法:node console-ui/public/portal/app-mount.test.mjs\n//\n// 這支守的是**協定的形狀**,不是長相——長相要用瀏覽器量(見 #152 的驗收留言)。\n// 之所以要有它:這一輪的病根是「出口與樣式被做成個案」——筆記 App 有返回鍵、總圖沒有;\n// CIS 表複製進 shadow 了、但 App 根本沒接上。個案會悄悄再長出來,所以把\n// 「不准再有個案」這件事寫成可執行的檢查。\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// ── ① 出口:每一個非首頁的 view 都必須有 .pagehead ────────────────────────────\n// ensureBackControls() 是往 `#v-\n \n —\n \n \n \n