fix(kbdb): cypher proxy 補 /kbdb/entries CRUD + report_feedback 改打 /entries
kbdb-base Phase 9.6/9.7(HANDOFF §2 缺口① + §3b 連帶): - 9.6 cypher kbdb-proxy 補 /kbdb/entries CRUD(POST/GET list/GET :id/PATCH :id) 純轉發到 KBDB 基本盤 /entries,解鎖 mira _kbdb_client.py 主線遷移。 租戶隔離同 9.5:寫入注入 owner_id、list 強制本租戶過濾、PATCH 剝 owner_id。 刻意不開 DELETE(基本盤 delete 無 owner 檢查 → 跨租戶刪除風險)。 - 9.7 arcrun_report_feedback 從死 route /blocks 改打基本盤 /entries (entry_type=agent-feedback)。9.4 漏網的同類修;基本盤無 /blocks → 原本 404 假紅。 順帶(HANDOFF §6 harness 表達優化): - 重寫 cli/harness/CLAUDE.block.md 補三盲點(recipe 是公共投稿 / 缺能力補 API 不拼裝 / 自製零件退場路徑),目標 Haiku 級 CC 讀懂。 - README 零件 vs recipe 段對齊同三點。 cypher + mcp tsc exit 0。端到端 smoke test 隨後。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -76,9 +76,15 @@ arcrun 只有兩種東西,分清楚就不會做歪:
|
|||||||
- recipe = `http_request` + 一組固定設定(endpoint / method / headers / body 模板)。
|
- recipe = `http_request` + 一組固定設定(endpoint / method / headers / body 模板)。
|
||||||
- 要打 Notion / Slack / 你自己的 API?**寫一個 recipe**,不是做一個零件。
|
- 要打 Notion / Slack / 你自己的 API?**寫一個 recipe**,不是做一個零件。
|
||||||
- recipe 是純文字、不用 deploy、改一次零成本。AI 讀得懂 API 文件就能幫你組出 recipe。
|
- recipe 是純文字、不用 deploy、改一次零成本。AI 讀得懂 API 文件就能幫你組出 recipe。
|
||||||
|
- **recipe 是公共資產,發現缺的就做一個投稿。** `acr recipe push` = 投稿到公共庫(公開/私有只是屬性,不是兩條路)。沒有現成 recipe 打你要的 API?**自己寫一個 push 上去**——這是被鼓勵的,不必問人,下一個人也能用。
|
||||||
|
|
||||||
> **一句話判準**:要打一個固定的外部 endpoint → **recipe**。要做流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**。其他大部分情況 → 直接寫**工作流**把它們串起來。
|
> **一句話判準**:要打一個固定的外部 endpoint → **recipe**。要做流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**。其他大部分情況 → 直接寫**工作流**把它們串起來。
|
||||||
|
|
||||||
|
### 兩條容易誤解的紅線(AI 操盤手特別注意)
|
||||||
|
|
||||||
|
- **缺一個能力 → 補 API,不准用 recipe / 多步工作流拼裝。** 口訣:「這段邏輯換個介面(CLI→MCP)要不要重寫?要 → 它是『能力』,該長在 API。」例:缺 `upsert` 就去 API 加 `upsert` endpoint,**不要**在工作流裡拼「先查、沒有再建」、也不要寫 recipe 假裝補上。recipe 只負責「打一個固定外部 API」,不是用來補 arcrun 自己缺的能力。
|
||||||
|
- **已經有自製零件的,讓它退場。** `claude_api` 之類「工作流回頭叫 LLM」→ 刪掉,AI 判斷由操盤的 CC 自己做;`kbdb_*` 資料存取 → 改走 `acr kbdb` / `kbdb_*` MCP 工具;純打固定外部 API 的假零件 → 改寫成 recipe 投稿。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速開始(Self-hosted)
|
## 快速開始(Self-hosted)
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
<!-- arcrun-harness:start -->
|
<!-- arcrun-harness:start -->
|
||||||
## 這個專案用 arcrun 做自動化
|
## 這個專案用 arcrun 做自動化
|
||||||
|
|
||||||
當需要「串服務 / 排程 / 打 API / 資料處理自動化」時:
|
當需要「串服務 / 排程 / 打 API / 資料處理自動化」時,用 arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
|
||||||
|
|
||||||
- **用 arcrun 工作流,不要自己寫 Python/Node 一次性腳本。** arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。選 arcrun 就是不想要每次重刻的腳本。
|
arcrun 只有三種東西,先分清楚就不會做歪:
|
||||||
- **打外部 API → 寫 recipe**(`acr recipe push`),不要自刻 HTTP client。recipe = http_request + 固定設定的 YAML,不用部署。
|
|
||||||
- **先查能力再動手**:`acr parts`(看可用零件)、`acr auth-recipe list`(看支援的認證服務)。
|
| 東西 | 是什麼 | 你能做的 |
|
||||||
- **不要自製零件**(WASM component)——零件由 arcrun 維護、走 GitHub PR。你能擴充的是 recipe + 工作流。
|
|---|---|---|
|
||||||
- **需要 AI 判斷/轉換時,你自己做**,再呼叫工作流做確定性的下一步。不要在工作流裡放零件回頭呼叫 LLM。
|
| **工作流(workflow)** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
|
||||||
|
| **recipe** | 打「一個固定外部 API」的設定(http_request + endpoint/header/body 模板) | 自由寫、**而且該投稿**(見下) |
|
||||||
|
| **零件(component)** | WASM 程式(流程控制 / 資料處理 / http_request / auth),固定一小套 | **你不自製**,由維護者管,走 GitHub PR |
|
||||||
|
|
||||||
|
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
||||||
|
|
||||||
|
### 三個最常踩的坑(讀懂這三條,就不會像前人誤解四輪)
|
||||||
|
|
||||||
|
**① recipe 是公共資產,發現缺的就自己做一個投稿,不必問人。**
|
||||||
|
`acr recipe push` = 把 recipe **投稿到公共庫**,不是存私人腳本。公開/私有只是 recipe 的一個屬性(同一身份可有公私版本並存),不是兩條不同的路。
|
||||||
|
→ 所以「想打某個 API 但沒有現成 recipe」時,**正解是自己寫一個 recipe 並 push 上去**(會 inject credential、push 時幫你檢查打不打得通)。這是被鼓勵的,別誤以為要自用、不上傳。
|
||||||
|
|
||||||
|
**② 缺一個能力 → 去補 API(cypher endpoint),不准用 recipe / 多步工作流拼裝出來。**
|
||||||
|
判準口訣:**「這段邏輯換一個介面(CLI→MCP)要不要重寫?要重寫 → 它是『能力』,該長在 API。」**
|
||||||
|
- ❌ 缺 `upsert` → 在工作流裡拼「先查、沒有再建」、或寫個 recipe 假裝補上。
|
||||||
|
- ✅ 缺 `upsert` → 去 API 加一個 `upsert` endpoint,CLI/MCP/recipe 都呼叫它。
|
||||||
|
recipe 只負責「打一個固定外部 API」這件單純事;它不是用來補 arcrun 自己缺的能力的。缺能力就回報 / 補在 API,不要繞。
|
||||||
|
|
||||||
|
**③ 已經有自製零件(例如 mira 的那幾個)→ 讓它退場,別再加新的。**
|
||||||
|
你不該自製零件;既有的自製零件要往這三條退場:
|
||||||
|
- `claude_api` 之類「工作流回頭叫 LLM」→ **刪掉**,需要 AI 判斷時是**你(操盤的 CC)自己做**,再叫工作流做確定性的下一步。arcrun 是 AI 用的工具,不是工具回頭用 AI。
|
||||||
|
- `kbdb_*` 之類資料存取 → 改走已備好的 **`acr kbdb` 薄殼 / `kbdb_*` MCP 工具**(template + record 模型),不要當零件。
|
||||||
|
- 純粹打某個固定外部 API 的假零件 → **改寫成 recipe** 投稿(見①)。
|
||||||
|
|
||||||
|
### 其餘鐵律
|
||||||
|
|
||||||
|
- **先查能力再動手**:`acr parts`(看可用零件)、`acr auth-recipe list`(看支援的認證服務)、`acr kbdb`(資料存取)。
|
||||||
- **暴露資料要人類同意**:部署對外 webhook / push recipe 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開。
|
- **暴露資料要人類同意**:部署對外 webhook / push recipe 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開。
|
||||||
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx / trace 為證,不口頭宣布。
|
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx / trace 為證,不口頭宣布。
|
||||||
|
|
||||||
|
|||||||
@@ -134,3 +134,64 @@ kbdbProxyRouter.get('/kbdb/search', async (c) => {
|
|||||||
);
|
);
|
||||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── entries(原子資料 / 樹節點,以租戶 namespace 為 owner_id 隔離)─────────────────
|
||||||
|
//
|
||||||
|
// kbdb-base 9.6:基本盤 /entries CRUD 的 proxy(HANDOFF §2 缺口①,mira _kbdb_client.py 遷移目標)。
|
||||||
|
// 租戶隔離同 records(選項①):寫入強制注入 owner_id、list 強制以本租戶 owner_id 過濾;
|
||||||
|
// by-id 沿用既有 records by-id 慣例(require-key,不額外做 owner 比對——與本檔其他 by-id 端點一致)。
|
||||||
|
|
||||||
|
// POST /kbdb/entries — 建一個 entry(entry_type 必填,如 block/value/project/workflow)。owner_id 自動注入。
|
||||||
|
kbdbProxyRouter.post('/kbdb/entries', async (c) => {
|
||||||
|
const owner = tenant(c);
|
||||||
|
if (!owner) return c.json(NEED_KEY, 401);
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (!body || !body.entry_type) return c.json({ error: 'entry_type 必填' }, 400);
|
||||||
|
const { base, headers } = kbdbBase(c.env);
|
||||||
|
const res = await fetch(`${base}/entries`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
|
||||||
|
body: JSON.stringify({ ...body, owner_id: owner }),
|
||||||
|
});
|
||||||
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /kbdb/entries — list(filters: entry_type / parent_id / page_name / limit / offset)。
|
||||||
|
// owner_id 強制覆寫成本租戶(防跨租戶讀;caller 不能查別人的 owner_id)。
|
||||||
|
kbdbProxyRouter.get('/kbdb/entries', async (c) => {
|
||||||
|
const owner = tenant(c);
|
||||||
|
if (!owner) return c.json(NEED_KEY, 401);
|
||||||
|
const { base, headers } = kbdbBase(c.env);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('owner_id', owner); // 強制本租戶,不接受 caller 覆寫
|
||||||
|
for (const k of ['entry_type', 'parent_id', 'page_name', 'limit', 'offset']) {
|
||||||
|
const v = c.req.query(k);
|
||||||
|
if (v) params.set(k, v);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
|
||||||
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /kbdb/entries/:id — 取單筆 entry。
|
||||||
|
kbdbProxyRouter.get('/kbdb/entries/:id', async (c) => {
|
||||||
|
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||||
|
const { base, headers } = kbdbBase(c.env);
|
||||||
|
const res = await fetch(`${base}/entries/${encodeURIComponent(c.req.param('id'))}`, { headers });
|
||||||
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /kbdb/entries/:id — 更新單筆 entry。owner_id 不可被改(剝除 caller 自帶的 owner_id)。
|
||||||
|
kbdbProxyRouter.patch('/kbdb/entries/:id', async (c) => {
|
||||||
|
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
// 不讓 patch 改 owner_id(防把別人的資料認領過來或踢給別人)
|
||||||
|
const { owner_id: _drop, ...patch } = body ?? {};
|
||||||
|
const { base, headers } = kbdbBase(c.env);
|
||||||
|
const res = await fetch(`${base}/entries/${encodeURIComponent(c.req.param('id'))}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
});
|
||||||
|
|||||||
@@ -51,11 +51,12 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
|||||||
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
|
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockBody = {
|
// kbdb-base 9.7:寫進基本盤 entries(entry_type=agent-feedback)。
|
||||||
api_key: env.PLATFORM_API_KEY || undefined, // 若 platform key 在,聚集;否則用用戶 namespace
|
// 舊版打 v3 死 route /blocks(基本盤只 mount entries/templates/records)→ 404 假紅,已改。
|
||||||
type: "agent-feedback",
|
// owner_id = 用戶 namespace(self-hosted 單租戶聚集)。基本盤無 source/api_key 欄 → 併入 metadata。
|
||||||
source: "mcp-tool-call",
|
const entryBody = {
|
||||||
user_id: orgNamespace,
|
entry_type: "agent-feedback",
|
||||||
|
owner_id: orgNamespace,
|
||||||
content: description,
|
content: description,
|
||||||
metadata_json: JSON.stringify({
|
metadata_json: JSON.stringify({
|
||||||
issue_type,
|
issue_type,
|
||||||
@@ -64,6 +65,7 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
|||||||
blocked: blocked ?? false,
|
blocked: blocked ?? false,
|
||||||
suggested_fix,
|
suggested_fix,
|
||||||
agent_user_agent,
|
agent_user_agent,
|
||||||
|
source: "mcp-tool-call",
|
||||||
reported_at: new Date().toISOString(),
|
reported_at: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
tags_json: JSON.stringify([
|
tags_json: JSON.stringify([
|
||||||
@@ -74,11 +76,11 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
|||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 走 KBDB service binding(既有 pattern)
|
// 走 KBDB service binding 打基本盤 /entries(薄殼模式不變)
|
||||||
const createResp = await kbdbFetch(env, `/blocks`, {
|
const createResp = await kbdbFetch(env, `/entries`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(blockBody),
|
body: JSON.stringify(entryBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!createResp.ok) {
|
if (!createResp.ok) {
|
||||||
@@ -92,7 +94,7 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
|||||||
error_code: "kbdb_write_failed",
|
error_code: "kbdb_write_failed",
|
||||||
human_message: `回饋寫入 KBDB 失敗:HTTP ${createResp.status}`,
|
human_message: `回饋寫入 KBDB 失敗:HTTP ${createResp.status}`,
|
||||||
next_actions: [
|
next_actions: [
|
||||||
"確認 KBDB 服務在線(試 https://kbdb-get.arcrun.dev/health)",
|
"確認 KBDB 服務在線(KBDB worker /health)",
|
||||||
"若持續失敗,可暫先在本地記下回饋,稍後重試",
|
"若持續失敗,可暫先在本地記下回饋,稍後重試",
|
||||||
],
|
],
|
||||||
detail: errBody.slice(0, 200),
|
detail: errBody.slice(0, 200),
|
||||||
@@ -114,7 +116,9 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
|||||||
data: {
|
data: {
|
||||||
reported: true,
|
reported: true,
|
||||||
issue_type,
|
issue_type,
|
||||||
block_id: (data as { id?: string } | null)?.id,
|
// 基本盤 /entries 回 { success, entry };舊 /blocks 回 { id } → 兩種都容忍
|
||||||
|
entry_id: (data as { entry?: { id?: string }; id?: string } | null)?.entry?.id
|
||||||
|
?? (data as { id?: string } | null)?.id,
|
||||||
},
|
},
|
||||||
hints: [
|
hints: [
|
||||||
issue_type === "success_story"
|
issue_type === "success_story"
|
||||||
|
|||||||
Reference in New Issue
Block a user