Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/gateway/src/dedup-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export type DedupPreviewCandidate = {
revision: number;
title: string;
content_excerpt: string;
scope: "project" | "shared";
project_id: string | null;
category: string;
confidence: number;
source_session: string | null;
updated_at: number | null;
/** Strongest dedup signal between this entry and another group member. */
score: number;
reasons: string[];
Expand Down Expand Up @@ -121,6 +127,15 @@ export function dedupPreviewGroups(
revision,
title: entry.title,
content_excerpt: excerpt(entry.content),
scope:
entry.project_id === null || entry.cross_project
? "shared"
: "project",
project_id: entry.project_id,
category: entry.category,
confidence: entry.confidence,
source_session: entry.source_session,
updated_at: entry.updated_at,
score: Number(score.toFixed(4)),
reasons: [...reasons].sort(),
});
Expand Down
54 changes: 54 additions & 0 deletions packages/gateway/test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,12 @@ describe("POST /api/v1/projects/:id/dedup (+ /apply)", () => {
revision: number;
title: string;
content_excerpt: string;
scope: "project" | "shared";
project_id: string | null;
category: string;
confidence: number;
source_session: string | null;
updated_at: number | null;
score: number;
reasons: string[];
};
Expand Down Expand Up @@ -814,6 +820,12 @@ describe("POST /api/v1/projects/:id/dedup (+ /apply)", () => {
expect(c.logical_id).toBe(c.id);
expect(c.title).toContain("Cache warming");
expect(c.content_excerpt.length).toBeLessThanOrEqual(200);
expect(c.scope).toBe("project");
expect(c.project_id).toBe(projectId);
expect(c.category).toBe("gotcha");
expect(c.confidence).toBe(1);
expect(c.source_session).toBe("test-session");
expect(c.updated_at).toEqual(expect.any(Number));
expect(c.score).toBeGreaterThanOrEqual(0.7);
expect(c.reasons).toEqual(["title_overlap"]);
}
Expand All @@ -824,6 +836,48 @@ describe("POST /api/v1/projects/:id/dedup (+ /apply)", () => {
).not.toContain(unrelated);
});

it("labels global duplicate candidates as shared with no project", async () => {
const { ensureProject, ltm } = await import("@loreai/core");
const projectPath = `/test/api/dedup-global-${Date.now()}-${seq++}`;
const projectId = ensureProject(projectPath, "dedup-global-project");
const title = "Shared cache warming slot configuration";
const sharedA = ltm.create({
id: crypto.randomUUID(),
category: "gotcha",
title,
content: "The shared cache warming slot uses a stable configuration.",
session: "shared-session",
scope: "global",
});
const sharedB = ltm.create({
id: crypto.randomUUID(),
category: "gotcha",
title: `${title} duplicate`,
content: "A duplicate shared cache warming slot configuration.",
session: "shared-session",
scope: "global",
});
const res = await post(`/api/v1/projects/${projectId}/dedup`, {});
expect(res.status).toBe(200);
const preview = (await res.json()) as Preview;
const group = preview.groups.find(
(candidateGroup) =>
candidateGroup.scope === "global" &&
candidateGroup.candidates.some((candidate) => candidate.id === sharedA),
);
if (!group) throw new Error("expected a shared duplicate group");
expect(group.project_id).toBeNull();
expect(group.candidates.map((candidate) => candidate.id).sort()).toEqual(
[sharedA, sharedB].sort(),
);
expect(group.candidates).toEqual(
expect.arrayContaining([
expect.objectContaining({ scope: "shared", project_id: null }),
expect.objectContaining({ scope: "shared", project_id: null }),
]),
);
});

it("preview never writes", async () => {
const { projectPath, projectId } = await seedDuplicates();
const { ltm } = await import("@loreai/core");
Expand Down
52 changes: 52 additions & 0 deletions packages/gateway/test/ui-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
syncStatus,
teamList,
costsSnapshot,
dedupPreviewResponse,
projectClearResult,
projectRenameResult,
projectsMergeResult,
Expand Down Expand Up @@ -73,6 +74,8 @@ const SEEDED = {
secondKnowledgeId: "",
sessionId: "",
entityId: "",
dedupProjectPath: "",
dedupProjectId: "",
};

beforeAll(async () => {
Expand Down Expand Up @@ -113,6 +116,29 @@ beforeAll(async () => {
session: SEEDED.sessionId,
scope: "project",
});
SEEDED.dedupProjectPath = "/test/ui-contracts/dedup-preview";
SEEDED.dedupProjectId = ensureProject(
SEEDED.dedupProjectPath,
"ui-contracts-dedup",
);
ltm.create({
id: crypto.randomUUID(),
projectPath: SEEDED.dedupProjectPath,
category: "gotcha",
title: "Gateway cache warming threshold configuration",
content: "The cache warming threshold is configured per project.",
session: "ui-contracts-dedup-session",
scope: "project",
});
ltm.create({
id: crypto.randomUUID(),
projectPath: SEEDED.dedupProjectPath,
category: "gotcha",
title: "Cache warming threshold configuration",
content: "Duplicate threshold configuration.",
session: "ui-contracts-dedup-session",
scope: "project",
});
for (const i of [0, 1]) {
temporal.store({
projectPath: SEEDED.projectPath,
Expand Down Expand Up @@ -232,6 +258,8 @@ function makeNormaliser() {
const uuids = new Map<string, string>();
const paths = new Map<string, string>();
const tmIds = new Map<string, string>();
const groupIds = new Map<string, string>();
const groupCounts = new Map<string, number>();
let uuidN = 0;
let epochN = 0;
let pathN = 0;
Expand All @@ -244,6 +272,20 @@ function makeNormaliser() {
if (!tag) tmIds.set(value, (tag = `<tm-${tmN++}>`));
return tag;
}
const groupMatch = /^(project|global):[0-9a-f]{16}$/.exec(value);
if (groupMatch) {
const scope = groupMatch[1];
if (scope) {
let tag = groupIds.get(value);
if (!tag) {
const index = groupCounts.get(scope) ?? 0;
tag = `${scope}:group-${index}`;
groupIds.set(value, tag);
groupCounts.set(scope, index + 1);
}
return tag;
}
}
if (UUID_RE.test(value)) {
let tag = uuids.get(value);
if (!tag) uuids.set(value, (tag = `<uuid-${uuidN++}>`));
Expand Down Expand Up @@ -355,6 +397,16 @@ describe("ui contracts against the real gateway", () => {
);
});

it("POST /projects/:id/dedup (preview)", async () => {
await contractRoute(
"dedup-preview.json",
`/projects/${SEEDED.dedupProjectId}/dedup`,
v1(["projects", SEEDED.dedupProjectId, "dedup"]),
dedupPreviewResponse,
{ method: "POST", ...JSON_BODY({}) },
);
});

it("GET /knowledge first cross-project page", async () => {
await contractRoute(
"knowledge-all-page.json",
Expand Down
36 changes: 31 additions & 5 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Routes (all under `/ui`, history-API fallback served by the gateway):
| `/ui` | Workspace: project navigation, all-knowledge link + "choose a project" document |
| `/ui/projects/:projectId` | Project identity, health, recent sessions and knowledge list |
| `/ui/projects/:projectId/knowledge` | Server-filtered and sorted knowledge table |
| `/ui/projects/:projectId/duplicates` | Read-only duplicate candidate review with browser-local marks |
| `/ui/projects/:projectId/knowledge/:knowledgeId` | Knowledge entry as a document; `:knowledgeId` is the **stable logical id** |
| `/ui/knowledge` (`?q=&category=&scope=&project=&sort=&cursor=`) | Cross-project, server-filtered and sorted knowledge table with a project column |
| `/ui/search` (`?q=`) | Ranked cross-project knowledge search, top 50 of an exact total |
Expand Down Expand Up @@ -65,6 +66,29 @@ Tests:
- pnpm --filter @loreai/ui exec vitest run test/contradictions-page.test.tsx test/contracts.test.ts test/api-client.test.ts
- pnpm --filter @loreai/ui test:e2e

### Duplicate review (#1803)

The project page's **Review duplicates** link opens a read-only evidence
comparison backed by `POST /api/v1/projects/:id/dedup`. The preview retains the
legacy `project` and `global` result payloads; a `global` group is labelled
"Shared (no project)" in the UI, and candidate scope is shown independently as
Project or Shared. No apply request or server mutation is available in this
screen.

Accept and Skip create marks in the local `reviewDecisions` IndexedDB store.
They remain in this browser and are not applied to Lore; if IndexedDB is
unavailable, marks last only for the current session. A mark is stale when the
fresh preview changes its candidate membership or any candidate revision.
Stale marks do not count as accepted. Marks for groups absent from a preview
are shown as orphaned and require an explicit discard action; they are never
removed automatically. Keyboard shortcuts are `j`/`k` for next/previous group,
`a` accept, `s` skip, `u` clear, and `1`–`9` to choose a keeper.

Tests:
- `pnpm --filter @loreai/ui exec vitest run test/duplicate-review.test.tsx test/dedup-review.test.ts test/db.test.ts`
- `pnpm exec vitest run packages/gateway/test/ui-contracts.test.ts packages/gateway/test/api.test.ts`
- `pnpm --filter @loreai/ui test:e2e` (`e2e/dedup-review.spec.ts`)

### Sidebar projects (#1918)

`GET /api/v1/projects` now returns a `last_activity` timestamp (max of the
Expand Down Expand Up @@ -483,12 +507,12 @@ the staged tree. `setUiAssetSource()` swaps in an explicit source for tests.

| Layer | Command | Where it runs |
|---|---|---|
| Unit (jsdom) | `pnpm --filter @loreai/ui test` — `test/api-client.test.ts`, `test/contracts.test.ts`, `test/db.test.ts`, `test/state.test.ts`, `test/shell.test.tsx`, `test/folk-status.test.ts`, `test/folk-shell.test.tsx`, `test/project-page.test.tsx`, `test/knowledge-table.test.tsx`, `test/knowledge-document.test.tsx`, `test/session-list.test.tsx`, `test/session-route.test.tsx`, `test/search-results.test.tsx`, `test/recall-text.test.ts`, `test/compat-smoke.test.tsx`, `test/entities-list.test.tsx`, `test/entity-page.test.tsx`, `test/entities-rebuild.test.tsx`, reader tests (see [Tests (#1843)](#tests-1843) and [Tests (#1846)](#tests-1846)) | root `pnpm test`, regular CI job |
| Unit (jsdom) | `pnpm --filter @loreai/ui test` — `test/api-client.test.ts`, `test/contracts.test.ts`, `test/db.test.ts`, `test/dedup-review.test.ts`, `test/duplicate-review.test.tsx`, `test/state.test.ts`, `test/shell.test.tsx`, `test/folk-status.test.ts`, `test/folk-shell.test.tsx`, `test/project-page.test.tsx`, `test/knowledge-table.test.tsx`, `test/knowledge-document.test.tsx`, `test/session-list.test.tsx`, `test/session-route.test.tsx`, `test/search-results.test.tsx`, `test/recall-text.test.ts`, `test/compat-smoke.test.tsx`, `test/entities-list.test.tsx`, `test/entity-page.test.tsx`, `test/entities-rebuild.test.tsx`, reader tests (see [Tests (#1843)](#tests-1843) and [Tests (#1846)](#tests-1846)) | root `pnpm test`, regular CI job |

| UI contract fixtures | `pnpm exec vitest run packages/gateway/test/ui-contracts.test.ts` — real gateway responses normalised (uuids/epochs/paths) and snapshotted into `packages/ui/test/fixtures/` | root `pnpm test`, regular CI job |
| Gateway static serving | `pnpm exec vitest run packages/gateway/test/ui-static.test.ts packages/gateway/test/review-actions.test.ts` | root `pnpm test`, regular CI job |
| Deep-link smoke (no browser) | `node scripts/ui-deep-link-smoke.mjs` — spawns the built gateway in a throw-away data dir, plain HTTP: `/` → `/ui`, deep link → `index.html` + CSP + no-cache, hashed assets → MIME + immutable, unknown asset → non-HTML 404 | regular CI job, after the bundle step |
| Browser e2e | `pnpm --filter @loreai/ui test:e2e` — `e2e/browse.spec.ts`, `e2e/all-knowledge.spec.ts`, `e2e/knowledge-table.spec.ts`, `e2e/knowledge-detail.spec.ts`, `e2e/fixture.spec.ts`, `e2e/reader.spec.ts`, `e2e/busy-fixture.spec.ts`, `e2e/entities.spec.ts`, `e2e/contradictions.spec.ts`, `e2e/project-actions.spec.ts`, `e2e/import-history.spec.ts`, `e2e/sessions.spec.ts`, `e2e/nav-background.spec.ts` (sidebar tint covers the whole scrolled nav, light + dark + mobile drawer, #1916), `e2e/provider-costs.spec.ts`, `e2e/folk-status.spec.ts`; Playwright desktop + mobile Chromium against the built gateway (reader fixture also uses Vite dev server). Requires core/gateway builds and `pnpm --filter @loreai/core build && pnpm --filter @loreai/gateway bundle && pnpm --filter @loreai/ui exec playwright install chromium` | `.github/workflows/ui-e2e.yml` only: PRs touching `packages/ui/**` or the gateway's UI-serving files, nightly on `main`, `workflow_dispatch`; browsers cached |
| Browser e2e | `pnpm --filter @loreai/ui test:e2e` — `e2e/browse.spec.ts`, `e2e/all-knowledge.spec.ts`, `e2e/knowledge-table.spec.ts`, `e2e/knowledge-detail.spec.ts`, `e2e/fixture.spec.ts`, `e2e/reader.spec.ts`, `e2e/busy-fixture.spec.ts`, `e2e/entities.spec.ts`, `e2e/contradictions.spec.ts`, `e2e/dedup-review.spec.ts`, `e2e/project-actions.spec.ts`, `e2e/import-history.spec.ts`, `e2e/sessions.spec.ts`, `e2e/nav-background.spec.ts` (sidebar tint covers the whole scrolled nav, light + dark + mobile drawer, #1916), `e2e/provider-costs.spec.ts`, `e2e/folk-status.spec.ts`; Playwright desktop + mobile Chromium against the built gateway (reader fixture also uses Vite dev server). Requires core/gateway builds and `pnpm --filter @loreai/core build && pnpm --filter @loreai/gateway bundle && pnpm --filter @loreai/ui exec playwright install chromium` | `.github/workflows/ui-e2e.yml` only: PRs touching `packages/ui/**` or the gateway's UI-serving files, nightly on `main`, `workflow_dispatch`; browsers cached |

## Session reader (#1801)

Expand Down Expand Up @@ -1268,7 +1292,7 @@ The owner signs this gate off; the implementation agent does not. Each row names
| Management security boundary | `packages/gateway/test/management-access.test.ts`, `hono-routing.test.ts`, `gateway-auth-config.test.ts` (socket-peer + Origin/Host checks, `LORE_ALLOW_REMOTE_MANAGEMENT`, `LORE_GATEWAY_AUTH_TOKEN`, hosted-mode write refusals); `e2e/browse.spec.ts` (dev-only routes absent in production). |
| CSP | `packages/gateway/test/ui-static.test.ts` asserts the `Content-Security-Policy` header on `/ui` responses (see "How the gateway serves the SPA"). |
| Inert content | `packages/ui/test/safe-html.test.ts` (unit) and `e2e/hostile-content.spec.ts` (every production screen, desktop + mobile, `window.__pwned` stays 0, no `script`/`iframe`/handler attributes/`javascript:` links). |
| IndexedDB migration + reset | `e2e/db-migration.spec.ts` (v1→v3 upgrade keeps `meta`, stale cache never authoritative, corrupted/future-version DB reset, cleared site data). |
| IndexedDB migration + reset | `e2e/db-migration.spec.ts` (v1→v4 upgrade keeps `meta`, stale cache never authoritative, corrupted/future-version DB reset, cleared site data). |
| Keyboard / focus | `e2e/keyboard.spec.ts`, `e2e/reader.spec.ts` ("keyboard: rows are focusable"). |
| Deep links + themes | `e2e/routes.spec.ts` (every README route, including `/ui/entities`, `/ui/entities/:entityId`, `/ui/contradictions`, `/ui/warming` and `/ui/costs`, survives reload; light/dark on every screen), `scripts/ui-deep-link-smoke.mjs` (always-on CI). |
| Playwright green in CI | `ui-e2e` workflow run on the release PR (desktop + mobile projects) — link the run here when signing off. |
Expand All @@ -1280,8 +1304,10 @@ The owner signs this gate off; the implementation agent does not. Each row names
authoritative for projects, knowledge, sessions and distillations.
- The browser holds **derived, disposable** state only: route, theme, pane
layout, an IndexedDB cache of API responses (`src/db/`, database
`lore-ui` v3) and local working state (`drafts`, `pendingChanges` —
per-device, never merged into entity stores). Anything in IndexedDB can
`lore-ui` v4) and local working state (`drafts`, `pendingChanges`,
`reviewDecisions` — per-device, never merged into entity stores). Duplicate
review marks are browser-local decisions only, not commands to merge
knowledge. Anything in IndexedDB can
be deleted without loss of Lore data; a reset never touches the server.
- The SPA calls the **read** routes (`GET /api/v1/projects`,
`GET /api/v1/projects/:id/knowledge` (+ `?page=` cursor variant),
Expand Down
4 changes: 1 addition & 3 deletions packages/ui/e2e/all-knowledge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,7 @@ test.describe("cross-project knowledge", () => {
await expect(
table.getByText("scratch", { exact: true }).first(),
).toHaveText("scratch");
await expect(table.getByText("No project", { exact: true })).toHaveText(
"No project",
);
await expect(table.getByText("No project", { exact: true })).toHaveCount(3);
const sharedRow = page
.getByTestId("knowledge-row")
.filter({ hasText: "Shared: prefer inert rendering" });
Expand Down
15 changes: 10 additions & 5 deletions packages/ui/e2e/db-migration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const STORES = [
"collections",
"drafts",
"pendingChanges",
"reviewDecisions",
] as const;

async function deleteDatabase(page: Page) {
Expand Down Expand Up @@ -67,6 +68,9 @@ async function openInfo(page: Page) {
} else if (name === "pendingChanges") {
const tx = db.transaction(name);
indexes[name] = Array.from(tx.objectStore(name).indexNames);
} else if (name === "reviewDecisions") {
const tx = db.transaction(name);
indexes[name] = Array.from(tx.objectStore(name).indexNames);
}
}
const legacy = db.transaction("meta").objectStore("meta").get("e2e-legacy");
Expand All @@ -81,15 +85,15 @@ async function openInfo(page: Page) {
}

test.describe("IndexedDB cache migrations and recovery", () => {
test("upgrades v1 without dropping meta and creates the complete v3 schema", async ({
test("upgrades v1 without dropping meta and creates the complete v4 schema", async ({
page,
}) => {
await page.goto("/ui");
await createV1(page);
await page.reload();
await expect(page.getByTestId("connection-status")).toBeVisible();
const info = await openInfo(page);
expect(info.version).toBe(3);
expect(info.version).toBe(4);
expect(info.stores.sort()).toEqual([...STORES].sort());
for (const name of ["projects", "knowledge", "sessions", "messageBlocks"]) {
expect(info.indexes[name]?.sort()).toEqual(
Expand All @@ -98,6 +102,7 @@ test.describe("IndexedDB cache migrations and recovery", () => {
}
expect(info.indexes.drafts).toEqual(["by-updated"]);
expect(info.indexes.pendingChanges).toEqual(["by-created"]);
expect(info.indexes.reviewDecisions).toEqual(["by-project"]);
expect(info.legacyValue).toEqual({ key: "e2e-legacy", value: "kept" });
});

Expand Down Expand Up @@ -242,7 +247,7 @@ test.describe("IndexedDB cache migrations and recovery", () => {
);
await page.reload();
await expect(page.getByTestId("connection-status")).toBeVisible();
expect((await openInfo(page)).version).toBe(3);
expect((await openInfo(page)).version).toBe(4);
});

test("survives user-cleared site data during SPA navigation", async ({
Expand Down Expand Up @@ -282,7 +287,7 @@ test.describe("IndexedDB cache migrations and recovery", () => {
const databases = await page.evaluate(() => indexedDB.databases());
return databases.find((db) => db.name === "lore-ui")?.version;
})
.toBe(3);
expect((await openInfo(page)).version).toBe(3);
.toBe(4);
expect((await openInfo(page)).version).toBe(4);
});
});
Loading
Loading