feat: implement memory and evidence administration with guided repairs
Publish documentation / publish (push) Successful in 1m27s
Publish documentation / publish (push) Successful in 1m27s
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation. Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let server: ViteDevServer;
|
||||
let url: string;
|
||||
test.beforeAll(async () => {
|
||||
server = await createServer({ server: { host: "127.0.0.1", port: 0 } });
|
||||
await server.listen();
|
||||
url = server.resolvedUrls!.local[0]! + "e2e/fixtures/archive-repair.html";
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
test("reviewer selects a correction, sees pending activation and retries on desktop/mobile", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", e => errors.push(e.message));
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(url);
|
||||
await expect(page.getByRole("region", { name: "Archive conflict repair" })).toBeVisible();
|
||||
await page.screenshot({ path: "/private/tmp/thothii-x1-repair-desktop.png", fullPage: true });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
await page.screenshot({ path: "/private/tmp/thothii-x1-repair-mobile.png", fullPage: true });
|
||||
await page.getByRole("button", { name: "Apply this correction to Evidence" }).click();
|
||||
await expect(page.getByRole("status").filter({ hasText: "Correction saved" })).toContainText("activation is incomplete");
|
||||
await page.getByRole("button", { name: "Retry selected correction" }).click();
|
||||
await expect(page.getByRole("status").filter({ hasText: "Correction saved" })).toContainText("active in the archive index");
|
||||
const responses = JSON.parse(await page.locator("#response").textContent() || "[]");
|
||||
expect(responses.map((r: { choices: string[] }) => r.choices)).toEqual([["e"], ["e"]]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Archive repair review</title></head><body><div id="root"></div><script type="module" src="/e2e/fixtures/archive-repair.tsx"></script></body></html>
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ArchiveRepairWidget } from "../../src/widgets/ArchiveRepairWidget";
|
||||
import "../../src/index.css";
|
||||
|
||||
function Preview() {
|
||||
const [step, setStep] = useState(0);
|
||||
const [responses, setResponses] = useState<unknown[]>([]);
|
||||
return <main className="mx-auto max-w-4xl p-4"><ArchiveRepairWidget key={step} descriptor={{
|
||||
id: `repair-${step}`, widget: "archive-repair", reserved: [], repair: {
|
||||
repair_id: "fixture", reason: "Memory defines an order by its number alone. Evidence requires the financial year too. Choose which shared definition to correct.",
|
||||
can_apply: true, choice: step ? "e" : null, saved: step > 0, indexed: step > 1,
|
||||
status: ["proposed", "pending_activation", "active"][Math.min(step, 2)], options: [
|
||||
{ id: "m", label: "Include the financial year in Memory", archive: "memory", target_id: "mem-order-key",
|
||||
before: { subject: "Order identity", detail: "Use the order number.", scope: "Sales" },
|
||||
content: { subject: "Order identity", detail: "Use order number and financial year.", scope: "Sales" } },
|
||||
{ id: "e", label: "Correct Evidence to use the order number", archive: "evidence", target_id: "evidence:order-key",
|
||||
before: { title: "Order key", payload: { rule: "Use order number and financial year." } },
|
||||
content: { title: "Order key", payload: { rule: "Use the order number." },
|
||||
provenance: { kind: "manual", declared_by: "Sales curator" } } },
|
||||
],
|
||||
},
|
||||
}} onRespond={response => { setResponses([...responses, response]); setStep(step + 1); }} />
|
||||
<output id="response" className="sr-only">{JSON.stringify(responses)}</output></main>;
|
||||
}
|
||||
createRoot(document.getElementById("root")!).render(<Preview />);
|
||||
@@ -85,7 +85,7 @@ dwh:
|
||||
supported_transports: [postgres_direct]
|
||||
`;
|
||||
|
||||
async function prepareF1Workspace(root) {
|
||||
async function prepareF1Workspace(root, memoryOnly = false) {
|
||||
const source = join(root, "workspace-source");
|
||||
const remote = join(root, "workspace-remote.git");
|
||||
secureDirectory(source);
|
||||
@@ -97,7 +97,8 @@ async function prepareF1Workspace(root) {
|
||||
" name: Fixture workspace",
|
||||
"",
|
||||
].join("\n"));
|
||||
writeSecure(join(source, F1_WORKSPACE_ID, "workspace.yaml"), F1_WORKSPACE_DESCRIPTOR);
|
||||
writeSecure(join(source, F1_WORKSPACE_ID, "workspace.yaml"),
|
||||
memoryOnly ? F1_WORKSPACE_DESCRIPTOR.split("dwh:")[0] : F1_WORKSPACE_DESCRIPTOR);
|
||||
|
||||
await runFixtureCommand("git", ["init", "--bare", "--initial-branch=main", remote], root);
|
||||
chmodSync(remote, 0o700);
|
||||
@@ -332,7 +333,7 @@ function cleanBackendEnvironment(overrides) {
|
||||
return { ...env, ...overrides };
|
||||
}
|
||||
|
||||
export async function createAuthenticationStack({ withF1Workspace = false } = {}) {
|
||||
export async function createAuthenticationStack({ withF1Workspace = false, memoryRuntime = {} } = {}) {
|
||||
const credentials = resolveFixtureCredentials();
|
||||
const root = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-e2e-"));
|
||||
secureDirectory(root);
|
||||
@@ -363,7 +364,7 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
||||
const [frontendPort, backendPort] = await Promise.all([freeLoopbackPort(), freeLoopbackPort()]);
|
||||
const publicUrl = `http://127.0.0.1:${frontendPort}`;
|
||||
const backendUrl = `http://127.0.0.1:${backendPort}`;
|
||||
const workspace = withF1Workspace ? await prepareF1Workspace(root) : undefined;
|
||||
const workspace = withF1Workspace ? await prepareF1Workspace(root, Object.keys(memoryRuntime).length > 0) : undefined;
|
||||
const provider = await startFakeOidcProvider({
|
||||
directory: providerRoot,
|
||||
registration: {
|
||||
@@ -515,6 +516,7 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
||||
THT_WS_FIXTURE_WORKSPACE_DWH_PASSWORD_FILE: fixtureDwhPasswordFile,
|
||||
THT_WS_FIXTURE_WORKSPACE_DWH_TLS_CA_FILE: fixtureDwhCaFile,
|
||||
} : {}),
|
||||
...memoryRuntime,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -535,6 +537,10 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
||||
|
||||
return {
|
||||
publicUrl,
|
||||
fixtureWorkspaceRoot() {
|
||||
if (!workspace) throw safeError("e2e_workspace_fixture_missing");
|
||||
return join(registryRoot, "repo", workspace.id);
|
||||
},
|
||||
localAccount(account) {
|
||||
const found = accounts[account];
|
||||
if (!found) throw safeError("e2e_local_account_unknown");
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
// So we skip the first two args after node (-c <configPath>).
|
||||
|
||||
const argv = process.argv.slice(2); // drop "node" + script path
|
||||
// Archive integration tests run the actual harness through the production runner.
|
||||
// Unrelated session/LLM operations remain deterministic fixtures.
|
||||
if ((argv[0] === "memory" || argv[0] === "evidence" && argv[1] === "admin") && process.env.THT_E2E_MEMORY_BIN) {
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
const result = spawnSync(process.env.THT_E2E_MEMORY_BIN, argv, {
|
||||
env: process.env, stdio: ["ignore", "inherit", "inherit", 3],
|
||||
});
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Strip leading "-c <value>" pair (ThtRunner always prepends this).
|
||||
let args = argv;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||
<body><div id="root"></div><script type="module" src="./memory-review.tsx"></script></body></html>
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryReviewWidget } from "../../src/widgets/MemoryReviewWidget";
|
||||
import "../../src/index.css";
|
||||
|
||||
const card = { family: "sql_rule", subject: "Join orders within the same financial year",
|
||||
detail: "Join order lines using both order_id and financial_year. The order number repeats across years.",
|
||||
scope: "Sales reporting", rationale: "Confirmed during review of annual totals.",
|
||||
question: "What is the total value of shipped orders?", sql: "", concepts: ["Order grain"],
|
||||
dependencies: [{ database: "warehouse", schema_name: "sales", table: "orders", column: "financial_year" }],
|
||||
links: [] };
|
||||
const descriptor = { id: "review", widget: "memory-review", reserved: [],
|
||||
summary: { summary_id: "fixture", items: [
|
||||
{ id: "rule", card, reason: "Preserve the approved join correction.", source_seqs: [12], before: null, target_id: null },
|
||||
{ id: "solved", card: { ...card, family: "solved_question", subject: "Shipped orders by year",
|
||||
sql: "SELECT financial_year, SUM(total) FROM sales.orders GROUP BY financial_year" },
|
||||
reason: "Keep the approved solution as an example.", source_seqs: [18], before: null, target_id: null },
|
||||
] } };
|
||||
createRoot(document.getElementById("root")!).render(<main className="mx-auto max-w-3xl p-4">
|
||||
<MemoryReviewWidget descriptor={descriptor} onRespond={response => {
|
||||
document.querySelector("#response")!.textContent = JSON.stringify(response);
|
||||
}} /><output id="response" /></main>);
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { resolve } from "node:path";
|
||||
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
|
||||
|
||||
// Opt in: real isolated PostgreSQL/Qdrant containers and the Python harness are required.
|
||||
// Auth, browser, Fastify, ThtRunner and Memory persistence are production code.
|
||||
// Embeddings are deterministic; unrelated sessions and Pi use the existing fixture.
|
||||
test.skip(process.env.THT_MEMORY_BROWSER_E2E !== "1", "Set THT_MEMORY_BROWSER_E2E=1");
|
||||
test.setTimeout(180_000);
|
||||
test.use({ actionTimeout: 15_000, viewport: { width: 1440, height: 1000 } });
|
||||
let stack: Awaited<ReturnType<typeof createAuthenticationStack>>;
|
||||
let embedding: Server;
|
||||
const containers: string[] = [];
|
||||
const root = resolve("..");
|
||||
let databaseUrl: string;
|
||||
let qdrantUrl: string;
|
||||
|
||||
function command(bin: string, args: string[], env = process.env) {
|
||||
return execFileSync(bin, args, { cwd: root, env, encoding: "utf8", timeout: 60_000 }).trim();
|
||||
}
|
||||
function container(image: string, port: number, args: string[] = []) {
|
||||
const id = command("docker", ["run", "-d", "--rm", "-p", `127.0.0.1::${port}`, ...args, image]);
|
||||
containers.push(id);
|
||||
return { id, port: Number(command("docker", ["port", id, `${port}/tcp`]).split(":").at(-1)) };
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const pg = container("postgres:16-alpine", 5432, ["-e", "POSTGRES_PASSWORD=memory-test"]);
|
||||
const qdrant = container("qdrant/qdrant:v1.18.2", 6333);
|
||||
qdrantUrl = `http://127.0.0.1:${qdrant.port}`;
|
||||
await expect.poll(async () => { try { return (await fetch(`${qdrantUrl}/healthz`)).ok; } catch { return false; } }).toBe(true);
|
||||
await expect.poll(() => {
|
||||
// The image's temporary initialization server accepts sockets before restarting.
|
||||
// TCP readiness identifies the final server and avoids racing that shutdown.
|
||||
try { command("docker", ["exec", pg.id, "pg_isready", "-h", "127.0.0.1", "-U", "postgres"]); return true; } catch { return false; }
|
||||
}).toBe(true);
|
||||
command("docker", ["exec", pg.id, "psql", "-U", "postgres", "-c",
|
||||
"CREATE ROLE thothii_catalog_runtime LOGIN PASSWORD 'memory-runtime-test'; " +
|
||||
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO thothii_catalog_runtime; " +
|
||||
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO thothii_catalog_runtime"]);
|
||||
const migratorUrl = `postgresql://postgres:memory-test@127.0.0.1:${pg.port}/postgres`;
|
||||
databaseUrl = `postgresql://thothii_catalog_runtime:memory-runtime-test@127.0.0.1:${pg.port}/postgres`;
|
||||
command(resolve(root, "harness/.venv/bin/python"), ["-m", "tht.memory.migrate"],
|
||||
{ ...process.env, THT_CATALOG_MIGRATOR_DATABASE_URL: migratorUrl });
|
||||
command(resolve(root, "backend/node_modules/.bin/tsx"), ["backend/src/catalog/migrate.ts"],
|
||||
{ ...process.env, THT_CATALOG_MIGRATOR_DATABASE_URL: migratorUrl });
|
||||
embedding = createServer(async (request, response) => {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
const payload = JSON.parse(body || "{}");
|
||||
const texts = Array.isArray(payload.input) ? payload.input : [payload.input];
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ embeddings: texts.map(() => [1, 0, 0]) }));
|
||||
});
|
||||
await new Promise<void>(done => embedding.listen(0, "127.0.0.1", done));
|
||||
const address = embedding.address();
|
||||
if (!address || typeof address === "string") throw new Error("embedding address missing");
|
||||
stack = await createAuthenticationStack({ withF1Workspace: true, memoryRuntime: {
|
||||
THT_E2E_MEMORY_BIN: resolve(root, "harness/.venv/bin/tht"),
|
||||
THT_CATALOG_RUNTIME_DATABASE_URL: databaseUrl,
|
||||
THT_CATALOG_DATABASE_URL: databaseUrl,
|
||||
THT_INTERNAL_QDRANT_URL: qdrantUrl,
|
||||
THT_INTERNAL_EMBEDDING_URL: `http://127.0.0.1:${address.port}`,
|
||||
THT_INTERNAL_EMBEDDING_DIMENSIONS: "3",
|
||||
} });
|
||||
await stack.useLocalMode();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stack?.close();
|
||||
if (embedding) await new Promise<void>(done => embedding.close(() => done()));
|
||||
for (const id of containers.reverse()) command("docker", ["rm", "-f", id]);
|
||||
});
|
||||
|
||||
test("admin manages both archives through the real stack without cross-deletion", async ({ page }) => {
|
||||
page.on("pageerror", error => console.error("Memory browser error:", error.message));
|
||||
await page.goto(stack.publicUrl);
|
||||
await page.getByLabel("Username").fill(stack.localAccount("admin").username);
|
||||
await page.getByLabel("Password", { exact: true }).fill(stack.localAccount("admin").password);
|
||||
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
await expect(page.getByTestId("app-shell")).toBeVisible();
|
||||
// Pull only the temporary fixture registry through the authenticated application API.
|
||||
const pulled = await page.evaluate(async () => {
|
||||
const me = await (await fetch("/api/me")).json();
|
||||
const result = await fetch("/api/workspace-registry/pull", { method: "POST",
|
||||
headers: { "x-thothii-csrf": me.csrfToken } });
|
||||
return { status: result.status, body: await result.json() };
|
||||
});
|
||||
expect(pulled.status, JSON.stringify(pulled.body)).toBe(200);
|
||||
// This checkout belongs to the temporary authentication fixture, never the PSD archive.
|
||||
command(resolve(root, "harness/.venv/bin/python"), ["-c", `
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tht.evidence.canonical import CuratedEvidence, dump_curated_markdown
|
||||
from tht.evidence.local_archive import LocalEvidenceArchive
|
||||
root = Path(os.environ["FIXTURE_ARCHIVE_ROOT"])
|
||||
unit = CuratedEvidence.model_validate({"schema_version":4,"id":"evidence:order-key",
|
||||
"kind":"domain","title":"Order key Evidence","language":"en","purposes":["sql_generation"],
|
||||
"payload":{"rule":"Use order_id and financial_year when joining order lines."},
|
||||
"provenance":{"kind":"manual","declared_by":"Acceptance fixture"}})
|
||||
path = root / "evidence/curated/domain/order-key.md"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(dump_curated_markdown(unit))
|
||||
LocalEvidenceArchive(root).initialize()
|
||||
`], { ...process.env, FIXTURE_ARCHIVE_ROOT: stack.fixtureWorkspaceRoot() });
|
||||
async function openMemory() {
|
||||
await page.getByRole("button", { name: "Administration", exact: true }).click();
|
||||
expect((await page.getByRole("button", {
|
||||
name: /^(Database management|Memory management|Evidence management)$/,
|
||||
}).allTextContents()).map(text => text.trim())).toEqual([
|
||||
"Database management", "Memory management", "Evidence management",
|
||||
]);
|
||||
await page.getByRole("button", { name: "Memory management", exact: true }).click();
|
||||
await page.screenshot({ path: "/private/tmp/thothii-m1-memory-opening.png", fullPage: true });
|
||||
const memory = page.getByRole("main", { name: "Memory management" });
|
||||
await expect(memory).toBeVisible();
|
||||
await memory.getByLabel("Workspace", { exact: true }).selectOption("fixture-workspace");
|
||||
await expect(page.getByRole("button", { name: "New card", exact: true })).toBeVisible();
|
||||
}
|
||||
await openMemory();
|
||||
await page.getByRole("button", { name: "New card", exact: true }).click();
|
||||
await page.getByLabel("Title", { exact: true }).fill("Orders approved");
|
||||
await page.getByLabel("Scope", { exact: true }).fill("Sales");
|
||||
await page.getByLabel("Content", { exact: true }).fill("Only shipped orders");
|
||||
await page.getByRole("button", { name: "Save card", exact: true }).click();
|
||||
await expect(page.getByText("Saved and indexed.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Edit card", exact: true }).click();
|
||||
await page.getByLabel("Content", { exact: true }).fill("Only fully shipped orders");
|
||||
await page.getByRole("button", { name: "Save card", exact: true }).click();
|
||||
await expect(page.getByText("Only fully shipped orders", { exact: true })).toBeVisible();
|
||||
await stack.restartBackend();
|
||||
await page.reload();
|
||||
await openMemory();
|
||||
await page.getByRole("button", { name: /Orders approved Domain clarification/ }).click();
|
||||
await expect(page.getByText("Only fully shipped orders", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "/private/tmp/thothii-m1-memory-desktop.png", fullPage: true });
|
||||
await page.getByRole("button", { name: "Delete card", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Confirm deletion", exact: true }).click();
|
||||
await expect(page.getByText("Card deleted and removed from recall.", { exact: true })).toBeVisible();
|
||||
const recalled = command(resolve(root, "harness/.venv/bin/python"), ["-c", `
|
||||
import json, os
|
||||
from tht.memory.runtime import admin_service
|
||||
from tht.adapters.vector.qdrant import QdrantVectorStore
|
||||
runtime = {"internalQdrantUrl": os.environ["QDRANT_URL"], "internalEmbeddingDimensions": 3}
|
||||
service = admin_service("fixture-workspace", runtime)
|
||||
store = QdrantVectorStore(base_url=runtime["internalQdrantUrl"], workspace_id="fixture-workspace",
|
||||
collections={"reference":"fixture-workspace-reference", "memory":"fixture-workspace-memory"}, expected_dimension=3)
|
||||
class Searcher:
|
||||
def search(self, vector, top_n, kinds, **kwargs): return store.search(["memory"], vector, limit=top_n, kinds=kinds, **kwargs)
|
||||
class Embedder:
|
||||
def embed_query(self, text): return [1,0,0]
|
||||
try: print(json.dumps(service.recall("orders", searcher=Searcher(), embedder=Embedder())))
|
||||
finally: service.close()
|
||||
`], { ...process.env, THT_CATALOG_RUNTIME_DATABASE_URL: databaseUrl, QDRANT_URL: qdrantUrl,
|
||||
THT_PRINCIPAL_ISSUER: "test", THT_PRINCIPAL_SUBJECT: "admin", THT_PRINCIPAL_IS_ADMIN: "true" });
|
||||
expect(JSON.parse(recalled)).toEqual([]);
|
||||
await expect(page.getByRole("button", { name: "New card", exact: true })).toBeEnabled();
|
||||
if (!await page.getByRole("button", { name: "Evidence management", exact: true }).isVisible())
|
||||
await page.getByRole("button", { name: "Administration", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Evidence management", exact: true }).click();
|
||||
const evidence = page.getByRole("main", { name: "Evidence management" });
|
||||
await expect(evidence).toBeVisible();
|
||||
await evidence.getByRole("combobox", { name: /Workspace/ }).selectOption("fixture-workspace");
|
||||
await evidence.getByRole("button", { name: "Order key Evidence", exact: true }).click();
|
||||
await expect(evidence.getByRole("region", { name: "Evidence detail" })).toContainText(
|
||||
"Use order_id and financial_year when joining order lines.");
|
||||
await evidence.getByLabel("Search Evidence", { exact: true }).fill("no matching knowledge");
|
||||
await expect(evidence.getByText(/No Evidence matches these filters/)).toBeVisible();
|
||||
await evidence.getByLabel("Search Evidence", { exact: true }).fill("financial_year");
|
||||
await expect(evidence.getByRole("button", { name: "Order key Evidence", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "/private/tmp/thothii-acceptance-evidence-desktop.png", fullPage: true });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.getByRole("button", { name: "Navigation", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("complementary", { name: "Session navigation" })).toBeHidden();
|
||||
expect((await evidence.boundingBox())!.width).toBeGreaterThanOrEqual(380);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
await page.screenshot({ path: "/private/tmp/thothii-acceptance-evidence-mobile.png", fullPage: true });
|
||||
await page.getByRole("button", { name: "Navigation", exact: true }).click();
|
||||
const navigation = page.getByRole("dialog", { name: "Navigation", exact: true });
|
||||
await expect(navigation.getByRole("button", { name: "Memory management", exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(navigation).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "Navigation", exact: true })).toBeFocused();
|
||||
await page.getByRole("button", { name: "Navigation", exact: true }).click();
|
||||
await navigation.getByRole("button", { name: "Memory management", exact: true }).click();
|
||||
await expect(navigation).toBeHidden();
|
||||
const mobileMemory = page.getByRole("main", { name: "Memory management" });
|
||||
await expect(mobileMemory).toBeVisible();
|
||||
expect((await mobileMemory.boundingBox())!.width).toBeGreaterThanOrEqual(380);
|
||||
const unknown = await page.evaluate(async () => Promise.all(["memory", "evidence"].map(async archive =>
|
||||
(await fetch(`/api/workspaces/unregistered-workspace/${archive}`)).status)));
|
||||
expect(unknown).toEqual([404, 404]);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let server: ViteDevServer;
|
||||
let url: string;
|
||||
test.beforeAll(async () => {
|
||||
server = await createServer({ server: { host: "127.0.0.1", port: 0 } });
|
||||
await server.listen();
|
||||
url = server.resolvedUrls!.local[0]! + "e2e/fixtures/memory-review.html";
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
test("reviewer edits the final summary and selects what to save on desktop and mobile", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", e => errors.push(e.message));
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(url);
|
||||
await expect(page.getByRole("form", { name: "Memory summary" })).toBeVisible();
|
||||
await page.getByLabel("rule detail").fill("Join on order number and year, within the sales database.");
|
||||
await page.getByRole("checkbox", { name: /Shipped orders by year/ }).uncheck();
|
||||
await page.screenshot({ path: "/private/tmp/thothii-m3-summary-desktop.png", fullPage: true });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
await page.screenshot({ path: "/private/tmp/thothii-m3-summary-mobile.png", fullPage: true });
|
||||
await page.getByRole("button", { name: "Save selected and finish" }).click();
|
||||
const response = JSON.parse(await page.locator("#response").textContent() || "{}");
|
||||
expect(JSON.parse(response.text).items).toHaveLength(1);
|
||||
expect(JSON.parse(response.text).items[0].card.detail).toContain("within the sales database");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -258,7 +258,7 @@ export type CatalogSyncScope = "tables" | "columns" | "relationships" | "all";
|
||||
export type CatalogSyncState = "queued" | "running" | "awaiting_confirmation" | "applying"
|
||||
| "succeeded" | "failed" | "cancelled" | "interrupted";
|
||||
export type CatalogSyncPhase = "queued" | "connecting" | "scanning_tables" | "scanning_columns"
|
||||
| "scanning_relationships" | "planning" | "awaiting_confirmation" | "applying" | "completed";
|
||||
| "scanning_relationships" | "planning" | "awaiting_confirmation" | "applying" | "memory_cleanup" | "completed";
|
||||
|
||||
export interface CatalogSchemaDiff {
|
||||
deletedTables: string[];
|
||||
@@ -276,7 +276,7 @@ export interface CatalogSyncRun {
|
||||
requestedDatabaseVersion: number;
|
||||
plannedDiff: CatalogSchemaDiff | null;
|
||||
confirmationToken: string | null;
|
||||
counts: { tables?: number; columns?: number; relationships?: number; created?: number; updated?: number; deleted?: number };
|
||||
counts: { tables?: number; columns?: number; relationships?: number; created?: number; updated?: number; deleted?: number; memoryDeleted?: number };
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
cancelRequested: boolean;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
|
||||
const MAX_ERROR_BODY_BYTES = 8 * 1024;
|
||||
const safeErrorCodes = new Set([
|
||||
"evidence_unavailable", "evidence_not_found",
|
||||
"memory_invalid", "memory_forbidden", "memory_not_found", "memory_conflict", "memory_unavailable",
|
||||
"auth_forbidden", "auth_invalid_credentials", "auth_not_authorized", "auth_unavailable",
|
||||
"auth_not_implemented", "authentication_required", "invalid_credentials", "login_rate_limited",
|
||||
"csrf_failed", "csrf_invalid", "dwh_unreachable", "model_unavailable",
|
||||
@@ -46,6 +48,13 @@ type SafeErrorPayload = {
|
||||
};
|
||||
|
||||
const localCodeMessages: Record<string, string> = {
|
||||
evidence_unavailable: "Evidence archive is unavailable. Check the local files and installation configuration.",
|
||||
evidence_not_found: "Evidence was not found. Refresh the file list.",
|
||||
memory_invalid: "Check the card fields, family requirements and schema dependencies.",
|
||||
memory_forbidden: "Memory administration is not permitted.",
|
||||
memory_not_found: "This Memory card or pending update no longer exists in the workspace.",
|
||||
memory_conflict: "A linked card is missing or invalid. Review the links and retry.",
|
||||
memory_unavailable: "The Memory archive is unavailable. Check the installation connection and migrations, then retry.",
|
||||
auth_forbidden: "Access is not permitted.",
|
||||
auth_invalid_credentials: "Invalid username or password.",
|
||||
auth_not_authorized: "Access is not permitted.",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export interface EvidenceUnit {
|
||||
id: string; title: string; kind: string; language: string; purposes: string[];
|
||||
applies_to: { concepts: string[]; tables: string[]; columns: string[] };
|
||||
payload: Record<string, string | string[] | Record<string, string>>;
|
||||
provenance: { kind?: "manual"; declared_by?: string; source_file?: string; supporting_excerpts?: string[];
|
||||
original?: { source_file: string; supporting_excerpts: string[] } | null };
|
||||
review_items: Array<{ code: string; message: string; field?: string }>;
|
||||
file: string; status: string; revision: string;
|
||||
}
|
||||
export interface EvidencePage {
|
||||
source_reviews?: EvidenceSourceReview[];
|
||||
items: EvidenceUnit[]; item?: EvidenceUnit; total: number; page: number; page_size: number;
|
||||
errors: Array<{ file: string; message: string }>; initialized: boolean;
|
||||
active_revision: string | null; pending_revision: string | null;
|
||||
location: { repository: string | null; workspace: string | null; runtime_workspace: string;
|
||||
host: string; command: string; git_commands: string[] };
|
||||
}
|
||||
|
||||
export interface EvidenceSourceReview {
|
||||
id: string; uri: string; revision: string; availability: "available" | "missing";
|
||||
status: "review" | "applying" | "accepted" | "kept"; decision?: "keep" | "replace";
|
||||
current?: EvidenceUnit[]; proposed: EvidenceUnit[]; removed_ids?: string[]; suppressed?: boolean;
|
||||
}
|
||||
export type EvidenceSourceAction = { action: "refresh" } | {
|
||||
action: "decide"; sourceId: string; revision: string; decision: "keep" | "replace";
|
||||
};
|
||||
export async function actOnEvidenceSources(workspace: string, action: EvidenceSourceAction) {
|
||||
const result = await apiFetch<{status: string; warnings?: string[]}>(
|
||||
`/workspaces/${encodeURIComponent(workspace)}/evidence/sources`, {method: "POST", body: JSON.stringify(action)});
|
||||
if (result.status !== "succeeded") throw new Error(result.warnings?.join(" ") || "Source operation failed.");
|
||||
return result;
|
||||
}
|
||||
export function listEvidence(workspace: string, filters: Record<string, string | number> = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) if (value !== "") params.set(key, String(value));
|
||||
return apiFetch<EvidencePage>(`/workspaces/${encodeURIComponent(workspace)}/evidence?${params}`);
|
||||
}
|
||||
export function getEvidence(workspace: string, id: string) {
|
||||
return apiFetch<EvidencePage>(`/workspaces/${encodeURIComponent(workspace)}/evidence/${encodeURIComponent(id)}`);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type MemoryFamily = "domain_clarification" | "sql_rule" | "solved_question" | "explained_error";
|
||||
export interface MemoryDependency { database: string; schema_name: string; table: string; column: string }
|
||||
export interface MemoryLink { target_id: string; meaning: string }
|
||||
export interface MemoryInput {
|
||||
family: MemoryFamily; subject: string; detail: string; scope: string; rationale: string;
|
||||
question: string; sql: string; concepts: string[]; dependencies: MemoryDependency[]; links: MemoryLink[];
|
||||
}
|
||||
export interface MemoryCard extends MemoryInput {
|
||||
id: string; workspace_id: string; origin: "manual" | "workflow"; session_id: string | null;
|
||||
decision_seq: number | null; created_at: string; updated_at: string; revision: string; indexed: boolean;
|
||||
}
|
||||
export interface MemoryPage { items: MemoryCard[]; total: number; page: number; page_size: number }
|
||||
export interface MemoryPending { card_id: string; action: "upsert" | "delete"; error: string | null }
|
||||
export interface MemoryOutcome {
|
||||
id: string; saved: boolean; indexed: boolean; action: "upsert" | "delete";
|
||||
card?: MemoryCard; error: string | null;
|
||||
}
|
||||
const base = (workspace: string) => `/workspaces/${encodeURIComponent(workspace)}/memory`;
|
||||
export function listMemory(workspace: string, filters: Record<string, string | number> = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) if (value !== "") params.set(key, String(value));
|
||||
return apiFetch<MemoryPage>(`${base(workspace)}?${params}`);
|
||||
}
|
||||
export const getMemory = (workspace: string, id: string) =>
|
||||
apiFetch<MemoryCard>(`${base(workspace)}/${encodeURIComponent(id)}`);
|
||||
export const pendingMemory = (workspace: string) => apiFetch<MemoryPending[]>(`${base(workspace)}/pending`);
|
||||
export const saveMemory = (workspace: string, card: MemoryInput, id?: string) =>
|
||||
apiFetch<MemoryOutcome>(`${base(workspace)}${id ? `/${encodeURIComponent(id)}` : ""}`, {
|
||||
method: id ? "PUT" : "POST", body: JSON.stringify(card),
|
||||
});
|
||||
export const deleteMemory = (workspace: string, id: string) =>
|
||||
apiFetch<MemoryOutcome>(`${base(workspace)}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
export const retryMemory = (workspace: string, id: string) =>
|
||||
apiFetch<MemoryOutcome>(`${base(workspace)}/${encodeURIComponent(id)}/retry`, { method: "POST" });
|
||||
|
||||
export function memoryInput(card: MemoryCard): MemoryInput {
|
||||
const { family, subject, detail, scope, rationale, question, sql, concepts, dependencies, links } = card;
|
||||
return { family, subject, detail, scope, rationale, question, sql, concepts, dependencies, links };
|
||||
}
|
||||
@@ -47,6 +47,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("authenticated shell permissions", () => {
|
||||
test("opens Memory administration independently of the core session", async () => {
|
||||
renderShell({ subject: "admin", isAdmin: true, roles: ["admin"],
|
||||
permissions: ["session.use", "memory.manage", "database.manage"] });
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Administration" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
|
||||
const memory = screen.getByRole("main", { name: "Memory management" });
|
||||
expect(memory).toBeVisible();
|
||||
expect(within(memory).getByRole("combobox", { name: "Workspace" })).toBeVisible();
|
||||
});
|
||||
test("does not use the legacy installation default as a preprocessing workspace", async () => {
|
||||
const requestedWorkspaceIds: string[] = [];
|
||||
server.use(
|
||||
@@ -125,15 +134,19 @@ describe("authenticated shell permissions", () => {
|
||||
|
||||
const panel = within(sessionNavigation).getByRole("region", { name: "Administration" });
|
||||
const database = within(panel).getByRole("button", { name: "Database management" });
|
||||
const memory = within(panel).getByRole("button", { name: "Memory management" });
|
||||
const evidence = within(panel).getByRole("button", { name: "Evidence management" });
|
||||
const separator = within(panel).getByRole("separator");
|
||||
const workspace = within(panel).getByRole("button", { name: "Workspace management" });
|
||||
const pi = within(panel).getByRole("button", { name: "Pi management" });
|
||||
const preprocessing = within(panel).getByRole("region", { name: "Workspace preprocessing" });
|
||||
expect(panel.children[0]).toBe(database);
|
||||
expect(panel.children[1]).toBe(separator);
|
||||
expect(panel.children[2]).toBe(workspace);
|
||||
expect(panel.children[3]).toBe(pi);
|
||||
expect(panel.children[4]).toBe(preprocessing);
|
||||
expect(panel.children[1]).toBe(memory);
|
||||
expect(panel.children[2]).toBe(evidence);
|
||||
expect(panel.children[3]).toBe(separator);
|
||||
expect(panel.children[4]).toBe(workspace);
|
||||
expect(panel.children[5]).toBe(pi);
|
||||
expect(panel.children[6]).toBe(preprocessing);
|
||||
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
|
||||
|
||||
await user.keyboard(" ");
|
||||
|
||||
@@ -17,6 +17,9 @@ import { StopConfirmDialog } from "./StopConfirmDialog";
|
||||
import { SteerInput, ComposerFooter } from "./SteerInput";
|
||||
import { WorkflowBar } from "./WorkflowBar";
|
||||
import { DatabaseManagementPage } from "./DatabaseManagementPage";
|
||||
import { MemoryManagementPage } from "./MemoryManagementPage";
|
||||
import { EvidenceManagementPage } from "./EvidenceManagementPage";
|
||||
import { ArchiveNavigation } from "./ArchiveNavigation";
|
||||
import { WorkspacePreprocessingControl } from "./WorkspacePreprocessingControl";
|
||||
import { Pencil, ArrowLeft, ArrowRight, ChevronDown, Trash2 } from "lucide-react";
|
||||
import { Accordion } from "@base-ui/react/accordion";
|
||||
@@ -46,7 +49,7 @@ interface AppShellProps {
|
||||
canLogout: boolean;
|
||||
}
|
||||
|
||||
type ActiveSurface = "core" | "database-management";
|
||||
type ActiveSurface = "core" | "database-management" | "memory-management" | "evidence-management";
|
||||
type ActiveManagementPanel = "workspace" | "pi" | null;
|
||||
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
|
||||
|
||||
@@ -153,6 +156,10 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
const canManageWorkspace = permissions.includes("workspace.manage");
|
||||
const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage");
|
||||
const canManageDatabase = permissions.includes("database.manage");
|
||||
const canManageMemory = permissions.includes("memory.manage");
|
||||
const canManageEvidence = permissions.includes("evidence.manage");
|
||||
const [memoryDirty, setMemoryDirty] = useState(false);
|
||||
const [memoryBusy, setMemoryBusy] = useState(false);
|
||||
const canManagePi = permissions.includes("pi.manage");
|
||||
const authGeneration = useAuthGeneration();
|
||||
const { data: installationSettings } = useQuery({
|
||||
@@ -299,6 +306,13 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
}
|
||||
|
||||
function canLeaveDatabaseManagement(): boolean {
|
||||
if (activeSurface === "memory-management" && memoryBusy) {
|
||||
toast.info("Wait for the Memory operation to finish before leaving this page");
|
||||
return false;
|
||||
}
|
||||
if (activeSurface === "memory-management" && memoryDirty) {
|
||||
return window.confirm("Discard unsaved Memory changes and leave Memory management?");
|
||||
}
|
||||
if (activeSurface !== "database-management") return true;
|
||||
if (databaseNavigationRef.current.busy) {
|
||||
toast.info("Wait for the database operation to finish before leaving this page");
|
||||
@@ -695,7 +709,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|
||||
const currentNavigation = activeSurface === "database-management"
|
||||
? "database"
|
||||
: activeManagementPanel ?? "core";
|
||||
: activeSurface === "memory-management" ? "memory" : activeSurface === "evidence-management" ? "evidence" : activeManagementPanel ?? "core";
|
||||
const adminNavigationOpen = adminNavigationValue.includes("administration");
|
||||
const managementNavigationCurrent = currentNavigation !== "core";
|
||||
|
||||
@@ -709,6 +723,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
data-session-resizing={sessionResizing}
|
||||
className={[
|
||||
"flex h-screen bg-background text-foreground",
|
||||
["memory-management", "evidence-management"].includes(activeSurface) ? "max-md:pt-12" : "",
|
||||
activityResizing || sessionResizing ? "select-none cursor-col-resize" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
@@ -749,6 +764,10 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|
||||
{/* Conversation column */}
|
||||
<WorkAreaPanelHost data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
|
||||
{activeSurface === "memory-management" && (
|
||||
<MemoryManagementPage canManage={canManageMemory} onDirtyChange={setMemoryDirty} onBusyChange={setMemoryBusy} />
|
||||
)}
|
||||
{activeSurface === "evidence-management" && <EvidenceManagementPage canManage={canManageEvidence} />}
|
||||
{activeSurface === "database-management" && (
|
||||
<DatabaseManagementPage
|
||||
canManage={canManageDatabase}
|
||||
@@ -853,7 +872,8 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</WorkAreaPanelHost>
|
||||
|
||||
{/* Right session rail */}
|
||||
{(activeSurface === "database-management" || !showActivity) && (
|
||||
{(activeSurface !== "core" || !showActivity) && (
|
||||
<ArchiveNavigation surface={activeSurface}>
|
||||
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
|
||||
<div className="thot-session-navigation__header relative text-center">
|
||||
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
||||
@@ -928,12 +948,33 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
disabled={!canManageDatabase}
|
||||
title={canManageDatabase ? undefined : "Database management permission is required"}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveManagementPanel(null);
|
||||
setActiveSurface("database-management");
|
||||
}}
|
||||
>
|
||||
Database management
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentNavigation === "memory" ? "navigationActive" : "outline"}
|
||||
size="sm"
|
||||
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||
aria-current={currentNavigation === "memory" ? "page" : undefined}
|
||||
disabled={!canManageMemory}
|
||||
title={canManageMemory ? undefined : "Memory management permission is required"}
|
||||
data-navigation-state={currentNavigation === "memory" ? "current" : "available"}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveManagementPanel(null);
|
||||
setActiveSurface("memory-management");
|
||||
}}
|
||||
>Memory management</Button>
|
||||
<Button variant={currentNavigation === "evidence" ? "navigationActive" : "outline"}
|
||||
size="sm" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||
aria-current={currentNavigation === "evidence" ? "page" : undefined}
|
||||
disabled={!canManageEvidence} title={canManageEvidence ? undefined : "Evidence management permission is required"}
|
||||
onClick={() => { if (!canLeaveDatabaseManagement()) return; setActiveManagementPanel(null); setActiveSurface("evidence-management"); }}
|
||||
>Evidence management</Button>
|
||||
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
|
||||
<Button
|
||||
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
||||
@@ -1154,6 +1195,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</ArchiveNavigation>
|
||||
)}
|
||||
<Toaster />
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "../components/ui/dialog";
|
||||
|
||||
const query = "(max-width: 767px)";
|
||||
function subscribe(listener: () => void) {
|
||||
const media = window.matchMedia(query);
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
}
|
||||
const isNarrow = () => window.matchMedia(query).matches;
|
||||
|
||||
/** Keep the archive content readable on phones, using the shared accessible dialog. */
|
||||
export function ArchiveNavigation({ surface, children }: { surface: string; children: ReactNode }) {
|
||||
const narrow = useSyncExternalStore(subscribe, isNarrow, () => false);
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => { setOpen(false); }, [surface, narrow]);
|
||||
if (!narrow || !["memory-management", "evidence-management"].includes(surface)) return children;
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<div className="fixed inset-x-0 top-0 z-30 flex h-12 items-center justify-between border-b bg-background px-4">
|
||||
<span className="font-heading font-semibold">ThothII</span>
|
||||
<DialogTrigger render={<Button variant="outline" size="sm" />}>Navigation</DialogTrigger>
|
||||
</div>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col">
|
||||
<DialogTitle>Navigation</DialogTitle>
|
||||
<DialogDescription className="sr-only">Choose an administration page or a session.</DialogDescription>
|
||||
<div className="min-h-0 overflow-y-auto [&>aside]:w-full">{children}</div>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/msw";
|
||||
import { EvidenceManagementPage } from "./EvidenceManagementPage";
|
||||
import type { EvidencePage, EvidenceUnit } from "../api/evidence";
|
||||
|
||||
const item: EvidenceUnit = { id: "evidence:order-key", title: "Order key", kind: "domain", language: "en", purposes: ["sql_generation"],
|
||||
applies_to: { concepts: ["Orders"], tables: ["sales.orders"], columns: [] }, payload: { rule: "Join by order number, year and company." },
|
||||
provenance: { kind: "manual", declared_by: "Curator", original: { source_file: "source/orders.md", supporting_excerpts: ["Old original wording."] } },
|
||||
review_items: [], file: "curated/domain/order-key.md", status: "modified", revision: "rev" };
|
||||
const page: EvidencePage = { items: [item], item, total: 1, page: 1, page_size: 25, errors: [], initialized: true, active_revision: "active", pending_revision: null,
|
||||
location: { repository: "/srv/workspaces/repo", workspace: "/srv/workspaces/repo/sales", runtime_workspace: "/data/registry/repo/sales", host: "Installation host",
|
||||
command: "tht workspace evidence consolidate --workspace sales", git_commands: ["git status", "git diff", "git add -A", "git commit", "git push"] } };
|
||||
function renderPage() {
|
||||
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}><EvidenceManagementPage canManage /></QueryClientProvider>);
|
||||
}
|
||||
beforeEach(() => server.use(
|
||||
http.get("/api/workspaces", () => HttpResponse.json([{ id: "sales", displayName: "Sales" }])),
|
||||
http.get("/api/workspaces/sales/evidence", () => HttpResponse.json(page)),
|
||||
http.get("/api/workspaces/sales/evidence/evidence:order-key", () => HttpResponse.json(page)),
|
||||
));
|
||||
async function choose() { await screen.findByRole("option", { name: "Sales" }); await userEvent.selectOptions(screen.getByLabelText("Workspace"), "sales"); }
|
||||
test("reads complete content, manual lineage and actual host file path", async () => {
|
||||
renderPage(); await choose(); await userEvent.click(await screen.findByRole("button", { name: "Order key" }));
|
||||
expect(await screen.findByText("Join by order number, year and company.")).toBeVisible();
|
||||
expect(screen.getByText("Manual declaration by Curator.")).toBeVisible();
|
||||
expect(screen.getByText(/Original document \(lineage\)/)).toBeVisible();
|
||||
expect(screen.getByText("/srv/workspaces/repo/sales/evidence/curated/domain/order-key.md")).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
|
||||
});
|
||||
test("shows manual consolidation instructions and all kind templates", async () => {
|
||||
renderPage(); await choose(); await userEvent.click(await screen.findByText("Edit files, consolidate and commit"));
|
||||
expect(screen.getByText(page.location.command)).toBeVisible();
|
||||
await userEvent.click(screen.getByText("Create a manual Evidence file"));
|
||||
await userEvent.selectOptions(screen.getByLabelText("Example kind"), "formula");
|
||||
expect(screen.getAllByText(/sum\(amount\)/).length).toBeGreaterThan(0);
|
||||
});
|
||||
test("combines complete-archive filters and refreshes pending state", async () => {
|
||||
server.use(http.get("/api/workspaces/sales/evidence", ({ request }) => {
|
||||
const query = new URL(request.url).searchParams;
|
||||
return HttpResponse.json({ ...page, items: query.get("kind") === "domain" && query.get("q") === "order" ? [item] : [], pending_revision: "pending" });
|
||||
}));
|
||||
renderPage(); await choose(); await userEvent.type(screen.getByLabelText("Search Evidence"), "order");
|
||||
await userEvent.selectOptions(screen.getByLabelText("Kind"), "domain");
|
||||
expect(await screen.findByRole("button", { name: "Order key" })).toBeVisible();
|
||||
expect(screen.getByText(/Consolidation is incomplete/)).toBeVisible();
|
||||
});
|
||||
test("invalid files remain actionable alongside valid units", async () => {
|
||||
server.use(http.get("/api/workspaces/sales/evidence", () => HttpResponse.json({ ...page, errors: [{ file: "curated/domain/broken.md", message: "Missing Rule section" }] })));
|
||||
renderPage(); await choose();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("curated/domain/broken.md");
|
||||
expect(within(screen.getByRole("region", { name: "Evidence list" })).getByRole("button", { name: "Order key" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("source refresh is explicit and presents current versus proposed content before a decision", async () => {
|
||||
const requests: unknown[] = [];
|
||||
const source = {id: "a".repeat(64), revision: "b".repeat(64), uri: "https://docs.example.test/orders.md", status: "review", availability: "available",
|
||||
current: [item], proposed: [{...item, payload: {rule: "Use the new approved key."}}], removed_ids: []};
|
||||
server.use(http.get("/api/workspaces/sales/evidence", () => HttpResponse.json({...page, source_reviews: [source]})),
|
||||
http.post("/api/workspaces/sales/evidence/sources", async ({request}) => { requests.push(await request.json()); return HttpResponse.json({status: "succeeded", warnings: ["Source decision activated locally."]}); }));
|
||||
renderPage(); await choose();
|
||||
await screen.findByText(source.uri);
|
||||
expect(requests).toHaveLength(0);
|
||||
await userEvent.click(screen.getByRole("button", {name: "Import or refresh sources"}));
|
||||
await screen.findByText("Source decision activated locally.");
|
||||
expect(requests).toEqual([{action: "refresh"}]);
|
||||
await userEvent.click(screen.getByText(source.uri));
|
||||
expect(screen.getByText("Current local Evidence")).toBeVisible();
|
||||
expect(screen.getByText("Use the new approved key.")).toBeVisible();
|
||||
await userEvent.click(screen.getByRole("button", {name: "Keep local Evidence"}));
|
||||
expect(requests[1]).toEqual({action: "decide", sourceId: source.id, revision: source.revision, decision: "keep"});
|
||||
});
|
||||
|
||||
test("saved source decisions expose retry and preserve the original choice", async () => {
|
||||
const source = {id: "a".repeat(64), revision: "b".repeat(64), uri: "s3://docs/orders.md", status: "applying", decision: "replace", availability: "available",
|
||||
current: [item], proposed: [item], removed_ids: []};
|
||||
server.use(http.get("/api/workspaces/sales/evidence", () => HttpResponse.json({...page, source_reviews: [source]})),
|
||||
http.post("/api/workspaces/sales/evidence/sources", () => HttpResponse.json({status: "failed", warnings: ["Source decision saved, but indexing failed. Retry the same decision."]})));
|
||||
renderPage(); await choose(); await userEvent.click(await screen.findByText(source.uri));
|
||||
expect(screen.getByRole("button", {name: "Import or refresh sources"})).toBeDisabled();
|
||||
expect(screen.queryByRole("button", {name: "Keep local Evidence"})).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", {name: "Retry saved decision"}));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Retry the same decision");
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiFetch, apiErrorMessage } from "../api/client";
|
||||
import { listEvidence, getEvidence, type EvidencePage, type EvidenceUnit } from "../api/evidence";
|
||||
import { useAuthGeneration } from "../auth/authState";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { MarkdownView } from "../viewers/MarkdownView";
|
||||
import { EvidenceSources } from "./EvidenceSourceReview";
|
||||
|
||||
const control = "rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
const kinds = ["domain", "glossary", "enum", "example", "mapping", "normalization", "formula", "reference"];
|
||||
const states: Record<string, string> = { new: "New file", modified: "Modified", active: "Active locally", removed: "Removed file", review_required: "Needs review", legacy: "Conversion required", invalid: "Invalid file" };
|
||||
const templates: Record<string, string> = {
|
||||
domain: "## Rule\n\nDescribe the approved domain rule.",
|
||||
glossary: "## Definition\n\nDefine the concept.\n\n## Synonyms\n\n- Alternative name\n\n## Variants\n",
|
||||
enum: '## Column\n\nsales.orders.status\n\n## Values\n\n### "A"\n\nActive',
|
||||
example: "## Question\n\nHow many orders?\n\n## Interpretation\n\nCount distinct orders.",
|
||||
mapping: "## Concept\n\nOrders\n\n## Tables\n\n- sales.orders\n\n## Columns\n\n- sales.orders.id",
|
||||
normalization: "## Input\n\nA\n\n## Output\n\nActive\n\n## Rule\n\nExpand the status abbreviation.",
|
||||
formula: "## Concept\n\nTotal amount\n\n## Columns\n\n- sales.lines.amount\n\n## SQL\n\n```sql\nsum(amount)\n```",
|
||||
reference: "## URL\n\nhttps://example.org/policy\n\n## Label\n\nPolicy\n\n## Description\n\nDescribe the reference.",
|
||||
};
|
||||
|
||||
function CopyText({ text, label, showText = true }: { text: string; label: string; showText?: boolean }) {
|
||||
const [notice, setNotice] = useState("");
|
||||
return <div className="flex min-w-0 flex-wrap items-start gap-2">
|
||||
{showText && <code className="min-w-0 flex-1 break-all text-xs select-text">{text}</code>}
|
||||
<Button size="sm" variant="outline" aria-label={`Copy ${label}`} onClick={async () => {
|
||||
try { await navigator.clipboard.writeText(text); setNotice("Copied"); }
|
||||
catch { setNotice("Select the text and copy it manually."); }
|
||||
}}>Copy</Button><span role="status" className="text-xs text-muted-foreground">{notice}</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function EvidenceManagementPage({ canManage }: { canManage: boolean }) {
|
||||
const generation = useAuthGeneration();
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const workspaces = useQuery({ queryKey: ["evidence-workspaces", generation],
|
||||
queryFn: () => apiFetch<Array<{ id: string; displayName?: string }>>("/workspaces"), enabled: canManage });
|
||||
if (!canManage) return <main className="p-6">Evidence administration is not permitted.</main>;
|
||||
return <main aria-label="Evidence management" className="flex min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4 border-b px-6 py-5">
|
||||
<div><h1 className="font-serif text-2xl font-semibold">Evidence management</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Review workspace knowledge and maintain its Markdown files.</p></div>
|
||||
<label className="grid min-w-52 gap-1 text-sm">Workspace
|
||||
<select className={control} value={workspace} onChange={event => setWorkspace(event.target.value)}>
|
||||
<option value="">Choose a workspace</option>
|
||||
{workspaces.data?.map(w => <option key={w.id} value={w.id}>{w.displayName ?? w.id}</option>)}
|
||||
</select></label>
|
||||
</header>
|
||||
{workspaces.isError && <p role="alert" className="p-6">{apiErrorMessage(workspaces.error)}</p>}
|
||||
{workspace ? <WorkspaceEvidence key={`${generation}:${workspace}`} workspace={workspace} generation={generation} />
|
||||
: <p className="p-8 text-muted-foreground">Choose a workspace to browse its Evidence and locate the files to edit.</p>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function WorkspaceEvidence({ workspace, generation }: { workspace: string; generation: number }) {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({ q: "", kind: "", purpose: "", status: "", sort: "title", direction: "asc" });
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState("");
|
||||
const list = useQuery({ queryKey: ["evidence", generation, workspace, filters, page], queryFn: () => listEvidence(workspace, { ...filters, page, page_size: 25 }) });
|
||||
const detail = useQuery({ queryKey: ["evidence-detail", generation, workspace, selected], queryFn: () => getEvidence(workspace, selected), enabled: Boolean(selected) });
|
||||
function filter(key: string, value: string) { setFilters(old => ({ ...old, [key]: value })); setPage(1); }
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-5 p-6">
|
||||
{list.data && <Instructions data={list.data} />}
|
||||
<EvidenceSources workspace={workspace} sources={list.data?.source_reviews ?? []} />
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid flex-1 gap-1 text-sm">Search Evidence<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder="Title, identifier or content" /></label>
|
||||
<Select label="Kind" value={filters.kind} values={kinds} onChange={v => filter("kind", v)} />
|
||||
<Select label="Purpose" value={filters.purpose} values={["disambiguation", "rewriting", "schema_linking", "sql_generation"]} onChange={v => filter("purpose", v)} />
|
||||
<Select label="Status" value={filters.status} values={Object.keys(states)} labels={states} onChange={v => filter("status", v)} />
|
||||
<Button variant="outline" disabled={list.isFetching || detail.isFetching} onClick={() => { void list.refetch(); if (selected) void detail.refetch(); }}>Refresh files</Button>
|
||||
</div>
|
||||
<details><summary className="cursor-pointer text-sm font-medium">More filters and sorting</summary>
|
||||
<div className="mt-3 flex flex-wrap gap-3">{["concept", "table", "column", "source"].map(key => <label key={key} className="grid gap-1 text-sm capitalize">{key}<input className={control} value={filters[key] ?? ""} onChange={e => filter(key, e.target.value)} /></label>)}
|
||||
<Select label="Sort by" value={filters.sort} values={["title", "id", "kind", "status"]} onChange={v => filter("sort", v)} all={false} />
|
||||
<Select label="Order" value={filters.direction} values={["asc", "desc"]} labels={{ asc: "Ascending", desc: "Descending" }} onChange={v => filter("direction", v)} all={false} />
|
||||
</div></details>
|
||||
{list.isError && <p role="alert">{apiErrorMessage(list.error)}</p>}
|
||||
{list.isPending && <p role="status">Loading Evidence files…</p>}
|
||||
{list.data?.errors.map(error => <div key={error.file} role="alert" className="rounded-md border border-destructive/40 p-3 text-sm"><strong>{error.file}</strong><p>{error.message}</p><p>Correct this file before consolidation.</p></div>)}
|
||||
<div className="grid min-h-0 gap-6 xl:grid-cols-[minmax(20rem,2fr)_minmax(25rem,3fr)]">
|
||||
<section aria-label="Evidence list" className="min-w-0">
|
||||
<p className="mb-2 text-sm text-muted-foreground">{list.data?.total ?? 0} Evidence units</p>
|
||||
<div className="overflow-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-muted-foreground"><th className="py-2 pr-3 font-medium">Evidence</th><th className="pr-3 font-medium">Kind</th><th className="font-medium">Status</th></tr></thead>
|
||||
<tbody>{list.data?.items.map(item => <tr key={item.id} className={`border-b ${selected === item.id ? "bg-muted" : ""}`}><td className="py-3 pr-3"><button className="text-left font-medium underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" onClick={() => setSelected(item.id)}>{item.title}</button><div className="mt-1 break-all text-xs text-muted-foreground">{item.id}</div></td><td className="pr-3 align-top pt-3">{item.kind}</td><td className="align-top pt-3">{states[item.status]}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
{list.data?.total === 0 && <p className="py-6 text-sm text-muted-foreground">No Evidence matches these filters. Clear the filters or use the file instructions to create a unit.</p>}
|
||||
<nav aria-label="Evidence pages" className="mt-4 flex items-center gap-3"><Button variant="outline" size="sm" disabled={page === 1 || list.isFetching} onClick={() => setPage(p => p-1)}>Previous</Button><span className="text-sm">Page {page}</span><Button variant="outline" size="sm" disabled={!list.data || page*25 >= list.data.total || list.isFetching} onClick={() => setPage(p => p+1)}>Next</Button></nav>
|
||||
</section>
|
||||
<section aria-label="Evidence detail" className="min-w-0">
|
||||
{detail.isError ? <p role="alert">{apiErrorMessage(detail.error)}</p> : selected && detail.isPending ? <p role="status">Loading Evidence…</p> : detail.data?.item ? <UnitDetail item={detail.data.item} location={detail.data.location} /> : <p className="py-10 text-sm text-muted-foreground">Select an Evidence unit to read its complete content and provenance.</p>}
|
||||
</section>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Select({ label, value, values, labels = {}, onChange, all = true }: { label: string; value: string; values: string[]; labels?: Record<string, string>; onChange: (value: string) => void; all?: boolean }) {
|
||||
return <label className="grid gap-1 text-sm">{label}<select className={control} value={value} onChange={e => onChange(e.target.value)}>{all && <option value="">All</option>}{values.map(v => <option key={v} value={v}>{labels[v] ?? v.replaceAll("_", " ")}</option>)}</select></label>;
|
||||
}
|
||||
|
||||
function Instructions({ data }: { data: EvidencePage }) {
|
||||
const [kind, setKind] = useState("domain");
|
||||
const example = `---\nschema_version: 4\nid: evidence:my-unit\nkind: ${kind}\nlanguage: en\npurposes: [sql_generation]\n---\n\n# My Evidence\n\n${templates[kind]}\n`;
|
||||
return <div className="border-b pb-5">
|
||||
<p className="mb-2 text-sm font-medium">{data.pending_revision ? "Consolidation is incomplete. The previous active content remains available." : data.active_revision ? "The core uses the last successfully consolidated version." : "Run the first consolidation to convert and activate the local archive."}</p>
|
||||
{data.location.workspace ? <><p className="mb-1 text-xs text-muted-foreground">Workspace on the installation host</p><CopyText text={data.location.workspace} label="workspace path" /></> : <p role="alert" className="text-sm">A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.</p>}
|
||||
<details className="mt-3"><summary className="cursor-pointer text-sm font-medium">Edit files, consolidate and commit</summary>
|
||||
<ol className="mt-3 max-w-[75ch] list-decimal space-y-3 pl-5 text-sm">
|
||||
<li>Edit the files under <code>evidence/curated/<kind>/</code> with your preferred editor. Keep existing IDs. Remove a unit's file to delete it; keep the curated directory.</li>
|
||||
<li>Check Git status and the diff. Saving a file does not update recall.
|
||||
{data.location.git_commands.slice(0, 2).map(command => <div className="mt-2" key={command}><CopyText text={command} label="Git inspection command" /></div>)}</li>
|
||||
<li>From the installation directory, run this command. It validates and activates Evidence without a full preprocessing run. If needed, add <code>--installation /absolute/path/thothii-installation.yaml</code> immediately after <code>tht</code>.
|
||||
<div className="mt-2"><CopyText text={data.location.command} label="consolidation command" /></div></li>
|
||||
<li>After success, review the generated metadata and complete Git commit/push manually. These commands use a POSIX shell on the installation host. A remote host's path is not a file on this browser's computer.
|
||||
{data.location.git_commands.slice(2).map(command => <div className="mt-2" key={command}><CopyText text={command} label="Git publication command" /></div>)}</li>
|
||||
</ol>
|
||||
<p className="mt-3 text-sm text-muted-foreground">Source drafts are preserved under evidence/source/. Maintain the refined files under evidence/curated/. New source acquisition and refresh are not part of consolidation.</p>
|
||||
<details className="mt-4"><summary className="cursor-pointer text-sm font-medium">Create a manual Evidence file</summary><div className="mt-3"><Select label="Example kind" value={kind} values={kinds} onChange={setKind} all={false} /><p className="my-2 text-sm">Save as <code>evidence/curated/{kind}/my-unit.md</code> with a unique ID. Replace the example with your domain knowledge. The system records manual provenance.</p><pre className="overflow-auto rounded-md bg-muted p-3 text-xs">{example}</pre><CopyText text={example} label="Markdown example" showText={false} /></div></details>
|
||||
</details>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UnitDetail({ item, location }: { item: EvidenceUnit; location: EvidencePage["location"] }) {
|
||||
const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance;
|
||||
const file = location.workspace ? `${location.workspace.replace(/\\/g, "/")}/evidence/${item.file}` : null;
|
||||
return <article className="max-w-[75ch] space-y-5 pb-8">
|
||||
<header><p className="text-xs text-muted-foreground">{states[item.status]} · {item.purposes.join(", ")}</p><h2 className="mt-1 font-serif text-xl font-semibold">{item.title}</h2></header>
|
||||
{file && <CopyText text={file} label="Evidence file path" />}
|
||||
{item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? "The file is invalid or removed. Its last active content is shown here." : "This is the file version awaiting consolidation."} The core continues to use its last active version.</p>}
|
||||
{Object.entries(item.applies_to).some(([, values]) => values.length > 0) && <section><h3 className="font-medium">Scope</h3>{Object.entries(item.applies_to).map(([name, values]) => values.length > 0 && <p className="mt-2 break-words text-sm" key={name}><span className="capitalize">{name}</span>: {values.join(", ")}</p>)}</section>}
|
||||
{Object.entries(item.payload).map(([field, value]) => <section key={field}><h3 className="mb-2 text-sm font-semibold capitalize">{field.replaceAll("_", " ")}</h3>{typeof value === "string" ? field === "sql" ? <pre className="overflow-auto rounded-md bg-muted p-3 text-sm">{value}</pre> : <MarkdownView source={value} /> : Array.isArray(value) ? <ul className="list-inside list-disc text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="space-y-2 text-sm">{Object.entries(value).map(([key, text]) => <div key={key}><dt className="font-mono font-medium">{key || "(empty value)"}</dt><dd>{text}</dd></div>)}</dl>}</section>)}
|
||||
<section className="border-t pt-4"><h3 className="font-medium">Provenance</h3><p className="mt-2 text-sm">{item.provenance.kind === "manual" ? `Manual declaration by ${item.provenance.declared_by}.` : "Derived from a source document."}</p>
|
||||
{source?.source_file && <><p className="mt-2 break-all text-xs">{item.provenance.kind === "manual" ? "Original document (lineage): " : "Source: "}{source.source_file}</p>{source.supporting_excerpts?.map((text, i) => <p key={i} className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">“{text}”</p>)}</>}
|
||||
</section>
|
||||
{item.review_items.length > 0 && <section><h3 className="font-medium">Review required</h3>{item.review_items.map((review, i) => <p key={i} className="mt-2 text-sm">{review.message}</p>)}</section>}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { actOnEvidenceSources, type EvidenceSourceAction, type EvidenceSourceReview, type EvidenceUnit } from "../api/evidence";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { MarkdownView } from "../viewers/MarkdownView";
|
||||
|
||||
export function EvidenceSources({ workspace, sources }: {workspace: string; sources: EvidenceSourceReview[]}) {
|
||||
const client = useQueryClient();
|
||||
const [notice, setNotice] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const action = useMutation({
|
||||
mutationFn: (input: EvidenceSourceAction) => actOnEvidenceSources(workspace, input),
|
||||
onSuccess: result => setNotice(result.warnings?.join(" ") ?? "Source operation completed."),
|
||||
onSettled: () => { void client.invalidateQueries({queryKey: ["evidence"]}); void client.invalidateQueries({queryKey: ["evidence-detail"]}); },
|
||||
});
|
||||
const pending = sources.filter(s => s.status === "review" || s.status === "applying");
|
||||
const settled = sources.filter(s => s.status !== "review" && s.status !== "applying");
|
||||
const visible = showAll ? [...pending, ...settled] : pending;
|
||||
return <section aria-label="Evidence sources" className="rounded-md border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><h2 className="font-serif text-lg font-semibold">Sources and imports</h2>
|
||||
<p className="mt-1 max-w-[75ch] text-sm text-muted-foreground">Place new Markdown drafts in the workspace’s <code>evidence/incoming/</code> folder. Import or refresh to acquire those drafts, original local documents and configured remote sources. Review changes before they enter recall.</p></div>
|
||||
<Button variant="outline" disabled={action.isPending || sources.some(s => s.status === "applying")}
|
||||
onClick={() => {setNotice(""); action.mutate({action: "refresh"});}}>Import or refresh sources</Button>
|
||||
</div>
|
||||
{action.isPending && <p role="status" className="mt-3 text-sm">{action.variables.action === "refresh" ? "Acquiring and refining sources. This may take a few minutes…" : "Saving the decision and updating the local index…"}</p>}
|
||||
{action.isError && <p role="alert" className="mt-3 text-sm text-destructive">{action.error.message}</p>}
|
||||
{notice && !action.isPending && <p role="status" className="mt-3 text-sm">{notice}</p>}
|
||||
{pending.length > 0 && <p className="mt-4 text-sm font-medium">{pending.length} source comparison{pending.length === 1 ? "" : "s"} to review</p>}
|
||||
{settled.length > 0 && <Button variant="ghost" size="sm" className="mt-3" aria-expanded={showAll} onClick={() => setShowAll(v => !v)}>
|
||||
{showAll ? "Hide decided sources" : `Show ${settled.length} unchanged or decided sources`}</Button>}
|
||||
{visible.length > 0 && <div className="mt-3 space-y-3">{visible.map(source => <details key={source.id} className="rounded-md border p-3">
|
||||
<summary className="cursor-pointer break-all text-sm"><span className="font-medium">{source.uri}</span><span className="ml-2 text-muted-foreground">{source.status === "review" ? "Review changes" : source.status === "applying" ? "Activation incomplete" : source.status === "kept" ? "Local curation retained" : "Source accepted"}</span></summary>
|
||||
{source.availability === "missing" && <p className="mt-3 text-sm">This source was absent from the latest successful acquisition. Existing Evidence has been retained; absence does not delete it.</p>}
|
||||
{(source.status === "review" || source.status === "applying") && <>
|
||||
<p className="mt-3 text-sm text-muted-foreground">The last active version remains in recall until a decision is successfully indexed. Keeping local content records a manual declaration; using the proposal adopts the acquired source.</p>
|
||||
{source.suppressed && <p className="mt-2 text-sm">Previous deletions are protected. Newly generated units from this source have been suppressed.</p>}
|
||||
<div className="mt-4 grid gap-5 lg:grid-cols-2"><div><h3 className="font-semibold">Current local Evidence</h3>
|
||||
{(source.current?.length ?? 0) === 0 && <p className="mt-2 text-sm text-muted-foreground">No existing units.</p>}
|
||||
{source.current?.map(unit => <SourceUnit key={unit.id} unit={unit} />)}</div>
|
||||
<div><h3 className="font-semibold">Proposed Evidence</h3>
|
||||
{source.proposed.length === 0 && <p className="mt-2 text-sm text-muted-foreground">No replacement units were proposed.</p>}
|
||||
{source.proposed.map(unit => <SourceUnit key={unit.id} unit={unit} />)}
|
||||
{!!source.removed_ids?.length && <p className="mt-3 text-sm">Using this proposal will retire: {source.removed_ids.join(", ")}</p>}
|
||||
</div></div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">{(["keep", "replace"] as const).filter(d => source.status !== "applying" || d === source.decision).map(decision => <Button key={decision} variant={decision === "keep" ? "outline" : "default"}
|
||||
disabled={action.isPending || (decision === "replace" && source.proposed.some(u => u.review_items.length > 0))}
|
||||
onClick={() => {setNotice(""); action.mutate({action: "decide", sourceId: source.id, revision: source.revision, decision});}}>
|
||||
{source.status === "applying" ? "Retry saved decision" : decision === "keep" ? "Keep local Evidence" : "Use proposed Evidence"}</Button>)}</div>
|
||||
</>}
|
||||
</details>)}</div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function SourceUnit({ unit }: {unit: EvidenceUnit}) {
|
||||
const origin = unit.provenance.kind === "manual" ? unit.provenance.original : unit.provenance;
|
||||
return <article className="mt-3 min-w-0 border-t pt-3">
|
||||
<h4 className="font-medium">{unit.title}</h4><p className="mt-1 break-all text-xs text-muted-foreground">{unit.id} · {unit.kind} · {unit.purposes.join(", ")}</p>
|
||||
{Object.entries(unit.applies_to).map(([key, values]) => values.length > 0 && <p key={key} className="mt-2 break-all text-sm">{key}: {values.join(", ")}</p>)}
|
||||
{Object.entries(unit.payload).map(([key, value]) => <section key={key} className="mt-3"><h5 className="text-sm font-medium capitalize">{key.replaceAll("_", " ")}</h5>
|
||||
{typeof value === "string" ? key === "sql" ? <pre className="overflow-auto rounded bg-muted p-2 text-sm">{value}</pre> : <MarkdownView source={value} /> : Array.isArray(value) ? <ul className="list-inside list-disc text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="text-sm">{Object.entries(value).map(([k,v]) => <div key={k}><dt className="font-mono">{k || "(empty value)"}</dt><dd>{v}</dd></div>)}</dl>}</section>)}
|
||||
<p className="mt-3 text-xs text-muted-foreground">{unit.provenance.kind === "manual" ? `Manual declaration by ${unit.provenance.declared_by}. Original excerpts:` : "Source excerpts:"}</p>
|
||||
{origin?.supporting_excerpts?.map((excerpt, i) => <blockquote key={i} className="mt-2 border-l-2 pl-3 text-sm text-muted-foreground">{excerpt}</blockquote>)}
|
||||
{unit.review_items.map((r,i) => <p key={i} className="mt-2 text-sm text-destructive">{r.message}</p>)}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/msw";
|
||||
import { MemoryManagementPage } from "./MemoryManagementPage";
|
||||
import type { MemoryCard } from "../api/memory";
|
||||
|
||||
const id = "mem-11111111-1111-4111-8111-111111111111";
|
||||
const fixture: MemoryCard = { id, workspace_id: "sales", family: "domain_clarification", subject: "Order",
|
||||
detail: "All lines shipped", scope: "Sales", rationale: "", question: "", sql: "", concepts: [],
|
||||
dependencies: [], links: [], origin: "manual", session_id: null, decision_seq: null,
|
||||
created_at: "2026-09-08T10:00:00Z", updated_at: "2026-09-08T10:00:00Z", revision: "r", indexed: true };
|
||||
|
||||
function renderPage() {
|
||||
return render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<MemoryManagementPage canManage onDirtyChange={() => undefined} />
|
||||
</QueryClientProvider>);
|
||||
}
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get("/api/workspaces", () => HttpResponse.json([{ id: "sales", displayName: "Sales workspace" }])),
|
||||
http.get("/api/workspaces/sales/memory", () => HttpResponse.json({ items: [fixture], total: 1, page: 1, page_size: 25 })),
|
||||
http.get("/api/workspaces/sales/memory/pending", () => HttpResponse.json([])),
|
||||
http.get(`/api/workspaces/sales/memory/${id}`, () => HttpResponse.json(fixture)),
|
||||
);
|
||||
});
|
||||
async function chooseWorkspace() {
|
||||
await screen.findByRole("option", { name: "Sales workspace" });
|
||||
await userEvent.selectOptions(screen.getByLabelText("Workspace"), "sales");
|
||||
}
|
||||
|
||||
test("reads complete content and cancels unsaved edits", async () => {
|
||||
renderPage(); await chooseWorkspace();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Order Domain clarification/ }));
|
||||
expect(await screen.findByText("All lines shipped")).toBeVisible();
|
||||
expect(screen.getByText("Created manually")).toBeVisible();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit card" }));
|
||||
await userEvent.clear(screen.getByLabelText("Title"));
|
||||
await userEvent.type(screen.getByLabelText("Title"), "Changed");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.getByRole("heading", { name: "Order" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("creates a manual card and explains incomplete indexing", async () => {
|
||||
let body: unknown;
|
||||
server.use(http.post("/api/workspaces/sales/memory", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id, saved: true, indexed: false, action: "upsert", card: { ...fixture, indexed: false } });
|
||||
}));
|
||||
renderPage(); await chooseWorkspace();
|
||||
await userEvent.click(screen.getByRole("button", { name: "New card" }));
|
||||
await userEvent.type(screen.getByLabelText("Title"), "Order");
|
||||
await userEvent.type(screen.getByLabelText("Scope"), "Sales");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
||||
expect(await screen.findByText(/previous content is excluded from recall/)).toBeVisible();
|
||||
expect(body).toMatchObject({ family: "domain_clarification", subject: "Order", scope: "Sales", concepts: [] });
|
||||
expect(body).not.toHaveProperty("session_id");
|
||||
});
|
||||
|
||||
test("validation errors preserve the draft", async () => {
|
||||
server.use(http.post("/api/workspaces/sales/memory", () => HttpResponse.json({ code: "memory_invalid" }, { status: 400 })));
|
||||
renderPage(); await chooseWorkspace();
|
||||
await userEvent.click(screen.getByRole("button", { name: "New card" }));
|
||||
await userEvent.type(screen.getByLabelText("Title"), "Keep my title");
|
||||
await userEvent.type(screen.getByLabelText("Scope"), "Sales");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Check the card fields");
|
||||
expect(screen.getByLabelText("Title")).toHaveValue("Keep my title");
|
||||
});
|
||||
|
||||
test("deleted cards remain reachable through pending index recovery", async () => {
|
||||
let pending = true;
|
||||
server.use(
|
||||
http.get("/api/workspaces/sales/memory", () => HttpResponse.json({ items: [], total: 0, page: 1, page_size: 25 })),
|
||||
http.get("/api/workspaces/sales/memory/pending", () => HttpResponse.json(pending ? [{ card_id: id, action: "delete", error: "pending" }] : [])),
|
||||
http.post(`/api/workspaces/sales/memory/${id}/retry`, () => {
|
||||
pending = false; return HttpResponse.json({ id, saved: true, indexed: true, action: "delete" });
|
||||
}),
|
||||
);
|
||||
renderPage(); await chooseWorkspace();
|
||||
const panel = await screen.findByRole("region", { name: "Pending index updates" });
|
||||
await userEvent.click(await within(panel).findByRole("button", { name: "Retry index update" }));
|
||||
expect(await screen.findByText("Card deleted and removed from recall.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unavailable archive is an error rather than an empty archive", async () => {
|
||||
server.use(http.get("/api/workspaces/sales/memory", () => HttpResponse.json({ code: "memory_unavailable" }, { status: 503 })));
|
||||
renderPage(); await chooseWorkspace();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Memory archive is unavailable");
|
||||
expect(screen.queryByText(/No cards match/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("search and combined filters query the complete archive", async () => {
|
||||
server.use(http.get("/api/workspaces/sales/memory", ({ request }) => {
|
||||
const query = new URL(request.url).searchParams;
|
||||
const match = query.get("q") === "refund" && query.get("family") === "sql_rule";
|
||||
return HttpResponse.json({ items: match ? [{ ...fixture, subject: "Refund rule", family: "sql_rule" }] : [], total: match ? 1 : 0, page: 1, page_size: 25 });
|
||||
}));
|
||||
renderPage(); await chooseWorkspace();
|
||||
await userEvent.type(screen.getByLabelText("Search cards"), "refund");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Filter by family" }), "sql_rule");
|
||||
expect(await screen.findByRole("button", { name: /Refund rule SQL rule/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("links and dependencies are saved together and links can be removed", async () => {
|
||||
const created = "mem-22222222-2222-4222-8222-222222222222";
|
||||
let saved: Record<string, unknown> = {};
|
||||
const respond = async (request: Request) => {
|
||||
saved = await request.json();
|
||||
return HttpResponse.json({ id: created, saved: true, indexed: true, action: "upsert",
|
||||
card: { ...fixture, ...saved, id: created } });
|
||||
};
|
||||
server.use(http.post("/api/workspaces/sales/memory", ({ request }) => respond(request)),
|
||||
http.put(`/api/workspaces/sales/memory/${created}`, ({ request }) => respond(request)));
|
||||
renderPage(); await chooseWorkspace();
|
||||
await userEvent.click(screen.getByRole("button", { name: "New card" }));
|
||||
await userEvent.type(screen.getByLabelText("Title"), "Related knowledge");
|
||||
await userEvent.type(screen.getByLabelText("Scope"), "Sales");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add dependency" }));
|
||||
await userEvent.type(screen.getByLabelText("Dependency 1 database"), "dwh");
|
||||
await userEvent.type(screen.getByLabelText("Search cards to link"), "Order");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Link Order" }));
|
||||
await userEvent.type(screen.getByLabelText("Link meaning"), "Explains");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
||||
await screen.findByRole("button", { name: "Edit card" });
|
||||
expect(saved.links).toEqual([{ target_id: id, meaning: "Explains" }]);
|
||||
expect(saved.dependencies).toEqual([{ database: "dwh", schema_name: "", table: "", column: "" }]);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit card" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Remove link 1" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
||||
await screen.findByRole("button", { name: "Edit card" });
|
||||
expect(saved.links).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useState, type FormEvent, type InputHTMLAttributes } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { apiErrorMessage, apiFetch } from "../api/client";
|
||||
import {
|
||||
deleteMemory, getMemory, listMemory, memoryInput, pendingMemory, retryMemory, saveMemory,
|
||||
type MemoryCard, type MemoryFamily, type MemoryInput, type MemoryOutcome,
|
||||
} from "../api/memory";
|
||||
import { useAuthGeneration } from "../auth/authState";
|
||||
|
||||
const families: Record<MemoryFamily, string> = {
|
||||
domain_clarification: "Domain clarification", sql_rule: "SQL rule",
|
||||
solved_question: "Solved question", explained_error: "Explained error",
|
||||
};
|
||||
const fieldLabels = { detail: "Content", scope: "Scope", rationale: "Rationale",
|
||||
question: "Question / context", sql: "Approved SQL" };
|
||||
const control = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50";
|
||||
function Input(props: InputHTMLAttributes<HTMLInputElement>) { return <input {...props} className={`${control} ${props.className ?? ""}`} />; }
|
||||
const emptyCard = (): MemoryInput => ({ family: "domain_clarification", subject: "", detail: "",
|
||||
scope: "", rationale: "", question: "", sql: "", concepts: [], dependencies: [], links: [] });
|
||||
|
||||
export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange }: {
|
||||
canManage: boolean; onDirtyChange: (dirty: boolean) => void; onBusyChange?: (busy: boolean) => void;
|
||||
}) {
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const generation = useAuthGeneration();
|
||||
const workspaces = useQuery({ queryKey: ["memory-workspaces", generation],
|
||||
queryFn: () => apiFetch<Array<{ id: string; displayName?: string }>>("/workspaces"), enabled: canManage });
|
||||
useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false); }, [dirty, onDirtyChange]);
|
||||
useEffect(() => { onBusyChange?.(busy); return () => onBusyChange?.(false); }, [busy, onBusyChange]);
|
||||
if (!canManage) return <main aria-label="Memory management" className="p-6">Memory administration is not permitted.</main>;
|
||||
return <main aria-label="Memory management" className="flex min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||
<header className="flex shrink-0 flex-wrap items-end justify-between gap-4 border-b px-6 py-5">
|
||||
<div><h1 className="font-serif text-2xl font-semibold">Memory management</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Curate reusable knowledge for each workspace.</p></div>
|
||||
<label className="grid min-w-52 gap-1 text-sm font-medium">Workspace
|
||||
<select aria-label="Workspace" disabled={busy} className={control} value={workspace} onChange={event => {
|
||||
if (dirty && !window.confirm("Discard unsaved Memory changes?")) return;
|
||||
setDirty(false); setWorkspace(event.target.value);
|
||||
}}><option value="">Choose a workspace</option>
|
||||
{workspaces.data?.map(w => <option value={w.id} key={w.id}>{w.displayName ?? w.id}</option>)}
|
||||
</select></label>
|
||||
</header>
|
||||
{workspaces.isError && <p role="alert" className="p-6">{apiErrorMessage(workspaces.error)}</p>}
|
||||
{workspace ? <MemoryWorkspace key={`${generation}:${workspace}`} workspace={workspace} onDirtyChange={setDirty} onBusyChange={setBusy} />
|
||||
: <p className="p-8 text-muted-foreground">Choose a workspace to browse its knowledge or create a Memory card.</p>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
|
||||
workspace: string; onDirtyChange: (dirty: boolean) => void; onBusyChange: (busy: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({ q: "", family: "", sort: "updated_at", direction: "desc" });
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState<MemoryCard | null>(null);
|
||||
const [draft, setDraft] = useState<MemoryInput | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [linkSearch, setLinkSearch] = useState("");
|
||||
const list = useQuery({ queryKey: ["memory", workspace, filters, page],
|
||||
queryFn: () => listMemory(workspace, { ...filters, page, page_size: 25 }) });
|
||||
const pending = useQuery({ queryKey: ["memory-pending", workspace], queryFn: () => pendingMemory(workspace) });
|
||||
const linkOptions = useQuery({ queryKey: ["memory-links", workspace, linkSearch],
|
||||
queryFn: () => listMemory(workspace, { q: linkSearch, page_size: 15 }),
|
||||
enabled: draft !== null && linkSearch.trim().length > 0 });
|
||||
const dirty = draft !== null && (!selected || JSON.stringify(draft) !== JSON.stringify(memoryInput(selected)));
|
||||
useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false); }, [dirty, onDirtyChange]);
|
||||
|
||||
function leaveDraft() { return !dirty || window.confirm("Discard unsaved Memory changes?"); }
|
||||
useEffect(() => { onBusyChange(busy); return () => onBusyChange(false); }, [busy, onBusyChange]);
|
||||
function filter(key: string, value: string) { setFilters(old => ({ ...old, [key]: value })); setPage(1); }
|
||||
function update<K extends keyof MemoryInput>(key: K, value: MemoryInput[K]) {
|
||||
setDraft(old => old ? { ...old, [key]: value } : old);
|
||||
}
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memory", workspace] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["memory-pending", workspace] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["memory-links", workspace] }),
|
||||
]);
|
||||
}
|
||||
async function open(id: string) {
|
||||
if (busy || !leaveDraft()) return;
|
||||
setBusy(true); setError(""); setDeleteConfirm(false);
|
||||
try { const card = await getMemory(workspace, id); setSelected(card); setDraft(null); }
|
||||
catch (e) { setError(apiErrorMessage(e)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function mutate(action: () => Promise<MemoryOutcome>) {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const outcome = await action();
|
||||
setNotice(outcome.indexed
|
||||
? outcome.action === "delete" ? "Card deleted and removed from recall." : "Saved and indexed."
|
||||
: outcome.action === "delete" ? "Card deleted. Index cleanup is incomplete; retry below."
|
||||
: "Saved. Index update is incomplete; retry below. The previous content is excluded from recall.");
|
||||
if (outcome.action === "delete" && outcome.id === selected?.id) { setSelected(null); setDraft(null); }
|
||||
else if (outcome.card) { setSelected(outcome.card); setDraft(null); }
|
||||
setDeleteConfirm(false); await refresh();
|
||||
} catch (e) { setError(apiErrorMessage(e)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
function save(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (draft) void mutate(() => saveMemory(workspace, {
|
||||
...draft, concepts: draft.concepts.map(c => c.trim()).filter(Boolean),
|
||||
}, selected?.id));
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className="flex shrink-0 flex-wrap items-end gap-3 px-6 py-4">
|
||||
<label className="grid min-w-52 flex-1 gap-1 text-sm">Search cards
|
||||
<Input value={filters.q} onChange={e => filter("q", e.target.value)} placeholder="Text or card ID" /></label>
|
||||
<label className="grid gap-1 text-sm">Family<select aria-label="Filter by family" className={control} value={filters.family} onChange={e => filter("family", e.target.value)}>
|
||||
<option value="">All families</option>{Object.entries(families).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select></label>
|
||||
<label className="grid gap-1 text-sm">Sort by<select aria-label="Sort by" className={control} value={filters.sort} onChange={e => filter("sort", e.target.value)}>
|
||||
<option value="updated_at">Updated</option><option value="created_at">Created</option><option value="subject">Title</option><option value="family">Family</option>
|
||||
</select></label>
|
||||
<Button variant="outline" onClick={() => filter("direction", filters.direction === "desc" ? "asc" : "desc")}>{filters.direction === "desc" ? "Descending" : "Ascending"}</Button>
|
||||
<Button disabled={busy} onClick={() => { if (!leaveDraft()) return; setSelected(null); setDraft(emptyCard()); setError(""); setNotice(""); }}>New card</Button>
|
||||
</div>
|
||||
<details className="shrink-0 border-b px-6 pb-4"><summary className="cursor-pointer text-sm font-medium">More filters</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
{(["concept", "database", "table", "column"] as const).map(key => <label className="grid gap-1 text-sm capitalize" key={key}>{key}
|
||||
<Input value={filters[key] ?? ""} onChange={e => filter(key, e.target.value)} /></label>)}
|
||||
<label className="grid gap-1 text-sm">Origin<select aria-label="Origin" className={control} value={filters.origin ?? ""} onChange={e => filter("origin", e.target.value)}>
|
||||
<option value="">All origins</option><option value="manual">Manual</option><option value="workflow">Workflow</option></select></label>
|
||||
{(["updated_after", "updated_before"] as const).map(key => <label className="grid gap-1 text-sm" key={key}>{key === "updated_after" ? "Updated from" : "Updated until"}
|
||||
<Input type="date" onChange={e => filter(key, e.target.value ? `${e.target.value}T${key === "updated_before" ? "23:59:59" : "00:00:00"}Z` : "")} /></label>)}
|
||||
</div>
|
||||
</details>
|
||||
{notice && <p role="status" className="border-b bg-muted/50 px-6 py-3 text-sm">{notice}</p>}
|
||||
{error && <p role="alert" className="px-6 py-3 text-sm text-destructive">{error}</p>}
|
||||
<div className="grid min-h-72 shrink-0 grid-cols-1 lg:grid-cols-[minmax(260px,0.8fr)_minmax(360px,1.2fr)]">
|
||||
<section aria-label="Memory cards" className="min-w-0 border-b lg:border-r lg:border-b-0">
|
||||
{list.isPending && <p role="status" className="p-6 text-sm">Loading Memory cards…</p>}
|
||||
{list.isError && <div role="alert" className="p-6"><p>{apiErrorMessage(list.error)}</p><Button variant="outline" onClick={() => void list.refetch()}>Retry loading</Button></div>}
|
||||
{list.data && <>
|
||||
<p className="px-6 py-3 text-xs text-muted-foreground">{list.data.total} {list.data.total === 1 ? "card" : "cards"}</p>
|
||||
{list.data.items.length === 0 && <p className="px-6 py-8 text-sm text-muted-foreground">No cards match these filters. Adjust the filters or create a card.</p>}
|
||||
<ul>{list.data.items.map(card => <li key={card.id} className="border-b">
|
||||
<button disabled={busy} aria-current={selected?.id === card.id ? "true" : undefined} onClick={() => void open(card.id)}
|
||||
className={`w-full px-6 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 ${selected?.id === card.id ? "bg-muted" : ""}`}>
|
||||
<span className="block break-words text-sm font-semibold">{card.subject}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{families[card.family]} · {card.origin === "manual" ? "Manual" : "Workflow"}</span>
|
||||
<span className="mt-2 block text-xs">{card.indexed ? "Index up to date" : "Index update pending"}</span>
|
||||
</button></li>)}</ul>
|
||||
<nav aria-label="Memory pagination" className="flex items-center justify-between gap-2 p-4">
|
||||
<Button variant="outline" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Previous</Button>
|
||||
<span className="text-xs">Page {page} of {Math.max(1, Math.ceil(list.data.total / 25))}</span>
|
||||
<Button variant="outline" disabled={page * 25 >= list.data.total} onClick={() => setPage(p => p + 1)}>Next</Button>
|
||||
</nav>
|
||||
</>}
|
||||
</section>
|
||||
<section aria-label="Memory detail" className="min-w-0 px-6 py-5">
|
||||
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">Select a card to inspect its content, scope and provenance.</p>}
|
||||
{selected && !draft && <div className="space-y-5">
|
||||
<div><p className="text-xs text-muted-foreground">{families[selected.family]}</p><h2 className="mt-1 break-words font-serif text-xl font-semibold">{selected.subject}</h2></div>
|
||||
<dl className="space-y-4 text-sm">
|
||||
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => selected[k]).map(key => <div key={key}>
|
||||
<dt className="mb-1 font-semibold capitalize">{key === "detail" ? "Content" : key === "sql" ? "Approved SQL" : key}</dt>
|
||||
<dd className={`max-w-[72ch] whitespace-pre-wrap break-words ${key === "sql" ? "font-mono text-xs" : ""}`}>{selected[key]}</dd></div>)}
|
||||
<div><dt className="font-semibold">Provenance</dt><dd>{selected.origin === "manual" ? "Created manually" : `Workflow session ${selected.session_id}`}</dd></div>
|
||||
{selected.decision_seq !== null && <div><dt className="font-semibold">Source decision</dt><dd>{selected.decision_seq}</dd></div>}
|
||||
<div><dt className="font-semibold">Card ID</dt><dd className="break-all font-mono text-xs">{selected.id}</dd></div>
|
||||
<div><dt className="font-semibold">Updated</dt><dd>{new Date(selected.updated_at).toLocaleString()}</dd></div>
|
||||
<div><dt className="font-semibold">Created</dt><dd>{new Date(selected.created_at).toLocaleString()}</dd></div>
|
||||
{selected.concepts.length > 0 && <div><dt className="font-semibold">Concepts</dt><dd>{selected.concepts.join(", ")}</dd></div>}
|
||||
{selected.dependencies.length > 0 && <div><dt className="font-semibold">Schema dependencies</dt><dd><ul>{selected.dependencies.map((d, i) => <li key={i}>{[d.database, d.schema_name, d.table, d.column].filter(Boolean).join(" / ")}</li>)}</ul></dd></div>}
|
||||
{selected.links.length > 0 && <div><dt className="font-semibold">Linked cards</dt><dd><ul>{selected.links.map(link => <li key={link.target_id} className="my-2"><button className="text-left text-primary underline" onClick={() => void open(link.target_id)}>{link.meaning}</button></li>)}</ul></dd></div>}
|
||||
</dl>
|
||||
<div className="flex gap-2"><Button disabled={busy} onClick={() => setDraft(memoryInput(selected))}>Edit card</Button>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(true)}>Delete card</Button></div>
|
||||
{deleteConfirm && <div className="space-y-3 border-t pt-4"><p className="text-sm">Delete “{selected.subject}” and its links? Other cards will be kept.</p>
|
||||
<div className="flex gap-2"><Button variant="destructive" disabled={busy} onClick={() => void mutate(() => deleteMemory(workspace, selected.id))}>Confirm deletion</Button>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(false)}>Keep card</Button></div></div>}
|
||||
</div>}
|
||||
{draft && <form onSubmit={save} className="space-y-5">
|
||||
<h2 className="font-serif text-xl font-semibold">{selected ? "Edit card" : "New Memory card"}</h2>
|
||||
<fieldset disabled={busy} className="space-y-4">
|
||||
<label className="grid gap-1 text-sm font-medium">Family<select aria-label="Family" className={control} value={draft.family} onChange={e => {
|
||||
update("family", e.target.value as MemoryFamily); if (e.target.value !== "solved_question") update("sql", "");
|
||||
}}>{Object.entries(families).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></label>
|
||||
<label className="grid gap-1 text-sm font-medium">Title<Input required maxLength={1000} value={draft.subject} onChange={e => update("subject", e.target.value)} /></label>
|
||||
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => k !== "sql" || draft.family === "solved_question").map(key => <label key={key} className="grid gap-1 text-sm font-medium">{fieldLabels[key]}
|
||||
<textarea aria-label={fieldLabels[key]} className={control} rows={key === "sql" ? 6 : 3} value={draft[key]}
|
||||
required={key === "scope" || (draft.family === "solved_question" && (key === "sql" || key === "question")) || (draft.family === "explained_error" && (key === "detail" || key === "rationale"))}
|
||||
onChange={e => update(key, e.target.value)} /></label>)}
|
||||
<label className="grid gap-1 text-sm font-medium">Concepts (comma separated)<Input value={draft.concepts.join(",")} onChange={e => update("concepts", e.target.value.split(","))} /></label>
|
||||
<fieldset className="space-y-3"><legend className="mb-2 text-sm font-semibold">Schema dependencies</legend>
|
||||
{draft.dependencies.map((dep, i) => <div key={i} className="grid grid-cols-2 gap-2 border-b pb-3">
|
||||
{(["database", "schema_name", "table", "column"] as const).map(key => <label key={key} className="grid gap-1 text-xs">{key === "schema_name" ? "Schema" : key[0].toUpperCase()+key.slice(1)}
|
||||
<Input required={key === "database"} aria-label={`Dependency ${i + 1} ${key}`} value={dep[key]} onChange={e => update("dependencies", draft.dependencies.map((d, n) => n === i ? { ...d, [key]: e.target.value } : d))} /></label>)}
|
||||
<Button type="button" variant="ghost" onClick={() => update("dependencies", draft.dependencies.filter((_, n) => n !== i))}>Remove dependency {i + 1}</Button></div>)}
|
||||
<Button type="button" variant="outline" onClick={() => update("dependencies", [...draft.dependencies, { database: "", schema_name: "", table: "", column: "" }])}>Add dependency</Button>
|
||||
</fieldset>
|
||||
<fieldset className="space-y-3"><legend className="mb-2 text-sm font-semibold">Linked cards</legend>
|
||||
{draft.links.map((link, i) => <div key={link.target_id} className="space-y-2 border-b pb-3"><p className="break-all font-mono text-xs">{link.target_id}</p>
|
||||
<label className="grid gap-1 text-sm">Link meaning<Input required value={link.meaning} onChange={e => update("links", draft.links.map((l, n) => n === i ? { ...l, meaning: e.target.value } : l))} /></label>
|
||||
<Button type="button" variant="ghost" onClick={() => update("links", draft.links.filter((_, n) => n !== i))}>Remove link {i + 1}</Button></div>)}
|
||||
<label className="grid gap-1 text-sm">Search cards to link<Input value={linkSearch} onChange={e => setLinkSearch(e.target.value)} /></label>
|
||||
{linkOptions.isError && <p role="alert">{apiErrorMessage(linkOptions.error)}</p>}
|
||||
<ul>{linkOptions.data?.items.filter(c => c.id !== selected?.id && !draft.links.some(l => l.target_id === c.id)).map(c => <li key={c.id}><Button type="button" variant="ghost" onClick={() => { update("links", [...draft.links, { target_id: c.id, meaning: "" }]); setLinkSearch(""); }}>Link {c.subject}</Button></li>)}</ul>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
<footer className="flex gap-2 border-t pt-4"><Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save card"}</Button>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setDraft(null)}>Cancel</Button></footer>
|
||||
</form>}
|
||||
</section>
|
||||
</div>
|
||||
<section aria-label="Pending index updates" className="shrink-0 border-t bg-muted/30 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold">Pending index updates</h2>
|
||||
{pending.isError && <p role="alert" className="mt-2 text-sm">{apiErrorMessage(pending.error)}</p>}
|
||||
{pending.data?.length === 0 && <p className="mt-1 text-xs text-muted-foreground">All saved changes have been indexed.</p>}
|
||||
<ul>{pending.data?.map(p => <li key={p.card_id} className="flex flex-wrap items-center justify-between gap-3 border-b py-3 text-sm">
|
||||
<div><span>{p.action === "delete" ? "Deleted card: index cleanup pending" : "Saved card: index update pending"}</span><p className="mt-1 break-all font-mono text-xs text-muted-foreground">{p.card_id}</p></div>
|
||||
<Button variant="outline" disabled={busy || dirty} onClick={() => void mutate(() => retryMemory(workspace, p.card_id))}>Retry index update</Button>
|
||||
</li>)}</ul>
|
||||
</section>
|
||||
</>;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ interface Props {
|
||||
|
||||
const phases: CatalogSyncPhase[] = [
|
||||
"queued", "connecting", "scanning_tables", "scanning_columns",
|
||||
"scanning_relationships", "planning", "awaiting_confirmation", "applying", "completed",
|
||||
"scanning_relationships", "planning", "awaiting_confirmation", "applying", "memory_cleanup", "completed",
|
||||
];
|
||||
|
||||
const phaseEnteredByEvent: Record<string, CatalogSyncPhase> = {
|
||||
@@ -45,6 +45,7 @@ const phaseEnteredByEvent: Record<string, CatalogSyncPhase> = {
|
||||
confirmation_required: "awaiting_confirmation",
|
||||
confirmation_received: "awaiting_confirmation",
|
||||
applying: "applying",
|
||||
memory_cleanup: "memory_cleanup",
|
||||
succeeded: "completed",
|
||||
};
|
||||
|
||||
@@ -65,6 +66,7 @@ const phaseLabels: Record<CatalogSyncPhase, { pending: string; current: string;
|
||||
completed: "Changes confirmed",
|
||||
},
|
||||
applying: { pending: "Apply changes", current: "Applying changes", completed: "Changes applied" },
|
||||
memory_cleanup: { pending: "Clean dependent Memory", current: "Cleaning dependent Memory", completed: "Memory updated" },
|
||||
completed: { pending: "Complete", current: "Completing", completed: "Completed" },
|
||||
};
|
||||
|
||||
@@ -409,6 +411,7 @@ export function CatalogSyncDrawer({
|
||||
<div>
|
||||
<h3 className="font-semibold">Confirm {deletionCount} removals</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">The database will be scanned again before anything is removed.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Memory cards that depend on removed tables or columns will also be deleted, including their links and search entries.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mt-3 max-h-40 space-y-1 overflow-y-auto font-mono text-xs">
|
||||
@@ -419,6 +422,9 @@ export function CatalogSyncDrawer({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{run.counts.memoryDeleted !== undefined && <p className="mt-4 text-sm text-muted-foreground">
|
||||
Dependent Memory cards removed: {run.counts.memoryDeleted}
|
||||
</p>}
|
||||
{run.errorMessage ? (
|
||||
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{run.errorMessage}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ArchiveRepairWidget } from "./ArchiveRepairWidget";
|
||||
|
||||
const repair = { repair_id: "receipt", reason: "Conflicting order definitions", choice: null,
|
||||
status: "proposed", saved: false, indexed: false, can_apply: true, options: [
|
||||
{ id: "m", label: "Correct Memory", archive: "memory", target_id: "mem-1",
|
||||
before: { detail: "Before memory" }, content: { detail: "After memory" } },
|
||||
{ id: "e", label: "Correct Evidence", archive: "evidence", target_id: "evidence:order",
|
||||
before: { payload: { rule: "Before evidence" } }, content: { payload: { rule: "After evidence" } } },
|
||||
] };
|
||||
|
||||
test.each(["Memory", "Evidence"])("shows both contents and sends only the selected %s choice", async archive => {
|
||||
const respond = vi.fn();
|
||||
render(<ArchiveRepairWidget descriptor={{ id: "gate", widget: "archive-repair", repair }} onRespond={respond} />);
|
||||
expect(screen.getByText("Before memory")).toBeVisible();
|
||||
expect(screen.getByText("After memory")).toBeVisible();
|
||||
expect(screen.getByText(/After evidence/)).toBeVisible();
|
||||
const button = screen.getByRole("button", { name: `Apply this correction to ${archive}` });
|
||||
await userEvent.click(button);
|
||||
expect(respond).toHaveBeenCalledWith({ id: "gate", choices: [archive === "Memory" ? "m" : "e"] });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test("non-admin can reject inadequate proposals without an archive write option", async () => {
|
||||
const respond = vi.fn();
|
||||
render(<ArchiveRepairWidget descriptor={{ id: "gate", widget: "archive-repair",
|
||||
repair: { ...repair, can_apply: false } }} onRespond={respond} />);
|
||||
expect(screen.queryByRole("button", { name: /Apply this correction/ })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /None are adequate/ }));
|
||||
expect(respond).toHaveBeenCalledWith({ id: "gate", choices: ["reject"] });
|
||||
});
|
||||
|
||||
test("saved but inactive corrections keep the choice fixed and offer retry", async () => {
|
||||
const respond = vi.fn();
|
||||
render(<ArchiveRepairWidget descriptor={{ id: "retry", widget: "archive-repair",
|
||||
repair: { ...repair, choice: "e", saved: true, status: "pending_activation" } }} onRespond={respond} />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Index activation is incomplete");
|
||||
expect(screen.queryByRole("button", { name: /None are adequate/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Continue with repair pending" })).toBeVisible();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry selected correction" }));
|
||||
expect(respond).toHaveBeenCalledWith({ id: "retry", choices: ["e"] });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from "react";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
|
||||
interface RepairOption {
|
||||
id: string; label: string; archive: "memory" | "evidence"; target_id: string;
|
||||
before: Record<string, unknown>; content: Record<string, unknown>;
|
||||
}
|
||||
interface Repair {
|
||||
repair_id: string; reason: string; options: RepairOption[]; choice: string | null;
|
||||
status: string; saved: boolean; indexed: boolean; can_apply: boolean;
|
||||
}
|
||||
const statuses: Record<string, string> = {
|
||||
proposed: "Awaiting your decision. No archive changes have been saved.",
|
||||
applying: "Your choice is recorded. The file update needs recovery; activation is not confirmed.",
|
||||
pending_activation: "Correction saved. Index activation is incomplete; retry to make it available.",
|
||||
active: "Correction saved and active in the archive index.",
|
||||
rejected: "Proposals rejected. No archive changes were made.",
|
||||
superseded: "The archive changed after this correction. Review its current content before proposing another repair.",
|
||||
};
|
||||
const fieldLabels: Record<string, string> = {
|
||||
id: "Identity", subject: "Subject", title: "Title", detail: "Detail", scope: "Scope",
|
||||
rationale: "Rationale", question: "Question", sql: "SQL", concepts: "Concepts",
|
||||
dependencies: "Dependencies", links: "Links", family: "Family", kind: "Kind",
|
||||
language: "Language", purposes: "Purposes", applies_to: "Applies to", payload: "Content",
|
||||
provenance: "Source history", review_items: "Review items",
|
||||
};
|
||||
|
||||
function Value({ value }: { value: unknown }) {
|
||||
if (value == null) return null;
|
||||
if (Array.isArray(value)) return <ul className="space-y-1">{value.map((item, index) =>
|
||||
<li key={index}><Value value={item} /></li>)}</ul>;
|
||||
if (typeof value === "object") return <dl className="space-y-1">{Object.entries(value).map(([key, item]) =>
|
||||
<div key={key}><dt className="text-xs capitalize">{key.replaceAll("_", " ")}</dt>
|
||||
<dd className="pl-2"><Value value={item} /></dd></div>)}</dl>;
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
function Content({ value }: { value: Record<string, unknown> }) {
|
||||
return <dl className="min-w-0 space-y-2 text-sm">{Object.entries(fieldLabels).map(([key, label]) => {
|
||||
const item = value[key];
|
||||
if (item == null || item === "" || (Array.isArray(item) && item.length === 0)) return null;
|
||||
return <div key={key}><dt className="font-medium">{label}</dt>
|
||||
<dd className="whitespace-pre-wrap break-words text-muted-foreground"><Value value={item} /></dd></div>;
|
||||
})}</dl>;
|
||||
}
|
||||
|
||||
export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const repair = descriptor.repair as Repair;
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const send = (choice: string) => {
|
||||
if (submitted) return;
|
||||
setSubmitted(true);
|
||||
onRespond({ id: descriptor.id, choices: [choice] });
|
||||
};
|
||||
const pending = repair.status === "applying" || repair.status === "pending_activation";
|
||||
return <section aria-label="Archive conflict repair" className="space-y-4 rounded-xl border border-border bg-card p-5">
|
||||
<div><h3 className="font-semibold">Resolve archive conflict</h3>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm">{repair.reason}</p></div>
|
||||
<p role="status" className="rounded-md bg-muted p-3 text-sm">{statuses[repair.status] ?? repair.status}</p>
|
||||
{Boolean(descriptor.error) && <p role="alert" className="text-sm text-destructive">{String(descriptor.error)}</p>}
|
||||
{!repair.can_apply && <p className="text-sm text-muted-foreground">An administrator must approve shared archive corrections.
|
||||
You can reject the proposals or continue reviewing this question.</p>}
|
||||
<fieldset disabled={submitted} className="min-w-0 space-y-4">
|
||||
{repair.options.map(option => <article key={option.id} className="space-y-3 border-t border-border pt-4">
|
||||
<div><h4 className="text-sm font-semibold">{option.label}</h4>
|
||||
<p className="text-xs text-muted-foreground">{option.archive === "memory" ? "Memory" : "Evidence"} · {option.target_id}
|
||||
{repair.choice === option.id ? " · Selected" : ""}</p></div>
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2">
|
||||
<div className="min-w-0 rounded-md bg-muted/50 p-3"><p className="mb-2 text-xs font-semibold">Before this proposal</p>
|
||||
<Content value={option.before} /></div>
|
||||
<div className="min-w-0 rounded-md border border-border p-3"><p className="mb-2 text-xs font-semibold">Proposed result</p>
|
||||
<Content value={option.content} /></div>
|
||||
</div>
|
||||
{repair.can_apply && !repair.choice && <button type="button" onClick={() => send(option.id)}
|
||||
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">
|
||||
Apply this correction to {option.archive === "memory" ? "Memory" : "Evidence"}
|
||||
</button>}
|
||||
</article>)}
|
||||
<div className="flex flex-wrap gap-3 border-t border-border pt-4">
|
||||
{!repair.choice && <button type="button" onClick={() => send("reject")} className="rounded-md border border-border px-3 py-2 text-sm">
|
||||
None are adequate — reformulate
|
||||
</button>}
|
||||
{pending && repair.can_apply && repair.choice && <button type="button" onClick={() => send(repair.choice!)}
|
||||
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">Retry selected correction</button>}
|
||||
{(repair.choice || !repair.can_apply) && <button type="button" onClick={() => send("continue")}
|
||||
className="rounded-md border border-border px-3 py-2 text-sm">{pending ? "Continue with repair pending" : "Continue question review"}</button>}
|
||||
</div>
|
||||
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => {
|
||||
if (!submitted) { setSubmitted(true); onRespond({ id: descriptor.id, control, text }); }
|
||||
}} />
|
||||
</fieldset>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryReviewWidget } from "./MemoryReviewWidget";
|
||||
import type { MemoryInput } from "../api/memory";
|
||||
|
||||
const card: MemoryInput = { family: "domain_clarification", subject: "Active patients",
|
||||
detail: "Active means the flag is true", scope: "clinical", rationale: "Approved definition",
|
||||
question: "", sql: "", concepts: ["Active"], dependencies: [], links: [] };
|
||||
const descriptor = { id: "review", widget: "memory-review", summary: { summary_id: "hash", items: [
|
||||
{ id: "domain", card, reason: "Approved clarification", source_seqs: [3], before: null, target_id: null },
|
||||
{ id: "solved", card: { ...card, family: "solved_question", subject: "Count active patients",
|
||||
question: "How many active patients?", sql: "SELECT COUNT(*) FROM patients WHERE active" },
|
||||
reason: "Approved solution", source_seqs: [7], before: null, target_id: null },
|
||||
] } };
|
||||
|
||||
test("sends only selected cards with reviewer edits and physical dependencies", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
await userEvent.clear(screen.getByLabelText("domain detail"));
|
||||
await userEvent.type(screen.getByLabelText("domain detail"), "Reviewer correction");
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /Count active patients/ }));
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Add dependency" })[0]!);
|
||||
await userEvent.type(screen.getByLabelText("domain dependency 1 database"), "warehouse");
|
||||
await userEvent.type(screen.getByLabelText("domain dependency 1 schema_name"), "clinical");
|
||||
await userEvent.type(screen.getByLabelText("domain dependency 1 table"), "patients");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
|
||||
expect(onRespond).toHaveBeenCalledTimes(1);
|
||||
const response = JSON.parse(onRespond.mock.calls[0]![0].text);
|
||||
expect(response).toEqual({ summary_id: "hash", items: [{ id: "domain", card: { ...card,
|
||||
detail: "Reviewer correction", dependencies: [{ database: "warehouse", schema_name: "clinical",
|
||||
table: "patients", column: "" }] } }] });
|
||||
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("declining every proposal submits an explicit empty review", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Deselect all" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Finish without saving Memory" }));
|
||||
expect(JSON.parse(onRespond.mock.calls[0]![0].text)).toEqual({ summary_id: "hash", items: [] });
|
||||
});
|
||||
|
||||
test("links selected proposals and keeps approved SQL read only", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
expect(screen.getByText("SELECT COUNT(*) FROM patients WHERE active")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: /sql/i })).not.toBeInTheDocument();
|
||||
await userEvent.selectOptions(screen.getByLabelText("domain add link"), "proposal:solved");
|
||||
await userEvent.type(screen.getByLabelText("domain link 1 meaning"), "Example using this definition");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
|
||||
expect(JSON.parse(onRespond.mock.calls[0]![0].text).items[0].card.links)
|
||||
.toEqual([{ target_id: "proposal:solved", meaning: "Example using this definition" }]);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState } from "react";
|
||||
import type { MemoryCard, MemoryInput } from "../api/memory";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
|
||||
interface Proposal {
|
||||
id: string; card: MemoryInput; reason: string; source_seqs: number[];
|
||||
before: MemoryCard | null; target_id: string | null;
|
||||
}
|
||||
interface Summary { summary_id: string; items: Proposal[] }
|
||||
const labels = { domain_clarification: "Domain clarification", sql_rule: "SQL rule",
|
||||
solved_question: "Solved question", explained_error: "Explained error" };
|
||||
const inputStyle = "min-w-0 w-full rounded-md border border-border bg-background p-2 text-sm text-foreground";
|
||||
|
||||
export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const summary = descriptor.summary as Summary;
|
||||
const [items, setItems] = useState(summary.items);
|
||||
const [selected, setSelected] = useState(new Set(summary.items.map(p => p.id)));
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const patch = (id: string, changes: Partial<MemoryInput>) => setItems(current => current.map(
|
||||
item => item.id === id ? { ...item, card: { ...item.card, ...changes } } : item));
|
||||
const toggle = (id: string) => setSelected(current => {
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
|
||||
return <form aria-label="Memory summary" className="space-y-5 rounded-xl border border-border bg-card p-5"
|
||||
onSubmit={event => {
|
||||
event.preventDefault();
|
||||
if (submitted) return;
|
||||
setSubmitted(true);
|
||||
onRespond({ id: descriptor.id, kind: "memory-review", text: JSON.stringify({
|
||||
summary_id: summary.summary_id,
|
||||
items: items.filter(item => selected.has(item.id)).map(item => ({ id: item.id,
|
||||
card: { ...item.card, concepts: item.card.concepts.map(c => c.trim()).filter(Boolean) } })),
|
||||
}) });
|
||||
}}>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">Memory for future questions</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Review the scope and wording, then choose what to save.
|
||||
Only selected cards and their links will enter the shared archive.</p>
|
||||
</div>
|
||||
<fieldset disabled={submitted} className="min-w-0 space-y-4">
|
||||
<button type="button" className="text-sm font-medium text-primary" onClick={() => setSelected(
|
||||
selected.size ? new Set() : new Set(items.map(item => item.id)))}>
|
||||
{selected.size ? "Deselect all" : "Select all"}
|
||||
</button>
|
||||
{items.map(item => <section key={item.id} className="space-y-3 border-t border-border pt-4">
|
||||
<label className="flex items-start gap-3 text-sm font-medium">
|
||||
<input type="checkbox" className="mt-1 size-4" checked={selected.has(item.id)} onChange={() => toggle(item.id)} />
|
||||
<span>{item.card.subject}<span className="block text-xs font-normal text-muted-foreground">
|
||||
{item.target_id ? "Update existing card" : "New card"} · {labels[item.card.family]}
|
||||
</span></span>
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">{item.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">Approved decisions: {item.source_seqs.map(seq => `#${seq}`).join(", ")}</p>
|
||||
{item.before && <details className="rounded-md bg-muted p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium">Current card being replaced</summary>
|
||||
<dl className="mt-2 space-y-2">
|
||||
{(["subject", "detail", "scope", "rationale", "question", "sql"] as const).map(key =>
|
||||
<div key={key}><dt className="font-medium capitalize">{key}</dt>
|
||||
<dd className="whitespace-pre-wrap break-words">{item.before![key] || "—"}</dd></div>)}
|
||||
</dl>
|
||||
</details>}
|
||||
<fieldset disabled={!selected.has(item.id)} className="min-w-0 space-y-3 disabled:opacity-60">
|
||||
{(["subject", "scope", "detail", "rationale", "question"] as const).map(key => <label key={key} className="block text-xs font-medium capitalize">
|
||||
{key}<textarea aria-label={`${item.id} ${key}`} className={`${inputStyle} mt-1`} rows={key === "detail" ? 3 : 2}
|
||||
required={key === "subject" || key === "scope" || (key === "rationale" && item.card.family === "explained_error")}
|
||||
value={item.card[key]} onChange={event => patch(item.id, { [key]: event.target.value })} />
|
||||
</label>)}
|
||||
{item.card.family === "solved_question" && <div className="text-sm">
|
||||
<p className="font-medium">Approved SQL</p><pre className="mt-1 overflow-x-auto rounded-md bg-muted p-3 text-xs">{item.card.sql}</pre>
|
||||
<p className="mt-1 text-xs text-muted-foreground">To change the solution, return to SQL review.</p>
|
||||
</div>}
|
||||
<label className="block text-xs font-medium">Concepts, separated by commas
|
||||
<input aria-label={`${item.id} concepts`} className={`${inputStyle} mt-1`} value={item.card.concepts.join(", ")}
|
||||
onChange={event => patch(item.id, { concepts: event.target.value.split(",").map(v => v.trim()) })} />
|
||||
</label>
|
||||
<div className="space-y-2"><p className="text-xs font-medium">Physical dependencies</p>
|
||||
{item.card.dependencies.map((dependency, index) => <div key={index} className="grid gap-2 sm:grid-cols-2">
|
||||
{(["database", "schema_name", "table", "column"] as const).map(key => <input key={key}
|
||||
className={inputStyle} aria-label={`${item.id} dependency ${index+1} ${key}`} placeholder={key}
|
||||
required={key === "database"} value={dependency[key]} onChange={event => patch(item.id, {
|
||||
dependencies: item.card.dependencies.map((d, i) => i === index ? { ...d, [key]: event.target.value } : d),
|
||||
})} />)}
|
||||
<button type="button" className="text-left text-xs text-muted-foreground" onClick={() => patch(item.id, {
|
||||
dependencies: item.card.dependencies.filter((_, i) => i !== index),
|
||||
})}>Remove dependency</button>
|
||||
</div>)}
|
||||
<button type="button" className="text-xs text-primary" onClick={() => patch(item.id, {
|
||||
dependencies: [...item.card.dependencies, { database: "", schema_name: "", table: "", column: "" }],
|
||||
})}>Add dependency</button>
|
||||
</div>
|
||||
<div className="space-y-2"><p className="text-xs font-medium">Links to other cards</p>
|
||||
{item.card.links.map((link, index) => <div key={index} className="space-y-2 rounded-md border border-border p-2">
|
||||
<input aria-label={`${item.id} link ${index+1} target`} className={inputStyle} required
|
||||
value={link.target_id} placeholder="Card ID or proposal reference" onChange={event => patch(item.id, {
|
||||
links: item.card.links.map((l, i) => i === index ? { ...l, target_id: event.target.value } : l),
|
||||
})} />
|
||||
<input aria-label={`${item.id} link ${index+1} meaning`} className={inputStyle} required
|
||||
value={link.meaning} placeholder="Why these cards are linked" onChange={event => patch(item.id, {
|
||||
links: item.card.links.map((l, i) => i === index ? { ...l, meaning: event.target.value } : l),
|
||||
})} />
|
||||
<button type="button" className="text-xs text-muted-foreground" onClick={() => patch(item.id, {
|
||||
links: item.card.links.filter((_, i) => i !== index),
|
||||
})}>Remove link</button>
|
||||
</div>)}
|
||||
<select aria-label={`${item.id} add link`} className={inputStyle} value="" onChange={event => {
|
||||
if (event.target.value) patch(item.id, { links: [...item.card.links, { target_id: event.target.value, meaning: "" }] });
|
||||
}}><option value="">Link to another proposed card…</option>
|
||||
{items.filter(p => p.id !== item.id).map(p => <option key={p.id} value={`proposal:${p.id}`}>{p.card.subject}</option>)}
|
||||
</select>
|
||||
<button type="button" className="text-xs text-primary" onClick={() => patch(item.id, {
|
||||
links: [...item.card.links, { target_id: "", meaning: "" }],
|
||||
})}>Link to an existing card</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<span className="text-sm text-muted-foreground">{selected.size} of {items.length} selected</span>
|
||||
<button type="submit" className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground">
|
||||
{submitted ? "Saving…" : selected.size ? "Save selected and finish" : "Finish without saving Memory"}
|
||||
</button>
|
||||
</div>
|
||||
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => onRespond({ id: descriptor.id, control, text })} />
|
||||
</fieldset>
|
||||
</form>;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { ArtifactGateWidget } from "./ArtifactGateWidget";
|
||||
import { ArtifactWidget } from "./ArtifactWidget";
|
||||
import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget";
|
||||
import { JoinReviewWidget } from "./JoinReviewWidget";
|
||||
import { MemoryReviewWidget } from "./MemoryReviewWidget";
|
||||
import { ArchiveRepairWidget } from "./ArchiveRepairWidget";
|
||||
register("select", SelectWidget);
|
||||
register("info", InfoWidget);
|
||||
register("freetext", FreetextWidget);
|
||||
@@ -15,4 +17,6 @@ register("artifact-gate", ArtifactGateWidget);
|
||||
register("artifact", ArtifactWidget);
|
||||
register("schema-linking", SchemaLinkingGateWidget);
|
||||
register("join-review", JoinReviewWidget);
|
||||
register("memory-review", MemoryReviewWidget);
|
||||
register("archive-repair", ArchiveRepairWidget);
|
||||
export { resolve } from "./registry";
|
||||
|
||||
Reference in New Issue
Block a user