feat(workflow-store): workflow 真相源 KV → KBDB API(entry_type=workflow)

SDD kbdb-base §8.3/§8.4 P2+P3。總管裁定 2026-07-06:
① upsert 走 KBDB base PUT /entries(by owner_id+page_name+entry_type)
② cron 掃 KBDB、不保留 KV cron-idx
③ 一 workflow=一筆 entry_type=workflow;content=description(保 embed)、
   graph+config+cron_expr 進 metadata_json

KBDB:
- entry-crud.ts 加 upsertEntry(read-then-write,無 UNIQUE、表不變)
- entries.ts 加 PUT /entries(upsert,回 created 旗標,content 變動時 embed-on-write)

cypher-executor:
- 新增 lib/workflow-store.ts(走 kbdbBase,禁直連 D1)
- webhooks-named register/trigger/list/delete 切雙軌(讀先 KBDB miss fallback KV、
  寫 KBDB 為主+暫雙寫 KV);writeWorkflowSearchEntry 上收進 putWorkflow(修重複 entry bug);
  backfill 升級為 KV→KBDB 遷移入口;移除 migrate-cron-index 端點
- scheduled 改掃 KBDB(listCronWorkflows);刪除 lib/cron-index.ts
- component-loader(trigger_workflow)、executions 擁有權檢查切雙軌讀

tsc 0 error;kbdb vitest 6/6;cypher vitest 41/42(1 失敗為 pre-existing,與本 PR 無關)。
不 merge、不部署、不動 production KV workflow。搬遷步驟見 MIGRATION.md。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
This commit is contained in:
Claude
2026-07-06 13:55:16 +00:00
parent befc63cfe0
commit 39e6f59178
9 changed files with 479 additions and 232 deletions
+41
View File
@@ -123,6 +123,47 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
}
// Upsert-by-(owner_id, page_name, entry_type) — capability lives in API (thin-shell §1 正例:
// 「API 提供 upsert 端點(內部 GET 找→有則 update 無則 insert)」)。base 沒有 UNIQUE 約束
// (表不變鐵律 + 既有可能有重複 page_name),故用 read-then-write,不靠 ON CONFLICT。
// 用途源: kbdb-base SDD §8.3 workflow 遷 D1 —— page_name 當唯一鍵(owner+type 內),
// register/redeploy 同名不再堆重複 entry(修掉 writeWorkflowSearchEntry 每次 POST 的重複 bug)。
// key tuple 三者皆為 idempotency key;命中最舊一筆(created_at ASC)更新,讓重跑收斂到同一 canonical。
export async function upsertEntry(
db: D1Database,
input: CreateEntryInput,
): Promise<{ entry: Entry; created: boolean }> {
if (!input.entry_type || !input.page_name) {
throw new Error('upsertEntry: entry_type 與 page_name 為 upsert key,必填');
}
const owner = input.owner_id ?? null;
const existing = owner === null
? await db
.prepare('SELECT * FROM entries WHERE entry_type = ? AND page_name = ? AND owner_id IS NULL ORDER BY created_at ASC LIMIT 1')
.bind(input.entry_type, input.page_name)
.first<Entry>()
: await db
.prepare('SELECT * FROM entries WHERE entry_type = ? AND page_name = ? AND owner_id = ? ORDER BY created_at ASC LIMIT 1')
.bind(input.entry_type, input.page_name, owner)
.first<Entry>();
if (existing) {
const entry = await updateEntry(db, existing.id, {
content: input.content ?? null,
parent_id: input.parent_id ?? null,
refs_json: input.refs_json,
tags_json: input.tags_json,
task_status: input.task_status ?? null,
confidence: input.confidence ?? null,
metadata_json: input.metadata_json ?? null,
});
if (!entry) throw new Error('upsertEntry: update 後找不到 row');
return { entry, created: false };
}
const entry = await createEntry(db, input);
return { entry, created: true };
}
// D1 LIKE keyword search (base; semantic search is the optional embed module).
// entry_type: optional base filter (generic — caller passes any type, base stays type-agnostic).
export async function searchEntries(
+16
View File
@@ -8,6 +8,7 @@ import {
updateEntry,
deleteEntry,
searchEntries,
upsertEntry,
} from '../actions/entry-crud';
import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
@@ -23,6 +24,21 @@ entryRoutes.post('/', async (c) => {
return c.json({ success: true, entry });
});
// PUT /entries — upsert by (owner_id, page_name, entry_type)(能力長在 APIthin-shell §1 正例)。
// 有則 update、無則 insert,回 created 旗標。用途源: kbdb-base SDD §8.3 workflow 遷 D1
// —— cypher workflow-store 靠這個「redeploy 同名不堆重複 entry」。
// 三者為 upsert key(缺 page_name/entry_type → 400);content/metadata_json 等隨之覆寫。
entryRoutes.put('/', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.entry_type || !body.page_name) {
return c.json({ success: false, error: 'entry_type 與 page_name requiredupsert key' }, 400);
}
const { entry, created } = await upsertEntry(c.env.DB, body);
// 內容可能新增或改寫 → 重 embed(保持向量新鮮)。embedOnWrite 內部自檢模組開 + embeddable。
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
return c.json({ success: true, entry, created });
});
// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search)
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun