diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html
index 14cdfd6..bfa059f 100644
--- a/console-ui/public/portal/index.html
+++ b/console-ui/public/portal/index.html
@@ -1558,6 +1558,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (!adminLibs.length) { $('ad-libs').innerHTML = '
同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡。
'; return; }
$('ad-libs').innerHTML = adminLibs.map(function (l) {
var disabled = l.status === 'disabled';
+ var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中
+ var removeBtn = l.auto
+ ? ''
+ : '';
return '' +
'
' +
'' + esc(l.display_name || l.name) + '' +
@@ -1565,8 +1569,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
(l.auto
? '啟用中同步自動出現'
: '' + (disabled ? '已停用' : '啟用中') + '') +
+ removeBtn +
'
' +
(!l.auto && l.description ? '
' + esc(l.description) + '
' : '') +
+ (notWatching ? '
小幫手目前沒有在同步這個資料夾
' : '') +
'
';
}).join('');
}
@@ -1682,6 +1688,43 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
+ // 移除已登記庫(有 record_id)
+ if (act === 'lib-remove') {
+ var libId = t.getAttribute('data-id') || '';
+ var libName = t.getAttribute('data-name') || libId;
+ if (!confirm('把「' + libName + '」從目錄移除?\n\n資料不會刪除,重新同步時會再出現。')) return;
+ t.disabled = true;
+ adminApi('DELETE', '/portal/admin/libraries/' + encodeURIComponent(libId))
+ .then(function (x) {
+ t.disabled = false;
+ if (guard401(x.status)) return;
+ if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; }
+ adminLibs = adminLibs.filter(function (l) { return l.record_id !== libId; });
+ renderAdminLibs();
+ toast('已從目錄移除');
+ })
+ .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
+ return;
+ }
+ // 移除 auto 庫(無 record_id,entries 標 deprecated)
+ if (act === 'lib-remove-auto') {
+ var autoName = t.getAttribute('data-name') || '';
+ var input = window.prompt('移除「' + autoName + '」會讓它的內容不再被搜尋到(資料保留可還原)。\n\n請輸入庫名確認:');
+ if (input === null) return; // 取消
+ if (input.trim() !== autoName) { toast('庫名輸入不符,取消移除'); return; }
+ t.disabled = true;
+ adminApi('DELETE', '/portal/admin/libraries/by-name/' + encodeURIComponent(autoName), { confirm: autoName })
+ .then(function (x) {
+ t.disabled = false;
+ if (guard401(x.status)) return;
+ if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; }
+ adminLibs = adminLibs.filter(function (l) { return l.name !== autoName; });
+ renderAdminLibs();
+ toast('已移除(' + (x.d.deprecated_count || 0) + ' 筆標為不可搜)');
+ })
+ .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
+ return;
+ }
});
// 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上)
$('ad-otp').addEventListener('click', function (ev) {
diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts
index 672e96b..c35c0c8 100644
--- a/cypher-executor/src/graph-executor.ts
+++ b/cypher-executor/src/graph-executor.ts
@@ -500,6 +500,26 @@ export class GraphExecutor {
iterResults.push(itemResult);
}
+ // t117: FOREACH 全部項目 success===false → 不再靜默,拋出含 status code 的錯誤
+ if (iterResults.length > 0) {
+ const failures = iterResults.filter(
+ r => r !== null && typeof r === 'object' && (r as Record).success === false
+ );
+ if (failures.length === iterResults.length) {
+ const first = failures[0] as Record;
+ const errParts: string[] = [];
+ if (first.error) errParts.push(String(first.error));
+ if (typeof first.status === 'number') errParts.push(`HTTP ${first.status}`);
+ const bodyData = first.data as { body?: string } | null | undefined;
+ if (bodyData && typeof bodyData.body === 'string' && bodyData.body) {
+ errParts.push(bodyData.body.slice(0, 200));
+ }
+ throw new Error(
+ `FOREACH 所有 ${iterResults.length} 項目均失敗(首項:${errParts.join(';') || '未知錯誤'})`
+ );
+ }
+ }
+
result = { ...(result as Record), results: iterResults };
break;
}
diff --git a/cypher-executor/src/lib/wasi-shim.ts b/cypher-executor/src/lib/wasi-shim.ts
index 7ea0aa4..6763cf1 100644
--- a/cypher-executor/src/lib/wasi-shim.ts
+++ b/cypher-executor/src/lib/wasi-shim.ts
@@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
const result = await hostFunctions!.http_request!(url, method, headers, body);
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
- } catch {
- return 1;
+ } catch (e) {
+ // t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
+ // 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。
+ // writeOut 失敗(memory 壞)才 fallback return 1。
+ const errDetail = e instanceof Error ? e.message : String(e);
+ const errEnv = new TextEncoder().encode(
+ JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
+ );
+ return writeOut(memory.buffer, outPtr, outLenPtr, errEnv);
}
})
: () => 1,
diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts
index 207eb0a..cf3ac55 100644
--- a/cypher-executor/src/routes/portal.ts
+++ b/cypher-executor/src/routes/portal.ts
@@ -184,6 +184,18 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record
return body.record;
}
+async function deleteKbdbRecord(env: Bindings, recordId: string): Promise {
+ const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' });
+ if (res.status === 404) return false;
+ if (!res.ok) throw new KbdbError(`DELETE /records/${recordId} → ${res.status}`);
+ return true;
+}
+
+/** KV key for daemon's most-recently-reported active library names(t135 daemon hint)。 */
+function daemonActiveKey(env: Bindings): string {
+ return `${portalTenant(env)}:portal:daemon_active_libs`;
+}
+
export async function listRecordsByTemplate(env: Bindings, template: string): Promise {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
@@ -751,6 +763,11 @@ portalRouter.post('/portal/daemon/libraries', (c) =>
created.push(name);
}
const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
+ // t135:記下本次 daemon 回報的所有庫名(48h TTL)供 GET /portal/admin/libraries 顯示「未同步」提示。
+ const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean);
+ if (activeNames.length > 0) {
+ await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 });
+ }
return c.json({ success: true, created, libraries: after.map(toPublicLibrary) });
}),
);
@@ -905,12 +922,23 @@ portalRouter.get('/portal/admin/extractor', (c) =>
// t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
+// t135:讀 daemon 最近回報的 active libs(KV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。
portalRouter.get('/portal/admin/libraries', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
- const out = libs.map(toPublicLibrary);
+ // 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint)
+ let daemonActive: Set | null = null;
+ try {
+ const raw = await c.env.WEBHOOKS.get(daemonActiveKey(c.env), 'text');
+ if (raw) daemonActive = new Set((JSON.parse(raw) as string[]).map((n) => String(n).trim()));
+ } catch { /* KV 不可達不擋主流程 */ }
+ const out = libs.map((rec) => {
+ const lib = toPublicLibrary(rec);
+ const watching = daemonActive === null ? undefined : daemonActive.has(lib.name);
+ return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) };
+ });
const known = new Set(out.map((l) => l.name));
// 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library)
try {
@@ -922,7 +950,13 @@ portalRouter.get('/portal/admin/libraries', (c) =>
// general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
if (!n || n === 'general' || known.has(n)) continue;
known.add(n);
- out.push({ record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true });
+ const watching = daemonActive === null ? undefined : daemonActive.has(n);
+ out.push({
+ record_id: '', name: n, display_name: n,
+ description: '資料同步時自動出現(可在此補顯示名)',
+ status: 'active', graph_source: false, auto: true,
+ ...(watching !== undefined ? { daemon_watching: watching } : {}),
+ });
}
}
} catch {
@@ -1005,3 +1039,53 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
return c.json({ success: true, library: toPublicLibrary(updated) });
}),
);
+
+// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。
+// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。
+// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。
+// ⚠️ 此路由必須在 DELETE /:id 之前宣告(Hono 先到先比;by-name 否則被當成 :id)。
+portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
+ run(c, async () => {
+ const auth = await requirePortalAdmin(c);
+ if (!auth.ok) return auth.res;
+ const name = decodeURIComponent(c.req.param('name'));
+ const body = await c.req.json().catch(() => null);
+ const confirm = String(body?.confirm ?? '').trim();
+ if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
+ if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
+ const ownerId = portalTenant(c.env);
+ const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
+ method: 'PATCH',
+ body: JSON.stringify({ owner_id: ownerId, library: name }),
+ });
+ if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
+ const data = (await res.json()) as { deprecated_count?: number };
+ return c.json({
+ success: true,
+ deprecated_count: data.deprecated_count ?? 0,
+ message: `已從自動清單移除「${name}」(共標記 ${data.deprecated_count ?? 0} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`,
+ });
+ }),
+);
+
+// DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。
+// 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。
+// 資料若有的話,重新同步後會以 auto 庫重新出現。
+portalRouter.delete('/portal/admin/libraries/:id', (c) =>
+ run(c, async () => {
+ const auth = await requirePortalAdmin(c);
+ if (!auth.ok) return auth.res;
+ const recordId = c.req.param('id');
+ // 成員資格驗(防憑空 id 打到不相干 record)
+ const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
+ const target = libs.find((l) => l.record_id === recordId);
+ if (!target) return c.json({ error: '庫不存在' }, 404);
+ const found = await deleteKbdbRecord(c.env, recordId);
+ if (!found) return c.json({ error: '庫不存在' }, 404);
+ return c.json({
+ success: true,
+ name: target.values.name ?? '',
+ message: `已從目錄移除「${target.values.display_name ?? target.values.name ?? ''}」。資料仍在,重新同步會再出現。`,
+ });
+ }),
+);
diff --git a/cypher-executor/tests/executor.test.ts b/cypher-executor/tests/executor.test.ts
index e192244..bb7bbd1 100644
--- a/cypher-executor/tests/executor.test.ts
+++ b/cypher-executor/tests/executor.test.ts
@@ -1,6 +1,8 @@
// Cypher Executor 端到端測試
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
+import { GraphExecutor } from '../src/graph-executor';
+import type { ComponentRunner, ExecutionGraph } from '../src/types';
describe('GET /', () => {
it('回傳服務狀態', async () => {
@@ -191,4 +193,60 @@ describe('POST /execute', () => {
});
expect(res.status).toBe(400);
});
+
+});
+
+// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status code(GraphExecutor 單元測試)
+describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => {
+ it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => {
+ // mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"}
+ const failLoader = async (_: string): Promise =>
+ async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } });
+
+ const executor = new GraphExecutor(failLoader);
+
+ const graph: ExecutionGraph = {
+ id: 'foreach-fail-t117',
+ name: 'FOREACH 全失敗',
+ nodes: [
+ { id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
+ { id: 'writer', type: 'Component', componentId: 'http_request' },
+ ],
+ edges: [
+ { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
+ ],
+ };
+
+ // t117 核心驗證:全部失敗 → throw(不再靜默)
+ await expect(executor.execute(graph, {})).rejects.toThrow(/401/);
+ });
+
+ it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => {
+ let callCount = 0;
+ // 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑)
+ const mixedLoader = async (_: string): Promise =>
+ async () => {
+ callCount++;
+ if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' };
+ return { success: true, data: { ok: true } };
+ };
+
+ const executor = new GraphExecutor(mixedLoader);
+
+ const graph: ExecutionGraph = {
+ id: 'foreach-mixed-t117',
+ name: 'FOREACH 部分失敗',
+ nodes: [
+ { id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
+ { id: 'writer', type: 'Component', componentId: 'http_request' },
+ ],
+ edges: [
+ { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
+ ],
+ };
+
+ // 部分失敗 → 不拋出,正常回傳 results 陣列
+ const result = await executor.execute(graph, {});
+ expect(result).toBeDefined();
+ });
});
diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts
index 4476146..88ce2a1 100644
--- a/cypher-executor/tests/portal-admin.test.ts
+++ b/cypher-executor/tests/portal-admin.test.ts
@@ -395,6 +395,82 @@ describe('/portal/admin/libraries', () => {
});
});
+// ═══════════════ t135 庫目錄移除 ═══════════════
+
+describe('DELETE /portal/admin/libraries(t135)', () => {
+ it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ // 成員驗證:list by template 回有該 record
+ mockListByTemplate('portal_library', [
+ { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
+ ]);
+ let deleteCalled = false;
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: '/records/rec_lib1', method: 'DELETE' })
+ .reply(200, () => { deleteCalled = true; return { success: true }; });
+ const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; name: string; message: string };
+ expect(data.success).toBe(true);
+ expect(data.name).toBe('finance');
+ expect(deleteCalled).toBe(true);
+ });
+
+ it('DELETE /:id — 庫不在目錄 → 404', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ mockListByTemplate('portal_library', []); // 空目錄
+ const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(404);
+ });
+
+ it('DELETE /:id — 非 admin → 403', async () => {
+ await seedAdminSession('tok-user', 'rec_u1');
+ mockGetRecord('rec_u1', userValues());
+ const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' });
+ expect(res.status).toBe(403);
+ });
+
+ it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ let deprecateCalled = false;
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' })
+ .reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; });
+ const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; deprecated_count: number };
+ expect(data.success).toBe(true);
+ expect(data.deprecated_count).toBe(12);
+ expect(deprecateCalled).toBe(true);
+ });
+
+ it('DELETE /by-name/:name — 無 confirm → 400', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(400);
+ });
+
+ it('DELETE /by-name/:name — confirm 不符 → 400', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(400);
+ });
+
+ it('DELETE /by-name/:name — 非 admin → 403', async () => {
+ await seedAdminSession('tok-user', 'rec_u1');
+ mockGetRecord('rec_u1', userValues());
+ const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' });
+ expect(res.status).toBe(403);
+ });
+});
+
// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════
describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => {
diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts
index e443d86..548ba37 100644
--- a/kbdb/src/actions/entry-crud.ts
+++ b/kbdb/src/actions/entry-crud.ts
@@ -126,6 +126,27 @@ export async function deleteEntry(db: D1Database, id: string): Promise {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
}
+/**
+ * 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。
+ * 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。
+ * 回 deprecated 的筆數(0 = 庫名不存在或早已全部 deprecated)。
+ */
+export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise {
+ const result = await db
+ .prepare(
+ `UPDATE entries
+ SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'),
+ updated_at = unixepoch()
+ WHERE owner_id = ?
+ AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ?
+ AND (json_extract(metadata_json, '$.status') IS NULL
+ OR json_extract(metadata_json, '$.status') != 'deprecated')`,
+ )
+ .bind(ownerId, library)
+ .run();
+ return (result.meta?.changes as number | undefined) ?? 0;
+}
+
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
diff --git a/kbdb/src/actions/record-crud.ts b/kbdb/src/actions/record-crud.ts
index 68f9cd3..83e181c 100644
--- a/kbdb/src/actions/record-crud.ts
+++ b/kbdb/src/actions/record-crud.ts
@@ -209,3 +209,18 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
}
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
}
+
+/** 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 */
+export async function deleteRecord(db: D1Database, recordId: string): Promise {
+ const evRes = await db
+ .prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
+ .bind(recordId)
+ .all<{ entry_id: string }>();
+ const rows = evRes.results ?? [];
+ if (rows.length === 0) return false;
+ await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
+ for (const { entry_id } of rows) {
+ await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run();
+ }
+ return true;
+}
diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts
index e02f928..99954cb 100644
--- a/kbdb/src/routes/entries.ts
+++ b/kbdb/src/routes/entries.ts
@@ -3,6 +3,7 @@ import { Hono } from 'hono';
import type { Bindings } from '../types';
import {
createEntry,
+ deprecateEntriesByLibrary,
getEntry,
listEntries,
updateEntry,
@@ -139,6 +140,18 @@ entryRoutes.get('/:id', async (c) => {
return c.json({ success: true, entry });
});
+// PATCH /entries/deprecate-by-library — body {owner_id, library}。
+// t135:把某租戶某庫的所有 entries 標 deprecated,讓庫從 auto 清單消失。
+// 此路由必須在 '/:id' 之前,否則 'deprecate-by-library' 會被當成 id 參數。
+entryRoutes.patch('/deprecate-by-library', async (c) => {
+ const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null;
+ const ownerId = String(body?.owner_id ?? '').trim();
+ const library = String(body?.library ?? '').trim();
+ if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400);
+ const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library);
+ return c.json({ success: true, deprecated_count: count });
+});
+
// PATCH /entries/:id
entryRoutes.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => ({}));
diff --git a/kbdb/src/routes/records.ts b/kbdb/src/routes/records.ts
index 7a2d891..cc24de0 100644
--- a/kbdb/src/routes/records.ts
+++ b/kbdb/src/routes/records.ts
@@ -1,7 +1,7 @@
// Records route — structured records (entry_values composed by a template).
import { Hono } from 'hono';
import type { Bindings } from '../types';
-import { createRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
+import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
@@ -47,3 +47,10 @@ recordRoutes.patch('/:recordId', async (c) => {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
+
+// DELETE /records/:recordId — 刪除一筆 record 及其底層 entries。
+recordRoutes.delete('/:recordId', async (c) => {
+ const found = await deleteRecord(c.env.DB, c.req.param('recordId'));
+ if (!found) return c.json({ success: false, error: 'not found' }, 404);
+ return c.json({ success: true });
+});
diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md
index dd043b2..bab03c6 100644
--- a/system-dev/docs/3-specs/portal-auth/tasks.md
+++ b/system-dev/docs/3-specs/portal-auth/tasks.md
@@ -277,6 +277,19 @@
執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。
測試:portal-data.test.ts 新增 5 案(t129 describe:合併計數/各保一筆/page 備用/空陣列/page_name 優先)。
+- [x] **t135 庫目錄移除(2026-07-29,任務層小改)**:
+ 來源=leo 裁定「需要加移除按鈕,別人裝錯我沒辦法幫他弄,需要可以自主」。
+ 後端:`DELETE /portal/admin/libraries/:id`(刪登記簿 record,資料不動)+
+ `DELETE /portal/admin/libraries/by-name/:name`(auto 庫;body `confirm:<庫名>` 才執行;
+ 呼叫 KBDB `PATCH /entries/deprecate-by-library`,entries 標 deprecated → 從 auto 清單消失)。
+ KBDB:`deleteRecord` action + `DELETE /records/:id` route;
+ `deprecateEntriesByLibrary` action + `PATCH /entries/deprecate-by-library` route(此路由在 `/:id` 之前)。
+ Daemon hint(根治只增不減):`POST /portal/daemon/libraries` 存 active lib names 到 KV(TTL 48h);
+ `GET /portal/admin/libraries` 讀 KV 後對登記庫標 `daemon_watching: false`(若 daemon 有回報但未含該庫)。
+ 前端:每張庫卡片加「移除」按鈕(登記庫=`confirm()` 對話框;auto 庫=`prompt()` 輸入庫名確認);
+ 移除後即時從列表消失(不必重整);`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。
+ 測試:portal-admin.test.ts 新增 7 案(DELETE/:id 成功/404/非admin403;by-name 成功/無confirm/confirm不符/非admin403)。
+
## 第二波(不在本 SDD 動工範圍,掛號)
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)