feat(t135): 庫目錄可自主移除+標示還在不在同步
leo:「需要加移除按鈕。因為別人裝錯我沒辦法幫他弄,需要可以自主」
+「你應該要顯示這個庫沒有本地對應的 folder,那就不容易刪錯」
(兩態非三態——leo 二修:「分兩種沒意義」,那是內部狀態不是用戶分類)。
- DELETE /portal/admin/libraries/:id(登記簿)與 by-name/:name(auto 庫需輸入庫名確認)
- kbdb 加 deprecate-by-library(auto 庫移除=標 deprecated,資料保留可還原)
- daemon/libraries 存 active 清單 → 卡片標 🟢同步中/灰目前沒有在同步
- 不自動刪(daemon 可能沒開機);daemon 從未回報時整列不標
vitest 24 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。
(實作=子 CC;驗證+commit=總管。含 t116/t117 先前未 commit 的 graph-executor/wasi-shim 修正)
This commit is contained in:
@@ -1558,6 +1558,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡。</div>'; return; }
|
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡。</div>'; return; }
|
||||||
$('ad-libs').innerHTML = adminLibs.map(function (l) {
|
$('ad-libs').innerHTML = adminLibs.map(function (l) {
|
||||||
var disabled = l.status === 'disabled';
|
var disabled = l.status === 'disabled';
|
||||||
|
var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中
|
||||||
|
var removeBtn = l.auto
|
||||||
|
? '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove-auto" data-name="' + esc(l.name) + '">移除</button>'
|
||||||
|
: '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove" data-id="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">移除</button>';
|
||||||
return '<div class="card-item">' +
|
return '<div class="card-item">' +
|
||||||
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||||||
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
|
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
|
||||||
@@ -1565,8 +1569,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
(l.auto
|
(l.auto
|
||||||
? '<span class="tag green">啟用中</span><span class="tag">同步自動出現</span>'
|
? '<span class="tag green">啟用中</span><span class="tag">同步自動出現</span>'
|
||||||
: '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>') +
|
: '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>') +
|
||||||
|
removeBtn +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(!l.auto && l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
|
(!l.auto && l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
|
||||||
|
(notWatching ? '<div style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.45)">小幫手目前沒有在同步這個資料夾</div>' : '') +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).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)); });
|
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||||||
return;
|
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 動態生成,掛在容器上)
|
// 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上)
|
||||||
$('ad-otp').addEventListener('click', function (ev) {
|
$('ad-otp').addEventListener('click', function (ev) {
|
||||||
|
|||||||
@@ -500,6 +500,26 @@ export class GraphExecutor {
|
|||||||
iterResults.push(itemResult);
|
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<string, unknown>).success === false
|
||||||
|
);
|
||||||
|
if (failures.length === iterResults.length) {
|
||||||
|
const first = failures[0] as Record<string, unknown>;
|
||||||
|
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<string, unknown>), results: iterResults };
|
result = { ...(result as Record<string, unknown>), results: iterResults };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
|||||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||||
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
|
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
|
||||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
||||||
} catch {
|
} catch (e) {
|
||||||
return 1;
|
// 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,
|
: () => 1,
|
||||||
|
|||||||
@@ -184,6 +184,18 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record
|
|||||||
return body.record;
|
return body.record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteKbdbRecord(env: Bindings, recordId: string): Promise<boolean> {
|
||||||
|
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<PortalRecord[]> {
|
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||||
const ns = portalNamespace(env);
|
const ns = portalNamespace(env);
|
||||||
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
|
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);
|
created.push(name);
|
||||||
}
|
}
|
||||||
const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
|
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) });
|
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 個庫,只有一個一定被罵」):
|
// t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
|
||||||
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
|
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
|
||||||
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
|
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
|
||||||
|
// t135:讀 daemon 最近回報的 active libs(KV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。
|
||||||
portalRouter.get('/portal/admin/libraries', (c) =>
|
portalRouter.get('/portal/admin/libraries', (c) =>
|
||||||
run(c, async () => {
|
run(c, async () => {
|
||||||
const auth = await requirePortalAdmin(c);
|
const auth = await requirePortalAdmin(c);
|
||||||
if (!auth.ok) return auth.res;
|
if (!auth.ok) return auth.res;
|
||||||
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
|
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
|
||||||
const out = libs.map(toPublicLibrary);
|
// 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint)
|
||||||
|
let daemonActive: Set<string> | 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));
|
const known = new Set(out.map((l) => l.name));
|
||||||
// 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library)
|
// 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library)
|
||||||
try {
|
try {
|
||||||
@@ -922,7 +950,13 @@ portalRouter.get('/portal/admin/libraries', (c) =>
|
|||||||
// general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
|
// general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
|
||||||
if (!n || n === 'general' || known.has(n)) continue;
|
if (!n || n === 'general' || known.has(n)) continue;
|
||||||
known.add(n);
|
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 {
|
} catch {
|
||||||
@@ -1005,3 +1039,53 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
|
|||||||
return c.json({ success: true, library: toPublicLibrary(updated) });
|
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 ?? ''}」。資料仍在,重新同步會再出現。`,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Cypher Executor 端到端測試
|
// Cypher Executor 端到端測試
|
||||||
import { SELF } from 'cloudflare:test';
|
import { SELF } from 'cloudflare:test';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { GraphExecutor } from '../src/graph-executor';
|
||||||
|
import type { ComponentRunner, ExecutionGraph } from '../src/types';
|
||||||
|
|
||||||
describe('GET /', () => {
|
describe('GET /', () => {
|
||||||
it('回傳服務狀態', async () => {
|
it('回傳服務狀態', async () => {
|
||||||
@@ -191,4 +193,60 @@ describe('POST /execute', () => {
|
|||||||
});
|
});
|
||||||
expect(res.status).toBe(400);
|
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<ComponentRunner> =>
|
||||||
|
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<ComponentRunner> =>
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 萃取引擎金鑰雲端下發 ═══════════════
|
// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════
|
||||||
|
|
||||||
describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => {
|
describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => {
|
||||||
|
|||||||
@@ -126,6 +126,27 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
|||||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
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<number> {
|
||||||
|
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 先例)。
|
// 「庫」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 (…)))——
|
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||||
|
|||||||
@@ -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);
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Hono } from 'hono';
|
|||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import {
|
import {
|
||||||
createEntry,
|
createEntry,
|
||||||
|
deprecateEntriesByLibrary,
|
||||||
getEntry,
|
getEntry,
|
||||||
listEntries,
|
listEntries,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
@@ -139,6 +140,18 @@ entryRoutes.get('/:id', async (c) => {
|
|||||||
return c.json({ success: true, entry });
|
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
|
// PATCH /entries/:id
|
||||||
entryRoutes.patch('/:id', async (c) => {
|
entryRoutes.patch('/:id', async (c) => {
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Records route — structured records (entry_values composed by a template).
|
// Records route — structured records (entry_values composed by a template).
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
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 }>();
|
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);
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -277,6 +277,19 @@
|
|||||||
執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。
|
執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。
|
||||||
測試:portal-data.test.ts 新增 5 案(t129 describe:合併計數/各保一筆/page 備用/空陣列/page_name 優先)。
|
測試: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 動工範圍,掛號)
|
## 第二波(不在本 SDD 動工範圍,掛號)
|
||||||
|
|
||||||
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
||||||
|
|||||||
Reference in New Issue
Block a user